Skip to content

Commit 0455e27

Browse files
authored
feat: select callback (#154)
1 parent 856be72 commit 0455e27

7 files changed

Lines changed: 346 additions & 5 deletions

File tree

.changeset/full-steaks-wait.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+
the live query select clause can now be a callback function that receives each row as a context object returning a new object with the selected fields. This also allows the for the callback to make more expressive changes to the returned data.

packages/db/src/query/query-builder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,9 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
198198
/**
199199
* Specify what columns to select.
200200
* Overwrites any previous select clause.
201+
* Also supports callback functions that receive the row context and return selected data.
201202
*
202-
* @param selects The columns to select
203+
* @param selects The columns to select (can include callbacks)
203204
* @returns A new QueryBuilder with the select clause set
204205
*/
205206
select<TSelects extends Array<Select<TContext>>>(

packages/db/src/query/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ export type Select<TContext extends Context = Context> =
191191
| AggregateFunctionCall<TContext>
192192
}
193193
| WildcardReferenceString<TContext>
194+
| SelectCallback<TContext>
195+
196+
export type SelectCallback<TContext extends Context = Context> = (
197+
context: TContext extends { schema: infer S } ? S : any
198+
) => any
194199

195200
export type As<TContext extends Context = Context> = string
196201

packages/db/src/query/select.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
evaluateOperandOnNamespacedRow,
44
extractValueFromNamespacedRow,
55
} from "./extractors"
6-
import type { ConditionOperand, Query } from "./schema"
6+
import type { ConditionOperand, Query, SelectCallback } from "./schema"
77
import type { KeyedStream, NamespacedAndKeyedStream } from "../types"
88

99
export function processSelect(
@@ -31,6 +31,29 @@ export function processSelect(
3131
}
3232

3333
for (const item of query.select) {
34+
// Handle callback functions
35+
if (typeof item === `function`) {
36+
const callback = item as SelectCallback
37+
const callbackResult = callback(namespacedRow)
38+
39+
// If the callback returns an object, merge its properties into the result
40+
if (
41+
callbackResult &&
42+
typeof callbackResult === `object` &&
43+
!Array.isArray(callbackResult)
44+
) {
45+
Object.assign(result, callbackResult)
46+
} else {
47+
// If the callback returns a primitive value, we can't merge it
48+
// This would need a specific key, but since we don't have one, we'll skip it
49+
// In practice, select callbacks should return objects with keys
50+
console.warn(
51+
`SelectCallback returned a non-object value. SelectCallbacks should return objects with key-value pairs.`
52+
)
53+
}
54+
continue
55+
}
56+
3457
if (typeof item === `string`) {
3558
// Handle wildcard select - all columns from all tables
3659
if ((item as string) === `@*`) {

packages/db/tests/query/conditions.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,5 +648,124 @@ describe(`Query`, () => {
648648
expect(result.price).toBeLessThan(1000)
649649
})
650650
})
651+
652+
test(`select callback function`, () => {
653+
const query: Query<Context> = {
654+
select: [
655+
({ products }) => ({
656+
displayName: `${products.name} (${products.category})`,
657+
priceLevel: products.price > 500 ? `expensive` : `affordable`,
658+
availability: products.inStock ? `in-stock` : `out-of-stock`,
659+
}),
660+
],
661+
from: `products`,
662+
where: [[`@id`, `<=`, 3]], // First three products
663+
}
664+
665+
const graph = new D2({ initialFrontier: v([0, 0]) })
666+
const input = graph.newInput<[number, Product]>()
667+
const pipeline = compileQueryPipeline(query, { [query.from]: input })
668+
669+
const messages: Array<Message<any>> = []
670+
pipeline.pipe(
671+
output((message) => {
672+
messages.push(message)
673+
})
674+
)
675+
676+
graph.finalize()
677+
678+
input.sendData(
679+
v([1, 0]),
680+
new MultiSet(
681+
sampleProducts.map((product) => [[product.id, product], 1])
682+
)
683+
)
684+
input.sendFrontier(new Antichain([v([1, 0])]))
685+
686+
graph.run()
687+
688+
// Check the transformed results
689+
const dataMessages = messages.filter((m) => m.type === MessageType.DATA)
690+
const results = dataMessages[0]!.data.collection
691+
.getInner()
692+
.map(([data]) => data)
693+
694+
expect(results).toHaveLength(3) // First three products
695+
696+
// Verify the callback transformation
697+
results.forEach(([_key, result]) => {
698+
expect(result).toHaveProperty(`displayName`)
699+
expect(result).toHaveProperty(`priceLevel`)
700+
expect(result).toHaveProperty(`availability`)
701+
expect(typeof result.displayName).toBe(`string`)
702+
expect([`expensive`, `affordable`]).toContain(result.priceLevel)
703+
expect([`in-stock`, `out-of-stock`]).toContain(result.availability)
704+
})
705+
706+
// Check specific transformations for known products
707+
const laptop = results.find(([_key, r]) =>
708+
r.displayName.includes(`Laptop`)
709+
)
710+
expect(laptop).toBeDefined()
711+
expect(laptop![1].priceLevel).toBe(`expensive`)
712+
expect(laptop![1].availability).toBe(`in-stock`)
713+
})
714+
715+
test(`mixed select: traditional columns and callback`, () => {
716+
const query: Query<Context> = {
717+
select: [
718+
`@id`,
719+
`@name`,
720+
({ products }) => ({
721+
computedField: `${products.name}_computed`,
722+
doublePrice: products.price * 2,
723+
}),
724+
],
725+
from: `products`,
726+
where: [[`@id`, `=`, 1]], // Just the laptop
727+
}
728+
729+
const graph = new D2({ initialFrontier: v([0, 0]) })
730+
const input = graph.newInput<[number, Product]>()
731+
const pipeline = compileQueryPipeline(query, { [query.from]: input })
732+
733+
const messages: Array<Message<any>> = []
734+
pipeline.pipe(
735+
output((message) => {
736+
messages.push(message)
737+
})
738+
)
739+
740+
graph.finalize()
741+
742+
input.sendData(
743+
v([1, 0]),
744+
new MultiSet(
745+
sampleProducts.map((product) => [[product.id, product], 1])
746+
)
747+
)
748+
input.sendFrontier(new Antichain([v([1, 0])]))
749+
750+
graph.run()
751+
752+
// Check the mixed results
753+
const dataMessages = messages.filter((m) => m.type === MessageType.DATA)
754+
const results = dataMessages[0]!.data.collection
755+
.getInner()
756+
.map(([data]) => data)
757+
758+
expect(results).toHaveLength(1)
759+
760+
const [_key, result] = results[0]!
761+
762+
// Check traditional columns
763+
expect(result.id).toBe(1)
764+
expect(result.name).toBe(`Laptop`)
765+
766+
// Check callback-generated fields
767+
expect(result.computedField).toBe(`Laptop_computed`)
768+
expect(result.doublePrice).toBe(2400) // 1200 * 2
769+
})
651770
})
652771
})

packages/db/tests/query/query-builder/select.test.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ describe(`QueryBuilder.select`, () => {
3939

4040
const builtQuery = query._query
4141
expect(builtQuery.select).toHaveLength(2)
42-
expect(builtQuery.select[0]).toBe(`@id`)
43-
expect(builtQuery.select[1]).toHaveProperty(`employee_name`, `@name`)
42+
expect(builtQuery.select![0]).toBe(`@id`)
43+
expect(builtQuery.select![1]).toHaveProperty(`employee_name`, `@name`)
4444
})
4545

4646
it(`handles function calls`, () => {
@@ -52,7 +52,7 @@ describe(`QueryBuilder.select`, () => {
5252

5353
const builtQuery = query._query
5454
expect(builtQuery.select).toHaveLength(2)
55-
expect(builtQuery.select[1]).toHaveProperty(`upper_name`)
55+
expect(builtQuery.select![1]).toHaveProperty(`upper_name`)
5656
})
5757

5858
it(`overrides previous select calls`, () => {
@@ -85,4 +85,59 @@ describe(`QueryBuilder.select`, () => {
8585
const builtQuery = query._query
8686
expect(builtQuery.select).toEqual([`@id`, `@name`])
8787
})
88+
89+
it(`supports callback functions`, () => {
90+
const callback = ({ employees }: any) => ({
91+
fullInfo: `${employees.name} (ID: ${employees.id})`,
92+
salaryLevel: employees.salary > 50000 ? `high` : `low`,
93+
})
94+
95+
const query = queryBuilder<TestSchema>().from(`employees`).select(callback)
96+
97+
const builtQuery = query._query
98+
expect(builtQuery.select).toHaveLength(1)
99+
expect(builtQuery.select).toBeDefined()
100+
expect(typeof builtQuery.select![0]).toBe(`function`)
101+
expect(builtQuery.select![0]).toBe(callback)
102+
})
103+
104+
it(`combines callback with traditional selects`, () => {
105+
const callback = ({ employees }: any) => ({
106+
computed: employees.salary * 1.1,
107+
})
108+
109+
const query = queryBuilder<TestSchema>()
110+
.from(`employees`)
111+
.select(`@id`, `@name`, callback, { department_name: `@employees.name` })
112+
113+
const builtQuery = query._query
114+
expect(builtQuery.select).toHaveLength(4)
115+
expect(builtQuery.select).toBeDefined()
116+
expect(builtQuery.select![0]).toBe(`@id`)
117+
expect(builtQuery.select![1]).toBe(`@name`)
118+
expect(typeof builtQuery.select![2]).toBe(`function`)
119+
expect(builtQuery.select![3]).toHaveProperty(`department_name`)
120+
})
121+
122+
it(`supports multiple callback functions`, () => {
123+
const callback1 = ({ employees }: any) => ({
124+
displayName: employees.name.toUpperCase(),
125+
})
126+
const callback2 = ({ employees }: any) => ({
127+
isActive: employees.active,
128+
experience: new Date().getFullYear() - 2020,
129+
})
130+
131+
const query = queryBuilder<TestSchema>()
132+
.from(`employees`)
133+
.select(callback1, callback2)
134+
135+
const builtQuery = query._query
136+
expect(builtQuery.select).toHaveLength(2)
137+
expect(builtQuery.select).toBeDefined()
138+
expect(typeof builtQuery.select![0]).toBe(`function`)
139+
expect(typeof builtQuery.select![1]).toBe(`function`)
140+
expect(builtQuery.select![0]).toBe(callback1)
141+
expect(builtQuery.select![1]).toBe(callback2)
142+
})
88143
})

0 commit comments

Comments
 (0)