@rangka/core 0.1.0 → 0.1.2
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/package.json +6 -2
- package/.claude/skills/extend-core/SKILL.md +0 -133
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -18
- package/CLAUDE.md +0 -180
- package/src/__tests__/coerce.test.ts +0 -154
- package/src/__tests__/context.test.ts +0 -111
- package/src/__tests__/helpers.ts +0 -21
- package/src/__tests__/index.test.ts +0 -7
- package/src/__tests__/widgets.test.ts +0 -197
- package/src/api/__tests__/handlers.test.ts +0 -389
- package/src/api/__tests__/include-resolver.test.ts +0 -393
- package/src/api/__tests__/middleware.test.ts +0 -100
- package/src/api/__tests__/openapi-schema.test.ts +0 -210
- package/src/api/__tests__/query-parser.test.ts +0 -291
- package/src/api/__tests__/route-generator.test.ts +0 -137
- package/src/api/__tests__/server.test.ts +0 -73
- package/src/api/__tests__/swagger.test.ts +0 -166
- package/src/api/handlers.ts +0 -274
- package/src/api/include-resolver.ts +0 -27
- package/src/api/index.ts +0 -4
- package/src/api/meta-handler.ts +0 -254
- package/src/api/openapi-schema.ts +0 -99
- package/src/api/query-parser.ts +0 -315
- package/src/api/route-generator.ts +0 -448
- package/src/api/server.ts +0 -147
- package/src/api/types.ts +0 -16
- package/src/audit/__tests__/audit.test.ts +0 -144
- package/src/audit/index.ts +0 -3
- package/src/audit/record.ts +0 -69
- package/src/audit/tables.ts +0 -48
- package/src/audit/types.ts +0 -26
- package/src/auth/__tests__/core-module.test.ts +0 -54
- package/src/auth/__tests__/debug.test.ts +0 -47
- package/src/auth/__tests__/field-permissions.test.ts +0 -245
- package/src/auth/__tests__/integration.test.ts +0 -208
- package/src/auth/__tests__/meta-boot.test.ts +0 -538
- package/src/auth/__tests__/model-permissions.test.ts +0 -205
- package/src/auth/__tests__/password.test.ts +0 -29
- package/src/auth/__tests__/permission-registry.test.ts +0 -313
- package/src/auth/__tests__/scope-hook.test.ts +0 -509
- package/src/auth/__tests__/scope-registry.test.ts +0 -297
- package/src/auth/__tests__/scopes.test.ts +0 -66
- package/src/auth/__tests__/session.test.ts +0 -214
- package/src/auth/core-models.ts +0 -52
- package/src/auth/core-module.ts +0 -59
- package/src/auth/debug.ts +0 -157
- package/src/auth/field-permissions.ts +0 -116
- package/src/auth/index.ts +0 -37
- package/src/auth/model-permissions.ts +0 -59
- package/src/auth/password.ts +0 -22
- package/src/auth/permission-registry.ts +0 -171
- package/src/auth/scope-filters.ts +0 -11
- package/src/auth/scope-registry.ts +0 -121
- package/src/auth/scopes.ts +0 -146
- package/src/auth/seed.ts +0 -44
- package/src/auth/session.ts +0 -178
- package/src/auth/types.ts +0 -50
- package/src/boot/__tests__/page-scanning.test.ts +0 -170
- package/src/boot/__tests__/page-utils.test.ts +0 -225
- package/src/boot/__tests__/project-scanner.test.ts +0 -88
- package/src/boot/dependency-sort.ts +0 -82
- package/src/boot/discovery.ts +0 -85
- package/src/boot/index.ts +0 -457
- package/src/boot/page-utils.ts +0 -110
- package/src/boot/project-scanner.ts +0 -397
- package/src/boot/schema-loader.ts +0 -26
- package/src/boot/schema-merger.ts +0 -125
- package/src/boot/traits.ts +0 -25
- package/src/boot/types.ts +0 -73
- package/src/context.ts +0 -105
- package/src/db/__tests__/cascade-delete.test.ts +0 -182
- package/src/db/__tests__/desired-state.test.ts +0 -136
- package/src/db/__tests__/diff-engine.test.ts +0 -635
- package/src/db/__tests__/field-mapper.test.ts +0 -355
- package/src/db/__tests__/introspect.test.ts +0 -70
- package/src/db/__tests__/search-filter.test.ts +0 -45
- package/src/db/__tests__/sequence.test.ts +0 -221
- package/src/db/auto-sync.ts +0 -133
- package/src/db/client.ts +0 -147
- package/src/db/desired-state.ts +0 -98
- package/src/db/diff-engine.ts +0 -305
- package/src/db/field-mapper.ts +0 -504
- package/src/db/filter-applier.ts +0 -89
- package/src/db/include-resolver.ts +0 -40
- package/src/db/index.ts +0 -23
- package/src/db/introspect.ts +0 -265
- package/src/db/model-include-resolver.ts +0 -327
- package/src/db/model-ops.ts +0 -281
- package/src/db/scope-enforcer.ts +0 -37
- package/src/db/types.ts +0 -98
- package/src/errors.ts +0 -41
- package/src/events/__tests__/bus.test.ts +0 -105
- package/src/events/bus.ts +0 -89
- package/src/events/index.ts +0 -2
- package/src/events/types.ts +0 -9
- package/src/external-model/__tests__/computed-fields.test.ts +0 -106
- package/src/external-model/__tests__/field-mapper.test.ts +0 -160
- package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
- package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
- package/src/external-model/__tests__/query-executor.test.ts +0 -284
- package/src/external-model/__tests__/schema-converter.test.ts +0 -174
- package/src/external-model/computed-fields.ts +0 -15
- package/src/external-model/define.ts +0 -5
- package/src/external-model/external-model-ops.ts +0 -108
- package/src/external-model/field-mapper.ts +0 -66
- package/src/external-model/in-memory-ops.ts +0 -107
- package/src/external-model/index.ts +0 -7
- package/src/external-model/mutation-executor.ts +0 -71
- package/src/external-model/query-executor.ts +0 -100
- package/src/external-model/schema-converter.ts +0 -53
- package/src/external-model/types.ts +0 -32
- package/src/fixtures/__tests__/fixtures.test.ts +0 -203
- package/src/fixtures/index.ts +0 -10
- package/src/fixtures/loader.ts +0 -196
- package/src/fixtures/registry.ts +0 -125
- package/src/fixtures/types.ts +0 -33
- package/src/helpers/assert-ownership.ts +0 -19
- package/src/helpers/coerce.ts +0 -28
- package/src/helpers/stamping.ts +0 -28
- package/src/helpers/validation.ts +0 -14
- package/src/hooks/__tests__/context.test.ts +0 -73
- package/src/hooks/__tests__/executor.test.ts +0 -433
- package/src/hooks/__tests__/middleware.test.ts +0 -224
- package/src/hooks/__tests__/registry.test.ts +0 -50
- package/src/hooks/context.ts +0 -89
- package/src/hooks/errors.ts +0 -11
- package/src/hooks/executor.ts +0 -115
- package/src/hooks/index.ts +0 -10
- package/src/hooks/middleware.ts +0 -220
- package/src/hooks/registry.ts +0 -20
- package/src/hooks/types.ts +0 -32
- package/src/index.ts +0 -172
- package/src/jobs/__tests__/enqueue.test.ts +0 -77
- package/src/jobs/__tests__/integration.test.ts +0 -71
- package/src/jobs/__tests__/registry.test.ts +0 -103
- package/src/jobs/__tests__/scheduler.test.ts +0 -92
- package/src/jobs/__tests__/worker-execution.test.ts +0 -202
- package/src/jobs/__tests__/worker.test.ts +0 -119
- package/src/jobs/enqueue.ts +0 -93
- package/src/jobs/index.ts +0 -14
- package/src/jobs/registry.ts +0 -92
- package/src/jobs/scheduler.ts +0 -205
- package/src/jobs/tables.ts +0 -132
- package/src/jobs/types.ts +0 -62
- package/src/jobs/worker.ts +0 -272
- package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
- package/src/model-api/__tests__/extended-api.test.ts +0 -244
- package/src/model-api/__tests__/filter-applier.test.ts +0 -177
- package/src/model-api/__tests__/filter-translator.test.ts +0 -186
- package/src/model-api/__tests__/include-resolver.test.ts +0 -226
- package/src/model-api/__tests__/model-access.test.ts +0 -284
- package/src/model-api/__tests__/query-builder.test.ts +0 -224
- package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
- package/src/model-api/field-access.ts +0 -28
- package/src/model-api/filter-applier.ts +0 -1
- package/src/model-api/filter-translator.ts +0 -67
- package/src/model-api/include-resolver.ts +0 -2
- package/src/model-api/index.ts +0 -86
- package/src/model-api/query-builder.ts +0 -155
- package/src/model-api/scope-enforcer.ts +0 -3
- package/src/model-api/types.ts +0 -139
- package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
- package/src/plugins/__tests__/lifecycle.test.ts +0 -96
- package/src/plugins/__tests__/loader.test.ts +0 -273
- package/src/plugins/__tests__/validator.test.ts +0 -275
- package/src/plugins/adapter-registry.ts +0 -42
- package/src/plugins/define.ts +0 -5
- package/src/plugins/index.ts +0 -28
- package/src/plugins/lifecycle.ts +0 -27
- package/src/plugins/loader.ts +0 -126
- package/src/plugins/types.ts +0 -76
- package/src/plugins/validator.ts +0 -141
- package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
- package/src/schema/registry.ts +0 -93
- package/src/schema/relationships.ts +0 -93
- package/src/schema/types.ts +0 -43
- package/src/services/__tests__/integration.test.ts +0 -63
- package/src/services/__tests__/registry.test.ts +0 -175
- package/src/services/index.ts +0 -13
- package/src/services/registry.ts +0 -156
- package/src/services/types.ts +0 -27
- package/src/validation/__tests__/field-validator.test.ts +0 -195
- package/src/validation/field-validator.ts +0 -113
- package/src/validation/index.ts +0 -1
- package/src/widgets/index.ts +0 -3
- package/src/widgets/slot-validator.ts +0 -87
- package/src/widgets/widget-registry.ts +0 -32
- package/tests/boot.test.ts +0 -323
- package/tests/dependency-sort.test.ts +0 -99
- package/tests/discovery.test.ts +0 -126
- package/tests/registry.test.ts +0 -216
- package/tests/schema-loader.test.ts +0 -52
- package/tests/schema-merger.test.ts +0 -180
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -14
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { ModelQueryBuilder } from '../query-builder.js';
|
|
3
|
-
import type { SchemaRegistry } from '../../schema/registry.js';
|
|
4
|
-
import type { ResolvedModel } from '../../schema/types.js';
|
|
5
|
-
import type { RequestContext } from '../../auth/types.js';
|
|
6
|
-
import { KyselyModelOps } from '../../db/model-ops.js';
|
|
7
|
-
|
|
8
|
-
function makeModel(qualifiedName: string, traits: string[] = []): ResolvedModel {
|
|
9
|
-
return {
|
|
10
|
-
qualifiedName,
|
|
11
|
-
app: 'test',
|
|
12
|
-
module: qualifiedName.split('.')[0],
|
|
13
|
-
name: qualifiedName.split('.')[1],
|
|
14
|
-
auditLog: false,
|
|
15
|
-
traits,
|
|
16
|
-
fields: [
|
|
17
|
-
{ name: 'id', config: { type: 'string' }, provenance: { source: 'base' } },
|
|
18
|
-
{ name: 'name', config: { type: 'string', required: true }, provenance: { source: 'base' } },
|
|
19
|
-
{ name: 'status', config: { type: 'string' }, provenance: { source: 'base' } },
|
|
20
|
-
{
|
|
21
|
-
name: 'created_by',
|
|
22
|
-
config: { type: 'string' },
|
|
23
|
-
provenance: { source: 'trait', trait: 'timestamped' },
|
|
24
|
-
},
|
|
25
|
-
],
|
|
26
|
-
indexes: [],
|
|
27
|
-
} as ResolvedModel;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function makeRegistry(models: ResolvedModel[]): SchemaRegistry {
|
|
31
|
-
return {
|
|
32
|
-
getModel: (qn: string) => models.find((m) => m.qualifiedName === qn),
|
|
33
|
-
getAllModels: () => models,
|
|
34
|
-
getRelationshipsForModel: () => [],
|
|
35
|
-
getFieldsForModel: (qn: string) => models.find((m) => m.qualifiedName === qn)?.fields ?? [],
|
|
36
|
-
} as unknown as SchemaRegistry;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function makeMockDbAdapter(records: Record<string, unknown>[] = []): any {
|
|
40
|
-
const buildQuery = () => {
|
|
41
|
-
const q: any = {
|
|
42
|
-
_wheres: [],
|
|
43
|
-
_limit: undefined,
|
|
44
|
-
_offset: undefined,
|
|
45
|
-
_values: null,
|
|
46
|
-
_set: null,
|
|
47
|
-
};
|
|
48
|
-
q.select = vi.fn((..._args: any[]) => {
|
|
49
|
-
q._hasSelect = true;
|
|
50
|
-
return q;
|
|
51
|
-
});
|
|
52
|
-
q.selectAll = vi.fn(() => q);
|
|
53
|
-
q.where = vi.fn((field: string, op: string, value: any) => {
|
|
54
|
-
q._wheres.push({ field, op, value });
|
|
55
|
-
return q;
|
|
56
|
-
});
|
|
57
|
-
q.orderBy = vi.fn(() => q);
|
|
58
|
-
q.limit = vi.fn((n: number) => {
|
|
59
|
-
q._limit = n;
|
|
60
|
-
return q;
|
|
61
|
-
});
|
|
62
|
-
q.offset = vi.fn((n: number) => {
|
|
63
|
-
q._offset = n;
|
|
64
|
-
return q;
|
|
65
|
-
});
|
|
66
|
-
q.execute = vi.fn(async () => {
|
|
67
|
-
let data = [...records];
|
|
68
|
-
for (const w of q._wheres) {
|
|
69
|
-
if (w.op === '=') data = data.filter((r: any) => r[w.field] === w.value);
|
|
70
|
-
if (w.op === 'is' && w.value === null) data = data.filter((r: any) => r[w.field] == null);
|
|
71
|
-
}
|
|
72
|
-
const offset = q._offset ?? 0;
|
|
73
|
-
const limit = q._limit ?? data.length;
|
|
74
|
-
return data.slice(offset, offset + limit);
|
|
75
|
-
});
|
|
76
|
-
q.executeTakeFirst = vi.fn(async () => {
|
|
77
|
-
let data = [...records];
|
|
78
|
-
for (const w of q._wheres) {
|
|
79
|
-
if (w.op === '=') data = data.filter((r: any) => r[w.field] === w.value);
|
|
80
|
-
if (w.op === 'is' && w.value === null) data = data.filter((r: any) => r[w.field] == null);
|
|
81
|
-
}
|
|
82
|
-
if (q._hasSelect && !q._selectAll) return { count: String(data.length) };
|
|
83
|
-
return data[0] ?? undefined;
|
|
84
|
-
});
|
|
85
|
-
q.executeTakeFirstOrThrow = vi.fn(async () => {
|
|
86
|
-
const result = await q.executeTakeFirst();
|
|
87
|
-
if (!result) throw new Error('No result');
|
|
88
|
-
return result;
|
|
89
|
-
});
|
|
90
|
-
q.values = vi.fn((v: any) => {
|
|
91
|
-
q._values = v;
|
|
92
|
-
return q;
|
|
93
|
-
});
|
|
94
|
-
q.set = vi.fn((v: any) => {
|
|
95
|
-
q._set = v;
|
|
96
|
-
return q;
|
|
97
|
-
});
|
|
98
|
-
q.returningAll = vi.fn(() => q);
|
|
99
|
-
return q;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
selectFrom: vi.fn(() => buildQuery()),
|
|
104
|
-
insertInto: vi.fn(() => {
|
|
105
|
-
const q = buildQuery();
|
|
106
|
-
q.executeTakeFirstOrThrow = vi.fn(async () => ({ id: 'new-1', ...q._values }));
|
|
107
|
-
return q;
|
|
108
|
-
}),
|
|
109
|
-
updateTable: vi.fn(() => {
|
|
110
|
-
const q = buildQuery();
|
|
111
|
-
q.executeTakeFirstOrThrow = vi.fn(async () => ({ id: 'up-1', ...q._set }));
|
|
112
|
-
return q;
|
|
113
|
-
}),
|
|
114
|
-
deleteFrom: vi.fn(() => buildQuery()),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
describe('ModelQueryBuilder extended API', () => {
|
|
119
|
-
const model = makeModel('sales.Order');
|
|
120
|
-
const registry = makeRegistry([model]);
|
|
121
|
-
|
|
122
|
-
function createBuilder(db: any, m: ResolvedModel = model, r: SchemaRegistry = registry) {
|
|
123
|
-
const ops = new KyselyModelOps({ db, model: m, registry: r });
|
|
124
|
-
return new ModelQueryBuilder(ops, m, r);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
describe('page()', () => {
|
|
128
|
-
it('sets page for execWithMeta', async () => {
|
|
129
|
-
const records = Array.from({ length: 30 }, (_, i) => ({
|
|
130
|
-
id: `r${i}`,
|
|
131
|
-
name: `R${i}`,
|
|
132
|
-
status: 'a',
|
|
133
|
-
}));
|
|
134
|
-
const db = makeMockDbAdapter(records);
|
|
135
|
-
|
|
136
|
-
const result = await createBuilder(db).page(2).limit(10).execWithMeta();
|
|
137
|
-
expect(result.meta.page).toBe(2);
|
|
138
|
-
expect(result.meta.limit).toBe(10);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe('includeArchived()', () => {
|
|
143
|
-
it('includes archived records for soft_delete model', async () => {
|
|
144
|
-
const softModel = makeModel('sales.Order', ['soft_delete']);
|
|
145
|
-
const records = [
|
|
146
|
-
{ id: '1', name: 'Active', status: 'a', archived_at: null },
|
|
147
|
-
{ id: '2', name: 'Archived', status: 'a', archived_at: '2026-01-01' },
|
|
148
|
-
];
|
|
149
|
-
const db = makeMockDbAdapter(records);
|
|
150
|
-
const registry2 = makeRegistry([softModel]);
|
|
151
|
-
|
|
152
|
-
const withArchived = await createBuilder(db, softModel, registry2).includeArchived().exec();
|
|
153
|
-
|
|
154
|
-
const without = await createBuilder(db, softModel, registry2).exec();
|
|
155
|
-
|
|
156
|
-
expect(withArchived.data).toHaveLength(2);
|
|
157
|
-
expect(without.data).toHaveLength(1);
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe('withAuth()', () => {
|
|
162
|
-
it('applies scope filters from auth context', async () => {
|
|
163
|
-
const records = [
|
|
164
|
-
{ id: '1', name: 'A', status: 'a', created_by: 'u1' },
|
|
165
|
-
{ id: '2', name: 'B', status: 'a', created_by: 'u2' },
|
|
166
|
-
];
|
|
167
|
-
const db = makeMockDbAdapter(records);
|
|
168
|
-
|
|
169
|
-
const auth: RequestContext = {
|
|
170
|
-
scopeFilters: [{ field: 'created_by', operator: 'eq', value: 'u1' }],
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const result = await createBuilder(db).withAuth(auth).exec();
|
|
174
|
-
|
|
175
|
-
expect(result.data).toHaveLength(1);
|
|
176
|
-
expect(result.data[0].created_by).toBe('u1');
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('does not apply scopes when unscoped', async () => {
|
|
180
|
-
const records = [
|
|
181
|
-
{ id: '1', name: 'A', status: 'a', created_by: 'u1' },
|
|
182
|
-
{ id: '2', name: 'B', status: 'a', created_by: 'u2' },
|
|
183
|
-
];
|
|
184
|
-
const db = makeMockDbAdapter(records);
|
|
185
|
-
|
|
186
|
-
const auth: RequestContext = {
|
|
187
|
-
scopeFilters: [{ field: 'created_by', operator: 'eq', value: 'u1' }],
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const result = await createBuilder(db).withAuth(auth).unscoped().exec();
|
|
191
|
-
|
|
192
|
-
expect(result.data).toHaveLength(2);
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
describe('filterRaw()', () => {
|
|
197
|
-
it('accepts pre-translated filter tuples', async () => {
|
|
198
|
-
const records = [
|
|
199
|
-
{ id: '1', name: 'Alpha', status: 'active' },
|
|
200
|
-
{ id: '2', name: 'Beta', status: 'draft' },
|
|
201
|
-
];
|
|
202
|
-
const db = makeMockDbAdapter(records);
|
|
203
|
-
|
|
204
|
-
const result = await createBuilder(db)
|
|
205
|
-
.filterRaw([{ field: 'status', operator: 'eq', value: 'active' }])
|
|
206
|
-
.exec();
|
|
207
|
-
|
|
208
|
-
expect(result.data).toHaveLength(1);
|
|
209
|
-
expect(result.data[0].name).toBe('Alpha');
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
describe('execWithMeta()', () => {
|
|
214
|
-
it('returns data with pagination metadata', async () => {
|
|
215
|
-
const records = Array.from({ length: 50 }, (_, i) => ({
|
|
216
|
-
id: `r${i}`,
|
|
217
|
-
name: `R${i}`,
|
|
218
|
-
status: 'a',
|
|
219
|
-
}));
|
|
220
|
-
const db = makeMockDbAdapter(records);
|
|
221
|
-
|
|
222
|
-
const result = await createBuilder(db).limit(10).page(1).execWithMeta();
|
|
223
|
-
|
|
224
|
-
expect(result.data).toHaveLength(10);
|
|
225
|
-
expect(result.meta.total).toBe(50);
|
|
226
|
-
expect(result.meta.page).toBe(1);
|
|
227
|
-
expect(result.meta.limit).toBe(10);
|
|
228
|
-
expect(result.meta.totalPages).toBe(5);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('calculates totalPages correctly with remainder', async () => {
|
|
232
|
-
const records = Array.from({ length: 7 }, (_, i) => ({
|
|
233
|
-
id: `r${i}`,
|
|
234
|
-
name: `R${i}`,
|
|
235
|
-
status: 'a',
|
|
236
|
-
}));
|
|
237
|
-
const db = makeMockDbAdapter(records);
|
|
238
|
-
|
|
239
|
-
const result = await createBuilder(db).limit(3).execWithMeta();
|
|
240
|
-
|
|
241
|
-
expect(result.meta.totalPages).toBe(3);
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
Kysely,
|
|
4
|
-
DummyDriver,
|
|
5
|
-
PostgresAdapter,
|
|
6
|
-
PostgresIntrospector,
|
|
7
|
-
PostgresQueryCompiler,
|
|
8
|
-
} from 'kysely';
|
|
9
|
-
import { applyModelFilters } from '../filter-applier.js';
|
|
10
|
-
import type { TranslatedFilter } from '../filter-translator.js';
|
|
11
|
-
|
|
12
|
-
function createTestDb() {
|
|
13
|
-
return new Kysely<any>({
|
|
14
|
-
dialect: {
|
|
15
|
-
createAdapter: () => new PostgresAdapter(),
|
|
16
|
-
createDriver: () => new DummyDriver(),
|
|
17
|
-
createIntrospector: (db: any) => new PostgresIntrospector(db),
|
|
18
|
-
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function compileQuery(db: Kysely<any>, filters: TranslatedFilter[]) {
|
|
24
|
-
let query = db.selectFrom('test_table').selectAll();
|
|
25
|
-
query = applyModelFilters(query, filters);
|
|
26
|
-
return query.compile();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe('applyModelFilters', () => {
|
|
30
|
-
const db = createTestDb();
|
|
31
|
-
|
|
32
|
-
describe('eq operator', () => {
|
|
33
|
-
it('generates = clause', () => {
|
|
34
|
-
const compiled = compileQuery(db, [{ field: 'status', operator: 'eq', value: 'active' }]);
|
|
35
|
-
expect(compiled.sql).toContain('"status" = $1');
|
|
36
|
-
expect(compiled.parameters).toEqual(['active']);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('neq operator', () => {
|
|
41
|
-
it('generates != clause', () => {
|
|
42
|
-
const compiled = compileQuery(db, [{ field: 'status', operator: 'neq', value: 'cancelled' }]);
|
|
43
|
-
expect(compiled.sql).toContain('"status" != $1');
|
|
44
|
-
expect(compiled.parameters).toEqual(['cancelled']);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('gt operator', () => {
|
|
49
|
-
it('generates > clause', () => {
|
|
50
|
-
const compiled = compileQuery(db, [{ field: 'total', operator: 'gt', value: 100 }]);
|
|
51
|
-
expect(compiled.sql).toContain('"total" > $1');
|
|
52
|
-
expect(compiled.parameters).toEqual([100]);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe('gte operator', () => {
|
|
57
|
-
it('generates >= clause', () => {
|
|
58
|
-
const compiled = compileQuery(db, [{ field: 'total', operator: 'gte', value: 100 }]);
|
|
59
|
-
expect(compiled.sql).toContain('"total" >= $1');
|
|
60
|
-
expect(compiled.parameters).toEqual([100]);
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('lt operator', () => {
|
|
65
|
-
it('generates < clause', () => {
|
|
66
|
-
const compiled = compileQuery(db, [{ field: 'total', operator: 'lt', value: 50 }]);
|
|
67
|
-
expect(compiled.sql).toContain('"total" < $1');
|
|
68
|
-
expect(compiled.parameters).toEqual([50]);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('lte operator', () => {
|
|
73
|
-
it('generates <= clause', () => {
|
|
74
|
-
const compiled = compileQuery(db, [{ field: 'total', operator: 'lte', value: 50 }]);
|
|
75
|
-
expect(compiled.sql).toContain('"total" <= $1');
|
|
76
|
-
expect(compiled.parameters).toEqual([50]);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe('in operator', () => {
|
|
81
|
-
it('generates IN clause', () => {
|
|
82
|
-
const compiled = compileQuery(db, [
|
|
83
|
-
{ field: 'status', operator: 'in', value: ['a', 'b', 'c'] },
|
|
84
|
-
]);
|
|
85
|
-
expect(compiled.sql).toContain('"status" in ($1, $2, $3)');
|
|
86
|
-
expect(compiled.parameters).toEqual(['a', 'b', 'c']);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('generates false condition for empty array', () => {
|
|
90
|
-
const compiled = compileQuery(db, [{ field: 'status', operator: 'in', value: [] }]);
|
|
91
|
-
expect(compiled.sql).toContain('1 = 0');
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe('notIn operator', () => {
|
|
96
|
-
it('generates NOT IN clause', () => {
|
|
97
|
-
const compiled = compileQuery(db, [
|
|
98
|
-
{ field: 'status', operator: 'notIn', value: ['x', 'y'] },
|
|
99
|
-
]);
|
|
100
|
-
expect(compiled.sql).toContain('"status" not in ($1, $2)');
|
|
101
|
-
expect(compiled.parameters).toEqual(['x', 'y']);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('is no-op for empty array', () => {
|
|
105
|
-
const compiled = compileQuery(db, [{ field: 'status', operator: 'notIn', value: [] }]);
|
|
106
|
-
expect(compiled.sql).not.toContain('not in');
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('contains operator', () => {
|
|
111
|
-
it('generates ILIKE with % wrapping', () => {
|
|
112
|
-
const compiled = compileQuery(db, [
|
|
113
|
-
{ field: 'email', operator: 'contains', value: '@acme.com' },
|
|
114
|
-
]);
|
|
115
|
-
expect(compiled.sql).toContain('"email" ilike $1');
|
|
116
|
-
expect(compiled.parameters).toEqual(['%@acme.com%']);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('escapes % and _ in the value', () => {
|
|
120
|
-
const compiled = compileQuery(db, [
|
|
121
|
-
{ field: 'name', operator: 'contains', value: '100%_test' },
|
|
122
|
-
]);
|
|
123
|
-
expect(compiled.parameters).toEqual(['%100\\%\\_test%']);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('startsWith operator', () => {
|
|
128
|
-
it('generates ILIKE with trailing %', () => {
|
|
129
|
-
const compiled = compileQuery(db, [{ field: 'name', operator: 'startsWith', value: 'John' }]);
|
|
130
|
-
expect(compiled.sql).toContain('"name" ilike $1');
|
|
131
|
-
expect(compiled.parameters).toEqual(['John%']);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('endsWith operator', () => {
|
|
136
|
-
it('generates ILIKE with leading %', () => {
|
|
137
|
-
const compiled = compileQuery(db, [{ field: 'email', operator: 'endsWith', value: '.org' }]);
|
|
138
|
-
expect(compiled.sql).toContain('"email" ilike $1');
|
|
139
|
-
expect(compiled.parameters).toEqual(['%.org']);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe('is null operator', () => {
|
|
144
|
-
it('generates IS NULL', () => {
|
|
145
|
-
const compiled = compileQuery(db, [{ field: 'deleted_at', operator: 'is', value: null }]);
|
|
146
|
-
expect(compiled.sql).toContain('"deleted_at" is null');
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('generates IS NOT NULL for "not_null"', () => {
|
|
150
|
-
const compiled = compileQuery(db, [
|
|
151
|
-
{ field: 'deleted_at', operator: 'is', value: 'not_null' },
|
|
152
|
-
]);
|
|
153
|
-
expect(compiled.sql).toContain('"deleted_at" is not null');
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
describe('multiple filters', () => {
|
|
158
|
-
it('chains as AND conditions', () => {
|
|
159
|
-
const compiled = compileQuery(db, [
|
|
160
|
-
{ field: 'status', operator: 'eq', value: 'active' },
|
|
161
|
-
{ field: 'total', operator: 'gt', value: 100 },
|
|
162
|
-
{ field: 'deleted_at', operator: 'is', value: null },
|
|
163
|
-
]);
|
|
164
|
-
expect(compiled.sql).toContain('"status" = $1');
|
|
165
|
-
expect(compiled.sql).toContain('"total" > $2');
|
|
166
|
-
expect(compiled.sql).toContain('"deleted_at" is null');
|
|
167
|
-
expect(compiled.parameters).toEqual(['active', 100]);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe('no filters', () => {
|
|
172
|
-
it('returns query unchanged for empty array', () => {
|
|
173
|
-
const compiled = compileQuery(db, []);
|
|
174
|
-
expect(compiled.sql).not.toContain('where');
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { translateFilters } from '../filter-translator.js';
|
|
3
|
-
|
|
4
|
-
describe('translateFilters', () => {
|
|
5
|
-
describe('simple equality (shorthand)', () => {
|
|
6
|
-
it('treats plain value as eq operator', () => {
|
|
7
|
-
const result = translateFilters({ status: 'confirmed' });
|
|
8
|
-
expect(result).toEqual([{ field: 'status', operator: 'eq', value: 'confirmed' }]);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('handles numeric equality', () => {
|
|
12
|
-
const result = translateFilters({ amount: 100 });
|
|
13
|
-
expect(result).toEqual([{ field: 'amount', operator: 'eq', value: 100 }]);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('handles boolean equality', () => {
|
|
17
|
-
const result = translateFilters({ active: true });
|
|
18
|
-
expect(result).toEqual([{ field: 'active', operator: 'eq', value: true }]);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('handles multiple fields as AND', () => {
|
|
22
|
-
const result = translateFilters({ status: 'confirmed', total: 500 });
|
|
23
|
-
expect(result).toEqual([
|
|
24
|
-
{ field: 'status', operator: 'eq', value: 'confirmed' },
|
|
25
|
-
{ field: 'total', operator: 'eq', value: 500 },
|
|
26
|
-
]);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('explicit eq operator', () => {
|
|
31
|
-
it('handles { eq: value }', () => {
|
|
32
|
-
const result = translateFilters({ status: { eq: 'draft' } });
|
|
33
|
-
expect(result).toEqual([{ field: 'status', operator: 'eq', value: 'draft' }]);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe('neq operator', () => {
|
|
38
|
-
it('handles { neq: value }', () => {
|
|
39
|
-
const result = translateFilters({ status: { neq: 'cancelled' } });
|
|
40
|
-
expect(result).toEqual([{ field: 'status', operator: 'neq', value: 'cancelled' }]);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('comparison operators', () => {
|
|
45
|
-
it('handles gt', () => {
|
|
46
|
-
const result = translateFilters({ total: { gt: 100 } });
|
|
47
|
-
expect(result).toEqual([{ field: 'total', operator: 'gt', value: 100 }]);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('handles gte', () => {
|
|
51
|
-
const result = translateFilters({ total: { gte: 100 } });
|
|
52
|
-
expect(result).toEqual([{ field: 'total', operator: 'gte', value: 100 }]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('handles lt', () => {
|
|
56
|
-
const result = translateFilters({ total: { lt: 50 } });
|
|
57
|
-
expect(result).toEqual([{ field: 'total', operator: 'lt', value: 50 }]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('handles lte', () => {
|
|
61
|
-
const result = translateFilters({ total: { lte: 50 } });
|
|
62
|
-
expect(result).toEqual([{ field: 'total', operator: 'lte', value: 50 }]);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('handles multiple operators on same field (range)', () => {
|
|
66
|
-
const result = translateFilters({ createdAt: { gte: '2026-01-01', lt: '2026-02-01' } });
|
|
67
|
-
expect(result).toEqual([
|
|
68
|
-
{ field: 'createdAt', operator: 'gte', value: '2026-01-01' },
|
|
69
|
-
{ field: 'createdAt', operator: 'lt', value: '2026-02-01' },
|
|
70
|
-
]);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe('array operators', () => {
|
|
75
|
-
it('handles in', () => {
|
|
76
|
-
const result = translateFilters({ status: { in: ['confirmed', 'shipped'] } });
|
|
77
|
-
expect(result).toEqual([
|
|
78
|
-
{ field: 'status', operator: 'in', value: ['confirmed', 'shipped'] },
|
|
79
|
-
]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('handles notIn', () => {
|
|
83
|
-
const result = translateFilters({ status: { notIn: ['cancelled', 'refunded'] } });
|
|
84
|
-
expect(result).toEqual([
|
|
85
|
-
{ field: 'status', operator: 'notIn', value: ['cancelled', 'refunded'] },
|
|
86
|
-
]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('handles empty array for in', () => {
|
|
90
|
-
const result = translateFilters({ status: { in: [] } });
|
|
91
|
-
expect(result).toEqual([{ field: 'status', operator: 'in', value: [] }]);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe('string operators', () => {
|
|
96
|
-
it('handles contains', () => {
|
|
97
|
-
const result = translateFilters({ email: { contains: '@acme.com' } });
|
|
98
|
-
expect(result).toEqual([{ field: 'email', operator: 'contains', value: '@acme.com' }]);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('handles startsWith', () => {
|
|
102
|
-
const result = translateFilters({ name: { startsWith: 'John' } });
|
|
103
|
-
expect(result).toEqual([{ field: 'name', operator: 'startsWith', value: 'John' }]);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('handles endsWith', () => {
|
|
107
|
-
const result = translateFilters({ email: { endsWith: '.org' } });
|
|
108
|
-
expect(result).toEqual([{ field: 'email', operator: 'endsWith', value: '.org' }]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('handles string with SQL wildcards (escapes them)', () => {
|
|
112
|
-
const result = translateFilters({ name: { contains: '100%' } });
|
|
113
|
-
expect(result).toEqual([{ field: 'name', operator: 'contains', value: '100%' }]);
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
describe('null checks', () => {
|
|
118
|
-
it('handles { is: null }', () => {
|
|
119
|
-
const result = translateFilters({ deletedAt: { is: null } });
|
|
120
|
-
expect(result).toEqual([{ field: 'deletedAt', operator: 'is', value: null }]);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('handles { is: "not_null" }', () => {
|
|
124
|
-
const result = translateFilters({ deletedAt: { is: 'not_null' } });
|
|
125
|
-
expect(result).toEqual([{ field: 'deletedAt', operator: 'is', value: 'not_null' }]);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe('edge cases', () => {
|
|
130
|
-
it('returns empty array for empty filter object', () => {
|
|
131
|
-
const result = translateFilters({});
|
|
132
|
-
expect(result).toEqual([]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('returns empty array for undefined input', () => {
|
|
136
|
-
const result = translateFilters(undefined as unknown as Record<string, unknown>);
|
|
137
|
-
expect(result).toEqual([]);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('returns empty array for null input', () => {
|
|
141
|
-
const result = translateFilters(null as unknown as Record<string, unknown>);
|
|
142
|
-
expect(result).toEqual([]);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('throws on unknown operator', () => {
|
|
146
|
-
expect(() => translateFilters({ status: { banana: 'yes' } as any })).toThrow(
|
|
147
|
-
/unknown filter operator.*banana/i,
|
|
148
|
-
);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('handles filter value of 0 as equality (not falsy)', () => {
|
|
152
|
-
const result = translateFilters({ count: 0 });
|
|
153
|
-
expect(result).toEqual([{ field: 'count', operator: 'eq', value: 0 }]);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('handles filter value of empty string as equality', () => {
|
|
157
|
-
const result = translateFilters({ name: '' });
|
|
158
|
-
expect(result).toEqual([{ field: 'name', operator: 'eq', value: '' }]);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('handles filter value of null as shorthand for is null', () => {
|
|
162
|
-
const result = translateFilters({ deletedAt: null });
|
|
163
|
-
expect(result).toEqual([{ field: 'deletedAt', operator: 'is', value: null }]);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
describe('complex combinations', () => {
|
|
168
|
-
it('handles multiple fields with mixed operators', () => {
|
|
169
|
-
const result = translateFilters({
|
|
170
|
-
status: { in: ['confirmed', 'shipped'] },
|
|
171
|
-
total: { gt: 100 },
|
|
172
|
-
email: { contains: '@acme.com' },
|
|
173
|
-
active: true,
|
|
174
|
-
});
|
|
175
|
-
expect(result).toHaveLength(4);
|
|
176
|
-
expect(result).toContainEqual({
|
|
177
|
-
field: 'status',
|
|
178
|
-
operator: 'in',
|
|
179
|
-
value: ['confirmed', 'shipped'],
|
|
180
|
-
});
|
|
181
|
-
expect(result).toContainEqual({ field: 'total', operator: 'gt', value: 100 });
|
|
182
|
-
expect(result).toContainEqual({ field: 'email', operator: 'contains', value: '@acme.com' });
|
|
183
|
-
expect(result).toContainEqual({ field: 'active', operator: 'eq', value: true });
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
});
|