Skip to content

Commit d469c39

Browse files
authored
Support ordering grouped results based on aggregates (#481)
1 parent 48f9d02 commit d469c39

File tree

5 files changed

+150
-14
lines changed

5 files changed

+150
-14
lines changed

.changeset/lemon-queens-pump.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Add support for queries to order results based on aggregated values

packages/db/src/query/compiler/group-by.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export function processGroupBy(
130130
if (havingClauses && havingClauses.length > 0) {
131131
for (const havingClause of havingClauses) {
132132
const havingExpression = getHavingExpression(havingClause)
133-
const transformedHavingClause = transformHavingClause(
133+
const transformedHavingClause = replaceAggregatesByRefs(
134134
havingExpression,
135135
selectClause || {}
136136
)
@@ -265,7 +265,7 @@ export function processGroupBy(
265265
if (havingClauses && havingClauses.length > 0) {
266266
for (const havingClause of havingClauses) {
267267
const havingExpression = getHavingExpression(havingClause)
268-
const transformedHavingClause = transformHavingClause(
268+
const transformedHavingClause = replaceAggregatesByRefs(
269269
havingExpression,
270270
selectClause || {}
271271
)
@@ -367,11 +367,12 @@ function getAggregateFunction(aggExpr: Aggregate) {
367367
}
368368

369369
/**
370-
* Transforms a HAVING clause to replace Agg expressions with references to computed values
370+
* Transforms basic expressions and aggregates to replace Agg expressions with references to computed values
371371
*/
372-
function transformHavingClause(
372+
export function replaceAggregatesByRefs(
373373
havingExpr: BasicExpression | Aggregate,
374-
selectClause: Select
374+
selectClause: Select,
375+
resultAlias: string = `result`
375376
): BasicExpression {
376377
switch (havingExpr.type) {
377378
case `agg`: {
@@ -380,7 +381,7 @@ function transformHavingClause(
380381
for (const [alias, selectExpr] of Object.entries(selectClause)) {
381382
if (selectExpr.type === `agg` && aggregatesEqual(aggExpr, selectExpr)) {
382383
// Replace with a reference to the computed aggregate
383-
return new PropRef([`result`, alias])
384+
return new PropRef([resultAlias, alias])
384385
}
385386
}
386387
// If no matching aggregate found in SELECT, throw error
@@ -392,7 +393,7 @@ function transformHavingClause(
392393
// Transform function arguments recursively
393394
const transformedArgs = funcExpr.args.map(
394395
(arg: BasicExpression | Aggregate) =>
395-
transformHavingClause(arg, selectClause)
396+
replaceAggregatesByRefs(arg, selectClause)
396397
)
397398
return new Func(funcExpr.name, transformedArgs)
398399
}
@@ -404,7 +405,7 @@ function transformHavingClause(
404405
const alias = refExpr.path[0]!
405406
if (selectClause[alias]) {
406407
// This is a reference to a SELECT alias, convert to result.alias
407-
return new PropRef([`result`, alias])
408+
return new PropRef([resultAlias, alias])
408409
}
409410
}
410411
// Return as-is for other refs

packages/db/src/query/compiler/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export function compileQuery(
259259
rawQuery,
260260
pipeline,
261261
query.orderBy,
262+
query.select || {},
262263
collections[mainCollectionId]!,
263264
optimizableOrderByCollections,
264265
query.limit,

packages/db/src/query/compiler/order-by.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { PropRef } from "../ir.js"
44
import { ensureIndexForField } from "../../indexes/auto-index.js"
55
import { findIndexForField } from "../../utils/index-optimization.js"
66
import { compileExpression } from "./evaluators.js"
7+
import { replaceAggregatesByRefs } from "./group-by.js"
78
import { followRef } from "./index.js"
89
import type { CompiledSingleRowExpression } from "./evaluators.js"
9-
import type { OrderByClause, QueryIR } from "../ir.js"
10+
import type { OrderByClause, QueryIR, Select } from "../ir.js"
1011
import type { NamespacedAndKeyedStream, NamespacedRow } from "../../types.js"
1112
import type { IStreamBuilder, KeyValue } from "@tanstack/db-ivm"
1213
import type { BaseIndex } from "../../indexes/base-index.js"
@@ -33,16 +34,24 @@ export function processOrderBy(
3334
rawQuery: QueryIR,
3435
pipeline: NamespacedAndKeyedStream,
3536
orderByClause: Array<OrderByClause>,
37+
selectClause: Select,
3638
collection: Collection,
3739
optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
3840
limit?: number,
3941
offset?: number
4042
): IStreamBuilder<KeyValue<unknown, [NamespacedRow, string]>> {
4143
// Pre-compile all order by expressions
42-
const compiledOrderBy = orderByClause.map((clause) => ({
43-
compiledExpression: compileExpression(clause.expression),
44-
compareOptions: clause.compareOptions,
45-
}))
44+
const compiledOrderBy = orderByClause.map((clause) => {
45+
const clauseWithoutAggregates = replaceAggregatesByRefs(
46+
clause.expression,
47+
selectClause,
48+
`__select_results`
49+
)
50+
return {
51+
compiledExpression: compileExpression(clauseWithoutAggregates),
52+
compareOptions: clause.compareOptions,
53+
}
54+
})
4655

4756
// Create a value extractor function for the orderBy operator
4857
const valueExtractor = (row: NamespacedRow & { __select_results?: any }) => {

packages/db/tests/query/order-by.test.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"
22
import { createCollection } from "../../src/collection.js"
33
import { mockSyncCollectionOptions } from "../utls.js"
44
import { createLiveQueryCollection } from "../../src/query/live-query-collection.js"
5-
import { eq, gt } from "../../src/query/builder/functions.js"
5+
import { eq, gt, max } from "../../src/query/builder/functions.js"
66

77
type Person = {
88
id: string
@@ -711,6 +711,126 @@ function createOrderByTests(autoIndex: `off` | `eager`): void {
711711
})
712712
})
713713

714+
describe(`OrderBy with GroupBy`, () => {
715+
it(`should order grouped results correctly`, async () => {
716+
type VehicleDocument = {
717+
id: number
718+
vin: string
719+
updatedAt: number
720+
}
721+
722+
const vehicleDocumentsData = [
723+
{ id: 1, vin: `1`, updatedAt: new Date(`2023-01-01`).getTime() },
724+
{ id: 2, vin: `2`, updatedAt: new Date(`2023-01-02`).getTime() },
725+
{ id: 3, vin: `1`, updatedAt: new Date(`2023-01-05`).getTime() },
726+
]
727+
728+
const vehicleDocumentCollection = createCollection(
729+
mockSyncCollectionOptions<VehicleDocument>({
730+
id: `vehicle-document-collection`,
731+
getKey: (doc) => doc.id,
732+
autoIndex: `eager`,
733+
initialData: vehicleDocumentsData,
734+
})
735+
)
736+
737+
const liveQuery = createLiveQueryCollection({
738+
query: (q) =>
739+
q
740+
.from({ vehicleDocuments: vehicleDocumentCollection })
741+
.groupBy((q) => q.vehicleDocuments.vin)
742+
.orderBy((q) => q.vehicleDocuments.vin, `asc`)
743+
.select((q) => ({
744+
vin: q.vehicleDocuments.vin,
745+
})),
746+
startSync: true,
747+
})
748+
749+
await liveQuery.stateWhenReady()
750+
expect(liveQuery.toArray).toEqual([{ vin: `1` }, { vin: `2` }])
751+
752+
// Insert a vehicle document
753+
vehicleDocumentCollection.utils.begin()
754+
vehicleDocumentCollection.utils.write({
755+
type: `insert`,
756+
value: {
757+
id: 4,
758+
vin: `3`,
759+
updatedAt: new Date(`2023-01-03`).getTime(),
760+
},
761+
})
762+
vehicleDocumentCollection.utils.commit()
763+
764+
expect(liveQuery.toArray).toEqual([
765+
{ vin: `1` },
766+
{ vin: `2` },
767+
{ vin: `3` },
768+
])
769+
})
770+
771+
it(`should order groups based on aggregates correctly`, async () => {
772+
type VehicleDocument = {
773+
id: number
774+
vin: string
775+
updatedAt: number
776+
}
777+
778+
const vehicleDocumentsData = [
779+
{ id: 1, vin: `1`, updatedAt: new Date(`2023-01-01`).getTime() },
780+
{ id: 2, vin: `2`, updatedAt: new Date(`2023-01-02`).getTime() },
781+
{ id: 3, vin: `1`, updatedAt: new Date(`2023-01-05`).getTime() },
782+
]
783+
784+
const vehicleDocumentCollection = createCollection(
785+
mockSyncCollectionOptions<VehicleDocument>({
786+
id: `vehicle-document-collection`,
787+
getKey: (doc) => doc.id,
788+
autoIndex: `eager`,
789+
initialData: vehicleDocumentsData,
790+
})
791+
)
792+
793+
const liveQuery = createLiveQueryCollection({
794+
query: (q) =>
795+
q
796+
.from({ vehicleDocuments: vehicleDocumentCollection })
797+
.groupBy((q) => q.vehicleDocuments.vin)
798+
.orderBy((q) => max(q.vehicleDocuments.updatedAt), `desc`)
799+
.select((q) => ({
800+
vin: q.vehicleDocuments.vin,
801+
updatedAt: max(q.vehicleDocuments.updatedAt),
802+
}))
803+
.offset(0)
804+
.limit(10),
805+
startSync: true,
806+
})
807+
808+
await liveQuery.stateWhenReady()
809+
expect(liveQuery.toArray).toEqual([
810+
{ vin: `1`, updatedAt: new Date(`2023-01-05`).getTime() },
811+
{ vin: `2`, updatedAt: new Date(`2023-01-02`).getTime() },
812+
])
813+
814+
// Insert a vehicle document
815+
vehicleDocumentCollection.utils.begin()
816+
vehicleDocumentCollection.utils.write({
817+
type: `insert`,
818+
value: {
819+
id: 4,
820+
vin: `3`,
821+
updatedAt: new Date(`2023-01-03`).getTime(),
822+
},
823+
})
824+
vehicleDocumentCollection.utils.commit()
825+
826+
expect(liveQuery.toArray).toEqual([
827+
{ vin: `1`, updatedAt: new Date(`2023-01-05`).getTime() },
828+
{ vin: `3`, updatedAt: new Date(`2023-01-03`).getTime() },
829+
{ vin: `2`, updatedAt: new Date(`2023-01-02`).getTime() },
830+
])
831+
})
832+
})
833+
714834
describe(`OrderBy with Where Clauses`, () => {
715835
it(`orders filtered results correctly`, async () => {
716836
const collection = createLiveQueryCollection((q) =>

0 commit comments

Comments
 (0)