@tanstack/db
Version:
A reactive client store for building super fast apps on sync
140 lines (125 loc) • 4.08 kB
text/typescript
import { groupBy, groupByOperators } from "@electric-sql/d2mini"
import {
evaluateOperandOnNamespacedRow,
extractValueFromNamespacedRow,
} from "./extractors"
import { isAggregateFunctionCall } from "./utils"
import type { ConditionOperand, FunctionCall, Query } from "./schema"
import type { NamespacedAndKeyedStream } from "../types.js"
const { sum, count, avg, min, max, median, mode } = groupByOperators
/**
* Process the groupBy clause in a D2QL query
*/
export function processGroupBy(
pipeline: NamespacedAndKeyedStream,
query: Query,
mainTableAlias: string
) {
// Normalize groupBy to an array of column references
const groupByColumns = Array.isArray(query.groupBy)
? query.groupBy
: [query.groupBy]
// Create a key extractor function for the groupBy operator
const keyExtractor = ([_oldKey, namespacedRow]: [
string,
Record<string, unknown>,
]) => {
const key: Record<string, unknown> = {}
// Extract each groupBy column value
for (const column of groupByColumns) {
if (typeof column === `string` && (column as string).startsWith(`@`)) {
const columnRef = (column as string).substring(1)
const columnName = columnRef.includes(`.`)
? columnRef.split(`.`)[1]
: columnRef
key[columnName!] = extractValueFromNamespacedRow(
namespacedRow,
columnRef,
mainTableAlias
)
}
}
return key
}
// Create aggregate functions for any aggregated columns in the SELECT clause
const aggregates: Record<string, any> = {}
if (!query.select) {
throw new Error(`SELECT clause is required for GROUP BY`)
}
// Scan the SELECT clause for aggregate functions
for (const item of query.select) {
if (typeof item === `object`) {
for (const [alias, expr] of Object.entries(item)) {
if (typeof expr === `object` && isAggregateFunctionCall(expr)) {
// Get the function name (the only key in the object)
const functionName = Object.keys(expr)[0]
// Get the column reference or expression to aggregate
const columnRef = (expr as FunctionCall)[
functionName as keyof FunctionCall
]
// Add the aggregate function to our aggregates object
aggregates[alias] = getAggregateFunction(
functionName!,
columnRef,
mainTableAlias
)
}
}
}
}
// Apply the groupBy operator if we have any aggregates
if (Object.keys(aggregates).length > 0) {
pipeline = pipeline.pipe(groupBy(keyExtractor, aggregates))
}
return pipeline
}
/**
* Helper function to get an aggregate function based on the function name
*/
export function getAggregateFunction(
functionName: string,
columnRef: string | ConditionOperand,
mainTableAlias: string
) {
// Create a value extractor function for the column to aggregate
const valueExtractor = ([_oldKey, namespacedRow]: [
string,
Record<string, unknown>,
]) => {
let value: unknown
if (typeof columnRef === `string` && columnRef.startsWith(`@`)) {
value = extractValueFromNamespacedRow(
namespacedRow,
columnRef.substring(1),
mainTableAlias
)
} else {
value = evaluateOperandOnNamespacedRow(
namespacedRow,
columnRef as ConditionOperand,
mainTableAlias
)
}
// Ensure we return a number for aggregate functions
return typeof value === `number` ? value : 0
}
// Return the appropriate aggregate function
switch (functionName.toUpperCase()) {
case `SUM`:
return sum(valueExtractor)
case `COUNT`:
return count() // count() doesn't need a value extractor
case `AVG`:
return avg(valueExtractor)
case `MIN`:
return min(valueExtractor)
case `MAX`:
return max(valueExtractor)
case `MEDIAN`:
return median(valueExtractor)
case `MODE`:
return mode(valueExtractor)
default:
throw new Error(`Unsupported aggregate function: ${functionName}`)
}
}