@spooky-sync/query-builder 0.0.1-canary.38 → 0.0.1-canary.381
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 +64 -0
- package/dist/index.d.mts +194 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +194 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +228 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +227 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/skills/sp00ky-query-builder/SKILL.md +1 -0
- package/src/index.ts +13 -0
- package/src/query-builder.test.ts +257 -1
- package/src/query-builder.ts +366 -19
- package/src/table-schema.ts +62 -3
- package/src/types.ts +117 -3
|
@@ -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 { TableModel } from './table-schema';
|
|
4
|
+
import type { ChannelTableNames, TableModel } from './table-schema';
|
|
5
5
|
|
|
6
6
|
// Schema for testing the new array-based API
|
|
7
7
|
const testSchema = {
|
|
@@ -92,6 +92,84 @@ 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(
|
|
110
|
+
'SELECT * FROM user WHERE (username = $username__or0 OR email = $email__or1);'
|
|
111
|
+
);
|
|
112
|
+
expect(result.vars).toEqual({ username__or0: 'x', email__or1: 'x' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should not collide an _or branch with a top-level condition on the same field', () => {
|
|
116
|
+
// Mirrors the game filter where a color filter (white = me) coexists with an
|
|
117
|
+
// opponent OR on white/black: the OR branch must use its own param name.
|
|
118
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
119
|
+
builder.where({ username: 'me', _or: [{ username: 'opp' }, { email: 'opp' }] });
|
|
120
|
+
const result = builder.build().run();
|
|
121
|
+
|
|
122
|
+
expect(result.query).toBe(
|
|
123
|
+
'SELECT * FROM user WHERE username = $username AND ' +
|
|
124
|
+
'(username = $username__or0 OR email = $email__or1);'
|
|
125
|
+
);
|
|
126
|
+
expect(result.vars).toEqual({ username: 'me', username__or0: 'opp', email__or1: 'opp' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should combine equality + comparison + OR group + order/limit/offset', () => {
|
|
130
|
+
// The shape the filtered game list produces: scope equality, a date floor as
|
|
131
|
+
// an integer sort_index comparison, an opponent OR group, paginated.
|
|
132
|
+
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
133
|
+
builder
|
|
134
|
+
.where({ email: 'e', created_at: { _op: '<=', _val: 5 }, _or: [{ username: 'p' }, { email: 'p' }] })
|
|
135
|
+
.orderBy('created_at', 'asc')
|
|
136
|
+
.limit(50)
|
|
137
|
+
.offset(0);
|
|
138
|
+
const result = builder.build().run();
|
|
139
|
+
|
|
140
|
+
expect(result.query).toBe(
|
|
141
|
+
'SELECT * FROM user WHERE email = $email AND created_at <= $created_at AND ' +
|
|
142
|
+
'(username = $username__or0 OR email = $email__or1) ORDER BY created_at asc LIMIT 50 START 0;'
|
|
143
|
+
);
|
|
144
|
+
expect(result.vars).toEqual({
|
|
145
|
+
email: 'e',
|
|
146
|
+
created_at: 5,
|
|
147
|
+
username__or0: 'p',
|
|
148
|
+
email__or1: 'p',
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should produce a stable hash for the same logical filtered query', () => {
|
|
153
|
+
const make = () =>
|
|
154
|
+
new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
|
|
155
|
+
.where({ email: 'e', _or: [{ username: 'p' }, { email: 'p' }] })
|
|
156
|
+
.orderBy('created_at', 'asc')
|
|
157
|
+
.limit(50)
|
|
158
|
+
.offset(0)
|
|
159
|
+
.build()
|
|
160
|
+
.run();
|
|
161
|
+
expect(make().hash).toBe(make().hash);
|
|
162
|
+
|
|
163
|
+
const different = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery)
|
|
164
|
+
.where({ email: 'e', _or: [{ username: 'q' }, { email: 'q' }] })
|
|
165
|
+
.orderBy('created_at', 'asc')
|
|
166
|
+
.limit(50)
|
|
167
|
+
.offset(0)
|
|
168
|
+
.build()
|
|
169
|
+
.run();
|
|
170
|
+
expect(different.hash).not.toBe(make().hash);
|
|
171
|
+
});
|
|
172
|
+
|
|
95
173
|
it('should build query with select fields', () => {
|
|
96
174
|
const builder = new QueryBuilder(testSchema, 'user', (q) => q.selectQuery);
|
|
97
175
|
builder.select('username', 'email');
|
|
@@ -181,6 +259,23 @@ describe('Relationship Queries', () => {
|
|
|
181
259
|
'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
260
|
);
|
|
183
261
|
});
|
|
262
|
+
|
|
263
|
+
// An unknown relationship (e.g. a table owned by a devOnly backend that a
|
|
264
|
+
// free/Cloudflare deployment never provisions, so codegen drops it from the
|
|
265
|
+
// client schema) must be SKIPPED, not throw — otherwise it takes the whole
|
|
266
|
+
// query (and its other `.related()` siblings) down. This is what left the
|
|
267
|
+
// ThreadDetail page stuck on "Loading..." when `jobs` disappeared.
|
|
268
|
+
it('skips an unknown relationship instead of throwing', () => {
|
|
269
|
+
const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
|
|
270
|
+
expect(() => {
|
|
271
|
+
builder.related('author' as any);
|
|
272
|
+
builder.related('does_not_exist' as any); // must NOT throw
|
|
273
|
+
}).not.toThrow();
|
|
274
|
+
const result = builder.build().run();
|
|
275
|
+
// The valid relation is still projected; the unknown one is simply absent.
|
|
276
|
+
expect(result.query).toContain('AS author');
|
|
277
|
+
expect(result.query).not.toContain('does_not_exist');
|
|
278
|
+
});
|
|
184
279
|
});
|
|
185
280
|
|
|
186
281
|
describe('buildQueryFromOptions', () => {
|
|
@@ -215,6 +310,28 @@ describe('buildQueryFromOptions', () => {
|
|
|
215
310
|
|
|
216
311
|
expect(result.query).toBe('LIVE SELECT * FROM user WHERE username = $username;');
|
|
217
312
|
});
|
|
313
|
+
|
|
314
|
+
// Regression guard for the thread-detail "crossed results → 404" bug: the
|
|
315
|
+
// engine-neutral plan's top-level WHERE must reference the SAME var the surql
|
|
316
|
+
// binds (`$username`), so materialization (`select(plan, params)`) filters by
|
|
317
|
+
// the query's own `params` (its identity) instead of a baked literal that
|
|
318
|
+
// could belong to another query's plan. So the top-level plan node must carry
|
|
319
|
+
// `paramRef` equal to the surql var name.
|
|
320
|
+
it('top-level plan WHERE uses paramRef matching the surql var (slaved to params)', () => {
|
|
321
|
+
const result = buildQueryFromOptions<TableModel<(typeof testSchema)['tables'][0]>, boolean>(
|
|
322
|
+
'SELECT',
|
|
323
|
+
'user',
|
|
324
|
+
{ where: { username: 'john' } },
|
|
325
|
+
testSchema
|
|
326
|
+
);
|
|
327
|
+
// surql binds $username …
|
|
328
|
+
expect(result.query).toBe('SELECT * FROM user WHERE username = $username;');
|
|
329
|
+
expect(result.vars).toEqual({ username: 'john' });
|
|
330
|
+
// … and the plan references that same var, not just a baked literal.
|
|
331
|
+
expect(result.plan?.where).toEqual([
|
|
332
|
+
{ field: 'username', op: '=', value: 'john', paramRef: 'username' },
|
|
333
|
+
]);
|
|
334
|
+
});
|
|
218
335
|
});
|
|
219
336
|
|
|
220
337
|
describe('RecordId Parsing', () => {
|
|
@@ -469,3 +586,142 @@ describe('Subquery Filtering', () => {
|
|
|
469
586
|
);
|
|
470
587
|
});
|
|
471
588
|
});
|
|
589
|
+
|
|
590
|
+
// An `-- @opaque` column is synced to the client but never stored server-side,
|
|
591
|
+
// so nothing on the server can evaluate a predicate against it. The failure mode
|
|
592
|
+
// without a guard is silent and asymmetric: the LOCAL cache does hold the value,
|
|
593
|
+
// so the clause filters correctly on screen while the server-side membership set
|
|
594
|
+
// it is reconciled against was computed without it — rows appear and vanish
|
|
595
|
+
// instead of erroring. Fail at the call site instead.
|
|
596
|
+
const opaqueSchema = {
|
|
597
|
+
tables: [
|
|
598
|
+
{
|
|
599
|
+
name: 'document' as const,
|
|
600
|
+
columns: {
|
|
601
|
+
id: { type: 'string' as const, optional: false },
|
|
602
|
+
title: { type: 'string' as const, optional: false },
|
|
603
|
+
thumbnail: {
|
|
604
|
+
type: 'Uint8Array' as const,
|
|
605
|
+
optional: true,
|
|
606
|
+
bytes: true,
|
|
607
|
+
opaque: true,
|
|
608
|
+
},
|
|
609
|
+
meta: { type: 'json' as const, optional: true, opaque: true },
|
|
610
|
+
},
|
|
611
|
+
primaryKey: ['id'] as const,
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
relationships: [],
|
|
615
|
+
backends: {},
|
|
616
|
+
} as const;
|
|
617
|
+
|
|
618
|
+
describe('@opaque column guards', () => {
|
|
619
|
+
const qb = () => new QueryBuilder(opaqueSchema, 'document');
|
|
620
|
+
|
|
621
|
+
it('rejects an opaque column in where()', () => {
|
|
622
|
+
expect(() => qb().where({ thumbnail: null } as never)).toThrow(/thumbnail/);
|
|
623
|
+
expect(() => qb().where({ thumbnail: null } as never)).toThrow(/@opaque/);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('rejects an opaque column used with a comparison operator object', () => {
|
|
627
|
+
expect(() =>
|
|
628
|
+
qb().where({ thumbnail: { _op: '!=', _val: null } } as never)
|
|
629
|
+
).toThrow(/@opaque/);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('rejects an opaque column inside an _or branch', () => {
|
|
633
|
+
expect(() =>
|
|
634
|
+
qb().where({
|
|
635
|
+
_or: [{ title: 'a' }, { thumbnail: null }],
|
|
636
|
+
} as never)
|
|
637
|
+
).toThrow(/thumbnail/);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('rejects a nested path rooted at an opaque column', () => {
|
|
641
|
+
expect(() => qb().where({ 'meta.secret': 'x' } as never)).toThrow(/@opaque/);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('rejects an opaque column in orderBy()', () => {
|
|
645
|
+
expect(() => qb().orderBy('thumbnail' as never)).toThrow(/@opaque/);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('allows a normal column in where() and orderBy()', () => {
|
|
649
|
+
expect(() => qb().where({ title: 'a' }).orderBy('title')).not.toThrow();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('allows selecting an opaque column', () => {
|
|
653
|
+
// Projection is the whole point of @opaque: the value IS delivered, the
|
|
654
|
+
// client just cannot ask the server to filter on it.
|
|
655
|
+
expect(() => qb().select('id', 'thumbnail' as never)).not.toThrow();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('does not reject an opaque column name on a different table', () => {
|
|
659
|
+
// The flag is per (table, column); names are not globally unique.
|
|
660
|
+
const multi = {
|
|
661
|
+
tables: [
|
|
662
|
+
...opaqueSchema.tables,
|
|
663
|
+
{
|
|
664
|
+
name: 'other' as const,
|
|
665
|
+
columns: {
|
|
666
|
+
id: { type: 'string' as const, optional: false },
|
|
667
|
+
thumbnail: { type: 'string' as const, optional: false },
|
|
668
|
+
},
|
|
669
|
+
primaryKey: ['id'] as const,
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
relationships: [],
|
|
673
|
+
backends: {},
|
|
674
|
+
} as const;
|
|
675
|
+
expect(() =>
|
|
676
|
+
new QueryBuilder(multi, 'other').where({ thumbnail: 'x' } as never)
|
|
677
|
+
).not.toThrow();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// `-- @channel` tables accept `.channel(peers)`; `-- @ephemeral` columns only
|
|
682
|
+
// travel between peers, so the server can never filter on them.
|
|
683
|
+
const channelSchema = {
|
|
684
|
+
tables: [
|
|
685
|
+
{
|
|
686
|
+
name: 'call' as const,
|
|
687
|
+
columns: {
|
|
688
|
+
id: { type: 'string' as const, optional: false, recordId: true },
|
|
689
|
+
caller: { type: 'string' as const, optional: false, recordId: true },
|
|
690
|
+
callee: { type: 'string' as const, optional: false, recordId: true },
|
|
691
|
+
view_pos: { type: 'string' as const, optional: true, ephemeral: true },
|
|
692
|
+
},
|
|
693
|
+
channel: true as const,
|
|
694
|
+
primaryKey: ['id'] as const,
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: 'note' as const,
|
|
698
|
+
columns: {
|
|
699
|
+
id: { type: 'string' as const, optional: false, recordId: true },
|
|
700
|
+
title: { type: 'string' as const, optional: false },
|
|
701
|
+
},
|
|
702
|
+
primaryKey: ['id'] as const,
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
relationships: [],
|
|
706
|
+
backends: {},
|
|
707
|
+
} as const;
|
|
708
|
+
|
|
709
|
+
describe('channel schema', () => {
|
|
710
|
+
it('rejects an ephemeral column in where() and orderBy()', () => {
|
|
711
|
+
expect(() => new QueryBuilder(channelSchema, 'call').where({ view_pos: '1' } as never)).toThrow(/@ephemeral/);
|
|
712
|
+
expect(() => new QueryBuilder(channelSchema, 'call').orderBy('view_pos' as never)).toThrow(/@ephemeral/);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('ignoreUnusedArgs marks the query without changing it', () => {
|
|
716
|
+
const plain = new QueryBuilder(channelSchema, 'call').limit(5).build();
|
|
717
|
+
const marked = new QueryBuilder(channelSchema, 'call').limit(5).ignoreUnusedArgs().build();
|
|
718
|
+
expect(plain.ignoresUnusedArgs).toBe(false);
|
|
719
|
+
expect(marked.ignoresUnusedArgs).toBe(true);
|
|
720
|
+
expect(marked.hash).toBe(plain.hash);
|
|
721
|
+
expect(marked.innerQuery.selectQuery.query).toBe(plain.innerQuery.selectQuery.query);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('names the channel tables', () => {
|
|
725
|
+
expectTypeOf<ChannelTableNames<typeof channelSchema>>().toEqualTypeOf<'call'>();
|
|
726
|
+
});
|
|
727
|
+
});
|