-
Notifications
You must be signed in to change notification settings - Fork 257
Expand file tree
/
Copy pathutils.ts
More file actions
479 lines (430 loc) · 14.7 KB
/
Copy pathutils.ts
File metadata and controls
479 lines (430 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
import { MultiSet, serializeValue } from '@tanstack/db-ivm'
import { UnsupportedRootScalarSelectError } from '../../errors.js'
import { normalizeOrderByPaths } from '../compiler/expressions.js'
import { buildQuery, getQueryIR } from '../builder/index.js'
import { ConditionalSelect, IncludesSubquery, isExpressionLike } from '../ir.js'
import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm'
import type { Collection } from '../../collection/index.js'
import type { ChangeMessage } from '../../types.js'
import type { InitialQueryBuilder, QueryBuilder } from '../builder/index.js'
import type { Context } from '../builder/types.js'
import type { OrderBy, QueryIR } from '../ir.js'
import type { OrderByOptimizationInfo } from '../compiler/order-by.js'
/**
* Helper function to extract collections from a compiled query.
* Traverses the query IR to find all collection references.
* Maps collections by their ID (not alias) as expected by the compiler.
*/
export function extractCollectionsFromQuery(
query: any,
): Record<string, Collection<any, any, any>> {
const collections: Record<string, any> = {}
// Helper function to recursively extract collections from a query or source
function extractFromSource(source: any) {
if (source.type === `collectionRef`) {
collections[source.collection.id] = source.collection
} else if (source.type === `queryRef`) {
// Recursively extract from subquery
extractFromQuery(source.query)
} else if (source.type === `unionFrom`) {
for (const childSource of source.sources) {
extractFromSource(childSource)
}
} else if (source.type === `unionAll`) {
for (const branch of source.queries) {
extractFromQuery(branch)
}
}
}
// Helper function to recursively extract collections from a query
function extractFromQuery(q: any) {
// Extract from FROM clause
if (q.from) {
extractFromSource(q.from)
}
// Extract from JOIN clauses
if (q.join && Array.isArray(q.join)) {
for (const joinClause of q.join) {
if (joinClause.from) {
extractFromSource(joinClause.from)
}
}
}
// Extract from SELECT (for IncludesSubquery)
if (q.select) {
extractFromSelect(q.select)
}
}
function extractFromSelect(select: any) {
for (const [key, value] of Object.entries(select)) {
if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) {
continue
}
if (value instanceof IncludesSubquery) {
extractFromQuery(value.query)
} else if (value instanceof ConditionalSelect) {
extractFromConditionalSelect(value)
} else if (isNestedSelectObject(value)) {
extractFromSelect(value)
}
}
}
function extractFromConditionalSelect(conditional: ConditionalSelect) {
for (const branch of conditional.branches) {
extractFromSelectValue(branch.value)
}
if (conditional.defaultValue !== undefined) {
extractFromSelectValue(conditional.defaultValue)
}
}
function extractFromSelectValue(value: any) {
if (value instanceof IncludesSubquery) {
extractFromQuery(value.query)
} else if (value instanceof ConditionalSelect) {
extractFromConditionalSelect(value)
} else if (isNestedSelectObject(value)) {
extractFromSelect(value)
}
}
// Start extraction from the root query
extractFromQuery(query)
return collections
}
/**
* Helper function to extract the collection that is referenced in the query's FROM clause.
* The FROM clause may refer directly to a collection or indirectly to a subquery.
*/
export function extractCollectionFromSource(
query: any,
): Collection<any, any, any> {
const from = query.from
if (from.type === `collectionRef`) {
return from.collection
} else if (from.type === `queryRef`) {
// Recursively extract from subquery
return extractCollectionFromSource(from.query)
} else if (from.type === `unionFrom`) {
return extractCollectionFromSource({ from: from.sources[0] })
} else if (from.type === `unionAll`) {
return extractCollectionFromSource(from.queries[0])
}
throw new Error(
`Failed to extract collection. Invalid FROM clause: ${JSON.stringify(query)}`,
)
}
/**
* Extracts all aliases used for each collection across the entire query tree.
*
* Traverses the QueryIR recursively to build a map from collection ID to all aliases
* that reference that collection. This is essential for self-join support, where the
* same collection may be referenced multiple times with different aliases.
*
* For example, given a query like:
* ```ts
* q.from({ employee: employeesCollection })
* .join({ manager: employeesCollection }, ({ employee, manager }) =>
* eq(employee.managerId, manager.id)
* )
* ```
*
* This function would return:
* ```
* Map { "employees" => Set { "employee", "manager" } }
* ```
*
* @param query - The query IR to extract aliases from
* @returns A map from collection ID to the set of all aliases referencing that collection
*/
export function extractCollectionAliases(
query: QueryIR,
): Map<string, Set<string>> {
const aliasesById = new Map<string, Set<string>>()
function recordAlias(source: any) {
if (!source) return
if (source.type === `collectionRef`) {
const { id } = source.collection
const existing = aliasesById.get(id)
if (existing) {
existing.add(source.alias)
} else {
aliasesById.set(id, new Set([source.alias]))
}
} else if (source.type === `queryRef`) {
traverse(source.query)
} else if (source.type === `unionFrom`) {
for (const childSource of source.sources) {
recordAlias(childSource)
}
} else if (source.type === `unionAll`) {
for (const branch of source.queries) {
traverse(branch)
}
}
}
function traverseSelect(select: any) {
for (const [key, value] of Object.entries(select)) {
if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) {
continue
}
if (value instanceof IncludesSubquery) {
traverse(value.query)
} else if (value instanceof ConditionalSelect) {
traverseConditionalSelect(value)
} else if (isNestedSelectObject(value)) {
traverseSelect(value)
}
}
}
function traverseConditionalSelect(conditional: ConditionalSelect) {
for (const branch of conditional.branches) {
traverseSelectValue(branch.value)
}
if (conditional.defaultValue !== undefined) {
traverseSelectValue(conditional.defaultValue)
}
}
function traverseSelectValue(value: any) {
if (value instanceof IncludesSubquery) {
traverse(value.query)
} else if (value instanceof ConditionalSelect) {
traverseConditionalSelect(value)
} else if (isNestedSelectObject(value)) {
traverseSelect(value)
}
}
function traverse(q?: QueryIR) {
if (!q) return
recordAlias(q.from)
if (q.join) {
for (const joinClause of q.join) {
recordAlias(joinClause.from)
}
}
if (q.select) {
traverseSelect(q.select)
}
}
traverse(query)
return aliasesById
}
/**
* Check if a value is a nested select object (plain object, not an expression)
*/
function isNestedSelectObject(obj: any): boolean {
if (obj === null || typeof obj !== `object`) return false
if (obj instanceof IncludesSubquery) return false
if (isExpressionLike(obj)) return false
// Ref proxies from spread operations
if (obj.__refProxy) return false
return true
}
/**
* Builds a query IR from a config object that contains either a query builder
* function or a QueryBuilder instance.
*/
export function buildQueryFromConfig<TContext extends Context>(config: {
query:
| ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
| QueryBuilder<TContext>
requireObjectResult?: boolean
}): QueryIR {
// Build the query using the provided query builder function or instance
const query =
typeof config.query === `function`
? buildQuery<TContext>(config.query)
: getQueryIR(config.query)
if (
config.requireObjectResult &&
query.select &&
!isNestedSelectObject(query.select)
) {
throw new UnsupportedRootScalarSelectError()
}
return query
}
/**
* Helper function to send changes to a D2 input stream.
* Converts ChangeMessages to D2 MultiSet data and sends to the input.
*
* @returns The number of multiset entries sent
*/
export function sendChangesToInput(
input: RootStreamBuilder<unknown>,
changes: Iterable<ChangeMessage>,
): number {
const multiSetArray: MultiSetArray<unknown> = []
for (const change of changes) {
const key = change.key
if (change.type === `insert`) {
multiSetArray.push([[key, change.value], 1])
} else if (change.type === `update`) {
multiSetArray.push([[key, change.previousValue], -1])
multiSetArray.push([[key, change.value], 1])
} else {
// change.type === `delete`
multiSetArray.push([[key, change.value], -1])
}
}
if (multiSetArray.length !== 0) {
input.sendData(new MultiSet(multiSetArray))
}
return multiSetArray.length
}
/** Splits updates into a delete of the old value and an insert of the new value */
export function* splitUpdates<
T extends object = Record<string, unknown>,
TKey extends string | number = string | number,
>(
changes: Iterable<ChangeMessage<T, TKey>>,
): Generator<ChangeMessage<T, TKey>> {
for (const change of changes) {
if (change.type === `update`) {
yield { type: `delete`, key: change.key, value: change.previousValue! }
yield { type: `insert`, key: change.key, value: change.value }
} else {
yield change
}
}
}
/**
* Filter changes to prevent duplicate inserts to a D2 pipeline.
* Maintains D2 multiplicity at 1 for visible items so that deletes
* properly reduce multiplicity to 0.
*
* Mutates `sentKeys` in place: adds keys on insert, removes on delete.
*/
export function filterDuplicateInserts(
changes: Array<ChangeMessage<any, string | number>>,
sentKeys: Set<string | number>,
): Array<ChangeMessage<any, string | number>> {
const filtered: Array<ChangeMessage<any, string | number>> = []
for (const change of changes) {
if (change.type === `insert`) {
if (sentKeys.has(change.key)) {
continue // Skip duplicate
}
sentKeys.add(change.key)
} else if (change.type === `delete`) {
if (!sentKeys.delete(change.key)) {
continue
}
}
filtered.push(change)
}
return filtered
}
/**
* Track the biggest value seen in a stream of changes, used for cursor-based
* pagination in ordered subscriptions. Returns whether the load request key
* should be reset (allowing another load).
*
* @param changes - changes to process (deletes are skipped)
* @param current - the current biggest value (or undefined if none)
* @param sentKeys - set of keys already sent to D2 (for new-key detection)
* @param comparator - orderBy comparator
* @returns `{ biggest, shouldResetLoadKey }` — the new biggest value and
* whether the caller should clear its last-load-request-key
*/
export function trackBiggestSentValue(
changes: Array<ChangeMessage<any, string | number>>,
current: unknown | undefined,
sentKeys: Set<string | number>,
comparator: (a: any, b: any) => number,
): { biggest: unknown; shouldResetLoadKey: boolean } {
let biggest = current
let shouldResetLoadKey = false
for (const change of changes) {
if (change.type === `delete`) continue
const isNewKey = !sentKeys.has(change.key)
if (biggest === undefined) {
biggest = change.value
shouldResetLoadKey = true
} else if (comparator(biggest, change.value) < 0) {
biggest = change.value
shouldResetLoadKey = true
} else if (isNewKey) {
// New key at same sort position — allow another load if needed
shouldResetLoadKey = true
}
}
return { biggest, shouldResetLoadKey }
}
/**
* Compute orderBy/limit subscription hints for an alias.
* Returns normalised orderBy and effective limit suitable for passing to
* `subscribeChanges`, or `undefined` values when the query's orderBy cannot
* be scoped to the given alias (e.g. cross-collection refs or aggregates).
*/
export function computeSubscriptionOrderByHints(
query: { orderBy?: OrderBy; limit?: number; offset?: number },
alias: string,
): { orderBy: OrderBy | undefined; limit: number | undefined } {
const { orderBy, limit, offset } = query
const effectiveLimit =
limit !== undefined && offset !== undefined ? limit + offset : limit
const normalizedOrderBy = orderBy
? normalizeOrderByPaths(orderBy, alias)
: undefined
// Only pass orderBy when it is scoped to this alias and uses simple refs,
// to avoid leaking cross-collection paths into backend-specific compilers.
const canPassOrderBy =
normalizedOrderBy?.every((clause) => {
const exp = clause.expression
if (exp.type !== `ref`) return false
const path = exp.path
return Array.isArray(path) && path.length === 1
}) ?? false
return {
orderBy: canPassOrderBy ? normalizedOrderBy : undefined,
limit: canPassOrderBy ? effectiveLimit : undefined,
}
}
/**
* Compute the cursor for loading the next batch of ordered data.
* Extracts values from the biggest sent row and builds the `minValues`
* array and a deduplication key.
*
* @returns `undefined` if the load should be skipped (duplicate request),
* otherwise `{ minValues, normalizedOrderBy, loadRequestKey }`.
*/
export function computeOrderedLoadCursor(
orderByInfo: Pick<
OrderByOptimizationInfo,
'orderBy' | 'valueExtractorForRawRow' | 'offset'
>,
biggestSentRow: unknown | undefined,
lastLoadRequestKey: string | undefined,
alias: string,
limit: number,
):
| {
minValues: Array<unknown> | undefined
normalizedOrderBy: OrderBy
loadRequestKey: string
}
| undefined {
const { orderBy, valueExtractorForRawRow, offset } = orderByInfo
// Extract all orderBy column values from the biggest sent row
// For single-column: returns single value, for multi-column: returns array
const extractedValues = biggestSentRow
? valueExtractorForRawRow(biggestSentRow as Record<string, unknown>)
: undefined
// Normalize to array format for minValues
let minValues: Array<unknown> | undefined
if (extractedValues !== undefined) {
minValues = Array.isArray(extractedValues)
? extractedValues
: [extractedValues]
}
// Deduplicate: skip if we already issued an identical load request
const loadRequestKey = serializeValue({
minValues: minValues ?? null,
offset,
limit,
})
if (lastLoadRequestKey === loadRequestKey) {
return undefined
}
const normalizedOrderBy = normalizeOrderByPaths(orderBy, alias)
return { minValues, normalizedOrderBy, loadRequestKey }
}