@spooky-sync/query-builder 0.0.1-canary.21 → 0.0.1-canary.211

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.
@@ -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 { TableNames, TableModel, GetTable } from './table-schema';
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,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', () => {
@@ -270,11 +387,15 @@ describe('Edge Cases', () => {
270
387
  describe('Type Tests', () => {
271
388
  it('should enforce correct table names', () => {
272
389
  // Valid table names should work
390
+ // oxlint-disable-next-line no-new
273
391
  new QueryBuilder(testSchema, 'user');
392
+ // oxlint-disable-next-line no-new
274
393
  new QueryBuilder(testSchema, 'thread');
394
+ // oxlint-disable-next-line no-new
275
395
  new QueryBuilder(testSchema, 'comment');
276
396
 
277
397
  // @ts-expect-error - invalid table name should not compile
398
+ // oxlint-disable-next-line no-new
278
399
  new QueryBuilder(testSchema, 'invalid_table');
279
400
  });
280
401
 
@@ -389,9 +510,6 @@ describe('Type Tests', () => {
389
510
  });
390
511
 
391
512
  describe('Schema Metadata Integration', () => {
392
- // Using testSchema from top-level scope
393
- type TestSchemaMetadata = typeof testSchema;
394
-
395
513
  it('should accept testSchema in constructor', () => {
396
514
  const builder = new QueryBuilder(testSchema, 'thread', (q) => q.selectQuery);
397
515
 
@@ -468,3 +586,94 @@ describe('Subquery Filtering', () => {
468
586
  );
469
587
  });
470
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
+ });