bunsane 0.3.1 → 0.4.0
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/CHANGELOG.md +445 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +173 -267
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/AccessLog.ts +8 -1
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +203 -59
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for FilterBuilder utility functions
|
|
3
|
-
*/
|
|
4
|
-
import { describe, test, expect } from 'bun:test';
|
|
5
|
-
import { buildJSONPath, composeFilters, type FilterBuilder, type FilterResult } from '../../../query/FilterBuilder';
|
|
6
|
-
|
|
7
|
-
describe('FilterBuilder', () => {
|
|
8
|
-
describe('buildJSONPath()', () => {
|
|
9
|
-
test('builds simple field path', () => {
|
|
10
|
-
const result = buildJSONPath('name', 'c');
|
|
11
|
-
expect(result).toBe("c.data->>'name'");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test('builds nested field path', () => {
|
|
15
|
-
const result = buildJSONPath('location.latitude', 'c');
|
|
16
|
-
expect(result).toBe("c.data->'location'->>'latitude'");
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('builds deeply nested path', () => {
|
|
20
|
-
const result = buildJSONPath('device.location.coordinates.latitude', 'c');
|
|
21
|
-
expect(result).toBe("c.data->'device'->'location'->'coordinates'->>'latitude'");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('uses provided alias', () => {
|
|
25
|
-
const result = buildJSONPath('field', 'comp');
|
|
26
|
-
expect(result).toBe("comp.data->>'field'");
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('composeFilters()', () => {
|
|
31
|
-
test('throws for empty array', () => {
|
|
32
|
-
expect(() => composeFilters([])).toThrow('Cannot compose empty array of filter builders');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('composes single builder', () => {
|
|
36
|
-
const mockBuilder: FilterBuilder = () => ({
|
|
37
|
-
sql: 'field = $1',
|
|
38
|
-
addedParams: 1
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const composed = composeFilters([mockBuilder]);
|
|
42
|
-
const result = composed(
|
|
43
|
-
{ field: 'test', operator: 'EQ', value: 'value' },
|
|
44
|
-
'c',
|
|
45
|
-
{} as any
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
expect(result.sql).toBe('(field = $1)');
|
|
49
|
-
expect(result.addedParams).toBe(1);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('composes multiple builders with AND', () => {
|
|
53
|
-
const builder1: FilterBuilder = () => ({
|
|
54
|
-
sql: 'field1 = $1',
|
|
55
|
-
addedParams: 1
|
|
56
|
-
});
|
|
57
|
-
const builder2: FilterBuilder = () => ({
|
|
58
|
-
sql: 'field2 > $2',
|
|
59
|
-
addedParams: 1
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const composed = composeFilters([builder1, builder2]);
|
|
63
|
-
const result = composed(
|
|
64
|
-
{ field: 'test', operator: 'EQ', value: 'value' },
|
|
65
|
-
'c',
|
|
66
|
-
{} as any
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
expect(result.sql).toBe('(field1 = $1) AND (field2 > $2)');
|
|
70
|
-
expect(result.addedParams).toBe(2);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('handles empty SQL from builder', () => {
|
|
74
|
-
const builder1: FilterBuilder = () => ({
|
|
75
|
-
sql: 'field1 = $1',
|
|
76
|
-
addedParams: 1
|
|
77
|
-
});
|
|
78
|
-
const builder2: FilterBuilder = () => ({
|
|
79
|
-
sql: '',
|
|
80
|
-
addedParams: 0
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const composed = composeFilters([builder1, builder2]);
|
|
84
|
-
const result = composed(
|
|
85
|
-
{ field: 'test', operator: 'EQ', value: 'value' },
|
|
86
|
-
'c',
|
|
87
|
-
{} as any
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
expect(result.sql).toBe('(field1 = $1)');
|
|
91
|
-
expect(result.addedParams).toBe(1);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test('handles whitespace-only SQL', () => {
|
|
95
|
-
const builder: FilterBuilder = () => ({
|
|
96
|
-
sql: ' ',
|
|
97
|
-
addedParams: 0
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const composed = composeFilters([builder]);
|
|
101
|
-
const result = composed(
|
|
102
|
-
{ field: 'test', operator: 'EQ', value: 'value' },
|
|
103
|
-
'c',
|
|
104
|
-
{} as any
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
expect(result.sql).toBe('');
|
|
108
|
-
expect(result.addedParams).toBe(0);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
});
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for JSONB Array Filter Builders
|
|
3
|
-
*/
|
|
4
|
-
import { describe, test, expect } from 'bun:test';
|
|
5
|
-
import { buildJSONBPath } from '../../../query/FilterBuilder';
|
|
6
|
-
import { QueryContext } from '../../../query/QueryContext';
|
|
7
|
-
import {
|
|
8
|
-
jsonbContainsBuilder,
|
|
9
|
-
jsonbContainedByBuilder,
|
|
10
|
-
jsonbHasAnyBuilder,
|
|
11
|
-
jsonbHasAllBuilder,
|
|
12
|
-
jsonbArrayOptions,
|
|
13
|
-
} from '../../../query/builders/JsonbArrayBuilder';
|
|
14
|
-
|
|
15
|
-
describe('buildJSONBPath', () => {
|
|
16
|
-
test('simple field returns JSONB node path', () => {
|
|
17
|
-
expect(buildJSONBPath('tags', 'c')).toBe("c.data->'tags'");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test('nested field returns JSONB node path', () => {
|
|
21
|
-
expect(buildJSONBPath('metadata.tags', 'c')).toBe("c.data->'metadata'->'tags'");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('deeply nested field', () => {
|
|
25
|
-
expect(buildJSONBPath('a.b.c', 'c')).toBe("c.data->'a'->'b'->'c'");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('uses provided alias', () => {
|
|
29
|
-
expect(buildJSONBPath('tags', 'comp')).toBe("comp.data->'tags'");
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('jsonbContainsBuilder (@>)', () => {
|
|
34
|
-
test('single string value is auto-wrapped in array', () => {
|
|
35
|
-
const ctx = new QueryContext();
|
|
36
|
-
const result = jsonbContainsBuilder(
|
|
37
|
-
{ field: 'tags', operator: 'CONTAINS', value: 'urgent' },
|
|
38
|
-
'c', ctx
|
|
39
|
-
);
|
|
40
|
-
expect(result.sql).toBe("c.data->'tags' @> $1::jsonb");
|
|
41
|
-
expect(ctx.params[0]).toEqual(['urgent']);
|
|
42
|
-
expect(result.addedParams).toBe(1);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('array value is passed as raw array', () => {
|
|
46
|
-
const ctx = new QueryContext();
|
|
47
|
-
const result = jsonbContainsBuilder(
|
|
48
|
-
{ field: 'tags', operator: 'CONTAINS', value: ['a', 'b'] },
|
|
49
|
-
'c', ctx
|
|
50
|
-
);
|
|
51
|
-
expect(result.sql).toBe("c.data->'tags' @> $1::jsonb");
|
|
52
|
-
expect(ctx.params[0]).toEqual(['a', 'b']);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('nested field path', () => {
|
|
56
|
-
const ctx = new QueryContext();
|
|
57
|
-
const result = jsonbContainsBuilder(
|
|
58
|
-
{ field: 'meta.tags', operator: 'CONTAINS', value: 'x' },
|
|
59
|
-
'c', ctx
|
|
60
|
-
);
|
|
61
|
-
expect(result.sql).toBe("c.data->'meta'->'tags' @> $1::jsonb");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('numeric value', () => {
|
|
65
|
-
const ctx = new QueryContext();
|
|
66
|
-
jsonbContainsBuilder(
|
|
67
|
-
{ field: 'scores', operator: 'CONTAINS', value: 42 },
|
|
68
|
-
'c', ctx
|
|
69
|
-
);
|
|
70
|
-
expect(ctx.params[0]).toEqual([42]);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe('jsonbContainedByBuilder (<@)', () => {
|
|
75
|
-
test('generates correct SQL', () => {
|
|
76
|
-
const ctx = new QueryContext();
|
|
77
|
-
const result = jsonbContainedByBuilder(
|
|
78
|
-
{ field: 'tags', operator: 'CONTAINED_BY', value: ['a', 'b', 'c'] },
|
|
79
|
-
'c', ctx
|
|
80
|
-
);
|
|
81
|
-
expect(result.sql).toBe("c.data->'tags' <@ $1::jsonb");
|
|
82
|
-
expect(ctx.params[0]).toEqual(['a', 'b', 'c']);
|
|
83
|
-
expect(result.addedParams).toBe(1);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('single value is auto-wrapped', () => {
|
|
87
|
-
const ctx = new QueryContext();
|
|
88
|
-
jsonbContainedByBuilder(
|
|
89
|
-
{ field: 'tags', operator: 'CONTAINED_BY', value: 'only' },
|
|
90
|
-
'c', ctx
|
|
91
|
-
);
|
|
92
|
-
expect(ctx.params[0]).toEqual(['only']);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('jsonbHasAnyBuilder (?|)', () => {
|
|
97
|
-
test('generates correct SQL with text[] cast', () => {
|
|
98
|
-
const ctx = new QueryContext();
|
|
99
|
-
const result = jsonbHasAnyBuilder(
|
|
100
|
-
{ field: 'tags', operator: 'HAS_ANY', value: ['a', 'b'] },
|
|
101
|
-
'c', ctx
|
|
102
|
-
);
|
|
103
|
-
expect(result.sql).toBe("c.data->'tags' ?| $1::text[]");
|
|
104
|
-
expect(ctx.params[0]).toEqual(['a', 'b']);
|
|
105
|
-
expect(result.addedParams).toBe(1);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('single value is auto-wrapped and stringified', () => {
|
|
109
|
-
const ctx = new QueryContext();
|
|
110
|
-
jsonbHasAnyBuilder(
|
|
111
|
-
{ field: 'tags', operator: 'HAS_ANY', value: 'solo' },
|
|
112
|
-
'c', ctx
|
|
113
|
-
);
|
|
114
|
-
expect(ctx.params[0]).toEqual(['solo']);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test('numeric values are cast to strings', () => {
|
|
118
|
-
const ctx = new QueryContext();
|
|
119
|
-
jsonbHasAnyBuilder(
|
|
120
|
-
{ field: 'ids', operator: 'HAS_ANY', value: [1, 2, 3] },
|
|
121
|
-
'c', ctx
|
|
122
|
-
);
|
|
123
|
-
expect(ctx.params[0]).toEqual(['1', '2', '3']);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('jsonbHasAllBuilder (?&)', () => {
|
|
128
|
-
test('generates correct SQL with text[] cast', () => {
|
|
129
|
-
const ctx = new QueryContext();
|
|
130
|
-
const result = jsonbHasAllBuilder(
|
|
131
|
-
{ field: 'tags', operator: 'HAS_ALL', value: ['x', 'y'] },
|
|
132
|
-
'c', ctx
|
|
133
|
-
);
|
|
134
|
-
expect(result.sql).toBe("c.data->'tags' ?& $1::text[]");
|
|
135
|
-
expect(ctx.params[0]).toEqual(['x', 'y']);
|
|
136
|
-
expect(result.addedParams).toBe(1);
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe('validation', () => {
|
|
141
|
-
const validate = jsonbArrayOptions.validate!;
|
|
142
|
-
|
|
143
|
-
test('rejects null value', () => {
|
|
144
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: null })).toBe(false);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('rejects undefined value', () => {
|
|
148
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: undefined })).toBe(false);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('rejects empty array', () => {
|
|
152
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: [] })).toBe(false);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test('accepts string value', () => {
|
|
156
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: 'tag' })).toBe(true);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test('accepts number value', () => {
|
|
160
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: 42 })).toBe(true);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test('accepts boolean value', () => {
|
|
164
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: true })).toBe(true);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('accepts array of strings', () => {
|
|
168
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: ['a', 'b'] })).toBe(true);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test('rejects object value', () => {
|
|
172
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: { key: 'val' } })).toBe(false);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test('rejects array with non-primitive elements', () => {
|
|
176
|
-
expect(validate({ field: 'f', operator: 'CONTAINS', value: [{ a: 1 }] })).toBe(false);
|
|
177
|
-
});
|
|
178
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Empty-string filter support. JSONB text extraction (c.data->>'field')
|
|
3
|
-
* returns text, so `= ''` / `!= ''` / LIKE against empty string are
|
|
4
|
-
* legitimate. UUID-cast path is gated on a regex that empty cannot match.
|
|
5
|
-
*/
|
|
6
|
-
import { describe, test, expect, beforeAll } from 'bun:test';
|
|
7
|
-
import { Entity } from '../../../core/Entity';
|
|
8
|
-
import { BaseComponent } from '../../../core/components/BaseComponent';
|
|
9
|
-
import { Component, CompData } from '../../../core/components/Decorators';
|
|
10
|
-
import { Query, FilterOp } from '../../../query/Query';
|
|
11
|
-
import { ensureComponentsRegistered } from '../../utils';
|
|
12
|
-
|
|
13
|
-
@Component
|
|
14
|
-
class EmptyableNote extends BaseComponent {
|
|
15
|
-
@CompData()
|
|
16
|
-
value: string = '';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe('Query empty-string filter', () => {
|
|
20
|
-
beforeAll(async () => {
|
|
21
|
-
await ensureComponentsRegistered(EmptyableNote);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test('Query.filter accepts empty-string value without throwing', () => {
|
|
25
|
-
expect(() => Query.filter('value', FilterOp.EQ, '')).not.toThrow();
|
|
26
|
-
const f = Query.filter('value', FilterOp.EQ, '');
|
|
27
|
-
expect(f.value).toBe('');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('Query.filter accepts whitespace-only value without throwing', () => {
|
|
31
|
-
expect(() => Query.filter('value', FilterOp.EQ, ' ')).not.toThrow();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('.with(C, filter EQ "") executes and returns matching rows', async () => {
|
|
35
|
-
const withEmpty = Entity.Create();
|
|
36
|
-
withEmpty.add(EmptyableNote, { value: '' });
|
|
37
|
-
await withEmpty.save();
|
|
38
|
-
|
|
39
|
-
const withData = Entity.Create();
|
|
40
|
-
withData.add(EmptyableNote, { value: 'not empty' });
|
|
41
|
-
await withData.save();
|
|
42
|
-
|
|
43
|
-
const rows = await new Query()
|
|
44
|
-
.with(EmptyableNote, Query.filters(Query.filter('value', FilterOp.EQ, '')))
|
|
45
|
-
.exec();
|
|
46
|
-
|
|
47
|
-
const ids = rows.map(e => e.id);
|
|
48
|
-
expect(ids).toContain(withEmpty.id);
|
|
49
|
-
expect(ids).not.toContain(withData.id);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('.with(C, filter != "") excludes rows with empty value', async () => {
|
|
53
|
-
const withEmpty = Entity.Create();
|
|
54
|
-
withEmpty.add(EmptyableNote, { value: '' });
|
|
55
|
-
await withEmpty.save();
|
|
56
|
-
|
|
57
|
-
const withData = Entity.Create();
|
|
58
|
-
withData.add(EmptyableNote, { value: 'populated' });
|
|
59
|
-
await withData.save();
|
|
60
|
-
|
|
61
|
-
const rows = await new Query()
|
|
62
|
-
.with(EmptyableNote, Query.filters(Query.filter('value', FilterOp.NEQ, '')))
|
|
63
|
-
.exec();
|
|
64
|
-
|
|
65
|
-
const ids = rows.map(e => e.id);
|
|
66
|
-
expect(ids).toContain(withData.id);
|
|
67
|
-
expect(ids).not.toContain(withEmpty.id);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for Query class
|
|
3
|
-
* Tests query builder methods and structure
|
|
4
|
-
*/
|
|
5
|
-
import { describe, test, expect, beforeAll } from 'bun:test';
|
|
6
|
-
import { Query, FilterOp } from '../../../query/Query';
|
|
7
|
-
import { TestUser, TestProduct } from '../../fixtures/components';
|
|
8
|
-
import { ensureComponentsRegistered } from '../../utils';
|
|
9
|
-
|
|
10
|
-
describe('Query', () => {
|
|
11
|
-
beforeAll(async () => {
|
|
12
|
-
await ensureComponentsRegistered(TestUser, TestProduct);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
describe('constructor', () => {
|
|
16
|
-
test('creates a new query instance', () => {
|
|
17
|
-
const query = new Query();
|
|
18
|
-
expect(query).toBeDefined();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test('creates query with transaction', () => {
|
|
22
|
-
// Query accepts optional transaction parameter
|
|
23
|
-
const query = new Query();
|
|
24
|
-
expect(query).toBeDefined();
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('findById()', () => {
|
|
29
|
-
test('sets up query to find by ID', () => {
|
|
30
|
-
const query = new Query();
|
|
31
|
-
const result = query.findById('test-id-123');
|
|
32
|
-
expect(result).toBe(query); // Returns this for chaining
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('throws for empty string id', () => {
|
|
36
|
-
const query = new Query();
|
|
37
|
-
expect(() => query.findById('')).toThrow();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('throws for whitespace id', () => {
|
|
41
|
-
const query = new Query();
|
|
42
|
-
expect(() => query.findById(' ')).toThrow();
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('with()', () => {
|
|
47
|
-
test('adds component requirement to query', () => {
|
|
48
|
-
const query = new Query();
|
|
49
|
-
const result = query.with(TestUser);
|
|
50
|
-
// with() returns same instance for chaining (type changes for type accumulation)
|
|
51
|
-
expect(result as any).toBe(query as any);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('allows chaining multiple with() calls', () => {
|
|
55
|
-
const query = new Query();
|
|
56
|
-
const result = query.with(TestUser).with(TestProduct);
|
|
57
|
-
expect(result as any).toBe(query as any);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('accepts component with filters', () => {
|
|
61
|
-
const query = new Query();
|
|
62
|
-
const result = query.with(TestUser, {
|
|
63
|
-
filters: [{ field: 'name', operator: FilterOp.EQ, value: 'John' }]
|
|
64
|
-
});
|
|
65
|
-
expect(result as any).toBe(query as any);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('accepts array of components with filters', () => {
|
|
69
|
-
const query = new Query();
|
|
70
|
-
const result = query.with([
|
|
71
|
-
{ component: TestUser, filters: [{ field: 'age', operator: FilterOp.GT, value: 18 }] },
|
|
72
|
-
{ component: TestProduct, filters: [{ field: 'price', operator: FilterOp.LT, value: 100 }] }
|
|
73
|
-
]);
|
|
74
|
-
expect(result).toBe(query);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe('without()', () => {
|
|
79
|
-
test('excludes entities with specified component', () => {
|
|
80
|
-
const query = new Query();
|
|
81
|
-
const result = query.with(TestUser).without(TestProduct);
|
|
82
|
-
expect(result as any).toBe(query as any);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('excludeEntityId()', () => {
|
|
87
|
-
test('excludes specific entity from results', () => {
|
|
88
|
-
const query = new Query();
|
|
89
|
-
const result = query.excludeEntityId('entity-to-exclude');
|
|
90
|
-
expect(result).toBe(query);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('populate()', () => {
|
|
95
|
-
test('enables component population', () => {
|
|
96
|
-
const query = new Query();
|
|
97
|
-
const result = query.with(TestUser).populate();
|
|
98
|
-
expect(result as any).toBe(query as any);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe('eagerLoadComponents()', () => {
|
|
103
|
-
test('sets up eager loading for components', () => {
|
|
104
|
-
const query = new Query();
|
|
105
|
-
const result = query.with(TestUser).eagerLoadComponents([TestUser, TestProduct]);
|
|
106
|
-
expect(result as any).toBe(query as any);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('eagerLoad()', () => {
|
|
111
|
-
test('is an alias for eagerLoadComponents', () => {
|
|
112
|
-
const query = new Query();
|
|
113
|
-
const result = query.with(TestUser).eagerLoad([TestUser]);
|
|
114
|
-
expect(result as any).toBe(query as any);
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe('take()', () => {
|
|
119
|
-
test('sets limit on query results', () => {
|
|
120
|
-
const query = new Query();
|
|
121
|
-
const result = query.with(TestUser).take(10);
|
|
122
|
-
expect(result as any).toBe(query as any);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe('offset()', () => {
|
|
127
|
-
test('sets offset for pagination', () => {
|
|
128
|
-
const query = new Query();
|
|
129
|
-
const result = query.with(TestUser).offset(20);
|
|
130
|
-
expect(result as any).toBe(query as any);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe('sortBy()', () => {
|
|
135
|
-
test('sets sort order', () => {
|
|
136
|
-
const query = new Query();
|
|
137
|
-
const result = query.with(TestUser).sortBy(TestUser, 'name', 'ASC');
|
|
138
|
-
expect(result as any).toBe(query as any);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test('accepts DESC direction', () => {
|
|
142
|
-
const query = new Query();
|
|
143
|
-
const result = query.with(TestUser).sortBy(TestUser, 'age', 'DESC');
|
|
144
|
-
expect(result as any).toBe(query as any);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('accepts nullsFirst option', () => {
|
|
148
|
-
const query = new Query();
|
|
149
|
-
const result = query.with(TestUser).sortBy(TestUser, 'bio', 'ASC', true);
|
|
150
|
-
expect(result as any).toBe(query as any);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('throws if component not in query', () => {
|
|
154
|
-
const query = new Query();
|
|
155
|
-
expect(() => query.sortBy(TestUser, 'name')).toThrow();
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('debugMode()', () => {
|
|
160
|
-
test('enables debug mode', () => {
|
|
161
|
-
const query = new Query();
|
|
162
|
-
const result = query.debugMode(true);
|
|
163
|
-
expect(result).toBe(query);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('can disable debug mode', () => {
|
|
167
|
-
const query = new Query();
|
|
168
|
-
const result = query.debugMode(false);
|
|
169
|
-
expect(result).toBe(query);
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
describe('noCache()', () => {
|
|
174
|
-
test('bypasses prepared statement cache by default', () => {
|
|
175
|
-
const query = new Query();
|
|
176
|
-
const result = query.noCache();
|
|
177
|
-
expect(result).toBe(query);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test('accepts cache options', () => {
|
|
181
|
-
const query = new Query();
|
|
182
|
-
const result = query.noCache({ preparedStatement: true, component: true });
|
|
183
|
-
expect(result).toBe(query);
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
describe('filter()', () => {
|
|
188
|
-
test('creates filter object', () => {
|
|
189
|
-
const filter = Query.filter('name', FilterOp.EQ, 'John');
|
|
190
|
-
expect(filter.field).toBe('name');
|
|
191
|
-
expect(filter.operator).toBe(FilterOp.EQ);
|
|
192
|
-
expect(filter.value).toBe('John');
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('accepts empty string value', () => {
|
|
196
|
-
const filter = Query.filter('name', FilterOp.EQ, '');
|
|
197
|
-
expect(filter.value).toBe('');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test('accepts whitespace value', () => {
|
|
201
|
-
const filter = Query.filter('name', FilterOp.EQ, ' ');
|
|
202
|
-
expect(filter.value).toBe(' ');
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
describe('typedFilter()', () => {
|
|
207
|
-
test('creates typed filter object', () => {
|
|
208
|
-
const filter = Query.typedFilter(TestUser, 'name', FilterOp.EQ, 'John');
|
|
209
|
-
expect(filter.field).toBe('name');
|
|
210
|
-
expect(filter.operator).toBe(FilterOp.EQ);
|
|
211
|
-
expect(filter.value).toBe('John');
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe('filters()', () => {
|
|
216
|
-
test('creates filter options from multiple filters', () => {
|
|
217
|
-
const filter1 = Query.filter('name', FilterOp.EQ, 'John');
|
|
218
|
-
const filter2 = Query.filter('age', FilterOp.GT, 18);
|
|
219
|
-
const options = Query.filters(filter1, filter2);
|
|
220
|
-
|
|
221
|
-
expect(options.filters).toBeDefined();
|
|
222
|
-
expect(options.filters?.length).toBe(2);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe('FilterOp', () => {
|
|
227
|
-
test('has all expected operators', () => {
|
|
228
|
-
expect(FilterOp.EQ).toBeDefined();
|
|
229
|
-
expect(FilterOp.NEQ).toBeDefined();
|
|
230
|
-
expect(FilterOp.GT).toBeDefined();
|
|
231
|
-
expect(FilterOp.GTE).toBeDefined();
|
|
232
|
-
expect(FilterOp.LT).toBeDefined();
|
|
233
|
-
expect(FilterOp.LTE).toBeDefined();
|
|
234
|
-
expect(FilterOp.LIKE).toBeDefined();
|
|
235
|
-
expect(FilterOp.IN).toBeDefined();
|
|
236
|
-
expect(FilterOp.NOT_IN).toBeDefined();
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe('chaining', () => {
|
|
241
|
-
test('supports full query chain', () => {
|
|
242
|
-
const query = new Query()
|
|
243
|
-
.with(TestUser, { filters: [Query.filter('age', FilterOp.GTE, 18)] })
|
|
244
|
-
.with(TestProduct)
|
|
245
|
-
.without(TestProduct)
|
|
246
|
-
.take(10)
|
|
247
|
-
.offset(0)
|
|
248
|
-
.sortBy(TestUser, 'name')
|
|
249
|
-
.populate()
|
|
250
|
-
.debugMode(false)
|
|
251
|
-
.noCache();
|
|
252
|
-
|
|
253
|
-
expect(query).toBeDefined();
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
describe('sum()', () => {
|
|
258
|
-
test('throws if component not in query', async () => {
|
|
259
|
-
const query = new Query();
|
|
260
|
-
// TestProduct not added via .with()
|
|
261
|
-
await expect(query.sum(TestProduct, 'price')).rejects.toThrow(
|
|
262
|
-
/not included in the query/
|
|
263
|
-
);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
test('throws if component is not registered', async () => {
|
|
267
|
-
class UnregisteredComponent {
|
|
268
|
-
value!: number;
|
|
269
|
-
}
|
|
270
|
-
const query = new Query();
|
|
271
|
-
// @ts-expect-error - Testing with unregistered component
|
|
272
|
-
await expect(query.sum(UnregisteredComponent, 'value')).rejects.toThrow(
|
|
273
|
-
/not registered/
|
|
274
|
-
);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test('returns a Promise', () => {
|
|
278
|
-
const query = new Query().with(TestProduct);
|
|
279
|
-
const result = query.sum(TestProduct, 'price');
|
|
280
|
-
expect(result).toBeInstanceOf(Promise);
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
describe('average()', () => {
|
|
285
|
-
test('throws if component not in query', async () => {
|
|
286
|
-
const query = new Query();
|
|
287
|
-
// TestProduct not added via .with()
|
|
288
|
-
await expect(query.average(TestProduct, 'price')).rejects.toThrow(
|
|
289
|
-
/not included in the query/
|
|
290
|
-
);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
test('throws if component is not registered', async () => {
|
|
294
|
-
class UnregisteredComponent {
|
|
295
|
-
value!: number;
|
|
296
|
-
}
|
|
297
|
-
const query = new Query();
|
|
298
|
-
// @ts-expect-error - Testing with unregistered component
|
|
299
|
-
await expect(query.average(UnregisteredComponent, 'value')).rejects.toThrow(
|
|
300
|
-
/not registered/
|
|
301
|
-
);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test('returns a Promise', () => {
|
|
305
|
-
const query = new Query().with(TestProduct);
|
|
306
|
-
const result = query.average(TestProduct, 'price');
|
|
307
|
-
expect(result).toBeInstanceOf(Promise);
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
});
|