@spooky-sync/query-builder 0.0.0-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,933 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type {
3
+ GenericModel,
4
+ QueryInfo,
5
+ QueryOptions,
6
+ QueryModifier,
7
+ RelatedQuery,
8
+ SchemaAwareQueryModifier,
9
+ SchemaAwareQueryModifierBuilder,
10
+ } from './types';
11
+ import type {
12
+ TableNames,
13
+ GetTable,
14
+ TableModel,
15
+ TableRelationships,
16
+ GetRelationship,
17
+ SchemaStructure,
18
+ TableFieldNames,
19
+ ColumnSchema,
20
+ } from './table-schema';
21
+
22
+ /**
23
+ * Parse a string ID to RecordId
24
+ * - If it's in the format "table:id", use it as-is
25
+ * - If it's just an ID without ":", prepend the table name
26
+ * @param value - The value to parse (could be a string ID)
27
+ * @param tableName - The table name to use if the ID doesn't contain ":"
28
+ * @param fieldName - The field name to determine if this is an ID field
29
+ */
30
+ function parseStringToRecordId(value: unknown, tableName?: string, fieldName?: string): unknown {
31
+ if (typeof value !== 'string') return value;
32
+
33
+ // If it already contains ":", parse it as a full record ID
34
+ if (value.includes(':')) {
35
+ const [table, ...idParts] = value.split(':');
36
+ const id = idParts.join(':'); // Handle IDs that contain colons
37
+ return new RecordId(table, id);
38
+ }
39
+
40
+ // If this is an "id" field and we have a table name, prepend it
41
+ if (fieldName === 'id' && tableName) {
42
+ return new RecordId(tableName, value);
43
+ }
44
+
45
+ // Otherwise, return as-is (it might not be an ID at all)
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * Recursively parse string IDs to RecordId in an object
51
+ * @param obj - The object to parse
52
+ * @param tableName - The table name to use for ID fields without ":"
53
+ */
54
+ function parseObjectIdsToRecordId(obj: unknown, tableName?: string): unknown {
55
+ if (obj === null || obj === undefined) return obj;
56
+
57
+ if (typeof obj === 'string') {
58
+ return parseStringToRecordId(obj, tableName);
59
+ }
60
+
61
+ if (Array.isArray(obj)) {
62
+ return obj.map((item) => parseObjectIdsToRecordId(item, tableName));
63
+ }
64
+
65
+ if (typeof obj === 'object' && obj.constructor === Object) {
66
+ const result: Record<string, unknown> = {};
67
+ for (const [key, value] of Object.entries(obj)) {
68
+ // Parse recursively, passing the field name to identify ID fields
69
+ result[key] =
70
+ typeof value === 'string'
71
+ ? parseStringToRecordId(value, tableName, key)
72
+ : parseObjectIdsToRecordId(value, tableName);
73
+ }
74
+ return result;
75
+ }
76
+
77
+ return obj;
78
+ }
79
+
80
+ export type Executor<T extends { columns: Record<string, ColumnSchema> }, R = void> = (
81
+ query: InnerQuery<T, boolean>
82
+ ) => R;
83
+
84
+ export class InnerQuery<
85
+ T extends { columns: Record<string, ColumnSchema> },
86
+ IsOne extends boolean,
87
+ R = void,
88
+ > {
89
+ private _hash: number;
90
+ private _mainQuery: QueryInfo;
91
+ private _selectQuery: QueryInfo;
92
+ private _selectLiveQuery: QueryInfo;
93
+ private _subqueries: InnerQuery<{ columns: Record<string, ColumnSchema> }, boolean>[];
94
+
95
+ constructor(
96
+ private readonly _tableName: string,
97
+ private readonly options: QueryOptions<TableModel<T>, IsOne>,
98
+ private readonly schema: SchemaStructure,
99
+ private readonly executor: Executor<any, R>
100
+ ) {
101
+ this._selectQuery = buildQueryFromOptions('SELECT', this._tableName, this.options, this.schema);
102
+
103
+ this._mainQuery = buildQueryFromOptions(
104
+ 'SELECT',
105
+ this._tableName,
106
+ { ...this.options, related: [] },
107
+ this.schema
108
+ );
109
+
110
+ this._hash = this._selectQuery.hash;
111
+
112
+ this._selectLiveQuery = buildQueryFromOptions(
113
+ 'LIVE SELECT',
114
+ this._tableName,
115
+ this.options,
116
+ this.schema
117
+ );
118
+
119
+ this._subqueries = extractSubqueryQueryInfos(
120
+ schema,
121
+ this._tableName,
122
+ this.options,
123
+ this.executor
124
+ );
125
+ }
126
+
127
+ get mainQuery(): QueryInfo {
128
+ return this._mainQuery;
129
+ }
130
+
131
+ get subqueries(): InnerQuery<{ columns: Record<string, ColumnSchema> }, boolean>[] {
132
+ return this._subqueries;
133
+ }
134
+
135
+ get selectQuery(): QueryInfo {
136
+ return this._selectQuery;
137
+ }
138
+
139
+ get selectLiveQuery(): QueryInfo {
140
+ return this._selectLiveQuery;
141
+ }
142
+
143
+ get tableName(): string {
144
+ return this._tableName;
145
+ }
146
+
147
+ get hash(): number {
148
+ return this._hash;
149
+ }
150
+
151
+ get isOne(): boolean {
152
+ return this.options.isOne ?? false;
153
+ }
154
+
155
+ public run(): R {
156
+ return this.executor(this);
157
+ }
158
+
159
+ public buildUpdateQuery(patches: any[]): QueryInfo {
160
+ return buildQueryFromOptions('UPDATE', this._tableName, this.options, this.schema, patches);
161
+ }
162
+
163
+ public buildDeleteQuery(): QueryInfo {
164
+ return buildQueryFromOptions('DELETE', this._tableName, this.options, this.schema);
165
+ }
166
+
167
+ public getOptions(): QueryOptions<TableModel<T>, IsOne> {
168
+ return this.options;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Helper type to get the model type for a related table
174
+ */
175
+ type GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
176
+ RelatedTableName extends TableNames<S> ? TableModel<GetTable<S, RelatedTableName>> : never;
177
+
178
+ /**
179
+ * Helper type to extract field names from RelatedFields
180
+ */
181
+ export type ExtractFieldNames<RelatedFields extends RelatedFieldsMap> = keyof RelatedFields;
182
+
183
+ export type RelatedFieldMapEntry = {
184
+ to: string;
185
+ cardinality: 'one' | 'many';
186
+ relatedFields: RelatedFieldsMap;
187
+ };
188
+
189
+ export type RelatedFieldsMap = Record<string, RelatedFieldMapEntry>;
190
+
191
+ /**
192
+ * Helper type to build the related fields object based on accumulated relationships
193
+ */
194
+ export type BuildRelatedFields<
195
+ S extends SchemaStructure,
196
+ RelatedFields extends RelatedFieldsMap,
197
+ > = {
198
+ [K in keyof RelatedFields]: QueryResult<
199
+ S,
200
+ RelatedFields[K]['to'],
201
+ RelatedFields[K]['relatedFields'],
202
+ RelatedFields[K]['cardinality'] extends 'one' ? true : false
203
+ >;
204
+ };
205
+
206
+ export type BuildResultModelOne<
207
+ S extends SchemaStructure,
208
+ TableName extends TableNames<S>,
209
+ RelatedFields extends RelatedFieldsMap,
210
+ > = Omit<TableModel<GetTable<S, TableName>>, ExtractFieldNames<RelatedFields>> &
211
+ BuildRelatedFields<S, RelatedFields>;
212
+
213
+ export type BuildResultModelMany<
214
+ S extends SchemaStructure,
215
+ TableName extends TableNames<S>,
216
+ RelatedFields extends RelatedFieldsMap,
217
+ > = (Omit<TableModel<GetTable<S, TableName>>, ExtractFieldNames<RelatedFields>> &
218
+ BuildRelatedFields<S, RelatedFields>)[];
219
+
220
+ /**
221
+ * The final result type combining base model with related fields
222
+ * Excludes related field keys from the base model to avoid type conflicts
223
+ */
224
+ export type QueryResult<
225
+ S extends SchemaStructure,
226
+ TableName extends TableNames<S>,
227
+ RelatedFields extends RelatedFieldsMap,
228
+ IsOne extends boolean,
229
+ > = IsOne extends true
230
+ ? BuildResultModelOne<S, TableName, RelatedFields>
231
+ : BuildResultModelMany<S, TableName, RelatedFields>;
232
+
233
+ export class FinalQuery<
234
+ S extends SchemaStructure,
235
+ TableName extends TableNames<S>,
236
+ T extends { columns: Record<string, ColumnSchema> },
237
+ RelatedFields extends RelatedFieldsMap,
238
+ IsOne extends boolean,
239
+ R = void,
240
+ > {
241
+ private _innerQuery: InnerQuery<T, IsOne, R>;
242
+
243
+ constructor(
244
+ private readonly tableName: TableName,
245
+ private readonly options: QueryOptions<TableModel<T>, IsOne>,
246
+ private readonly schema: S,
247
+ private readonly executor: Executor<T, R>
248
+ ) {
249
+ this._innerQuery = new InnerQuery<T, IsOne, R>(
250
+ this.tableName,
251
+ this.options,
252
+ this.schema,
253
+ this.executor
254
+ );
255
+ }
256
+
257
+ run(): R {
258
+ return this.executor(this._innerQuery);
259
+ }
260
+
261
+ buildUpdateQuery(patches: any[]): QueryInfo {
262
+ return this._innerQuery.buildUpdateQuery(patches);
263
+ }
264
+
265
+ buildDeleteQuery(): QueryInfo {
266
+ return this._innerQuery.buildDeleteQuery();
267
+ }
268
+
269
+ selectLive(): QueryInfo {
270
+ return this._innerQuery.selectLiveQuery;
271
+ }
272
+
273
+ get innerQuery(): InnerQuery<T, IsOne, R> {
274
+ return this._innerQuery;
275
+ }
276
+
277
+ get isOne(): boolean {
278
+ return this.options.isOne ?? false;
279
+ }
280
+
281
+ get hash(): number {
282
+ return this._innerQuery.hash;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Schema-aware query modifier builder implementation
288
+ * This version provides full type safety for nested relationships
289
+ */
290
+ class SchemaAwareQueryModifierBuilderImpl<
291
+ S extends SchemaStructure,
292
+ TableName extends TableNames<S>,
293
+ RelatedFields extends RelatedFieldsMap = {},
294
+ > implements SchemaAwareQueryModifierBuilder<S, TableName, RelatedFields> {
295
+ private options: QueryOptions<TableModel<GetTable<S, TableName>>, boolean> = {};
296
+
297
+ constructor(
298
+ private readonly tableName: TableName,
299
+ private readonly schema: S
300
+ ) {}
301
+
302
+ where(conditions: Partial<TableModel<GetTable<S, TableName>>>): this {
303
+ this.options.where = { ...this.options.where, ...conditions };
304
+ return this;
305
+ }
306
+
307
+ select(...fields: ((keyof TableModel<GetTable<S, TableName>> & string) | '*')[]): this {
308
+ if (this.options.select) {
309
+ throw new Error('Select can only be called once per query');
310
+ }
311
+ this.options.select = fields;
312
+ return this;
313
+ }
314
+
315
+ limit(count: number): this {
316
+ this.options.limit = count;
317
+ return this;
318
+ }
319
+
320
+ offset(count: number): this {
321
+ this.options.offset = count;
322
+ return this;
323
+ }
324
+
325
+ orderBy(
326
+ field: keyof TableModel<GetTable<S, TableName>> & string,
327
+ direction: 'asc' | 'desc' = 'asc'
328
+ ): this {
329
+ this.options.orderBy = {
330
+ ...this.options.orderBy,
331
+ [field]: direction,
332
+ } as Partial<Record<keyof TableModel<GetTable<S, TableName>>, 'asc' | 'desc'>>;
333
+ return this;
334
+ }
335
+
336
+ // Schema-aware implementation for nested relationships with full type inference
337
+ related<
338
+ Field extends TableRelationships<S, TableName>['field'],
339
+ Rel extends GetRelationship<S, TableName, Field>,
340
+ RelatedFields2 extends RelatedFieldsMap = {},
341
+ >(
342
+ relatedField: Field,
343
+ modifier?: SchemaAwareQueryModifier<S, Rel['to'], RelatedFields2>
344
+ ): SchemaAwareQueryModifierBuilderImpl<
345
+ S,
346
+ TableName,
347
+ RelatedFields & {
348
+ [K in Field]: {
349
+ to: Rel['to'];
350
+ cardinality: Rel['cardinality'];
351
+ relatedFields: RelatedFields2;
352
+ };
353
+ }
354
+ > {
355
+ if (!this.options.related) {
356
+ this.options.related = [];
357
+ }
358
+
359
+ const exists = this.options.related.some((r) => (r.alias || r.relatedTable) === relatedField);
360
+
361
+ if (!exists) {
362
+ // Look up the relationship from schema
363
+ const relationship = this.schema.relationships.find(
364
+ (r) => r.from === this.tableName && r.field === relatedField
365
+ );
366
+
367
+ if (!relationship) {
368
+ throw new Error(
369
+ `Relationship '${String(relatedField)}' not found for table '${this.tableName}'`
370
+ );
371
+ }
372
+
373
+ const relatedTable = relationship.to;
374
+ const cardinality = relationship.cardinality;
375
+ const foreignKeyField = cardinality === 'many' ? this.tableName : relatedField;
376
+
377
+ this.options.related.push({
378
+ relatedTable,
379
+ alias: relatedField as string,
380
+ modifier: modifier as QueryModifier<GenericModel>,
381
+ cardinality,
382
+ foreignKeyField: foreignKeyField as string,
383
+ } as RelatedQuery & { foreignKeyField: string });
384
+ }
385
+ return this as any;
386
+ }
387
+
388
+ _getOptions(): QueryOptions<TableModel<GetTable<S, TableName>>, boolean> {
389
+ return this.options;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Fluent query builder for constructing queries with chainable methods
395
+ * Now with full type inference from schema constant AND related field accumulation!
396
+ */
397
+ export class QueryBuilder<
398
+ const S extends SchemaStructure,
399
+ const TableName extends TableNames<S>,
400
+ const R = void,
401
+ const RelatedFields extends RelatedFieldsMap = {},
402
+ const IsOne extends boolean = false,
403
+ > {
404
+ constructor(
405
+ private readonly schema: S,
406
+ private readonly tableName: TableName,
407
+ private readonly executer: Executor<GetTable<S, TableName>, R> = () => undefined as R,
408
+ private options: QueryOptions<TableModel<GetTable<S, TableName>>, IsOne> = {}
409
+ ) {}
410
+
411
+ /**
412
+ * Add additional where conditions
413
+ */
414
+ where(
415
+ conditions: Partial<TableModel<GetTable<S, TableName>>>
416
+ ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
417
+ this.options.where = { ...this.options.where, ...conditions };
418
+ return this;
419
+ }
420
+
421
+ /**
422
+ * Specify fields to select
423
+ */
424
+ select(
425
+ ...fields: ((keyof TableModel<GetTable<S, TableName>> & string) | '*')[]
426
+ ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
427
+ if (this.options.select) {
428
+ throw new Error('Select can only be called once per query');
429
+ }
430
+ this.options.select = fields;
431
+ return this;
432
+ }
433
+
434
+ /**
435
+ * Add ordering to the query (only for non-live queries)
436
+ */
437
+ orderBy(
438
+ field: TableFieldNames<GetTable<S, TableName>>,
439
+ direction: 'asc' | 'desc' = 'asc'
440
+ ): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
441
+ this.options.orderBy = {
442
+ ...this.options.orderBy,
443
+ [field]: direction,
444
+ } as Partial<Record<keyof TableModel<GetTable<S, TableName>>, 'asc' | 'desc'>>;
445
+ return this;
446
+ }
447
+
448
+ /**
449
+ * Add limit to the query (only for non-live queries)
450
+ */
451
+ limit(count: number): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
452
+ this.options.limit = count;
453
+ return this;
454
+ }
455
+
456
+ /**
457
+ * Add offset to the query (only for non-live queries)
458
+ */
459
+ offset(count: number): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
460
+ this.options.offset = count;
461
+ return this;
462
+ }
463
+
464
+ one(): QueryBuilder<S, TableName, R, RelatedFields, true> {
465
+ return new QueryBuilder<S, TableName, R, RelatedFields, true>(
466
+ this.schema,
467
+ this.tableName,
468
+ this.executer,
469
+ { ...this.options, isOne: true }
470
+ );
471
+ }
472
+
473
+ /**
474
+ * Include related data via subqueries
475
+ * Field and cardinality are validated against schema relationships
476
+ * Now accumulates the related field in the type!
477
+ */
478
+ related<
479
+ Field extends TableRelationships<S, TableName>['field'],
480
+ Rel extends GetRelationship<S, TableName, Field>,
481
+ RelatedFields2 extends RelatedFieldsMap = {},
482
+ >(
483
+ field: Field,
484
+ modifierOrCardinality?:
485
+ | SchemaAwareQueryModifier<S, Rel['to'], RelatedFields2>
486
+ | Rel['cardinality'],
487
+ modifier?: SchemaAwareQueryModifier<S, Rel['to'], RelatedFields2>
488
+ ): QueryBuilder<
489
+ S,
490
+ TableName,
491
+ R,
492
+ RelatedFields & {
493
+ [K in Field]: {
494
+ to: Rel['to'];
495
+ cardinality: Rel['cardinality'];
496
+ relatedFields: RelatedFields2;
497
+ };
498
+ },
499
+ IsOne
500
+ > {
501
+ if (!this.options.related) {
502
+ this.options.related = [];
503
+ }
504
+
505
+ // Check if field already exists
506
+ const exists = this.options.related.some((r) => (r.alias || r.relatedTable) === field);
507
+
508
+ if (exists) {
509
+ return this as any;
510
+ }
511
+
512
+ // Look up relationship metadata from schema
513
+ const relationship = this.schema.relationships.find(
514
+ (r) => r.from === this.tableName && r.field === field
515
+ );
516
+
517
+ if (!relationship) {
518
+ throw new Error(`Relationship '${String(field)}' not found for table '${this.tableName}'`);
519
+ }
520
+
521
+ // Determine cardinality and modifier based on arguments
522
+ let actualCardinality: 'one' | 'many';
523
+ let actualModifier: SchemaAwareQueryModifier<S, Rel['to']> | undefined;
524
+
525
+ if (typeof modifierOrCardinality === 'function') {
526
+ // Signature: related(field, modifier)
527
+ actualCardinality = relationship.cardinality;
528
+ actualModifier = modifierOrCardinality;
529
+ } else if (modifierOrCardinality === 'one' || modifierOrCardinality === 'many') {
530
+ // Signature: related(field, cardinality, modifier)
531
+ actualCardinality = modifierOrCardinality;
532
+ actualModifier = modifier;
533
+ } else {
534
+ // Signature: related(field)
535
+ actualCardinality = relationship.cardinality;
536
+ actualModifier = undefined;
537
+ }
538
+
539
+ // Determine foreign key field based on cardinality
540
+ let foreignKeyField: string =
541
+ actualCardinality === 'many' ? (this.tableName as string) : (field as string);
542
+
543
+ if (actualCardinality === 'many') {
544
+ // For one-to-many, we need to find the field on the child table that points back to the parent
545
+ // We look for a relationship from Child -> Parent
546
+ const reverseRelationships = this.schema.relationships.filter(
547
+ (r) => r.from === relationship.to && r.to === this.tableName && r.cardinality === 'one'
548
+ );
549
+
550
+ if (reverseRelationships.length > 0) {
551
+ // Prioritize field that matches parent table name
552
+ const exactMatch = reverseRelationships.find((r) => r.field === this.tableName);
553
+ if (exactMatch) {
554
+ foreignKeyField = exactMatch.field;
555
+ } else {
556
+ foreignKeyField = reverseRelationships[0].field;
557
+ }
558
+ } else {
559
+ // Fallback heuristics
560
+ if (this.tableName.startsWith(`${relationship.to}_`)) {
561
+ // If parent table is "game_database" and child is "game", try "database"
562
+ foreignKeyField = this.tableName.slice(relationship.to.length + 1);
563
+ }
564
+ }
565
+ }
566
+
567
+ // Cast the schema-aware modifier to the runtime type
568
+ // At runtime, QueryModifierBuilderImpl will work correctly with the schema
569
+ const wrappedModifier = actualModifier as QueryModifier<GenericModel> | undefined;
570
+
571
+ this.options.related.push({
572
+ relatedTable: relationship.to,
573
+ alias: field as string,
574
+ modifier: wrappedModifier,
575
+ cardinality: actualCardinality,
576
+ foreignKeyField: foreignKeyField as any,
577
+ } as RelatedQuery & { foreignKeyField: string });
578
+
579
+ return this as any;
580
+ }
581
+
582
+ /**
583
+ * Get the current query options
584
+ */
585
+ getOptions(): QueryOptions<TableModel<GetTable<S, TableName>>, IsOne> {
586
+ return this.options;
587
+ }
588
+
589
+ /**
590
+ * Build query methods for SELECT and LIVE SELECT (custom implementation)
591
+ * @returns FinalQuery object with select() method for custom usage
592
+ */
593
+ build(): FinalQuery<S, TableName, GetTable<S, TableName>, RelatedFields, IsOne, R> {
594
+ return new FinalQuery<S, TableName, GetTable<S, TableName>, RelatedFields, IsOne, R>(
595
+ this.tableName,
596
+ this.options,
597
+ this.schema,
598
+ this.executer
599
+ );
600
+ }
601
+ }
602
+
603
+ export function cyrb53(str: string, seed: number = 0): number {
604
+ let h1 = 0xdeadbeef ^ seed,
605
+ h2 = 0x41c6ce57 ^ seed;
606
+ for (let i = 0, ch; i < str.length; i++) {
607
+ ch = str.charCodeAt(i);
608
+ h1 = Math.imul(h1 ^ ch, 2654435761);
609
+ h2 = Math.imul(h2 ^ ch, 1597334677);
610
+ }
611
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
612
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
613
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
614
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
615
+
616
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
617
+ }
618
+
619
+ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
620
+ schema: S,
621
+ parentTableName: string,
622
+ options: QueryOptions<GenericModel, boolean>,
623
+ executer: Executor<{ columns: Record<string, ColumnSchema> }>
624
+ ): InnerQuery<{ columns: Record<string, ColumnSchema> }, boolean>[] {
625
+ if (!options.related) {
626
+ return [];
627
+ }
628
+
629
+ return options.related.map((rel) => {
630
+ // Get base options from modifier
631
+ const subOptions =
632
+ rel
633
+ .modifier?.(new SchemaAwareQueryModifierBuilderImpl(rel.relatedTable, schema))
634
+ ._getOptions() ?? {};
635
+
636
+ // Find relationship to determine how to filter
637
+ const relationship = schema.relationships.find(
638
+ (r) => r.from === parentTableName && r.field === rel.alias
639
+ );
640
+
641
+ if (relationship) {
642
+ // Determine foreign key field
643
+ // rel.alias is guaranteed to be defined if relationship is found (matched r.field)
644
+ let foreignKeyField = rel.alias!;
645
+
646
+ if (relationship.cardinality === 'many') {
647
+ // For one-to-many, we need to find the field on the child table that points back to the parent
648
+ // We look for a relationship from Child -> Parent
649
+ const reverseRelationships = schema.relationships.filter(
650
+ (r) => r.from === rel.relatedTable && r.to === parentTableName && r.cardinality === 'one'
651
+ );
652
+
653
+ if (reverseRelationships.length > 0) {
654
+ // Prioritize field that matches parent table name
655
+ const exactMatch = reverseRelationships.find((r) => r.field === parentTableName);
656
+ if (exactMatch) {
657
+ foreignKeyField = exactMatch.field;
658
+ } else {
659
+ foreignKeyField = reverseRelationships[0].field;
660
+ }
661
+ } else {
662
+ // Fallback heuristics
663
+ if (parentTableName.startsWith(`${rel.relatedTable}_`)) {
664
+ // If parent table is "game_database" and child is "game", try "database"
665
+ foreignKeyField = parentTableName.slice(rel.relatedTable.length + 1);
666
+ } else {
667
+ // Default to parent table name
668
+ foreignKeyField = parentTableName;
669
+ }
670
+ }
671
+ }
672
+
673
+ // Add parent filter to where clause
674
+ subOptions.where = subOptions.where || {};
675
+
676
+ if (relationship.cardinality === 'many') {
677
+ // One-to-Many: Child has foreign key to parent
678
+ // WHERE $parentIds ∋ child.parent_id
679
+ (subOptions.where as any)[foreignKeyField] = { _op: '∋', _val: '$parentIds', _swap: true };
680
+ } else {
681
+ // One-to-One: Parent has foreign key to child
682
+ // WHERE $parent_<foreignKeyField> ∋ child.id
683
+ // We use a dynamic variable name derived from the foreign key field on the parent
684
+ (subOptions.where as any).id = {
685
+ _op: '∋',
686
+ _val: `$parent_${foreignKeyField}`,
687
+ _swap: true,
688
+ };
689
+ }
690
+ }
691
+
692
+ return new InnerQuery(rel.relatedTable, subOptions, schema, executer);
693
+ });
694
+ }
695
+
696
+ /**
697
+ * Build a query string from query options
698
+ * @param method - The query method (SELECT or LIVE SELECT)
699
+ * @param tableName - The table name to query
700
+ * @param options - The query options (where, select, orderBy, etc.)
701
+ * @param schema - Optional schema for resolving nested relationships
702
+ * @returns QueryInfo with the generated SQL and variables
703
+ */
704
+ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends boolean>(
705
+ method: 'SELECT' | 'LIVE SELECT' | 'LIVE SELECT DIFF' | 'UPDATE' | 'DELETE',
706
+ tableName: string,
707
+ options: QueryOptions<TModel, IsOne>,
708
+ schema: SchemaStructure,
709
+ patches?: any[]
710
+ ): QueryInfo {
711
+ if (options.isOne) {
712
+ options.limit = 1;
713
+ }
714
+ const isLiveQuery = method === 'LIVE SELECT' || method === 'LIVE SELECT DIFF';
715
+
716
+ // Parse where conditions to convert string IDs to RecordId
717
+ const parsedWhere = options.where
718
+ ? parseObjectIdsToRecordId(options.where, tableName)
719
+ : undefined;
720
+
721
+ // Build SELECT clause
722
+ let selectClause = '*';
723
+
724
+ if (method === 'LIVE SELECT DIFF') {
725
+ selectClause = '';
726
+ } else {
727
+ if (options.select && options.select.length > 0) {
728
+ selectClause = options.select.join(', ');
729
+ }
730
+ }
731
+
732
+ // Build related subqueries (fetch clauses)
733
+ let fetchClauses = '';
734
+ if (!isLiveQuery && options.related && options.related.length > 0) {
735
+ const subqueries = options.related.map((rel) => buildSubquery(rel, schema));
736
+ fetchClauses = ', ' + subqueries.join(', ');
737
+ }
738
+
739
+ // Start building the query
740
+ let query = '';
741
+
742
+ if (method === 'UPDATE') {
743
+ query = `UPDATE ${tableName}`;
744
+ } else if (method === 'DELETE') {
745
+ query = `DELETE FROM ${tableName}`;
746
+ } else {
747
+ query = `${method}${selectClause ? ` ${selectClause}` : ''}${fetchClauses} FROM ${tableName}`;
748
+ }
749
+
750
+ // Build WHERE clause
751
+ const vars: Record<string, unknown> = {};
752
+ if (parsedWhere && Object.keys(parsedWhere).length > 0) {
753
+ const conditions: string[] = [];
754
+ for (const [key, value] of Object.entries(parsedWhere)) {
755
+ const varName = key;
756
+
757
+ // Handle operator objects { _op, _val }
758
+ if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
759
+ const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
760
+
761
+ let rightSide = '';
762
+ if (typeof _val === 'string' && _val.startsWith('$')) {
763
+ rightSide = _val;
764
+ } else {
765
+ vars[varName] = _val;
766
+ rightSide = `$${varName}`;
767
+ }
768
+
769
+ if (_swap) {
770
+ conditions.push(`${rightSide} ${_op} ${key}`);
771
+ } else {
772
+ conditions.push(`${key} ${_op} ${rightSide}`);
773
+ }
774
+ } else {
775
+ vars[varName] = value;
776
+ conditions.push(`${key} = $${varName}`);
777
+ }
778
+ }
779
+ query += ` WHERE ${conditions.join(' AND ')}`;
780
+ }
781
+
782
+ // Add PATCH for UPDATE
783
+ if (method === 'UPDATE' && patches) {
784
+ query += ` PATCH ${JSON.stringify(patches)}`;
785
+ }
786
+
787
+ // Add ORDER BY, LIMIT, START only for non-live queries and non-update/delete queries (unless supported)
788
+ // SurrealDB UPDATE/DELETE supports WHERE, but LIMIT/START/ORDER BY might be restricted or behave differently.
789
+ // For now, let's allow them if they are set, as SurrealDB supports them for DELETE/UPDATE.
790
+ if (!isLiveQuery) {
791
+ if (options.orderBy && Object.keys(options.orderBy).length > 0) {
792
+ const orderClauses = Object.entries(options.orderBy).map(
793
+ ([field, direction]) => `${field} ${direction}`
794
+ );
795
+ query += ` ORDER BY ${orderClauses.join(', ')}`;
796
+ }
797
+
798
+ if (options.limit !== undefined) {
799
+ query += ` LIMIT ${options.limit}`;
800
+ }
801
+
802
+ if (options.offset !== undefined) {
803
+ query += ` START ${options.offset}`;
804
+ }
805
+ }
806
+
807
+ query += ';';
808
+
809
+ return {
810
+ query,
811
+ hash: cyrb53(
812
+ `${query}::${Object.entries(vars)
813
+ .map(([key, value]) => `${key}=${value}`)
814
+ .join('&')}`,
815
+ 0
816
+ ),
817
+ vars: Object.keys(vars).length > 0 ? vars : undefined,
818
+ };
819
+ }
820
+
821
+ /**
822
+ * Build a subquery for a related field
823
+ */
824
+ function buildSubquery(
825
+ rel: RelatedQuery & { foreignKeyField?: string },
826
+ schema: SchemaStructure
827
+ ): string {
828
+ const { relatedTable, alias, modifier, cardinality } = rel;
829
+ const foreignKeyField = rel.foreignKeyField || alias;
830
+
831
+ let subquerySelect = '*';
832
+ let subqueryWhere = '';
833
+ let subqueryOrderBy = '';
834
+ let subqueryLimit = '';
835
+
836
+ // If there's a modifier, apply it to get the sub-options
837
+ if (modifier) {
838
+ const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
839
+ modifier(modifierBuilder);
840
+ const subOptions = modifierBuilder._getOptions();
841
+
842
+ // Build sub-select
843
+ if (subOptions.select && subOptions.select.length > 0) {
844
+ subquerySelect = subOptions.select.join(', ');
845
+ }
846
+
847
+ // Build sub-where
848
+ if (subOptions.where && Object.keys(subOptions.where).length > 0) {
849
+ const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
850
+ string,
851
+ unknown
852
+ >;
853
+ const conditions = Object.entries(parsedSubWhere).map(([key, value]) => {
854
+ if (value instanceof RecordId) {
855
+ return `${key} = ${value.toString()}`;
856
+ }
857
+ return `${key} = ${JSON.stringify(value)}`;
858
+ });
859
+ subqueryWhere = ` AND ${conditions.join(' AND ')}`;
860
+ }
861
+
862
+ // Build sub-orderBy
863
+ if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
864
+ const orderClauses = Object.entries(subOptions.orderBy).map(
865
+ ([field, direction]) => `${field} ${direction}`
866
+ );
867
+ subqueryOrderBy = ` ORDER BY ${orderClauses.join(', ')}`;
868
+ }
869
+
870
+ // Build sub-limit
871
+ if (subOptions.limit !== undefined) {
872
+ subqueryLimit = ` LIMIT ${subOptions.limit}`;
873
+ }
874
+
875
+ // Handle nested relationships
876
+ if (subOptions.related && subOptions.related.length > 0) {
877
+ // Resolve nested relationship metadata if schema is available
878
+ const resolvedNestedRels = subOptions.related.map((nestedRel) => {
879
+ if (schema) {
880
+ // Look up the actual relationship metadata from schema
881
+ const relationship = schema.relationships.find(
882
+ (r) => r.from === relatedTable && r.field === nestedRel.alias
883
+ );
884
+
885
+ if (relationship) {
886
+ // Use the resolved table name and add foreign key field
887
+ const nestedForeignKeyField =
888
+ relationship.cardinality === 'many' ? relatedTable : nestedRel.alias;
889
+
890
+ return {
891
+ ...nestedRel,
892
+ relatedTable: relationship.to,
893
+ cardinality: relationship.cardinality,
894
+ foreignKeyField: nestedForeignKeyField,
895
+ } as RelatedQuery & { foreignKeyField: string };
896
+ }
897
+ }
898
+ return nestedRel;
899
+ });
900
+
901
+ const nestedSubqueries = resolvedNestedRels.map((nestedRel) =>
902
+ buildSubquery(nestedRel, schema)
903
+ );
904
+ subquerySelect += ', ' + nestedSubqueries.join(', ');
905
+ }
906
+ }
907
+
908
+ // Determine the WHERE condition based on cardinality
909
+ let whereCondition: string;
910
+ if (cardinality === 'one') {
911
+ // For one-to-one, the related table's id matches parent's foreign key field
912
+ whereCondition = `WHERE id=$parent.${foreignKeyField}`;
913
+ // Add LIMIT 1 for one-to-one relationships if not already set
914
+ if (!subqueryLimit) {
915
+ subqueryLimit = ' LIMIT 1';
916
+ }
917
+ } else {
918
+ // For one-to-many, the related table has a foreign key field pointing to parent's id
919
+ whereCondition = `WHERE ${foreignKeyField}=$parent.id`;
920
+ }
921
+
922
+ // Build the complete subquery
923
+ let subquery = `(SELECT ${subquerySelect} FROM ${relatedTable} ${whereCondition}${subqueryWhere}${subqueryOrderBy}${subqueryLimit})`;
924
+
925
+ // For one-to-one relationships, select the first element
926
+ if (cardinality === 'one') {
927
+ subquery += '[0]';
928
+ }
929
+
930
+ subquery += ` AS ${alias}`;
931
+
932
+ return subquery;
933
+ }