@spooky-sync/query-builder 0.0.1-canary.13 → 0.0.1-canary.131
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.
- package/AGENTS.md +63 -0
- package/dist/index.d.mts +122 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +122 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +129 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +129 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
- package/skills/sp00ky-query-builder/SKILL.md +201 -0
- package/skills/sp00ky-query-builder/references/type-helpers.md +80 -0
- package/src/index.ts +9 -0
- package/src/query-builder.test.ts +92 -4
- package/src/query-builder.ts +225 -20
- package/src/repro_relationship.test.ts +1 -1
- package/src/table-schema.ts +26 -3
- package/src/types.ts +110 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, expectTypeOf } from 'vitest';
|
|
2
2
|
import { QueryBuilder, buildQueryFromOptions } from './query-builder';
|
|
3
3
|
import { RecordId } from 'surrealdb';
|
|
4
|
-
import type {
|
|
4
|
+
import type { TableModel } from './table-schema';
|
|
5
5
|
|
|
6
6
|
// Schema for testing the new array-based API
|
|
7
7
|
const testSchema = {
|
|
@@ -92,6 +92,76 @@ describe('QueryBuilder', () => {
|
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it('should build a comparison operator condition via { _op, _val }', () => {
|
|
96
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
97
|
+
builder.where({ created_at: { _op: '<=', _val: 5 } });
|
|
98
|
+
const result = builder.build().run();
|
|
99
|
+
|
|
100
|
+
expect(result.query).toBe('SELECT * FROM user WHERE created_at <= $created_at;');
|
|
101
|
+
expect(result.vars).toEqual({ created_at: 5 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should build an OR group via _or with position-indexed params', () => {
|
|
105
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
106
|
+
builder.where({ _or: [{ username: 'x' }, { email: 'x' }] });
|
|
107
|
+
const result = builder.build().run();
|
|
108
|
+
|
|
109
|
+
expect(result.query).toBe('SELECT * FROM user WHERE (username = $or0 OR email = $or1);');
|
|
110
|
+
expect(result.vars).toEqual({ or0: 'x', or1: 'x' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should not collide an _or branch with a top-level condition on the same field', () => {
|
|
114
|
+
// Mirrors the game filter where a color filter (white = me) coexists with an
|
|
115
|
+
// opponent OR on white/black: the OR branch must use its own param name.
|
|
116
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
117
|
+
builder.where({ username: 'me', _or: [{ username: 'opp' }, { email: 'opp' }] });
|
|
118
|
+
const result = builder.build().run();
|
|
119
|
+
|
|
120
|
+
expect(result.query).toBe(
|
|
121
|
+
'SELECT * FROM user WHERE username = $username AND (username = $or0 OR email = $or1);'
|
|
122
|
+
);
|
|
123
|
+
expect(result.vars).toEqual({ username: 'me', or0: 'opp', or1: 'opp' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should combine equality + comparison + OR group + order/limit/offset', () => {
|
|
127
|
+
// The shape the filtered game list produces: scope equality, a date floor as
|
|
128
|
+
// an integer sort_index comparison, an opponent OR group, paginated.
|
|
129
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
130
|
+
builder
|
|
131
|
+
.where({ email: 'e', created_at: { _op: '<=', _val: 5 }, _or: [{ username: 'p' }, { email: 'p' }] })
|
|
132
|
+
.orderBy('created_at', 'asc')
|
|
133
|
+
.limit(50)
|
|
134
|
+
.offset(0);
|
|
135
|
+
const result = builder.build().run();
|
|
136
|
+
|
|
137
|
+
expect(result.query).toBe(
|
|
138
|
+
'SELECT * FROM user WHERE email = $email AND created_at <= $created_at AND ' +
|
|
139
|
+
'(username = $or0 OR email = $or1) ORDER BY created_at asc LIMIT 50 START 0;'
|
|
140
|
+
);
|
|
141
|
+
expect(result.vars).toEqual({ email: 'e', created_at: 5, or0: 'p', or1: 'p' });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should produce a stable hash for the same logical filtered query', () => {
|
|
145
|
+
const make = () =>
|
|
146
|
+
new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
|
|
147
|
+
.where({ email: 'e', _or: [{ username: 'p' }, { email: 'p' }] })
|
|
148
|
+
.orderBy('created_at', 'asc')
|
|
149
|
+
.limit(50)
|
|
150
|
+
.offset(0)
|
|
151
|
+
.build()
|
|
152
|
+
.run();
|
|
153
|
+
expect(make().hash).toBe(make().hash);
|
|
154
|
+
|
|
155
|
+
const different = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
|
|
156
|
+
.where({ email: 'e', _or: [{ username: 'q' }, { email: 'q' }] })
|
|
157
|
+
.orderBy('created_at', 'asc')
|
|
158
|
+
.limit(50)
|
|
159
|
+
.offset(0)
|
|
160
|
+
.build()
|
|
161
|
+
.run();
|
|
162
|
+
expect(different.hash).not.toBe(make().hash);
|
|
163
|
+
});
|
|
164
|
+
|
|
95
165
|
it('should build query with select fields', () => {
|
|
96
166
|
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
97
167
|
builder.select('username', 'email');
|
|
@@ -181,6 +251,23 @@ describe('Relationship Queries', () => {
|
|
|
181
251
|
'SELECT *, (SELECT *, (SELECT * FROM user WHERE id=$parent.author LIMIT 1)[0] AS author FROM comment WHERE thread=$parent.id) AS comments FROM thread;'
|
|
182
252
|
);
|
|
183
253
|
});
|
|
254
|
+
|
|
255
|
+
// An unknown relationship (e.g. a table owned by a devOnly backend that a
|
|
256
|
+
// free/Cloudflare deployment never provisions, so codegen drops it from the
|
|
257
|
+
// client schema) must be SKIPPED, not throw — otherwise it takes the whole
|
|
258
|
+
// query (and its other `.related()` siblings) down. This is what left the
|
|
259
|
+
// ThreadDetail page stuck on "Loading..." when `jobs` disappeared.
|
|
260
|
+
it('skips an unknown relationship instead of throwing', () => {
|
|
261
|
+
const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
|
|
262
|
+
expect(() => {
|
|
263
|
+
builder.related('author' as any);
|
|
264
|
+
builder.related('does_not_exist' as any); // must NOT throw
|
|
265
|
+
}).not.toThrow();
|
|
266
|
+
const result = builder.build().run();
|
|
267
|
+
// The valid relation is still projected; the unknown one is simply absent.
|
|
268
|
+
expect(result.query).toContain('AS author');
|
|
269
|
+
expect(result.query).not.toContain('does_not_exist');
|
|
270
|
+
});
|
|
184
271
|
});
|
|
185
272
|
|
|
186
273
|
describe('buildQueryFromOptions', () => {
|
|
@@ -270,11 +357,15 @@ describe('Edge Cases', () => {
|
|
|
270
357
|
describe('Type Tests', () => {
|
|
271
358
|
it('should enforce correct table names', () => {
|
|
272
359
|
// Valid table names should work
|
|
360
|
+
// oxlint-disable-next-line no-new
|
|
273
361
|
new QueryBuilder(testSchema, 'user');
|
|
362
|
+
// oxlint-disable-next-line no-new
|
|
274
363
|
new QueryBuilder(testSchema, 'thread');
|
|
364
|
+
// oxlint-disable-next-line no-new
|
|
275
365
|
new QueryBuilder(testSchema, 'comment');
|
|
276
366
|
|
|
277
367
|
// @ts-expect-error - invalid table name should not compile
|
|
368
|
+
// oxlint-disable-next-line no-new
|
|
278
369
|
new QueryBuilder(testSchema, 'invalid_table');
|
|
279
370
|
});
|
|
280
371
|
|
|
@@ -389,9 +480,6 @@ describe('Type Tests', () => {
|
|
|
389
480
|
});
|
|
390
481
|
|
|
391
482
|
describe('Schema Metadata Integration', () => {
|
|
392
|
-
// Using testSchema from top-level scope
|
|
393
|
-
type TestSchemaMetadata = typeof testSchema;
|
|
394
|
-
|
|
395
483
|
it('should accept testSchema in constructor', () => {
|
|
396
484
|
const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
|
|
397
485
|
|
package/src/query-builder.ts
CHANGED
|
@@ -7,6 +7,12 @@ import type {
|
|
|
7
7
|
RelatedQuery,
|
|
8
8
|
SchemaAwareQueryModifier,
|
|
9
9
|
SchemaAwareQueryModifierBuilder,
|
|
10
|
+
WhereInput,
|
|
11
|
+
QueryPlan,
|
|
12
|
+
RelationPlan,
|
|
13
|
+
WhereNode,
|
|
14
|
+
WhereComparison,
|
|
15
|
+
ComparisonOp,
|
|
10
16
|
} from './types';
|
|
11
17
|
import type {
|
|
12
18
|
TableNames,
|
|
@@ -172,7 +178,7 @@ export class InnerQuery<
|
|
|
172
178
|
/**
|
|
173
179
|
* Helper type to get the model type for a related table
|
|
174
180
|
*/
|
|
175
|
-
type
|
|
181
|
+
type _GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
|
|
176
182
|
RelatedTableName extends TableNames<S> ? TableModel<GetTable<S, RelatedTableName>> : never;
|
|
177
183
|
|
|
178
184
|
/**
|
|
@@ -234,6 +240,7 @@ export class FinalQuery<
|
|
|
234
240
|
S extends SchemaStructure,
|
|
235
241
|
TableName extends TableNames<S>,
|
|
236
242
|
T extends { columns: Record<string, ColumnSchema> },
|
|
243
|
+
// oxlint-disable-next-line no-unused-vars -- RelatedFields is used externally for type inference
|
|
237
244
|
RelatedFields extends RelatedFieldsMap,
|
|
238
245
|
IsOne extends boolean,
|
|
239
246
|
R = void,
|
|
@@ -299,7 +306,7 @@ class SchemaAwareQueryModifierBuilderImpl<
|
|
|
299
306
|
private readonly schema: S
|
|
300
307
|
) {}
|
|
301
308
|
|
|
302
|
-
where(conditions:
|
|
309
|
+
where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this {
|
|
303
310
|
this.options.where = { ...this.options.where, ...conditions };
|
|
304
311
|
return this;
|
|
305
312
|
}
|
|
@@ -365,9 +372,18 @@ class SchemaAwareQueryModifierBuilderImpl<
|
|
|
365
372
|
);
|
|
366
373
|
|
|
367
374
|
if (!relationship) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
375
|
+
// No such relationship in the client schema — e.g. a table owned by a
|
|
376
|
+
// devOnly backend (the outbox `job`) that a free/Cloudflare deployment
|
|
377
|
+
// never provisions, so codegen omits it + its relationships. Skip the
|
|
378
|
+
// projection instead of throwing, which would take the whole query
|
|
379
|
+
// (and its other `.related()` siblings — author, comments) down.
|
|
380
|
+
// Mirrors the server's "unpermitted subquery → empty" degradation.
|
|
381
|
+
if (typeof console !== 'undefined') {
|
|
382
|
+
console.warn(
|
|
383
|
+
`[sp00ky] .related('${String(relatedField)}') skipped — no such relationship on '${this.tableName}' in the client schema`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return this as any;
|
|
371
387
|
}
|
|
372
388
|
|
|
373
389
|
const relatedTable = relationship.to;
|
|
@@ -412,7 +428,7 @@ export class QueryBuilder<
|
|
|
412
428
|
* Add additional where conditions
|
|
413
429
|
*/
|
|
414
430
|
where(
|
|
415
|
-
conditions:
|
|
431
|
+
conditions: WhereInput<TableModel<GetTable<S, TableName>>>
|
|
416
432
|
): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
|
|
417
433
|
this.options.where = { ...this.options.where, ...conditions };
|
|
418
434
|
return this;
|
|
@@ -515,7 +531,15 @@ export class QueryBuilder<
|
|
|
515
531
|
);
|
|
516
532
|
|
|
517
533
|
if (!relationship) {
|
|
518
|
-
|
|
534
|
+
// See the note on the other `.related()` overload: skip an unknown
|
|
535
|
+
// relationship (warn) rather than throwing, so a table absent from the
|
|
536
|
+
// client schema (e.g. the free-plan `job` outbox) can't crash the query.
|
|
537
|
+
if (typeof console !== 'undefined') {
|
|
538
|
+
console.warn(
|
|
539
|
+
`[sp00ky] .related('${String(field)}') skipped — no such relationship on '${this.tableName}' in the client schema`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
return this as any;
|
|
519
543
|
}
|
|
520
544
|
|
|
521
545
|
// Determine cardinality and modifier based on arguments
|
|
@@ -641,6 +665,7 @@ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
|
|
|
641
665
|
if (relationship) {
|
|
642
666
|
// Determine foreign key field
|
|
643
667
|
// rel.alias is guaranteed to be defined if relationship is found (matched r.field)
|
|
668
|
+
// oxlint-disable-next-line no-non-null-assertion -- alias is guaranteed defined when relationship is found
|
|
644
669
|
let foreignKeyField = rel.alias!;
|
|
645
670
|
|
|
646
671
|
if (relationship.cardinality === 'many') {
|
|
@@ -751,32 +776,52 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
|
|
|
751
776
|
const vars: Record<string, unknown> = {};
|
|
752
777
|
if (parsedWhere && Object.keys(parsedWhere).length > 0) {
|
|
753
778
|
const conditions: string[] = [];
|
|
754
|
-
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
755
|
-
const varName = key;
|
|
756
779
|
|
|
757
|
-
|
|
780
|
+
// Build a single condition for `field`, binding its value under `varName`.
|
|
781
|
+
// Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
|
|
782
|
+
// 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
|
|
783
|
+
// Plain values mean equality (`field = $varName`).
|
|
784
|
+
const buildCondition = (field: string, value: unknown, varName: string): string => {
|
|
758
785
|
if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
|
|
759
786
|
const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
|
|
760
|
-
|
|
761
|
-
let rightSide = '';
|
|
787
|
+
let rightSide: string;
|
|
762
788
|
if (typeof _val === 'string' && _val.startsWith('$')) {
|
|
763
789
|
rightSide = _val;
|
|
764
790
|
} else {
|
|
765
791
|
vars[varName] = _val;
|
|
766
792
|
rightSide = `$${varName}`;
|
|
767
793
|
}
|
|
794
|
+
return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
|
|
795
|
+
}
|
|
796
|
+
vars[varName] = value;
|
|
797
|
+
return `${field} = $${varName}`;
|
|
798
|
+
};
|
|
768
799
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
800
|
+
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
801
|
+
// OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
|
|
802
|
+
// to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
|
|
803
|
+
// a unique, position-indexed param name (`or0`, `or1`, …) so it never
|
|
804
|
+
// collides with a top-level condition on the same field (e.g. a `white =
|
|
805
|
+
// $white` filter alongside an opponent `_or` on white/black) — keeping the
|
|
806
|
+
// surql + vars, and thus the query hash, stable and deterministic.
|
|
807
|
+
if (key === '_or' && Array.isArray(value)) {
|
|
808
|
+
const orParts: string[] = [];
|
|
809
|
+
let i = 0;
|
|
810
|
+
for (const branch of value) {
|
|
811
|
+
if (branch && typeof branch === 'object') {
|
|
812
|
+
for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
|
|
813
|
+
orParts.push(buildCondition(bField, bVal, `or${i++}`));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
773
816
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
conditions.push(`${key} = $${varName}`);
|
|
817
|
+
if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
|
|
818
|
+
continue;
|
|
777
819
|
}
|
|
820
|
+
|
|
821
|
+
conditions.push(buildCondition(key, value, key));
|
|
778
822
|
}
|
|
779
|
-
|
|
823
|
+
|
|
824
|
+
if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
|
|
780
825
|
}
|
|
781
826
|
|
|
782
827
|
// Add PATCH for UPDATE
|
|
@@ -815,7 +860,167 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
|
|
|
815
860
|
0
|
|
816
861
|
),
|
|
817
862
|
vars: Object.keys(vars).length > 0 ? vars : undefined,
|
|
863
|
+
// Engine-neutral plan mirrors the SELECT above for non-SurrealQL backends.
|
|
864
|
+
// Only SELECT carries a plan; the isOne→limit=1 mutation above is already
|
|
865
|
+
// reflected in `options.limit`, so the plan sees it too.
|
|
866
|
+
plan: method === 'SELECT' ? buildQueryPlan(tableName, options, schema) : undefined,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Build the engine-neutral {@link QueryPlan} for a SELECT. Mirrors the string
|
|
872
|
+
* assembly in {@link buildQueryFromOptions} / {@link buildSubquery} exactly so a
|
|
873
|
+
* non-SurrealQL backend produces results identical to the SurrealQL path.
|
|
874
|
+
*/
|
|
875
|
+
function buildQueryPlan<TModel extends GenericModel, IsOne extends boolean>(
|
|
876
|
+
tableName: string,
|
|
877
|
+
options: QueryOptions<TModel, IsOne>,
|
|
878
|
+
schema: SchemaStructure
|
|
879
|
+
): QueryPlan {
|
|
880
|
+
const plan: QueryPlan = { table: tableName };
|
|
881
|
+
|
|
882
|
+
if (options.select && options.select.length > 0 && !options.select.includes('*')) {
|
|
883
|
+
plan.select = options.select.filter((f) => f !== '*') as string[];
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const parsedWhere = options.where
|
|
887
|
+
? (parseObjectIdsToRecordId(options.where, tableName) as Record<string, unknown>)
|
|
888
|
+
: undefined;
|
|
889
|
+
if (parsedWhere && Object.keys(parsedWhere).length > 0) {
|
|
890
|
+
const nodes = buildWhereNodes(parsedWhere);
|
|
891
|
+
if (nodes.length > 0) plan.where = nodes;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (options.orderBy && Object.keys(options.orderBy).length > 0) {
|
|
895
|
+
plan.orderBy = Object.entries(options.orderBy).map(
|
|
896
|
+
([field, direction]) => [field, direction as 'asc' | 'desc']
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (options.limit !== undefined) plan.limit = options.limit;
|
|
901
|
+
if (options.offset !== undefined) plan.offset = options.offset;
|
|
902
|
+
|
|
903
|
+
if (options.related && options.related.length > 0) {
|
|
904
|
+
plan.relations = options.related.map((rel) => buildRelationPlan(rel, schema));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return plan;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Engine-neutral counterpart of {@link buildSubquery}. Resolves the same
|
|
912
|
+
* cardinality / foreign-key / nested-relation metadata but returns a structured
|
|
913
|
+
* {@link RelationPlan} instead of a SurrealQL subquery string.
|
|
914
|
+
*/
|
|
915
|
+
function buildRelationPlan(
|
|
916
|
+
rel: RelatedQuery & { foreignKeyField?: string },
|
|
917
|
+
schema: SchemaStructure
|
|
918
|
+
): RelationPlan {
|
|
919
|
+
const { relatedTable, alias, modifier, cardinality } = rel;
|
|
920
|
+
// Same fallback chain as buildSubquery (`rel.foreignKeyField || alias`); the
|
|
921
|
+
// top-level foreignKeyField is already reverse-resolved by `.related()`.
|
|
922
|
+
const foreignKeyField = (rel.foreignKeyField || alias || relatedTable) as string;
|
|
923
|
+
|
|
924
|
+
const plan: RelationPlan = {
|
|
925
|
+
alias: (alias || relatedTable) as string,
|
|
926
|
+
table: relatedTable,
|
|
927
|
+
cardinality,
|
|
928
|
+
foreignKeyField,
|
|
818
929
|
};
|
|
930
|
+
|
|
931
|
+
if (modifier) {
|
|
932
|
+
const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
|
|
933
|
+
modifier(modifierBuilder as any);
|
|
934
|
+
const subOptions = modifierBuilder._getOptions();
|
|
935
|
+
|
|
936
|
+
if (subOptions.select && subOptions.select.length > 0 && !subOptions.select.includes('*')) {
|
|
937
|
+
plan.select = subOptions.select.filter((f) => f !== '*') as string[];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (subOptions.where && Object.keys(subOptions.where).length > 0) {
|
|
941
|
+
const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
|
|
942
|
+
string,
|
|
943
|
+
unknown
|
|
944
|
+
>;
|
|
945
|
+
const nodes = buildWhereNodes(parsedSubWhere);
|
|
946
|
+
if (nodes.length > 0) plan.where = nodes;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
|
|
950
|
+
plan.orderBy = Object.entries(subOptions.orderBy).map(
|
|
951
|
+
([field, direction]) => [field, direction as 'asc' | 'desc']
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (subOptions.limit !== undefined) plan.limit = subOptions.limit;
|
|
956
|
+
|
|
957
|
+
// Nested relations: re-resolve exactly as buildSubquery does — the child's
|
|
958
|
+
// foreignKeyField comes from the relatedTable-based lookup, not the reverse
|
|
959
|
+
// heuristic used for top-level relations.
|
|
960
|
+
if (subOptions.related && subOptions.related.length > 0) {
|
|
961
|
+
const resolvedNestedRels = subOptions.related.map((nestedRel) => {
|
|
962
|
+
const relationship = schema.relationships.find(
|
|
963
|
+
(r) => r.from === relatedTable && r.field === nestedRel.alias
|
|
964
|
+
);
|
|
965
|
+
if (relationship) {
|
|
966
|
+
const nestedForeignKeyField =
|
|
967
|
+
relationship.cardinality === 'many' ? relatedTable : (nestedRel.alias as string);
|
|
968
|
+
return {
|
|
969
|
+
...nestedRel,
|
|
970
|
+
relatedTable: relationship.to,
|
|
971
|
+
cardinality: relationship.cardinality,
|
|
972
|
+
foreignKeyField: nestedForeignKeyField,
|
|
973
|
+
} as RelatedQuery & { foreignKeyField: string };
|
|
974
|
+
}
|
|
975
|
+
return nestedRel;
|
|
976
|
+
});
|
|
977
|
+
plan.relations = resolvedNestedRels.map((nestedRel) => buildRelationPlan(nestedRel, schema));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// one-to-one gets an implicit per-parent LIMIT 1 (matches buildSubquery).
|
|
982
|
+
if (cardinality === 'one' && plan.limit === undefined) {
|
|
983
|
+
plan.limit = 1;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return plan;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Convert a parsed WHERE object (string IDs already → RecordId) into the
|
|
991
|
+
* engine-neutral {@link WhereNode}[] conjunction. Mirrors the `_or` / comparison
|
|
992
|
+
* / equality handling in {@link buildQueryFromOptions}. A `$`-prefixed `_val`
|
|
993
|
+
* becomes a `paramRef` (with the leading `$` stripped).
|
|
994
|
+
*/
|
|
995
|
+
function buildWhereNodes(parsedWhere: Record<string, unknown>): WhereNode[] {
|
|
996
|
+
const toComparison = (field: string, value: unknown): WhereComparison => {
|
|
997
|
+
if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
|
|
998
|
+
const { _op, _val, _swap } = value as ComparisonOp;
|
|
999
|
+
if (typeof _val === 'string' && _val.startsWith('$')) {
|
|
1000
|
+
return { field, op: _op, value: undefined, paramRef: _val.slice(1), swap: _swap };
|
|
1001
|
+
}
|
|
1002
|
+
return { field, op: _op, value: _val, swap: _swap };
|
|
1003
|
+
}
|
|
1004
|
+
return { field, op: '=', value };
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
const nodes: WhereNode[] = [];
|
|
1008
|
+
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
1009
|
+
if (key === '_or' && Array.isArray(value)) {
|
|
1010
|
+
const or: WhereComparison[] = [];
|
|
1011
|
+
for (const branch of value) {
|
|
1012
|
+
if (branch && typeof branch === 'object') {
|
|
1013
|
+
for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
|
|
1014
|
+
or.push(toComparison(bField, bVal));
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (or.length > 0) nodes.push({ or });
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
nodes.push(toComparison(key, value));
|
|
1022
|
+
}
|
|
1023
|
+
return nodes;
|
|
819
1024
|
}
|
|
820
1025
|
|
|
821
1026
|
/**
|
package/src/table-schema.ts
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Supported value types in the schema
|
|
3
3
|
*/
|
|
4
|
-
export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json';
|
|
4
|
+
export type ValueType = 'string' | 'number' | 'boolean' | 'null' | 'json' | 'Uint8Array';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Column metadata defining the type and optionality of a field
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* CRDT types supported by Sp00ky's Loro integration
|
|
11
|
+
*/
|
|
12
|
+
export type CrdtType = 'text' | 'map' | 'list' | 'counter';
|
|
13
|
+
|
|
9
14
|
export interface ColumnSchema {
|
|
10
15
|
readonly type: ValueType;
|
|
11
16
|
readonly optional: boolean;
|
|
12
17
|
readonly dateTime?: boolean;
|
|
13
18
|
readonly recordId?: boolean;
|
|
19
|
+
readonly crdt?: CrdtType;
|
|
20
|
+
readonly cursor?: boolean;
|
|
21
|
+
/** True for `TYPE bytes` columns. Runtime values are `Uint8Array`. */
|
|
22
|
+
readonly bytes?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* True for `TYPE array<...>` columns. `type` then names the ELEMENT type, so
|
|
25
|
+
* the runtime value is `ElementType[]` (e.g. `array<string>` → `string[]`).
|
|
26
|
+
*/
|
|
27
|
+
readonly array?: boolean;
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
/**
|
|
@@ -62,16 +76,25 @@ export type TypeNameToTypeMap = {
|
|
|
62
76
|
boolean: boolean;
|
|
63
77
|
null: null;
|
|
64
78
|
json: unknown;
|
|
79
|
+
Uint8Array: Uint8Array;
|
|
65
80
|
};
|
|
66
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The element/base TS type of a column, wrapping in an array for `array: true`
|
|
84
|
+
* columns (where `type` names the element type).
|
|
85
|
+
*/
|
|
86
|
+
export type ColumnBaseTSType<T extends ColumnSchema> = T extends { array: true }
|
|
87
|
+
? TypeNameToTypeMap[T['type']][]
|
|
88
|
+
: TypeNameToTypeMap[T['type']];
|
|
89
|
+
|
|
67
90
|
/**
|
|
68
91
|
* Convert a column type to its TypeScript type
|
|
69
92
|
*/
|
|
70
93
|
export type ColumnToTSType<T extends ColumnSchema> = T extends {
|
|
71
94
|
optional: true;
|
|
72
95
|
}
|
|
73
|
-
?
|
|
74
|
-
:
|
|
96
|
+
? ColumnBaseTSType<T> | null
|
|
97
|
+
: ColumnBaseTSType<T>;
|
|
75
98
|
|
|
76
99
|
/**
|
|
77
100
|
* Helper to extract relationship field names for a table
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,79 @@ export interface QueryInfo {
|
|
|
23
23
|
query: string;
|
|
24
24
|
hash: number;
|
|
25
25
|
vars?: Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* Engine-neutral description of the same SELECT, used by non-SurrealQL local
|
|
28
|
+
* cache backends (e.g. SQLite) that cannot parse the `query` string. Only
|
|
29
|
+
* populated for `SELECT` (undefined for LIVE/UPDATE/DELETE). See `QueryPlan`.
|
|
30
|
+
*/
|
|
31
|
+
plan?: QueryPlan;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A single WHERE comparison. `value` is the resolved value (string IDs already
|
|
36
|
+
* converted to `RecordId`); when `paramRef` is set the condition references an
|
|
37
|
+
* existing query param verbatim (`$name`) instead of an inline value. `swap`
|
|
38
|
+
* flips the operands (`value op field`), mirroring `ComparisonOp._swap`.
|
|
39
|
+
*/
|
|
40
|
+
export interface WhereComparison {
|
|
41
|
+
field: string;
|
|
42
|
+
op: ComparisonOp['_op'];
|
|
43
|
+
value: unknown;
|
|
44
|
+
paramRef?: string;
|
|
45
|
+
swap?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A parenthesised `(c1 OR c2 …)` group, from a `_or` fragment. */
|
|
49
|
+
export interface WhereOr {
|
|
50
|
+
or: WhereComparison[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Engine-neutral WHERE: a top-level conjunction (AND) of comparisons and/or
|
|
55
|
+
* OR-groups. Mirrors `buildQueryFromOptions`'s condition assembly exactly.
|
|
56
|
+
*/
|
|
57
|
+
export type WhereNode = WhereComparison | WhereOr;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Engine-neutral description of a SELECT query. Backends render it to their own
|
|
61
|
+
* dialect (SurrealQL, SQLite, …). Relations are resolved by the caller via
|
|
62
|
+
* level-ordered decomposition rather than nested projection, so `relations`
|
|
63
|
+
* carries the tree rather than a flattened subquery string.
|
|
64
|
+
*/
|
|
65
|
+
export interface QueryPlan {
|
|
66
|
+
table: string;
|
|
67
|
+
/** Projection field names; undefined means all (`*`). */
|
|
68
|
+
select?: string[];
|
|
69
|
+
where?: WhereNode[];
|
|
70
|
+
orderBy?: [field: string, direction: 'asc' | 'desc'][];
|
|
71
|
+
limit?: number;
|
|
72
|
+
offset?: number;
|
|
73
|
+
relations?: RelationPlan[];
|
|
74
|
+
/**
|
|
75
|
+
* Window materialization: when set, the base rows are EXACTLY these record
|
|
76
|
+
* ids (the window the SSP already computed), ignoring `where`/`limit`/
|
|
77
|
+
* `offset`. `orderBy`, `select` and `relations` still apply. Set by
|
|
78
|
+
* {@link buildWindowMaterializationPlan}; see `window-query.ts`.
|
|
79
|
+
*/
|
|
80
|
+
ids?: unknown[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One `.related()` edge in a {@link QueryPlan}. Correlation:
|
|
85
|
+
* - `one` → parent[`foreignKeyField`] = child.id (attach `bucket[0] ?? null`)
|
|
86
|
+
* - `many` → child[`foreignKeyField`] = parent.id (attach `bucket`)
|
|
87
|
+
* `limit`/`orderBy` are applied PER PARENT during decomposition.
|
|
88
|
+
*/
|
|
89
|
+
export interface RelationPlan {
|
|
90
|
+
alias: string;
|
|
91
|
+
table: string;
|
|
92
|
+
cardinality: 'one' | 'many';
|
|
93
|
+
foreignKeyField: string;
|
|
94
|
+
select?: string[];
|
|
95
|
+
where?: WhereNode[];
|
|
96
|
+
orderBy?: [field: string, direction: 'asc' | 'desc'][];
|
|
97
|
+
limit?: number;
|
|
98
|
+
relations?: RelationPlan[];
|
|
26
99
|
}
|
|
27
100
|
|
|
28
101
|
export interface RelatedQuery {
|
|
@@ -36,9 +109,40 @@ export interface RelatedQuery {
|
|
|
36
109
|
cardinality: 'one' | 'many';
|
|
37
110
|
}
|
|
38
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Comparison-operator descriptor for a single WHERE field, e.g.
|
|
114
|
+
* `{ _op: '<=', _val: 5 }` → `field <= $field`. A `$`-prefixed string `_val`
|
|
115
|
+
* references an existing query param verbatim; `_swap: true` flips the operands
|
|
116
|
+
* (`$val _op field`). Plain values still mean equality (`field = $field`).
|
|
117
|
+
*/
|
|
118
|
+
export interface ComparisonOp {
|
|
119
|
+
_op: '=' | '!=' | '>' | '>=' | '<' | '<=' | (string & {});
|
|
120
|
+
_val: unknown;
|
|
121
|
+
_swap?: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A single WHERE field value: an equality value or a comparison descriptor. */
|
|
125
|
+
export type WhereFieldValue<V> = V | ComparisonOp;
|
|
126
|
+
|
|
127
|
+
/** A flat conjunction of field conditions (equality or comparison). */
|
|
128
|
+
export type WhereConditions<TModel extends GenericModel> = {
|
|
129
|
+
[K in keyof TModel]?: WhereFieldValue<TModel[K]>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* WHERE input for `.where()`. Supports equality (`{ field: value }`), comparison
|
|
134
|
+
* operators (`{ field: { _op, _val } }`), and a single top-level `_or` group of
|
|
135
|
+
* condition fragments that compile to a parenthesised `(... OR ...)` conjunct —
|
|
136
|
+
* e.g. `{ _or: [{ white: x }, { black: x }] }` → `(white = $or0 OR black = $or1)`.
|
|
137
|
+
* Backward-compatible with plain `Partial<TModel>` equality objects.
|
|
138
|
+
*/
|
|
139
|
+
export type WhereInput<TModel extends GenericModel> = WhereConditions<TModel> & {
|
|
140
|
+
_or?: WhereConditions<TModel>[];
|
|
141
|
+
};
|
|
142
|
+
|
|
39
143
|
export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean> {
|
|
40
144
|
select?: ((keyof TModel & string) | '*')[];
|
|
41
|
-
where?:
|
|
145
|
+
where?: WhereInput<TModel>;
|
|
42
146
|
limit?: number;
|
|
43
147
|
offset?: number;
|
|
44
148
|
orderBy?: Partial<Record<keyof TModel, 'asc' | 'desc'>>;
|
|
@@ -47,10 +151,10 @@ export interface QueryOptions<TModel extends GenericModel, IsOne extends boolean
|
|
|
47
151
|
isOne?: IsOne;
|
|
48
152
|
}
|
|
49
153
|
|
|
50
|
-
export
|
|
154
|
+
export type LiveQueryOptions<TModel extends GenericModel> = Omit<
|
|
51
155
|
QueryOptions<TModel, boolean>,
|
|
52
156
|
'orderBy'
|
|
53
|
-
|
|
157
|
+
>;
|
|
54
158
|
|
|
55
159
|
// Import schema types for schema-aware modifiers
|
|
56
160
|
import type {
|
|
@@ -78,7 +182,7 @@ export type SchemaAwareQueryModifier<
|
|
|
78
182
|
|
|
79
183
|
// Simplified query builder interface for modifying subqueries
|
|
80
184
|
export interface QueryModifierBuilder<TModel extends GenericModel> {
|
|
81
|
-
where(conditions:
|
|
185
|
+
where(conditions: WhereInput<TModel>): this;
|
|
82
186
|
select(...fields: ((keyof TModel & string) | '*')[]): this;
|
|
83
187
|
limit(count: number): this;
|
|
84
188
|
offset(count: number): this;
|
|
@@ -93,7 +197,7 @@ export interface SchemaAwareQueryModifierBuilder<
|
|
|
93
197
|
TableName extends TableNames<S>,
|
|
94
198
|
RelatedFields extends Record<string, any> = {},
|
|
95
199
|
> {
|
|
96
|
-
where(conditions:
|
|
200
|
+
where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this;
|
|
97
201
|
select(...fields: ((keyof TableModel<GetTable<S, TableName>> & string) | '*')[]): this;
|
|
98
202
|
limit(count: number): this;
|
|
99
203
|
offset(count: number): this;
|
|
@@ -139,6 +243,7 @@ export type RelationshipFields<TModel extends GenericModel> = {
|
|
|
139
243
|
* Simplified to directly access the nested structure
|
|
140
244
|
*/
|
|
141
245
|
export type InferRelatedModelFromMetadata<
|
|
246
|
+
// oxlint-disable-next-line no-unused-vars -- Schema is used as a generic constraint
|
|
142
247
|
Schema extends GenericSchema,
|
|
143
248
|
TableName extends string,
|
|
144
249
|
FieldName extends string,
|