@spooky-sync/query-builder 0.0.1-canary.20 → 0.0.1-canary.201
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 +134 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +134 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +187 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +187 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/skills/{spooky-query-builder → sp00ky-query-builder}/SKILL.md +9 -9
- package/src/index.ts +9 -0
- package/src/query-builder.test.ts +205 -4
- package/src/query-builder.ts +317 -20
- package/src/repro_relationship.test.ts +1 -1
- package/src/table-schema.ts +38 -3
- package/src/types.ts +110 -5
- /package/skills/{spooky-query-builder → sp00ky-query-builder}/references/type-helpers.md +0 -0
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,
|
|
@@ -19,6 +25,59 @@ import type {
|
|
|
19
25
|
ColumnSchema,
|
|
20
26
|
} from './table-schema';
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Reject a query clause that references an `-- @opaque` column.
|
|
30
|
+
*
|
|
31
|
+
* An `@opaque` value is synced down to the client but never held server-side, so
|
|
32
|
+
* the SSP has nothing to evaluate a predicate against. Left unchecked, the
|
|
33
|
+
* failure is silent and asymmetric: the local cache DOES hold the value, so the
|
|
34
|
+
* clause filters correctly on screen while the server-side membership set it is
|
|
35
|
+
* compared against was computed without it. The result reads as rows randomly
|
|
36
|
+
* appearing and vanishing rather than as an error, so fail loudly at the call
|
|
37
|
+
* site instead.
|
|
38
|
+
*
|
|
39
|
+
* `clause` names the offending API ('where', 'orderBy', …) for the message.
|
|
40
|
+
*/
|
|
41
|
+
function assertNotOpaque(
|
|
42
|
+
schema: SchemaStructure,
|
|
43
|
+
tableName: string,
|
|
44
|
+
clause: string,
|
|
45
|
+
fields: readonly string[]
|
|
46
|
+
): void {
|
|
47
|
+
const table = schema.tables.find((t) => t.name === tableName);
|
|
48
|
+
if (!table) return;
|
|
49
|
+
for (const field of fields) {
|
|
50
|
+
// A nested path (`meta.secret`) is checked against its root, since the column
|
|
51
|
+
// flag lives on `meta`. Comparison operators use `{ field: { _op, _val } }`
|
|
52
|
+
// rather than a name suffix, so the key is always the bare column name.
|
|
53
|
+
const column: ColumnSchema | undefined =
|
|
54
|
+
table.columns[field] ?? table.columns[field.split('.')[0]];
|
|
55
|
+
if (column?.opaque) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Cannot use '${field}' in ${clause}(): it is marked '-- @opaque' on ` +
|
|
58
|
+
`${tableName}, so the sync engine never stores its value and cannot ` +
|
|
59
|
+
`evaluate it. Read the field from query results instead, or remove ` +
|
|
60
|
+
`'-- @opaque' from the schema if you need to query on it.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every column name a `where` clause touches, including `_or` branches. */
|
|
67
|
+
function whereFieldNames(conditions: Record<string, unknown>): string[] {
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
70
|
+
if (key === '_or') {
|
|
71
|
+
for (const branch of (value ?? []) as Record<string, unknown>[]) {
|
|
72
|
+
out.push(...whereFieldNames(branch ?? {}));
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
out.push(key);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
22
81
|
/**
|
|
23
82
|
* Parse a string ID to RecordId
|
|
24
83
|
* - If it's in the format "table:id", use it as-is
|
|
@@ -172,7 +231,7 @@ export class InnerQuery<
|
|
|
172
231
|
/**
|
|
173
232
|
* Helper type to get the model type for a related table
|
|
174
233
|
*/
|
|
175
|
-
type
|
|
234
|
+
type _GetRelatedModel<S extends SchemaStructure, RelatedTableName extends string> =
|
|
176
235
|
RelatedTableName extends TableNames<S> ? TableModel<GetTable<S, RelatedTableName>> : never;
|
|
177
236
|
|
|
178
237
|
/**
|
|
@@ -234,6 +293,7 @@ export class FinalQuery<
|
|
|
234
293
|
S extends SchemaStructure,
|
|
235
294
|
TableName extends TableNames<S>,
|
|
236
295
|
T extends { columns: Record<string, ColumnSchema> },
|
|
296
|
+
// oxlint-disable-next-line no-unused-vars -- RelatedFields is used externally for type inference
|
|
237
297
|
RelatedFields extends RelatedFieldsMap,
|
|
238
298
|
IsOne extends boolean,
|
|
239
299
|
R = void,
|
|
@@ -299,7 +359,13 @@ class SchemaAwareQueryModifierBuilderImpl<
|
|
|
299
359
|
private readonly schema: S
|
|
300
360
|
) {}
|
|
301
361
|
|
|
302
|
-
where(conditions:
|
|
362
|
+
where(conditions: WhereInput<TableModel<GetTable<S, TableName>>>): this {
|
|
363
|
+
assertNotOpaque(
|
|
364
|
+
this.schema,
|
|
365
|
+
this.tableName,
|
|
366
|
+
'where',
|
|
367
|
+
whereFieldNames(conditions as Record<string, unknown>)
|
|
368
|
+
);
|
|
303
369
|
this.options.where = { ...this.options.where, ...conditions };
|
|
304
370
|
return this;
|
|
305
371
|
}
|
|
@@ -326,6 +392,7 @@ class SchemaAwareQueryModifierBuilderImpl<
|
|
|
326
392
|
field: keyof TableModel<GetTable<S, TableName>> & string,
|
|
327
393
|
direction: 'asc' | 'desc' = 'asc'
|
|
328
394
|
): this {
|
|
395
|
+
assertNotOpaque(this.schema, this.tableName, 'orderBy', [field]);
|
|
329
396
|
this.options.orderBy = {
|
|
330
397
|
...this.options.orderBy,
|
|
331
398
|
[field]: direction,
|
|
@@ -365,9 +432,18 @@ class SchemaAwareQueryModifierBuilderImpl<
|
|
|
365
432
|
);
|
|
366
433
|
|
|
367
434
|
if (!relationship) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
435
|
+
// No such relationship in the client schema — e.g. a table owned by a
|
|
436
|
+
// devOnly backend (the outbox `job`) that a free/Cloudflare deployment
|
|
437
|
+
// never provisions, so codegen omits it + its relationships. Skip the
|
|
438
|
+
// projection instead of throwing, which would take the whole query
|
|
439
|
+
// (and its other `.related()` siblings — author, comments) down.
|
|
440
|
+
// Mirrors the server's "unpermitted subquery → empty" degradation.
|
|
441
|
+
if (typeof console !== 'undefined') {
|
|
442
|
+
console.warn(
|
|
443
|
+
`[sp00ky] .related('${String(relatedField)}') skipped — no such relationship on '${this.tableName}' in the client schema`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return this as any;
|
|
371
447
|
}
|
|
372
448
|
|
|
373
449
|
const relatedTable = relationship.to;
|
|
@@ -412,8 +488,14 @@ export class QueryBuilder<
|
|
|
412
488
|
* Add additional where conditions
|
|
413
489
|
*/
|
|
414
490
|
where(
|
|
415
|
-
conditions:
|
|
491
|
+
conditions: WhereInput<TableModel<GetTable<S, TableName>>>
|
|
416
492
|
): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
|
|
493
|
+
assertNotOpaque(
|
|
494
|
+
this.schema,
|
|
495
|
+
this.tableName,
|
|
496
|
+
'where',
|
|
497
|
+
whereFieldNames(conditions as Record<string, unknown>)
|
|
498
|
+
);
|
|
417
499
|
this.options.where = { ...this.options.where, ...conditions };
|
|
418
500
|
return this;
|
|
419
501
|
}
|
|
@@ -438,6 +520,7 @@ export class QueryBuilder<
|
|
|
438
520
|
field: TableFieldNames<GetTable<S, TableName>>,
|
|
439
521
|
direction: 'asc' | 'desc' = 'asc'
|
|
440
522
|
): QueryBuilder<S, TableName, R, RelatedFields, IsOne> {
|
|
523
|
+
assertNotOpaque(this.schema, this.tableName, 'orderBy', [field as string]);
|
|
441
524
|
this.options.orderBy = {
|
|
442
525
|
...this.options.orderBy,
|
|
443
526
|
[field]: direction,
|
|
@@ -515,7 +598,15 @@ export class QueryBuilder<
|
|
|
515
598
|
);
|
|
516
599
|
|
|
517
600
|
if (!relationship) {
|
|
518
|
-
|
|
601
|
+
// See the note on the other `.related()` overload: skip an unknown
|
|
602
|
+
// relationship (warn) rather than throwing, so a table absent from the
|
|
603
|
+
// client schema (e.g. the free-plan `job` outbox) can't crash the query.
|
|
604
|
+
if (typeof console !== 'undefined') {
|
|
605
|
+
console.warn(
|
|
606
|
+
`[sp00ky] .related('${String(field)}') skipped — no such relationship on '${this.tableName}' in the client schema`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return this as any;
|
|
519
610
|
}
|
|
520
611
|
|
|
521
612
|
// Determine cardinality and modifier based on arguments
|
|
@@ -641,6 +732,7 @@ export function extractSubqueryQueryInfos<S extends SchemaStructure>(
|
|
|
641
732
|
if (relationship) {
|
|
642
733
|
// Determine foreign key field
|
|
643
734
|
// rel.alias is guaranteed to be defined if relationship is found (matched r.field)
|
|
735
|
+
// oxlint-disable-next-line no-non-null-assertion -- alias is guaranteed defined when relationship is found
|
|
644
736
|
let foreignKeyField = rel.alias!;
|
|
645
737
|
|
|
646
738
|
if (relationship.cardinality === 'many') {
|
|
@@ -751,32 +843,52 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
|
|
|
751
843
|
const vars: Record<string, unknown> = {};
|
|
752
844
|
if (parsedWhere && Object.keys(parsedWhere).length > 0) {
|
|
753
845
|
const conditions: string[] = [];
|
|
754
|
-
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
755
|
-
const varName = key;
|
|
756
846
|
|
|
757
|
-
|
|
847
|
+
// Build a single condition for `field`, binding its value under `varName`.
|
|
848
|
+
// Supports operator objects `{ _op, _val, _swap }` (e.g. `{ _op: '<=', _val:
|
|
849
|
+
// 5 }`); a `$`-prefixed string `_val` references an existing param verbatim.
|
|
850
|
+
// Plain values mean equality (`field = $varName`).
|
|
851
|
+
const buildCondition = (field: string, value: unknown, varName: string): string => {
|
|
758
852
|
if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
|
|
759
853
|
const { _op, _val, _swap } = value as { _op: string; _val: unknown; _swap?: boolean };
|
|
760
|
-
|
|
761
|
-
let rightSide = '';
|
|
854
|
+
let rightSide: string;
|
|
762
855
|
if (typeof _val === 'string' && _val.startsWith('$')) {
|
|
763
856
|
rightSide = _val;
|
|
764
857
|
} else {
|
|
765
858
|
vars[varName] = _val;
|
|
766
859
|
rightSide = `$${varName}`;
|
|
767
860
|
}
|
|
861
|
+
return _swap ? `${rightSide} ${_op} ${field}` : `${field} ${_op} ${rightSide}`;
|
|
862
|
+
}
|
|
863
|
+
vars[varName] = value;
|
|
864
|
+
return `${field} = $${varName}`;
|
|
865
|
+
};
|
|
768
866
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
867
|
+
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
868
|
+
// OR-group: `{ _or: [ {field: val}, {field: {_op,_val}}, ... ] }` compiles
|
|
869
|
+
// to one parenthesised `(c1 OR c2 ...)` conjunct. Each branch condition gets
|
|
870
|
+
// a unique, position-indexed param name (`or0`, `or1`, …) so it never
|
|
871
|
+
// collides with a top-level condition on the same field (e.g. a `white =
|
|
872
|
+
// $white` filter alongside an opponent `_or` on white/black) — keeping the
|
|
873
|
+
// surql + vars, and thus the query hash, stable and deterministic.
|
|
874
|
+
if (key === '_or' && Array.isArray(value)) {
|
|
875
|
+
const orParts: string[] = [];
|
|
876
|
+
let i = 0;
|
|
877
|
+
for (const branch of value) {
|
|
878
|
+
if (branch && typeof branch === 'object') {
|
|
879
|
+
for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
|
|
880
|
+
orParts.push(buildCondition(bField, bVal, `or${i++}`));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
773
883
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
conditions.push(`${key} = $${varName}`);
|
|
884
|
+
if (orParts.length > 0) conditions.push(`(${orParts.join(' OR ')})`);
|
|
885
|
+
continue;
|
|
777
886
|
}
|
|
887
|
+
|
|
888
|
+
conditions.push(buildCondition(key, value, key));
|
|
778
889
|
}
|
|
779
|
-
|
|
890
|
+
|
|
891
|
+
if (conditions.length > 0) query += ` WHERE ${conditions.join(' AND ')}`;
|
|
780
892
|
}
|
|
781
893
|
|
|
782
894
|
// Add PATCH for UPDATE
|
|
@@ -815,9 +927,194 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
|
|
|
815
927
|
0
|
|
816
928
|
),
|
|
817
929
|
vars: Object.keys(vars).length > 0 ? vars : undefined,
|
|
930
|
+
// Engine-neutral plan mirrors the SELECT above for non-SurrealQL backends.
|
|
931
|
+
// Only SELECT carries a plan; the isOne→limit=1 mutation above is already
|
|
932
|
+
// reflected in `options.limit`, so the plan sees it too.
|
|
933
|
+
plan: method === 'SELECT' ? buildQueryPlan(tableName, options, schema) : undefined,
|
|
818
934
|
};
|
|
819
935
|
}
|
|
820
936
|
|
|
937
|
+
/**
|
|
938
|
+
* Build the engine-neutral {@link QueryPlan} for a SELECT. Mirrors the string
|
|
939
|
+
* assembly in {@link buildQueryFromOptions} / {@link buildSubquery} exactly so a
|
|
940
|
+
* non-SurrealQL backend produces results identical to the SurrealQL path.
|
|
941
|
+
*/
|
|
942
|
+
function buildQueryPlan<TModel extends GenericModel, IsOne extends boolean>(
|
|
943
|
+
tableName: string,
|
|
944
|
+
options: QueryOptions<TModel, IsOne>,
|
|
945
|
+
schema: SchemaStructure
|
|
946
|
+
): QueryPlan {
|
|
947
|
+
const plan: QueryPlan = { table: tableName };
|
|
948
|
+
|
|
949
|
+
if (options.select && options.select.length > 0 && !options.select.includes('*')) {
|
|
950
|
+
plan.select = options.select.filter((f) => f !== '*') as string[];
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const parsedWhere = options.where
|
|
954
|
+
? (parseObjectIdsToRecordId(options.where, tableName) as Record<string, unknown>)
|
|
955
|
+
: undefined;
|
|
956
|
+
if (parsedWhere && Object.keys(parsedWhere).length > 0) {
|
|
957
|
+
// slaveToParams: top-level filters materialize from `params` (the query's
|
|
958
|
+
// identity), not a baked literal — see buildWhereNodes. Prevents a query's
|
|
959
|
+
// rows ever coming from a different query's plan.
|
|
960
|
+
const nodes = buildWhereNodes(parsedWhere, true);
|
|
961
|
+
if (nodes.length > 0) plan.where = nodes;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (options.orderBy && Object.keys(options.orderBy).length > 0) {
|
|
965
|
+
plan.orderBy = Object.entries(options.orderBy).map(
|
|
966
|
+
([field, direction]) => [field, direction as 'asc' | 'desc']
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (options.limit !== undefined) plan.limit = options.limit;
|
|
971
|
+
if (options.offset !== undefined) plan.offset = options.offset;
|
|
972
|
+
|
|
973
|
+
if (options.related && options.related.length > 0) {
|
|
974
|
+
plan.relations = options.related.map((rel) => buildRelationPlan(rel, schema));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return plan;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Engine-neutral counterpart of {@link buildSubquery}. Resolves the same
|
|
982
|
+
* cardinality / foreign-key / nested-relation metadata but returns a structured
|
|
983
|
+
* {@link RelationPlan} instead of a SurrealQL subquery string.
|
|
984
|
+
*/
|
|
985
|
+
function buildRelationPlan(
|
|
986
|
+
rel: RelatedQuery & { foreignKeyField?: string },
|
|
987
|
+
schema: SchemaStructure
|
|
988
|
+
): RelationPlan {
|
|
989
|
+
const { relatedTable, alias, modifier, cardinality } = rel;
|
|
990
|
+
// Same fallback chain as buildSubquery (`rel.foreignKeyField || alias`); the
|
|
991
|
+
// top-level foreignKeyField is already reverse-resolved by `.related()`.
|
|
992
|
+
const foreignKeyField = (rel.foreignKeyField || alias || relatedTable) as string;
|
|
993
|
+
|
|
994
|
+
const plan: RelationPlan = {
|
|
995
|
+
alias: (alias || relatedTable) as string,
|
|
996
|
+
table: relatedTable,
|
|
997
|
+
cardinality,
|
|
998
|
+
foreignKeyField,
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
if (modifier) {
|
|
1002
|
+
const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
|
|
1003
|
+
modifier(modifierBuilder as any);
|
|
1004
|
+
const subOptions = modifierBuilder._getOptions();
|
|
1005
|
+
|
|
1006
|
+
if (subOptions.select && subOptions.select.length > 0 && !subOptions.select.includes('*')) {
|
|
1007
|
+
plan.select = subOptions.select.filter((f) => f !== '*') as string[];
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (subOptions.where && Object.keys(subOptions.where).length > 0) {
|
|
1011
|
+
const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
|
|
1012
|
+
string,
|
|
1013
|
+
unknown
|
|
1014
|
+
>;
|
|
1015
|
+
const nodes = buildWhereNodes(parsedSubWhere);
|
|
1016
|
+
if (nodes.length > 0) plan.where = nodes;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
|
|
1020
|
+
plan.orderBy = Object.entries(subOptions.orderBy).map(
|
|
1021
|
+
([field, direction]) => [field, direction as 'asc' | 'desc']
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (subOptions.limit !== undefined) plan.limit = subOptions.limit;
|
|
1026
|
+
|
|
1027
|
+
// Nested relations: re-resolve exactly as buildSubquery does — the child's
|
|
1028
|
+
// foreignKeyField comes from the relatedTable-based lookup, not the reverse
|
|
1029
|
+
// heuristic used for top-level relations.
|
|
1030
|
+
if (subOptions.related && subOptions.related.length > 0) {
|
|
1031
|
+
const resolvedNestedRels = subOptions.related.map((nestedRel) => {
|
|
1032
|
+
const relationship = schema.relationships.find(
|
|
1033
|
+
(r) => r.from === relatedTable && r.field === nestedRel.alias
|
|
1034
|
+
);
|
|
1035
|
+
if (relationship) {
|
|
1036
|
+
const nestedForeignKeyField =
|
|
1037
|
+
relationship.cardinality === 'many' ? relatedTable : (nestedRel.alias as string);
|
|
1038
|
+
return {
|
|
1039
|
+
...nestedRel,
|
|
1040
|
+
relatedTable: relationship.to,
|
|
1041
|
+
cardinality: relationship.cardinality,
|
|
1042
|
+
foreignKeyField: nestedForeignKeyField,
|
|
1043
|
+
} as RelatedQuery & { foreignKeyField: string };
|
|
1044
|
+
}
|
|
1045
|
+
return nestedRel;
|
|
1046
|
+
});
|
|
1047
|
+
plan.relations = resolvedNestedRels.map((nestedRel) => buildRelationPlan(nestedRel, schema));
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// one-to-one gets an implicit per-parent LIMIT 1 (matches buildSubquery).
|
|
1052
|
+
if (cardinality === 'one' && plan.limit === undefined) {
|
|
1053
|
+
plan.limit = 1;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return plan;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Convert a parsed WHERE object (string IDs already → RecordId) into the
|
|
1061
|
+
* engine-neutral {@link WhereNode}[] conjunction. Mirrors the `_or` / comparison
|
|
1062
|
+
* / equality handling in {@link buildQueryFromOptions}. A `$`-prefixed `_val`
|
|
1063
|
+
* becomes a `paramRef` (with the leading `$` stripped).
|
|
1064
|
+
*/
|
|
1065
|
+
/**
|
|
1066
|
+
* @param slaveToParams When true, top-level plain/operator-literal comparisons
|
|
1067
|
+
* ALSO carry a `paramRef` equal to the field name — the same var name
|
|
1068
|
+
* `buildQueryFromOptions` binds the value under (`field = $field`). The
|
|
1069
|
+
* engines then materialize by reading `params[field]` (falling back to the
|
|
1070
|
+
* baked `value` when the param is absent), so a query's rows are slaved to
|
|
1071
|
+
* its `params` (its identity) and can never come from a different query's
|
|
1072
|
+
* baked plan. Only safe at the TOP LEVEL, where the field is a schema column
|
|
1073
|
+
* that survives `parseParams` and the caller passes `params`. NOT used for
|
|
1074
|
+
* relation sub-wheres (rendered with a params-less ctx) or `_or` branches
|
|
1075
|
+
* (bound under synthetic `or0…` names that `parseParams` strips) — those
|
|
1076
|
+
* stay baked.
|
|
1077
|
+
*/
|
|
1078
|
+
function buildWhereNodes(
|
|
1079
|
+
parsedWhere: Record<string, unknown>,
|
|
1080
|
+
slaveToParams = false
|
|
1081
|
+
): WhereNode[] {
|
|
1082
|
+
const toComparison = (field: string, value: unknown, slave: boolean): WhereComparison => {
|
|
1083
|
+
if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
|
|
1084
|
+
const { _op, _val, _swap } = value as ComparisonOp;
|
|
1085
|
+
if (typeof _val === 'string' && _val.startsWith('$')) {
|
|
1086
|
+
return { field, op: _op, value: undefined, paramRef: _val.slice(1), swap: _swap };
|
|
1087
|
+
}
|
|
1088
|
+
// Literal operand: keep `value` as a fallback and add `paramRef: field`
|
|
1089
|
+
// (slave mode) so materialization reads the query's own `params[field]`.
|
|
1090
|
+
return slave
|
|
1091
|
+
? { field, op: _op, value: _val, paramRef: field, swap: _swap }
|
|
1092
|
+
: { field, op: _op, value: _val, swap: _swap };
|
|
1093
|
+
}
|
|
1094
|
+
return slave ? { field, op: '=', value, paramRef: field } : { field, op: '=', value };
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
const nodes: WhereNode[] = [];
|
|
1098
|
+
for (const [key, value] of Object.entries(parsedWhere)) {
|
|
1099
|
+
if (key === '_or' && Array.isArray(value)) {
|
|
1100
|
+
const or: WhereComparison[] = [];
|
|
1101
|
+
for (const branch of value) {
|
|
1102
|
+
if (branch && typeof branch === 'object') {
|
|
1103
|
+
for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
|
|
1104
|
+
// OR branches bind under synthetic `or0…` names (see
|
|
1105
|
+
// buildQueryFromOptions) that parseParams strips — keep them baked.
|
|
1106
|
+
or.push(toComparison(bField, bVal, false));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (or.length > 0) nodes.push({ or });
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
nodes.push(toComparison(key, value, slaveToParams));
|
|
1114
|
+
}
|
|
1115
|
+
return nodes;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
821
1118
|
/**
|
|
822
1119
|
* Build a subquery for a related field
|
|
823
1120
|
*/
|
package/src/table-schema.ts
CHANGED
|
@@ -1,16 +1,42 @@
|
|
|
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;
|
|
28
|
+
/**
|
|
29
|
+
* True for `-- @opaque` columns: the value IS synced to the client and can be
|
|
30
|
+
* read from a query result, but the sync engine never stores it server-side.
|
|
31
|
+
*
|
|
32
|
+
* That makes it unusable for anything the server has to evaluate — `where`,
|
|
33
|
+
* `orderBy`, joins, aggregates, table permissions — because the SSP has no
|
|
34
|
+
* value to evaluate against. A predicate on such a column would appear to work
|
|
35
|
+
* locally (the local cache does hold the value) while matching nothing
|
|
36
|
+
* server-side, so the query builder rejects it outright instead of letting the
|
|
37
|
+
* two diverge silently.
|
|
38
|
+
*/
|
|
39
|
+
readonly opaque?: boolean;
|
|
14
40
|
}
|
|
15
41
|
|
|
16
42
|
/**
|
|
@@ -62,16 +88,25 @@ export type TypeNameToTypeMap = {
|
|
|
62
88
|
boolean: boolean;
|
|
63
89
|
null: null;
|
|
64
90
|
json: unknown;
|
|
91
|
+
Uint8Array: Uint8Array;
|
|
65
92
|
};
|
|
66
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The element/base TS type of a column, wrapping in an array for `array: true`
|
|
96
|
+
* columns (where `type` names the element type).
|
|
97
|
+
*/
|
|
98
|
+
export type ColumnBaseTSType<T extends ColumnSchema> = T extends { array: true }
|
|
99
|
+
? TypeNameToTypeMap[T['type']][]
|
|
100
|
+
: TypeNameToTypeMap[T['type']];
|
|
101
|
+
|
|
67
102
|
/**
|
|
68
103
|
* Convert a column type to its TypeScript type
|
|
69
104
|
*/
|
|
70
105
|
export type ColumnToTSType<T extends ColumnSchema> = T extends {
|
|
71
106
|
optional: true;
|
|
72
107
|
}
|
|
73
|
-
?
|
|
74
|
-
:
|
|
108
|
+
? ColumnBaseTSType<T> | null
|
|
109
|
+
: ColumnBaseTSType<T>;
|
|
75
110
|
|
|
76
111
|
/**
|
|
77
112
|
* 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,
|
|
File without changes
|