[Guide] Dynamic pivot tables using Object Table widget + Typescript

On many scenarios, pivot tables provide a concise way to summarize information in user-facing applications and dashboards. If you’ve ever wondered if there’s a more flexible way to create a pivot table in Workshop that:

  1. Can return a dynamic set of columns based on an input
  2. Inherits some of Foundry’s native quality-of-life features such as ontology formatting
  3. Lets you define custom aggregation logic for each column

Then this guide might come in handy! We’ll build a dynamic pivot table using an Object Table as the scaffolding for returning aggregation logic in columns using Typescript.

The use case (for context)

Consider a portfolio of companies under the ownership of a private equity (Object type: Company). Some companies (level: L1) can own several other smaller companies (level: L2). We track this hierarchy using a property with API “parentCompanyId”.

These companies are evaluated on a specific set of functions, such that:

  • If an L1 company has no L2 companies under it, the L1 company is evaluated directly.
  • If an L1 company has at least one L2 company under it, the results should reflect a weighted aggregation logic defined bottom-up.

For this latter case, we needed to create a view such that:

  • Each row is related to one of the dimensions being evaluated
  • The first column is an ordered list of all of these dimensions.
  • Each additional column header is a company name, with the first column representing the L1 company (e.g., the weighted aggregation total) and then the results from L2 companies under it, if any.

Pivot table widget limitations

The pivot table widget currently can’t support all the requirements above. Mainly, one column with a custom aggregation (L1) and the direct results in the others (L2). Moreover, the ordering of the columns is not guaranteed, as one can pre-define a custom order, but that ordering will not be preserved when switching the L1 company for another one that has a different set of L2s under it. The solution below is our current implementation of it, and it works as intended. Leaving the details here for anyone who might be facing similar limitations with the pivot table widget, as this can be adapted/generalized quite easily.

The solution

We begin the code below by defining a static interface that must contain all pre-defined company names. We use the “?” after each key to denote that values are not required for all keys when implementing it. This interface needs to be declared before the class export in your .ts file:

import {Function, Filters, Double, FunctionsMap, ThreeDimensionalAggregation} from "@foundry/functions-api"; 
import {Objects, ObjectSet, Company, YourObject} from "@foundry/ontology-api"; 


interface Scores { 
    CompanyName1?: Double, 
    CompanyName2?: Double, 
    … 
    “Company Name with spaces”?: Double, 
    CompanyNameN?: Double 
} 


export class YourFunctions { 
…
} 

Now, inside the export class YourFunctions, we define a function that will handle the aggregation logic and return a dynamic FunctionsMap<YourObject, Scores>, thus making use of the interface we just defined. The input will be the Company object representing the L1 company that owns several L2 companies. We’ll get all required companies using the parentCompanyId (split into three due to formatting limitations).

@Function() 
public async pivotYourObjectsResults(companies: Company[]): Promise<FunctionsMap<YourObject, Scores>> { 
	let companySet = new Set(companies.map(company => company.companyId)); 
	const resultMap = new FunctionsMap<YourObject, Scores>(); 
	const L1CompanyName = new String(...companies.map(comp => comp.CompanyName)); 
	const L1CompanyResults = await this.get_agg_child_YourObjects(companySet) 

    const YourObject = await Objects 
	    .search() 
	    .YourObject() 
	    .filter(obj => obj.companyId.exactMatch(...companySet)) 
	    .allAsync();
    
    /*Extract distinct dimensionId values 
	Create a Map to store dimensionId keys with starting (null) values 
	This will prevent errors if some score is undefined/empty */ 
    const YourObjectMap = YourObject.reduce((acc, item) => { 
	    acc.set(item.dimensionId!, null); 
	    return acc; 
	    }, new Map<string, null | Scores | undefined>());
    // Look for companies and YourObjects 
	const allCompanies = await Objects 
	    .search() 
	    .company() 
	    .filter(comp => Filters.or(comp.companyId.exactMatch(...companySet), 
		        comp.parentCompanyId.exactMatch(...companySet))) 
	    .allAsync() 

    const YourObjects = await Objects
        .search()
        .YourObject() 
	    .filter(obj => obj.companyId.exactMatch(...allCompanies.map(company => company.companyId))) 
	    .groupBy(obj => obj.dimensionId.exactValues({maxBuckets: 22})) 
	    .segmentBy(obj => obj.companyName.exactValues({maxBuckets: 100})) 
	    .max(obj => obj.score)
    YourObjects.buckets?.forEach(outerBucket => { 
		const outerKey = outerBucket.key; 
		let dimension_scores : Scores = {}; 
		outerBucket.value?.forEach(innerBucket => { 
		    const innerKey = innerBucket.key; 
		    dimension_scores[innerKey as keyof Scores] = Number(innerBucket.value !== undefined ? innerBucket.value.toFixed(2) : undefined) 
		    }); 
		    dimension_scores[L1CompanyName as keyof Scores] = L1CompanyResults.get(outerKey) !== undefined ? Number(L1CompanyResults.get(outerKey)?.toFixed(2)) : undefined 
		    YourObjectMap.set(outerKey, dimension_scores) 
	    }); 
	// Return results 
	YourObject.forEach(obj =>{ 
		resultMap.set(obj, YourObjectMap.get(obj.dimensionId!)!) 
	}); 
	return resultMap; 
}

By calling and grouping the YourObjects by the dimensionIds and then by the company names, for each outerBucket (each dimensionId, or row in our resulting table), we then create a Scores instance, which we fill with the statement dimension_scores[innerKey as keyof Scores] = (…) inside the loop. Finally, we set the results for the L1 company with the statement: dimension_scores[L1CompanyName as keyof Scores] = (…).

You might have noticed that the L1 company aggregation is done through the call:

const L1CompanyResults = await this.get_agg_child_YourObjects(companySet) 

In this case, get_agg_child_YourObjects is a private function that hosts the custom aggregation logic. In case you’re interested in a similar example, this is a simplified version of it (note that in this example, Company has a one-to-many link to YourObject (in this case, via companyId):

private async get_agg_child_YourObjects(companySet: Set<string>): Promise<Map<string, Double | undefined>>{ 
    let childCompaniesYourObjectPercentageMap = new Map<string, Double | undefined>(); 
    let childCompaniesWeightedYourObjectMap = new Map<string, Double | undefined>(); 
    // Get aggregation weight percentages (number between 0 and 1) by parentCompany, CompanyId 
    const childCompaniesYourObjectPercentage = await Objects 
        .search() 
        .company() 
        .filter(comp => comp.parentCompanyId.exactMatch(...companySet)) 
        .groupBy(comp => comp.parentCompanyId.exactValues({maxBuckets: 1000})) 
        .segmentBy(comp => comp.companyId.exactValues({maxBuckets: 1000})) 
        .max(comp => comp.aggregation_weight)
    childCompaniesYourObjectPercentage.buckets.forEach(outerBucket => { 
        outerBucket.value.forEach(innerBucket => { 
            childCompaniesYourObjectPercentageMap.set(innerBucket.key, innerBucket.value); 
            }); 
    }); 

    // Get child companies YourObjects 
    const childCompaniesYourObject = await Objects 
        .search() 
        .company() 
        .filter(comp => comp.parentCompanyId.exactMatch(...companySet)) 
        .searchAroundYourObject() 
        .groupBy(obj => obj.companyId.exactValues({maxBuckets: 1000})) 
        .segmentBy(obj => obj.dimensionId.exactValues({maxBuckets: 22})) 
        .average(obj => obj.score) 
    childCompaniesYourObject.buckets.forEach(outerBucket =>  { 
            let weight = childCompaniesYourObjectPercentageMap.get(outerBucket.key); 
            outerBucket.value.forEach(innerBucket => { 
                let dimensionId: string = innerBucket.key; 
                if (!childCompaniesWeightedYourObjectMap.has(dimensionId)) { 
                    childCompaniesWeightedYourObjectMap.set(dimensionId, weight!*innerBucket.value); 
                } else { 
                    let OldValue = childCompaniesWeightedYourObjectMap.get(dimensionId)!; 
                    let newValue: Double | undefined = OldValue + weight!*innerBucket.value; 
                    childCompaniesWeightedYourObjectMap.set(dimensionId, newValue)}; 
                }); 
            });            
    return childCompaniesWeightedYourObjectMap; 
}; 

Finally, we need one more piece of code that will come in handy for dynamically selecting which properties of the interface Scores we’ll be displaying in workshop based on the same input that we’re providing the previous function. Note that by ordering first by level and then by CompanyName, we ensure the first column will be the L1 company.

@Function() 
public async ArrayofYourObjectsForPivotVisibility(companies: Company[]): Promise<Array<string>> { 
    // Look for companies and YourObjects 
    const allCompanies = await Objects 
        .search() 
        .company() 
        .filter(comp => Filteobj.or(comp.companyId.exactMatch(...companies.map(company => company.companyId)), 
                comp.parentCompanyId.exactMatch(...companies.map(company => company.companyId)))) 
        .orderBy(comp => comp.level.asc()) 
        .orderBy(comp => comp.CompanyName.asc()) 
        .takeAsync(100) 

    const newElement: string = 'property_dimension_id' 
    const keysArray: Array<string> = Array.from(allCompanies.map(comp => comp.companyName)) 
        .map(element => "derived_column." + element); 
        keysArray.unshift(newElement)

    return keysArray; 
}

Final steps: configuring the function-backed columns in workshop.

It’s time to put all of these pieces together in workshop: create an Object Table and feed it with a placeholder YourObject ObjectSet. In this object set, each object is a dimension being evaluated. We use as a basis the YourObjects linked to the L1 Company. We won’t display properties or aggregations directly, but rather setting our custom aggregation function. Set an object set holding the L1 company only as input for the function pivotYourObjectsByDimension, and even if the interface keys were set to optional with the ? and only some of them return values, workshop will fill the full list by default (you’ll have many NULL columns, column names redacted here, each being a company name, as shown below).

To remove unwanted columns (e.g., all companies not in scope when selecting the corresponding L1), scroll down to the configuration of the object table widget, enable the “Variable-backed column visibility”:

As input for the “Visible Columns”, set the output array from the function ArrayofYourObjectsForPivotVisibility. As input for the function, you’ll provide the Company object with the user selection for L1 company.

The final result will loke something like this:

Since the underlying widget is an Object Table widget, Dimension ID is inheriting the color formatting from the ontology, and we have the totals from the aggregations at L1 level as the first numeric column, and then all relevant L2 results, even for companies for which we don’t have results yet. This will refresh automatically using the interface we defined when the user selects another L1s with a different set of L2s.

Conclusion and potential improvements

This process highlights an alternative to using the pivot table widget for enhanced flexibility. Not only that, but it can also be extended for returning non-numeric columns as well on workflows that require custom / complex logic. Defining dynamic interfaces is not yet supported, as FunctionsMap requires that all interface properties are explicitly declared beforehand. A nice improvement/addition would be to have the possibility of declaring dynamic interfaces, such as:

Interface Scores {
    [key: string]?: Double,
}

thus removing the requirement of having to pre-define all the keys.

Hope this helps someone who has faced the limitations of the pivot table widget before! Do you agree with this approach?

Any improvements / alternatives you’d like to share? I’d love to read your comments below!

7 Likes

Hi @sboari. Big kudos! This really is a creative workaround and we‘ll give it a try! However, also bit of a pity that we have to become so creative on pivot tables… Pivot tables are a great way to condense a lot of information into small real estate. And we often times use them to let the users navigate to the scope „on which they want to perform an action“. So fingers crossed that the enhancements rumouring here in the forum for „early 2025“ will come soon and alleviate some pains with pivots :sweat_smile: (We‘d greatly appreciate some details if some has intel?)

One question on your solution: Do you have an overview of how much compute your implementation costs per run? Depending on the pricing model or your foundry instance - I can imagine that this will produce quite significant costs (in case of compute based costing). We lately had some „surprises“ how much compute just a few function-backed columns can produce compared to simple aggregates through linked columns.

2 Likes

I agree with Phil-M, Pivot tables are vital for financial reporting. In one of our projects, we’re replacing hundreds of SAP-BW reports with Workshop, focusing on pivot tables. However, we underuse the ontology and linked Object types, relying on workarounds with custom Object types. Future updates will hopefully address these gaps.

Sboari thanks for the guide because we can use it regarding our earlier question that you solved of the derived properties, so thanks again.

1 Like

Thanks for your comments, @Phil-M, @LexPalmakers! Hope you found it useful and can adapt something like this to your workstreams.

Regarding:

We haven’t monitored costs for this function yet, so I can’t provide specific comments on costs at this point. In terms of performance, the compute time for this function is roughly ~1 sec per run.

From experience on developing functions using similar aggregation patterns (async API calls / mapping results / final loop to put it together), we haven’t noticed a significant bump on costs before and after including them in our workshop apps, even for cases where functions are dealing with much larger input object sets.

Just out of curiosity, for the increase in costs you mention, can you comment on whether you were running multiple derivate columns as independent functions or as a single function? How much compute time was it taking per run?

Best,