bun-sqlite-for-rxdb 1.0.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.
Files changed (68) hide show
  1. package/.serena/project.yml +84 -0
  2. package/CHANGELOG.md +300 -0
  3. package/LICENSE +21 -0
  4. package/README.md +87 -0
  5. package/ROADMAP.md +532 -0
  6. package/benchmarks/benchmark.ts +145 -0
  7. package/benchmarks/case-insensitive-10runs.ts +156 -0
  8. package/benchmarks/fts5-1m-scale.ts +126 -0
  9. package/benchmarks/fts5-before-after.ts +104 -0
  10. package/benchmarks/indexed-benchmark.ts +141 -0
  11. package/benchmarks/new-operators-benchmark.ts +140 -0
  12. package/benchmarks/query-builder-benchmark.ts +88 -0
  13. package/benchmarks/query-builder-consistency.ts +109 -0
  14. package/benchmarks/raw-better-sqlite3-10m.ts +85 -0
  15. package/benchmarks/raw-better-sqlite3.ts +86 -0
  16. package/benchmarks/raw-bun-sqlite-10m.ts +85 -0
  17. package/benchmarks/raw-bun-sqlite.ts +86 -0
  18. package/benchmarks/regex-10runs-all.ts +216 -0
  19. package/benchmarks/regex-comparison-benchmark.ts +161 -0
  20. package/benchmarks/regex-real-comparison.ts +213 -0
  21. package/benchmarks/run-10x.sh +19 -0
  22. package/benchmarks/smart-regex-benchmark.ts +148 -0
  23. package/benchmarks/sql-vs-mingo-benchmark.ts +210 -0
  24. package/benchmarks/sql-vs-mingo-comparison.ts +175 -0
  25. package/benchmarks/text-vs-jsonb.ts +167 -0
  26. package/benchmarks/wal-benchmark.ts +112 -0
  27. package/docs/architectural-patterns.md +1336 -0
  28. package/docs/id1-testsuite-journey.md +839 -0
  29. package/docs/official-test-suite-setup.md +393 -0
  30. package/nul +0 -0
  31. package/package.json +44 -0
  32. package/src/changestream.test.ts +182 -0
  33. package/src/cleanup.test.ts +110 -0
  34. package/src/collection-isolation.test.ts +74 -0
  35. package/src/connection-pool.test.ts +102 -0
  36. package/src/connection-pool.ts +38 -0
  37. package/src/findDocumentsById.test.ts +122 -0
  38. package/src/index.ts +2 -0
  39. package/src/instance.ts +382 -0
  40. package/src/multi-instance-events.test.ts +204 -0
  41. package/src/query/and-operator.test.ts +39 -0
  42. package/src/query/builder.test.ts +96 -0
  43. package/src/query/builder.ts +154 -0
  44. package/src/query/elemMatch-operator.test.ts +24 -0
  45. package/src/query/exists-operator.test.ts +28 -0
  46. package/src/query/in-operators.test.ts +54 -0
  47. package/src/query/mod-operator.test.ts +22 -0
  48. package/src/query/nested-query.test.ts +198 -0
  49. package/src/query/not-operators.test.ts +49 -0
  50. package/src/query/operators.test.ts +70 -0
  51. package/src/query/operators.ts +185 -0
  52. package/src/query/or-operator.test.ts +68 -0
  53. package/src/query/regex-escaping-regression.test.ts +43 -0
  54. package/src/query/regex-operator.test.ts +44 -0
  55. package/src/query/schema-mapper.ts +27 -0
  56. package/src/query/size-operator.test.ts +22 -0
  57. package/src/query/smart-regex.ts +52 -0
  58. package/src/query/type-operator.test.ts +37 -0
  59. package/src/query-cache.test.ts +286 -0
  60. package/src/rxdb-helpers.test.ts +348 -0
  61. package/src/rxdb-helpers.ts +262 -0
  62. package/src/schema-version-isolation.test.ts +126 -0
  63. package/src/statement-manager.ts +69 -0
  64. package/src/storage.test.ts +589 -0
  65. package/src/storage.ts +21 -0
  66. package/src/types.ts +14 -0
  67. package/test/rxdb-test-suite.ts +27 -0
  68. package/tsconfig.json +31 -0
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { buildWhereClause } from './builder';
3
+ import type { RxJsonSchema, RxDocumentData } from 'rxdb';
4
+
5
+ interface TestDocType {
6
+ id: string;
7
+ name: string;
8
+ age: number;
9
+ status: string;
10
+ }
11
+
12
+ const mockSchema: RxJsonSchema<RxDocumentData<TestDocType>> = {
13
+ version: 0,
14
+ primaryKey: 'id',
15
+ type: 'object',
16
+ properties: {
17
+ id: { type: 'string' },
18
+ name: { type: 'string' },
19
+ age: { type: 'number' },
20
+ status: { type: 'string' },
21
+ _deleted: { type: 'boolean' },
22
+ _attachments: { type: 'object' },
23
+ _rev: { type: 'string' },
24
+ _meta: {
25
+ type: 'object',
26
+ properties: {
27
+ lwt: { type: 'number' }
28
+ }
29
+ }
30
+ },
31
+ required: ['id', '_deleted', '_attachments', '_rev', '_meta']
32
+ };
33
+
34
+ describe('Query Builder', () => {
35
+ describe('buildWhereClause', () => {
36
+ it('builds simple equality', () => {
37
+ const result = buildWhereClause({ age: 18 }, mockSchema);
38
+ expect(result.sql).toContain('=');
39
+ expect(result.args).toEqual([18]);
40
+ });
41
+
42
+ it('builds $gt operator', () => {
43
+ const result = buildWhereClause({ age: { $gt: 18 } }, mockSchema);
44
+ expect(result.sql).toContain('>');
45
+ expect(result.args).toEqual([18]);
46
+ });
47
+
48
+ it('builds $gte operator', () => {
49
+ const result = buildWhereClause({ age: { $gte: 18 } }, mockSchema);
50
+ expect(result.sql).toContain('>=');
51
+ expect(result.args).toEqual([18]);
52
+ });
53
+
54
+ it('builds $lt operator', () => {
55
+ const result = buildWhereClause({ age: { $lt: 18 } }, mockSchema);
56
+ expect(result.sql).toContain('<');
57
+ expect(result.args).toEqual([18]);
58
+ });
59
+
60
+ it('builds $lte operator', () => {
61
+ const result = buildWhereClause({ age: { $lte: 18 } }, mockSchema);
62
+ expect(result.sql).toContain('<=');
63
+ expect(result.args).toEqual([18]);
64
+ });
65
+
66
+ it('builds multiple conditions with AND', () => {
67
+ const result = buildWhereClause({ age: 18, status: 'active' }, mockSchema);
68
+ expect(result.sql).toContain('AND');
69
+ expect(result.args).toEqual([18, 'active']);
70
+ });
71
+
72
+ it('handles null values', () => {
73
+ const result = buildWhereClause({ status: { $eq: null } }, mockSchema);
74
+ expect(result.sql).toContain('IS NULL');
75
+ expect(result.args).toEqual([]);
76
+ });
77
+
78
+ it('handles empty selector', () => {
79
+ const result = buildWhereClause({}, mockSchema);
80
+ expect(result.sql).toBe('1=1');
81
+ expect(result.args).toEqual([]);
82
+ });
83
+
84
+ it('uses column for _deleted', () => {
85
+ const result = buildWhereClause({ _deleted: false }, mockSchema);
86
+ expect(result.sql).toContain('deleted');
87
+ expect(result.args).toEqual([false]);
88
+ });
89
+
90
+ it('uses column for primary key', () => {
91
+ const result = buildWhereClause({ id: 'user1' }, mockSchema);
92
+ expect(result.sql).toContain('id');
93
+ expect(result.args).toEqual(['user1']);
94
+ });
95
+ });
96
+ });
@@ -0,0 +1,154 @@
1
+ import type { RxJsonSchema, MangoQuerySelector, RxDocumentData } from 'rxdb';
2
+ import { getColumnInfo } from './schema-mapper';
3
+ import { translateEq, translateNe, translateGt, translateGte, translateLt, translateLte, translateIn, translateNin, translateExists, translateRegex, translateElemMatch, translateNot, translateNor, translateType, translateSize, translateMod } from './operators';
4
+ import type { SqlFragment } from './operators';
5
+ import stringify from 'fast-stable-stringify';
6
+
7
+ const QUERY_CACHE = new Map<string, SqlFragment>();
8
+ const MAX_CACHE_SIZE = 500;
9
+
10
+ export function getCacheSize(): number {
11
+ return QUERY_CACHE.size;
12
+ }
13
+
14
+ export function clearCache(): void {
15
+ QUERY_CACHE.clear();
16
+ }
17
+
18
+ export function buildWhereClause<RxDocType>(
19
+ selector: MangoQuerySelector<RxDocumentData<RxDocType>>,
20
+ schema: RxJsonSchema<RxDocumentData<RxDocType>>
21
+ ): SqlFragment {
22
+ const cacheKey = `v${schema.version}_${stringify(selector)}`;
23
+
24
+ const cached = QUERY_CACHE.get(cacheKey);
25
+ if (cached) {
26
+ QUERY_CACHE.delete(cacheKey);
27
+ QUERY_CACHE.set(cacheKey, cached);
28
+ return cached;
29
+ }
30
+
31
+ const result = processSelector(selector, schema, 0);
32
+
33
+ if (QUERY_CACHE.size >= MAX_CACHE_SIZE) {
34
+ const firstKey = QUERY_CACHE.keys().next().value;
35
+ if (firstKey) QUERY_CACHE.delete(firstKey);
36
+ }
37
+
38
+ QUERY_CACHE.set(cacheKey, result);
39
+ return result;
40
+ }
41
+
42
+ function processSelector<RxDocType>(
43
+ selector: MangoQuerySelector<RxDocumentData<RxDocType>>,
44
+ schema: RxJsonSchema<RxDocumentData<RxDocType>>,
45
+ logicalDepth: number
46
+ ): SqlFragment {
47
+ const conditions: string[] = [];
48
+ const args: (string | number | boolean | null)[] = [];
49
+
50
+ for (const [field, value] of Object.entries(selector)) {
51
+ if (field === '$and' && Array.isArray(value)) {
52
+ const andFragments = value.map(subSelector => processSelector(subSelector, schema, logicalDepth));
53
+ const andConditions = andFragments.map(f => f.sql);
54
+ const needsParens = logicalDepth > 0 && andConditions.length > 1;
55
+ const joined = andConditions.join(' AND ');
56
+ conditions.push(needsParens ? `(${joined})` : joined);
57
+ andFragments.forEach(f => args.push(...f.args));
58
+ continue;
59
+ }
60
+
61
+ if (field === '$or' && Array.isArray(value)) {
62
+ const orFragments = value.map(subSelector => processSelector(subSelector, schema, logicalDepth + 1));
63
+ const orConditions = orFragments.map(f => f.sql);
64
+ const needsParens = logicalDepth > 0 && orConditions.length > 1;
65
+ const joined = orConditions.join(' OR ');
66
+ conditions.push(needsParens ? `(${joined})` : joined);
67
+ orFragments.forEach(f => args.push(...f.args));
68
+ continue;
69
+ }
70
+
71
+ if (field === '$nor' && Array.isArray(value)) {
72
+ const norFragment = translateNor(value);
73
+ conditions.push(norFragment.sql);
74
+ args.push(...norFragment.args);
75
+ continue;
76
+ }
77
+
78
+ const columnInfo = getColumnInfo(field, schema);
79
+ const fieldName = columnInfo.column || `json_extract(data, '${columnInfo.jsonPath}')`;
80
+
81
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
82
+ for (const [op, opValue] of Object.entries(value)) {
83
+ let fragment: SqlFragment;
84
+
85
+ switch (op) {
86
+ case '$eq':
87
+ fragment = translateEq(fieldName, opValue);
88
+ break;
89
+ case '$ne':
90
+ fragment = translateNe(fieldName, opValue);
91
+ break;
92
+ case '$gt':
93
+ fragment = translateGt(fieldName, opValue);
94
+ break;
95
+ case '$gte':
96
+ fragment = translateGte(fieldName, opValue);
97
+ break;
98
+ case '$lt':
99
+ fragment = translateLt(fieldName, opValue);
100
+ break;
101
+ case '$lte':
102
+ fragment = translateLte(fieldName, opValue);
103
+ break;
104
+ case '$in':
105
+ fragment = translateIn(fieldName, opValue as unknown[]);
106
+ break;
107
+ case '$nin':
108
+ fragment = translateNin(fieldName, opValue as unknown[]);
109
+ break;
110
+ case '$exists':
111
+ fragment = translateExists(fieldName, opValue as boolean);
112
+ break;
113
+ case '$regex':
114
+ const options = (value as Record<string, unknown>).$options as string | undefined;
115
+ const regexFragment = translateRegex(fieldName, opValue as string, options);
116
+ if (!regexFragment) continue;
117
+ fragment = regexFragment;
118
+ break;
119
+ case '$elemMatch':
120
+ const elemMatchFragment = translateElemMatch(fieldName, opValue);
121
+ if (!elemMatchFragment) continue;
122
+ fragment = elemMatchFragment;
123
+ break;
124
+ case '$not':
125
+ fragment = translateNot(fieldName, opValue);
126
+ break;
127
+ case '$type':
128
+ const typeFragment = translateType(fieldName, opValue as string);
129
+ if (!typeFragment) continue;
130
+ fragment = typeFragment;
131
+ break;
132
+ case '$size':
133
+ fragment = translateSize(fieldName, opValue as number);
134
+ break;
135
+ case '$mod':
136
+ fragment = translateMod(fieldName, opValue as [number, number]);
137
+ break;
138
+ default:
139
+ continue;
140
+ }
141
+
142
+ conditions.push(fragment.sql);
143
+ args.push(...fragment.args);
144
+ }
145
+ } else {
146
+ const fragment = translateEq(fieldName, value);
147
+ conditions.push(fragment.sql);
148
+ args.push(...fragment.args);
149
+ }
150
+ }
151
+
152
+ const where = conditions.length > 0 ? conditions.join(' AND ') : '1=1';
153
+ return { sql: where, args };
154
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { translateElemMatch } from './operators';
3
+
4
+ describe('$elemMatch Operator', () => {
5
+ it('returns null for simple equality match', () => {
6
+ const result = translateElemMatch('tags', { $eq: 'urgent' });
7
+ expect(result).toBeNull();
8
+ });
9
+
10
+ it('returns null for object with multiple conditions', () => {
11
+ const result = translateElemMatch('items', { price: { $gt: 100 }, qty: { $gte: 5 } });
12
+ expect(result).toBeNull();
13
+ });
14
+
15
+ it('returns null for nested conditions', () => {
16
+ const result = translateElemMatch('awards', { award: 'Turing Award', year: { $gt: 1980 } });
17
+ expect(result).toBeNull();
18
+ });
19
+
20
+ it('returns null for complex nested operators', () => {
21
+ const result = translateElemMatch('data', { $and: [{ status: 'active' }, { count: { $gte: 10 } }] });
22
+ expect(result).toBeNull();
23
+ });
24
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { translateExists } from './operators';
3
+
4
+ describe('$exists Operator', () => {
5
+ it('translates $exists: true to IS NOT NULL', () => {
6
+ const result = translateExists('age', true);
7
+ expect(result.sql).toBe('age IS NOT NULL');
8
+ expect(result.args).toEqual([]);
9
+ });
10
+
11
+ it('translates $exists: false to IS NULL', () => {
12
+ const result = translateExists('age', false);
13
+ expect(result.sql).toBe('age IS NULL');
14
+ expect(result.args).toEqual([]);
15
+ });
16
+
17
+ it('works with nested fields using json_extract', () => {
18
+ const result = translateExists("json_extract(data, '$.address.city')", true);
19
+ expect(result.sql).toBe("json_extract(data, '$.address.city') IS NOT NULL");
20
+ expect(result.args).toEqual([]);
21
+ });
22
+
23
+ it('handles boolean false correctly', () => {
24
+ const result = translateExists('status', false);
25
+ expect(result.sql).toBe('status IS NULL');
26
+ expect(result.args).toEqual([]);
27
+ });
28
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { translateIn, translateNin } from './operators';
3
+
4
+ describe('$in operator', () => {
5
+ it('generates IN clause for array of values', () => {
6
+ const result = translateIn('age', [25, 30, 35]);
7
+ expect(result.sql).toBe('age IN (?, ?, ?)');
8
+ expect(result.args).toEqual([25, 30, 35]);
9
+ });
10
+
11
+ it('handles NULL in array with OR IS NULL', () => {
12
+ const result = translateIn('status', ['active', null, 'pending']);
13
+ expect(result.sql).toBe('(status IN (?, ?) OR status IS NULL)');
14
+ expect(result.args).toEqual(['active', 'pending']);
15
+ });
16
+
17
+ it('handles array with only NULL', () => {
18
+ const result = translateIn('field', [null]);
19
+ expect(result.sql).toBe('field IS NULL');
20
+ expect(result.args).toEqual([]);
21
+ });
22
+
23
+ it('handles empty array as always false', () => {
24
+ const result = translateIn('field', []);
25
+ expect(result.sql).toBe('1=0');
26
+ expect(result.args).toEqual([]);
27
+ });
28
+ });
29
+
30
+ describe('$nin operator', () => {
31
+ it('generates NOT IN clause for array of values', () => {
32
+ const result = translateNin('age', [25, 30, 35]);
33
+ expect(result.sql).toBe('age NOT IN (?, ?, ?)');
34
+ expect(result.args).toEqual([25, 30, 35]);
35
+ });
36
+
37
+ it('handles NULL in array with AND IS NOT NULL', () => {
38
+ const result = translateNin('status', ['archived', null, 'deleted']);
39
+ expect(result.sql).toBe('(status NOT IN (?, ?) AND status IS NOT NULL)');
40
+ expect(result.args).toEqual(['archived', 'deleted']);
41
+ });
42
+
43
+ it('handles array with only NULL', () => {
44
+ const result = translateNin('field', [null]);
45
+ expect(result.sql).toBe('field IS NOT NULL');
46
+ expect(result.args).toEqual([]);
47
+ });
48
+
49
+ it('handles empty array as always true', () => {
50
+ const result = translateNin('field', []);
51
+ expect(result.sql).toBe('1=1');
52
+ expect(result.args).toEqual([]);
53
+ });
54
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { translateMod } from './operators';
3
+
4
+ describe('$mod Operator', () => {
5
+ it('translates modulo check to % operator', () => {
6
+ const result = translateMod('count', [5, 0]);
7
+ expect(result?.sql).toBe('count % ? = ?');
8
+ expect(result?.args).toEqual([5, 0]);
9
+ });
10
+
11
+ it('handles non-zero remainder', () => {
12
+ const result = translateMod('age', [10, 3]);
13
+ expect(result?.sql).toBe('age % ? = ?');
14
+ expect(result?.args).toEqual([10, 3]);
15
+ });
16
+
17
+ it('handles divisor of 1', () => {
18
+ const result = translateMod('value', [1, 0]);
19
+ expect(result?.sql).toBe('value % ? = ?');
20
+ expect(result?.args).toEqual([1, 0]);
21
+ });
22
+ });
@@ -0,0 +1,198 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { buildWhereClause } from './builder';
3
+ import type { MangoQuerySelector, RxJsonSchema, RxDocumentData } from 'rxdb';
4
+
5
+ interface TestDocType {
6
+ id: string;
7
+ name: string;
8
+ age: number;
9
+ status: string;
10
+ verified: boolean;
11
+ country: string;
12
+ role: string;
13
+ }
14
+
15
+ const mockSchema: RxJsonSchema<RxDocumentData<TestDocType>> = {
16
+ version: 0,
17
+ primaryKey: 'id',
18
+ type: 'object',
19
+ properties: {
20
+ id: { type: 'string' },
21
+ name: { type: 'string' },
22
+ age: { type: 'number' },
23
+ status: { type: 'string' },
24
+ verified: { type: 'boolean' },
25
+ country: { type: 'string' },
26
+ role: { type: 'string' },
27
+ _deleted: { type: 'boolean' },
28
+ _attachments: { type: 'object' },
29
+ _rev: { type: 'string' },
30
+ _meta: {
31
+ type: 'object',
32
+ properties: {
33
+ lwt: { type: 'number' }
34
+ }
35
+ }
36
+ },
37
+ required: ['id']
38
+ };
39
+
40
+ describe('Nested Query Builder - Depth Tracking', () => {
41
+ test('deeply nested $or inside $and inside $or', () => {
42
+ const selector: MangoQuerySelector<any> = {
43
+ $or: [
44
+ {
45
+ $and: [
46
+ { age: { $gte: 30 } },
47
+ {
48
+ $or: [
49
+ { status: 'active' },
50
+ { status: 'premium' }
51
+ ]
52
+ }
53
+ ]
54
+ },
55
+ { age: { $lt: 18 } }
56
+ ]
57
+ };
58
+
59
+ const result = buildWhereClause(selector, mockSchema);
60
+
61
+ expect(result.sql).toBe(
62
+ "(json_extract(data, '$.age') >= ? AND (json_extract(data, '$.status') = ? OR json_extract(data, '$.status') = ?)) OR json_extract(data, '$.age') < ?"
63
+ );
64
+ expect(result.args).toEqual([30, 'active', 'premium', 18]);
65
+ });
66
+
67
+ test('triple nested $and inside $or inside $and', () => {
68
+ const selector: MangoQuerySelector<any> = {
69
+ $and: [
70
+ { name: { $ne: null } },
71
+ {
72
+ $or: [
73
+ {
74
+ $and: [
75
+ { age: { $gte: 21 } },
76
+ { status: 'verified' }
77
+ ]
78
+ },
79
+ { role: 'admin' }
80
+ ]
81
+ }
82
+ ]
83
+ };
84
+
85
+ const result = buildWhereClause(selector, mockSchema);
86
+
87
+ expect(result.sql).toBe(
88
+ "json_extract(data, '$.name') IS NOT NULL AND (json_extract(data, '$.age') >= ? AND json_extract(data, '$.status') = ?) OR json_extract(data, '$.role') = ?"
89
+ );
90
+ expect(result.args).toEqual([21, 'verified', 'admin']);
91
+ });
92
+
93
+ test('complex nested with $in inside $or inside $and', () => {
94
+ const selector: MangoQuerySelector<any> = {
95
+ $and: [
96
+ {
97
+ $or: [
98
+ { age: { $in: [18, 19, 20] } },
99
+ { status: 'student' }
100
+ ]
101
+ },
102
+ { verified: true }
103
+ ]
104
+ };
105
+
106
+ const result = buildWhereClause(selector, mockSchema);
107
+
108
+ expect(result.sql).toBe(
109
+ "json_extract(data, '$.age') IN (?, ?, ?) OR json_extract(data, '$.status') = ? AND json_extract(data, '$.verified') = ?"
110
+ );
111
+ expect(result.args).toEqual([18, 19, 20, 'student', true]);
112
+ });
113
+
114
+ test('four-level nesting with mixed operators', () => {
115
+ const selector: MangoQuerySelector<any> = {
116
+ $or: [
117
+ {
118
+ $and: [
119
+ { country: 'US' },
120
+ {
121
+ $or: [
122
+ {
123
+ $and: [
124
+ { age: { $gte: 18 } },
125
+ { age: { $lte: 65 } }
126
+ ]
127
+ },
128
+ { status: 'exempt' }
129
+ ]
130
+ }
131
+ ]
132
+ },
133
+ { role: 'admin' }
134
+ ]
135
+ };
136
+
137
+ const result = buildWhereClause(selector, mockSchema);
138
+
139
+ expect(result.sql).toBe(
140
+ "(json_extract(data, '$.country') = ? AND ((json_extract(data, '$.age') >= ? AND json_extract(data, '$.age') <= ?) OR json_extract(data, '$.status') = ?)) OR json_extract(data, '$.role') = ?"
141
+ );
142
+ expect(result.args).toEqual(['US', 18, 65, 'exempt', 'admin']);
143
+ });
144
+
145
+ test('nested $or with $nin and $gt', () => {
146
+ const selector: MangoQuerySelector<any> = {
147
+ $or: [
148
+ {
149
+ $and: [
150
+ { status: { $nin: ['banned', 'suspended'] } },
151
+ { age: { $gt: 21 } }
152
+ ]
153
+ },
154
+ { role: { $in: ['admin', 'moderator'] } }
155
+ ]
156
+ };
157
+
158
+ const result = buildWhereClause(selector, mockSchema);
159
+
160
+ expect(result.sql).toBe(
161
+ "(json_extract(data, '$.status') NOT IN (?, ?) AND json_extract(data, '$.age') > ?) OR json_extract(data, '$.role') IN (?, ?)"
162
+ );
163
+ expect(result.args).toEqual(['banned', 'suspended', 21, 'admin', 'moderator']);
164
+ });
165
+
166
+ test('parentheses placement with single $or at root', () => {
167
+ const selector: MangoQuerySelector<any> = {
168
+ $or: [
169
+ { age: { $lt: 18 } },
170
+ { age: { $gt: 65 } }
171
+ ]
172
+ };
173
+
174
+ const result = buildWhereClause(selector, mockSchema);
175
+
176
+ expect(result.sql).toBe("json_extract(data, '$.age') < ? OR json_extract(data, '$.age') > ?");
177
+ expect(result.args).toEqual([18, 65]);
178
+ });
179
+
180
+ test('parentheses placement with nested $or at depth 1', () => {
181
+ const selector: MangoQuerySelector<any> = {
182
+ $and: [
183
+ { verified: true },
184
+ {
185
+ $or: [
186
+ { status: 'active' },
187
+ { status: 'trial' }
188
+ ]
189
+ }
190
+ ]
191
+ };
192
+
193
+ const result = buildWhereClause(selector, mockSchema);
194
+
195
+ expect(result.sql).toBe("json_extract(data, '$.verified') = ? AND json_extract(data, '$.status') = ? OR json_extract(data, '$.status') = ?");
196
+ expect(result.args).toEqual([true, 'active', 'trial']);
197
+ });
198
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { translateNot, translateNor } from './operators';
3
+
4
+ describe('$not Operator', () => {
5
+ it('negates simple equality', () => {
6
+ const result = translateNot('age', { $eq: 25 });
7
+ expect(result.sql).toBe('NOT(age = ?)');
8
+ expect(result.args).toEqual([25]);
9
+ });
10
+
11
+ it('negates greater than', () => {
12
+ const result = translateNot('age', { $gt: 50 });
13
+ expect(result.sql).toBe('NOT(age > ?)');
14
+ expect(result.args).toEqual([50]);
15
+ });
16
+
17
+ it('negates IN operator', () => {
18
+ const result = translateNot('status', { $in: ['active', 'pending'] });
19
+ expect(result.sql).toBe('NOT(status IN (?, ?))');
20
+ expect(result.args).toEqual(['active', 'pending']);
21
+ });
22
+ });
23
+
24
+ describe('$nor Operator', () => {
25
+ it('negates OR of two conditions', () => {
26
+ const result = translateNor([
27
+ { age: { $lt: 18 } },
28
+ { age: { $gt: 65 } }
29
+ ]);
30
+ expect(result.sql).toBe('NOT((age < ?) OR (age > ?))');
31
+ expect(result.args).toEqual([18, 65]);
32
+ });
33
+
34
+ it('negates OR of multiple conditions', () => {
35
+ const result = translateNor([
36
+ { status: { $eq: 'inactive' } },
37
+ { status: { $eq: 'deleted' } },
38
+ { status: { $eq: 'banned' } }
39
+ ]);
40
+ expect(result.sql).toBe('NOT((status = ?) OR (status = ?) OR (status = ?))');
41
+ expect(result.args).toEqual(['inactive', 'deleted', 'banned']);
42
+ });
43
+
44
+ it('handles empty array', () => {
45
+ const result = translateNor([]);
46
+ expect(result.sql).toBe('1=1');
47
+ expect(result.args).toEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { translateEq, translateNe, translateGt, translateGte, translateLt, translateLte } from './operators';
3
+
4
+ describe('Query Operators', () => {
5
+ describe('translateEq', () => {
6
+ it('translates equality with value', () => {
7
+ const result = translateEq('age', 18);
8
+ expect(result.sql).toBe('age = ?');
9
+ expect(result.args).toEqual([18]);
10
+ });
11
+
12
+ it('translates equality with null', () => {
13
+ const result = translateEq('status', null);
14
+ expect(result.sql).toBe('status IS NULL');
15
+ expect(result.args).toEqual([]);
16
+ });
17
+
18
+ it('translates equality with string', () => {
19
+ const result = translateEq('name', 'Alice');
20
+ expect(result.sql).toBe('name = ?');
21
+ expect(result.args).toEqual(['Alice']);
22
+ });
23
+ });
24
+
25
+ describe('translateNe', () => {
26
+ it('translates not equal with value', () => {
27
+ const result = translateNe('age', 18);
28
+ expect(result.sql).toBe('age <> ?');
29
+ expect(result.args).toEqual([18]);
30
+ });
31
+
32
+ it('translates not equal with null', () => {
33
+ const result = translateNe('status', null);
34
+ expect(result.sql).toBe('status IS NOT NULL');
35
+ expect(result.args).toEqual([]);
36
+ });
37
+ });
38
+
39
+ describe('translateGt', () => {
40
+ it('translates greater than', () => {
41
+ const result = translateGt('age', 18);
42
+ expect(result.sql).toBe('age > ?');
43
+ expect(result.args).toEqual([18]);
44
+ });
45
+ });
46
+
47
+ describe('translateGte', () => {
48
+ it('translates greater than or equal', () => {
49
+ const result = translateGte('age', 18);
50
+ expect(result.sql).toBe('age >= ?');
51
+ expect(result.args).toEqual([18]);
52
+ });
53
+ });
54
+
55
+ describe('translateLt', () => {
56
+ it('translates less than', () => {
57
+ const result = translateLt('age', 18);
58
+ expect(result.sql).toBe('age < ?');
59
+ expect(result.args).toEqual([18]);
60
+ });
61
+ });
62
+
63
+ describe('translateLte', () => {
64
+ it('translates less than or equal', () => {
65
+ const result = translateLte('age', 18);
66
+ expect(result.sql).toBe('age <= ?');
67
+ expect(result.args).toEqual([18]);
68
+ });
69
+ });
70
+ });