Advanced SQL Calculations - A Concept for Advanced BAQs

Hello all,

I’ve been working on some complex reporting challenges in Epicor, specifically with SQL queries that use advanced SQL functions like LAG() and OVER(). As many of you are aware, the BAQ designer’s parser struggles with these functions, which prevents us from using them directly in calculated fields.

I want to propose a concept that combines a few techniques discussed on this forum, particularly Kevin Lincrum’s “Dynamic Parameters” method and the use of the DynamicQuery object in a BPM. This isn’t a simple workaround; it’s a way to leverage the Epicor framework to achieve what the BAQ designer can’t do on its own.

The Problem

The BAQ designer is a powerful tool, but its parser is rigid; it often fails to validate calculated fields with complex clauses. This forces us to either write the query from scratch in a BPM or settle for less-efficient, multi-step BAQs.

The Proposed Solution: An Unofficial Hybrid Approach

I suggest a hybrid approach that combines the best of both worlds: the BAQ designer’s visual structure and the BPM’s ability to execute custom SQL.

  • Use a mostly valid BAQ with a Placeholder: In the BAQ designer, create the main query with all the necessary tables and joins. For the complex calculated field (e.g., Run_ID ), we would make a simple placeholder, like /* ApprovedPO */ null as Run_ID. The BAQ designer will validate this simple code without any errors.
-- This is the code generated by the BAQ designer based on your visual query.
-- The "NULL" is a simple placeholder that the BPM will later replace.
SELECT
    POHeader.Company AS POHeader_Company,
    PORel.Plant AS PORel_Plant,
    PODetail.PartNum AS PODetail_PartNum,
    POHeader.OrderDate AS POHeader_OrderDate,
    POHeader.VendorNum AS POHeader_VendorVum,
    POVendor.VendorID AS POHeader_VendorID,
    -- ... other fields as needed
    /* ApprovedPO */ NULL AS RunID,
FROM
    Erp.POHeader
    INNER JOIN Erp.PODetail ON ...
    INNER JOIN Erp.PORel ON ...
    INNER JOIN Erp.Vendor AS POVendor ON ...
WHERE
    POHeader.Approve = 1
    AND POHeader.Company = @CurrentCompany
    AND POHeader.OrderDate >= @StartOfYear
    AND PODetail.PartNum IN (@PartNum)
  • Embed the Complex Logic in an Unused Subquery: The clever part is to create a separate subquery that is never joined to the main query . In this unreferenced subquery, we would add the complete, complex SQL code for our calculated field. The BAQ designer’s validation process doesn’t fully parse unlinked subqueries, which allows the advanced syntax to be saved without throwing an error.
    • Use corresponding field names.
    • Use the label to indicate the start marker of the table or placeholder.
-- This is the advanced SQL code you will put into the unused subquery.
-- It will be "injected" into the main query by the BPM at runtime.
       SUM(CASE WHEN POHeader.VendorNum <> LAG(POHeader.VendorNum, 1, POHeader.VendorNum) OVER (
        PARTITION BY POHeader.Company, PORel.Plant, PODetail.PartNum
        ORDER BY POHeader.OrderDate
    ) THEN 1 ELSE 0 END) OVER (
        PARTITION BY POHeader.Company, PORel.Plant, PODetail.PartNum
        ORDER BY POHeader.OrderDate
        ROWS UNBOUNDED PRECEDING
    ) AS Run_ID
  • Use a BPM to replace the Code: The final step is to create a Pre-Processing BPM on the BAQ. This BPM would perform a find-and-replace operation on the BAQ’s generated SQL string at runtime. It would find our simple placeholder (/* ApprovedPO */ null as Run_ID ) and replace it with the complex, unvalidated SQL from our dummy subquery. Finally, the BPM would execute this new, complete SQL string using the DynamicQuery object and then populate the BAQ’s result table with the data.

Why This Could Work

This approach is highly speculative and would require careful testing, but it seems possible in principle. It leverages the following:

  • The BAQ designer’s validation blind spot for unreferenced subqueries.
  • The BAQ’s ability to generate a valid SQL string based on a simple, validated structure.
  • The BPM’s power to manipulate and execute a complete SQL string.

By using this method, we could build complex reports with the BAQ designer’s familiar interface while still incorporating advanced T-SQL that would otherwise be impossible.

Potential Risks and Considerations

While this method holds promise, it’s essential to be aware of the potential risks and limitations.

  • Performance Overhead: The find-and-replace operation incurs additional overhead during query execution, particularly for very large or complex BAQ strings.
  • Unofficial and Unsupported: This is an unorthodox, unofficial workaround. As a result, it is not supported by Epicor and could be impacted or broken by future software updates.
  • Debugging Challenges: Debugging a BAQ that relies on a hidden subquery and a BPM can be very challenging. The BAQ designer’s trace will not show the final, modified SQL string, making it challenging to troubleshoot errors.
  • Possible exploitation: As with all custom code, this has the potential of increasing the risk of exploitation by malicious actors.

What do you all think? Has anyone else tried something like this? It’s unorthodox, but it might be the key to unlocking more of SQL’s power within the Epicor environment.

Cheers,

Kevin

3 Likes

I use both of these in baqs all the time. Did chatgpt write this?

7 Likes

That’s not a BAQ error.

Implementing MITM SQL injection won’t solve this. It’s still not coherent SQL.

4 Likes

I’ll admit that my raw SQL skills are (very) rusty; I hadn’t done much with SQL for 10 years before getting back into Enterprise software. But I believe I used something very similar in the past. I can’t remember which dialect.
I know I didn’t always have to separate subqueries to perform this action.

What does RunID represent? It kinda looks like it’s aiming to apply a sequence to things based on the order date.

I use External BAQS for the more complicated stuff. I have a datasource that just loops right back to our main DB. Then I get all the SQL magic but I can also make it a UBAQ and integrate it into other stuff seamlessly.

3 Likes

9 Likes

Sorry to pile on, but if it can’t be done in BAQ/SQL, then a preprocessing BPM on get list with an updateable BAQ gives you the ability to write C#, and if you can’t do whatever you need done with that level of code access…

I Cant Tv Land GIF by YoungerTV

6 Likes

That is somewhat correct, I want to apply a sequence

That’s close, essentially, I want to apply a sequence to PO Vendors based on order date.

When the vendor changes, I want a new sequence ID so that I can group by that at a higher level.

One of the values I am looking to obtain is number of consecutive orders by the same vendor without relying on huge temp tables to obtain that number.

e.g., PO 1 2 3 4 5, for PN-1A to vendor 01 6,7 to vendor 02 8, 9, 10 to vendor 01.

So PO 1-5 would get Run_ID 1 6 & 7 would get Run_ID 2, and then 8-10 would get Run_ID 3.

I can possibly obtain the numbers I am looking for with grouping sets.

Would something like row_number do the trick? Maybe with PONum tossed in the ordering so there’s some sense when there’s more than one order on a single date. Or if multiple orders on the same date should get the same RunID, dense_rank instead of row_number would do that.

row_number() over(
    partition by POHeader.Company, PORel.Plant, PODetail.PartNum, POHeader.VendorID
    order by POHeader.OrderDate, POHeader.PONum
)

Rearrange partition/order as necessary, I’m just riffing without reading closely… :slight_smile:

2 Likes

That gives me a new number for each OrderDate/POnum. I’ve also tried Rank() and Dense_RANK(), neither of which returns the values I need.

PrimaryVendorPO_YTD,
AltVendorPO_YTD,
OtherVendorPO_YTD,
TotalPO_YTD,
ConsecutivePrimaryPO_QTY,
PrimaryAndNonPrimary,
MultipleVendors,
LastNonPrimaryVendor,
LastPOdate_SecondaryVendor,
LastPOdate_PrimaryVendor,
LastPOdate_OtherVendor,
FirstPrimaryVendorPOdate,

These are some of the values I’m trying to obtain.

CASE WHEN POHeader.VendorNum <> LAG(POHeader.VendorNum, 1, POHeader.VendorNum) OVER (
        PARTITION BY POHeader.Company, PORel.Plant, PODetail.PartNum
        ORDER BY POHeader.OrderDate
    ) THEN 1 ELSE 0 END

This essentially marks the first row where the VendorNum changes.

The SUM() over () was intended essentially to create a new Run_ID each time the vendor changes.

with [Rank_Calc] as 
(select  
	[POHeader].[Company] as [POHeader_Company], 
	[PORel].[Plant] as [PORel_Plant], 
	[PODetail].[PartNum] as [PODetail_PartNum], 
	[POHeader].[PONum] as [POHeader_PONum], 
	[POHeader].[OrderDate] as [POHeader_OrderDate], 
	[POHeader].[VendorNum] as [POHeader_VendorNum], 
	(CASE WHEN POHeader.VendorNum <> LAG(POHeader.VendorNum, 1, POHeader.VendorNum) OVER (
        PARTITION BY POHeader.Company, PORel.Plant, PODetail.PartNum
        ORDER BY POHeader.OrderDate
    ) THEN 1 ELSE 0 END) as [Calculated_Run_Increment] 

from Erp.POHeader as [POHeader]
inner join Erp.PODetail as [PODetail] on 
	  POHeader.Company = PODetail.Company
	and  POHeader.PONum = PODetail.PONUM
inner join Erp.PORel as [PORel] on 
	  PODetail.Company = PORel.Company
	and  PODetail.PONUM = PORel.PONum
	and  PODetail.POLine = PORel.POLine
group by 
	[POHeader].[Company], 
	[PORel].[Plant], 
	[PODetail].[PartNum], 
	[POHeader].[PONum], 
	[POHeader].[OrderDate], 
	[POHeader].[VendorNum])

select  
	[Rank_Calc].[POHeader_Company] as [POHeader_Company], 
	[Rank_Calc].[PORel_Plant] as [PORel_Plant], 
	[Rank_Calc].[PODetail_PartNum] as [PODetail_PartNum], 
	[Rank_Calc].[POHeader_PONum] as [POHeader_PONum], 
	[Rank_Calc].[POHeader_OrderDate] as [POHeader_OrderDate], 
	[Rank_Calc].[POHeader_VendorNum] as [POHeader_VendorNum], 
	(sum(Rank_Calc.Calculated_Run_Increment) OVER (
        PARTITION BY Rank_Calc.POHeader_Company, Rank_Calc.PORel_Plant, Rank_Calc.PODetail_PartNum
        ORDER BY Rank_Calc.POHeader_OrderDate, Rank_Calc.POHeader_PONum
        ROWS UNBOUNDED PRECEDING
    )) as [Calculated_Run_ID] 

from  Rank_Calc  as [Rank_Calc]

This essentially generates the Run_ID I’m looking for.

Joining back to this temp table is going to be intensive, though, I’ll have to play with it more.

Best,
Kevin

I’m not at a computer right now to figure out the syntax, but why don’t you just use lag in your else to get the last value and add one?

Basically, if this row doesn’t match the last row, then one. If it does, add one to the last row. Lag will help you get both of those values.

edit. I read what you are looking for wrong, but this should still work.

If the last row vendor = this row vendor then last row run ID
else last row run ID +1

And you can use lag for all of that.

Edit (again) now I see what your issue is. You can’t refer to the same column when using lag, so you wouldn’t be able when to count up in the same column by using lag. Still poking at it.

I still think this can be done in SQL, but you probably need a CTE to do it, which is a pain, and I don’t want to take the time to figure out.

But, I did just do a quick BPM on get list post processing to loop through the rows and number the run IDs.

So, worst case, you can do it this way.

2 Likes

What makes po# 6799 a Run_ID of 2, for example? It’s unique in that set.

Everything’s unique in that set.

Crap, I realized again that I misread your logic. You want to increment the run ID every time the vendor changes. This is the logic for that.

It’s a different supplier number. 236 vs 296. (The logic is messing with my brain too)

On the same order date presumably in a hidden portion of the set? Supplier 296 gets 1 and 3 and 5, but why?

Just looking for a problem statement of how Run_ID is ordered and partitioned that isn’t phrased as a solution.

It’s the same run ID until a vendor changes. Once it changes, then it increments up. If it goes back it still increments up.

The following PO in the sequence of POs for PN 1990599 is from a separate vendor.

So, what you’re suggesting is that I use the UBAQ or a preprocessing BPM to update the incoming data to include a RUN_ID, and then continue to process it in SQL?

Or am I not completely following?

Nah, I was thinking that this was your end goal, and this was a way to get that. SQL can hand off to the BPM engine, but it can’t go back, so if you need this for more stuff, then my approach won’t work.

And now that I’m looking at your SQL closer, I get how your SQl is doing it. That’s clever.

In plain english-
Subquery column: If it’s a “new run” (by comparing Vendor Num to that last rows Vendor Num) then 1. else 0.

Then do a running sum, paritioned by part, and ordered by date on the “new run column”, so every time it gets to a new one, it will increment up one.

So what you have should work fine, I just wasn’t understanding what I was seeing.

I don’t know what you are doing with this info next, so I’m not quite understanding why you have to join to this subquery and you can’t just add to the query you have, or what performance issues you are having.