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,576 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for Query execution
|
|
3
|
-
* Tests query execution against the database
|
|
4
|
-
*/
|
|
5
|
-
import { describe, test, expect, beforeAll, beforeEach, afterEach } from 'bun:test';
|
|
6
|
-
import { Entity } from '../../../core/Entity';
|
|
7
|
-
import { Query, FilterOp } from '../../../query/Query';
|
|
8
|
-
import { TestUser, TestProduct, TestOrder } from '../../fixtures/components';
|
|
9
|
-
import { createTestContext, ensureComponentsRegistered } from '../../utils';
|
|
10
|
-
|
|
11
|
-
describe('Query Execution', () => {
|
|
12
|
-
const ctx = createTestContext();
|
|
13
|
-
|
|
14
|
-
beforeAll(async () => {
|
|
15
|
-
await ensureComponentsRegistered(TestUser, TestProduct, TestOrder);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe('basic query execution', () => {
|
|
19
|
-
test('exec() returns only entities with specified component', async () => {
|
|
20
|
-
// Positive case: entity WITH TestUser component
|
|
21
|
-
const withUser = ctx.tracker.create();
|
|
22
|
-
withUser.add(TestUser, { name: 'QueryTest', email: 'query@example.com', age: 30 });
|
|
23
|
-
await withUser.save();
|
|
24
|
-
|
|
25
|
-
// Negative case: entity WITHOUT TestUser component (only has TestProduct)
|
|
26
|
-
const withoutUser = ctx.tracker.create();
|
|
27
|
-
withoutUser.add(TestProduct, { sku: 'NO_USER', name: 'No User Product', price: 10, inStock: true });
|
|
28
|
-
await withoutUser.save();
|
|
29
|
-
|
|
30
|
-
const results = await new Query()
|
|
31
|
-
.with(TestUser)
|
|
32
|
-
.exec();
|
|
33
|
-
|
|
34
|
-
// Positive: entity with TestUser should be found
|
|
35
|
-
const foundWithUser = results.some(e => e.id === withUser.id);
|
|
36
|
-
expect(foundWithUser).toBe(true);
|
|
37
|
-
|
|
38
|
-
// Negative: entity without TestUser should NOT be found
|
|
39
|
-
const foundWithoutUser = results.some(e => e.id === withoutUser.id);
|
|
40
|
-
expect(foundWithoutUser).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('populate() loads all component data', async () => {
|
|
44
|
-
const entity = ctx.tracker.create();
|
|
45
|
-
entity.add(TestUser, { name: 'PopulateTest', email: 'populate@example.com', age: 25 });
|
|
46
|
-
await entity.save();
|
|
47
|
-
|
|
48
|
-
const results = await new Query()
|
|
49
|
-
.with(TestUser)
|
|
50
|
-
.populate()
|
|
51
|
-
.exec();
|
|
52
|
-
|
|
53
|
-
const found = results.find(e => e.id === entity.id);
|
|
54
|
-
expect(found).toBeDefined();
|
|
55
|
-
expect(found?.getInMemory(TestUser)).toBeDefined();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('returns empty array when no matches', async () => {
|
|
59
|
-
// Query for entities with a unique component
|
|
60
|
-
const results = await new Query()
|
|
61
|
-
.with(TestUser, {
|
|
62
|
-
filters: [Query.filter('email', FilterOp.EQ, 'definitely-does-not-exist@nowhere.com')]
|
|
63
|
-
})
|
|
64
|
-
.exec();
|
|
65
|
-
|
|
66
|
-
expect(results.length).toBe(0);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe('findById()', () => {
|
|
71
|
-
test('finds entity by ID', async () => {
|
|
72
|
-
const entity = ctx.tracker.create();
|
|
73
|
-
entity.add(TestUser, { name: 'FindById', email: 'findby@example.com', age: 35 });
|
|
74
|
-
await entity.save();
|
|
75
|
-
|
|
76
|
-
const results = await new Query()
|
|
77
|
-
.findById(entity.id)
|
|
78
|
-
.exec();
|
|
79
|
-
|
|
80
|
-
expect(results.length).toBe(1);
|
|
81
|
-
expect(results[0]!.id).toBe(entity.id);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('findOneById returns single entity', async () => {
|
|
85
|
-
const entity = ctx.tracker.create();
|
|
86
|
-
entity.add(TestUser, { name: 'FindOne', email: 'findone@example.com', age: 40 });
|
|
87
|
-
await entity.save();
|
|
88
|
-
|
|
89
|
-
const result = await new Query().findOneById(entity.id);
|
|
90
|
-
|
|
91
|
-
expect(result).not.toBeNull();
|
|
92
|
-
expect(result?.id).toBe(entity.id);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('findOneById returns null for non-existent ID', async () => {
|
|
96
|
-
const result = await new Query().findOneById('00000000-0000-0000-0000-000000000000');
|
|
97
|
-
expect(result).toBeNull();
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('filtering', () => {
|
|
102
|
-
beforeEach(async () => {
|
|
103
|
-
// Create test data
|
|
104
|
-
const entity1 = ctx.tracker.create();
|
|
105
|
-
entity1.add(TestUser, { name: 'Alice', email: 'alice@example.com', age: 25 });
|
|
106
|
-
await entity1.save();
|
|
107
|
-
|
|
108
|
-
const entity2 = ctx.tracker.create();
|
|
109
|
-
entity2.add(TestUser, { name: 'Bob', email: 'bob@example.com', age: 35 });
|
|
110
|
-
await entity2.save();
|
|
111
|
-
|
|
112
|
-
const entity3 = ctx.tracker.create();
|
|
113
|
-
entity3.add(TestUser, { name: 'Charlie', email: 'charlie@example.com', age: 45 });
|
|
114
|
-
await entity3.save();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test('EQ filter finds exact match', async () => {
|
|
118
|
-
const results = await new Query()
|
|
119
|
-
.with(TestUser, {
|
|
120
|
-
filters: [Query.filter('name', FilterOp.EQ, 'Alice')]
|
|
121
|
-
})
|
|
122
|
-
.populate()
|
|
123
|
-
.exec();
|
|
124
|
-
|
|
125
|
-
const alice = results.find(e => e.getInMemory(TestUser)?.name === 'Alice');
|
|
126
|
-
expect(alice).toBeDefined();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test('GT filter finds greater values', async () => {
|
|
130
|
-
const results = await new Query()
|
|
131
|
-
.with(TestUser, {
|
|
132
|
-
filters: [Query.filter('age', FilterOp.GT, 30)]
|
|
133
|
-
})
|
|
134
|
-
.populate()
|
|
135
|
-
.exec();
|
|
136
|
-
|
|
137
|
-
for (const entity of results) {
|
|
138
|
-
const user = entity.getInMemory(TestUser);
|
|
139
|
-
if (user) {
|
|
140
|
-
expect(user.age).toBeGreaterThan(30);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test('LT filter finds lesser values', async () => {
|
|
146
|
-
const results = await new Query()
|
|
147
|
-
.with(TestUser, {
|
|
148
|
-
filters: [Query.filter('age', FilterOp.LT, 40)]
|
|
149
|
-
})
|
|
150
|
-
.populate()
|
|
151
|
-
.exec();
|
|
152
|
-
|
|
153
|
-
for (const entity of results) {
|
|
154
|
-
const user = entity.getInMemory(TestUser);
|
|
155
|
-
if (user) {
|
|
156
|
-
expect(user.age).toBeLessThan(40);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('LIKE filter finds partial matches', async () => {
|
|
162
|
-
const results = await new Query()
|
|
163
|
-
.with(TestUser, {
|
|
164
|
-
filters: [Query.filter('email', FilterOp.LIKE, '%example.com')]
|
|
165
|
-
})
|
|
166
|
-
.populate()
|
|
167
|
-
.exec();
|
|
168
|
-
|
|
169
|
-
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
describe('pagination', () => {
|
|
174
|
-
beforeEach(async () => {
|
|
175
|
-
// Create multiple entities for pagination
|
|
176
|
-
for (let i = 0; i < 5; i++) {
|
|
177
|
-
const entity = ctx.tracker.create();
|
|
178
|
-
entity.add(TestUser, {
|
|
179
|
-
name: `PaginationUser${i}`,
|
|
180
|
-
email: `page${i}@example.com`,
|
|
181
|
-
age: 20 + i
|
|
182
|
-
});
|
|
183
|
-
await entity.save();
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test('take() limits results', async () => {
|
|
188
|
-
const results = await new Query()
|
|
189
|
-
.with(TestUser)
|
|
190
|
-
.take(2)
|
|
191
|
-
.exec();
|
|
192
|
-
|
|
193
|
-
expect(results.length).toBeLessThanOrEqual(2);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test('offset() skips results', async () => {
|
|
197
|
-
const allResults = await new Query()
|
|
198
|
-
.with(TestUser)
|
|
199
|
-
.exec();
|
|
200
|
-
|
|
201
|
-
const offsetResults = await new Query()
|
|
202
|
-
.with(TestUser)
|
|
203
|
-
.offset(2)
|
|
204
|
-
.take(100)
|
|
205
|
-
.exec();
|
|
206
|
-
|
|
207
|
-
expect(offsetResults.length).toBe(Math.max(0, allResults.length - 2));
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test('take() and offset() work together', async () => {
|
|
211
|
-
const results = await new Query()
|
|
212
|
-
.with(TestUser)
|
|
213
|
-
.take(2)
|
|
214
|
-
.offset(1)
|
|
215
|
-
.exec();
|
|
216
|
-
|
|
217
|
-
expect(results.length).toBeLessThanOrEqual(2);
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe('sorting', () => {
|
|
222
|
-
beforeEach(async () => {
|
|
223
|
-
const ages = [30, 20, 40, 25, 35];
|
|
224
|
-
for (let i = 0; i < ages.length; i++) {
|
|
225
|
-
const entity = ctx.tracker.create();
|
|
226
|
-
entity.add(TestUser, {
|
|
227
|
-
name: `SortUser${i}`,
|
|
228
|
-
email: `sort${i}@example.com`,
|
|
229
|
-
age: ages[i]!
|
|
230
|
-
});
|
|
231
|
-
await entity.save();
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test('sortBy ASC orders correctly', async () => {
|
|
236
|
-
const results = await new Query()
|
|
237
|
-
.with(TestUser, {
|
|
238
|
-
filters: [Query.filter('email', FilterOp.LIKE, 'sort%@example.com')]
|
|
239
|
-
})
|
|
240
|
-
.sortBy(TestUser, 'age', 'ASC')
|
|
241
|
-
.populate()
|
|
242
|
-
.exec();
|
|
243
|
-
|
|
244
|
-
for (let i = 1; i < results.length; i++) {
|
|
245
|
-
const prevAge = results[i - 1]!.getInMemory(TestUser)?.age ?? 0;
|
|
246
|
-
const currAge = results[i]!.getInMemory(TestUser)?.age ?? 0;
|
|
247
|
-
expect(currAge).toBeGreaterThanOrEqual(prevAge);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test('sortBy DESC orders correctly', async () => {
|
|
252
|
-
const results = await new Query()
|
|
253
|
-
.with(TestUser, {
|
|
254
|
-
filters: [Query.filter('email', FilterOp.LIKE, 'sort%@example.com')]
|
|
255
|
-
})
|
|
256
|
-
.sortBy(TestUser, 'age', 'DESC')
|
|
257
|
-
.populate()
|
|
258
|
-
.exec();
|
|
259
|
-
|
|
260
|
-
for (let i = 1; i < results.length; i++) {
|
|
261
|
-
const prevAge = results[i - 1]!.getInMemory(TestUser)?.age ?? 0;
|
|
262
|
-
const currAge = results[i]!.getInMemory(TestUser)?.age ?? 0;
|
|
263
|
-
expect(currAge).toBeLessThanOrEqual(prevAge);
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
describe('count()', () => {
|
|
269
|
-
test('returns count of matching entities', async () => {
|
|
270
|
-
const entity = ctx.tracker.create();
|
|
271
|
-
entity.add(TestUser, { name: 'CountTest', email: 'count@example.com', age: 30 });
|
|
272
|
-
await entity.save();
|
|
273
|
-
|
|
274
|
-
const count = await new Query()
|
|
275
|
-
.with(TestUser)
|
|
276
|
-
.count();
|
|
277
|
-
|
|
278
|
-
expect(count).toBeGreaterThanOrEqual(1);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test('count respects filters', async () => {
|
|
282
|
-
const entity = ctx.tracker.create();
|
|
283
|
-
entity.add(TestUser, { name: 'UniqueCountName', email: 'uniquecount@example.com', age: 99 });
|
|
284
|
-
await entity.save();
|
|
285
|
-
|
|
286
|
-
const count = await new Query()
|
|
287
|
-
.with(TestUser, {
|
|
288
|
-
filters: [Query.filter('name', FilterOp.EQ, 'UniqueCountName')]
|
|
289
|
-
})
|
|
290
|
-
.count();
|
|
291
|
-
|
|
292
|
-
expect(count).toBe(1);
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
describe('multiple components', () => {
|
|
297
|
-
test('with() multiple components finds only entities with ALL components', async () => {
|
|
298
|
-
// Positive case: entity with BOTH TestUser AND TestProduct
|
|
299
|
-
const withBoth = ctx.tracker.create();
|
|
300
|
-
withBoth.add(TestUser, { name: 'MultiComp', email: 'multi@example.com', age: 30 });
|
|
301
|
-
withBoth.add(TestProduct, { sku: 'MULTI', name: 'Multi Product', price: 50, inStock: true });
|
|
302
|
-
await withBoth.save();
|
|
303
|
-
|
|
304
|
-
// Negative case: entity with ONLY TestUser (missing TestProduct)
|
|
305
|
-
const withOnlyUser = ctx.tracker.create();
|
|
306
|
-
withOnlyUser.add(TestUser, { name: 'OnlyUser', email: 'onlyuser@example.com', age: 25 });
|
|
307
|
-
await withOnlyUser.save();
|
|
308
|
-
|
|
309
|
-
const results = await new Query()
|
|
310
|
-
.with(TestUser)
|
|
311
|
-
.with(TestProduct)
|
|
312
|
-
.populate()
|
|
313
|
-
.exec();
|
|
314
|
-
|
|
315
|
-
// Positive: entity with both components should be found
|
|
316
|
-
const foundWithBoth = results.find(e => e.id === withBoth.id);
|
|
317
|
-
expect(foundWithBoth).toBeDefined();
|
|
318
|
-
expect(foundWithBoth?.getInMemory(TestUser)).toBeDefined();
|
|
319
|
-
expect(foundWithBoth?.getInMemory(TestProduct)).toBeDefined();
|
|
320
|
-
|
|
321
|
-
// Negative: entity with only one component should NOT be found
|
|
322
|
-
const foundWithOnlyUser = results.some(e => e.id === withOnlyUser.id);
|
|
323
|
-
expect(foundWithOnlyUser).toBe(false);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
test('without() excludes entities with component', async () => {
|
|
327
|
-
const withProduct = ctx.tracker.create();
|
|
328
|
-
withProduct.add(TestUser, { name: 'WithProduct', email: 'withprod@example.com', age: 25 });
|
|
329
|
-
withProduct.add(TestProduct, { sku: 'WITH', name: 'With', price: 10, inStock: true });
|
|
330
|
-
await withProduct.save();
|
|
331
|
-
|
|
332
|
-
const withoutProduct = ctx.tracker.create();
|
|
333
|
-
withoutProduct.add(TestUser, { name: 'WithoutProduct', email: 'withoutprod@example.com', age: 30 });
|
|
334
|
-
await withoutProduct.save();
|
|
335
|
-
|
|
336
|
-
const results = await new Query()
|
|
337
|
-
.with(TestUser)
|
|
338
|
-
.without(TestProduct)
|
|
339
|
-
.exec();
|
|
340
|
-
|
|
341
|
-
const hasWithProduct = results.some(e => e.id === withProduct.id);
|
|
342
|
-
expect(hasWithProduct).toBe(false);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
test('with() 3+ components without filters finds entities with ALL components', async () => {
|
|
346
|
-
// Positive case: entity with ALL 3 components
|
|
347
|
-
const withAll = ctx.tracker.create();
|
|
348
|
-
withAll.add(TestUser, { name: 'AllThree', email: 'allthree@example.com', age: 30 });
|
|
349
|
-
withAll.add(TestProduct, { sku: 'ALL3', name: 'All Three Product', price: 100, inStock: true });
|
|
350
|
-
withAll.add(TestOrder, { orderNumber: 'ORD-ALL3', total: 100, status: 'pending' });
|
|
351
|
-
await withAll.save();
|
|
352
|
-
|
|
353
|
-
// Negative case: entity with only 2 of 3 components (missing TestOrder)
|
|
354
|
-
const withTwo = ctx.tracker.create();
|
|
355
|
-
withTwo.add(TestUser, { name: 'TwoOnly', email: 'twoonly@example.com', age: 25 });
|
|
356
|
-
withTwo.add(TestProduct, { sku: 'TWO', name: 'Two Only Product', price: 50, inStock: true });
|
|
357
|
-
await withTwo.save();
|
|
358
|
-
|
|
359
|
-
// Query for entities with all 3 components - NO FILTERS
|
|
360
|
-
const results = await new Query()
|
|
361
|
-
.with(TestUser)
|
|
362
|
-
.with(TestProduct)
|
|
363
|
-
.with(TestOrder)
|
|
364
|
-
.exec();
|
|
365
|
-
|
|
366
|
-
// Positive: entity with all 3 components should be found
|
|
367
|
-
const foundWithAll = results.some(e => e.id === withAll.id);
|
|
368
|
-
expect(foundWithAll).toBe(true);
|
|
369
|
-
|
|
370
|
-
// Negative: entity with only 2 components should NOT be found
|
|
371
|
-
const foundWithTwo = results.some(e => e.id === withTwo.id);
|
|
372
|
-
expect(foundWithTwo).toBe(false);
|
|
373
|
-
});
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
describe('excludeEntityId()', () => {
|
|
377
|
-
test('excludes specific entity from results', async () => {
|
|
378
|
-
const entity1 = ctx.tracker.create();
|
|
379
|
-
entity1.add(TestUser, { name: 'Include', email: 'include@example.com', age: 30 });
|
|
380
|
-
await entity1.save();
|
|
381
|
-
|
|
382
|
-
const entity2 = ctx.tracker.create();
|
|
383
|
-
entity2.add(TestUser, { name: 'Exclude', email: 'exclude@example.com', age: 30 });
|
|
384
|
-
await entity2.save();
|
|
385
|
-
|
|
386
|
-
const results = await new Query()
|
|
387
|
-
.with(TestUser)
|
|
388
|
-
.excludeEntityId(entity2.id)
|
|
389
|
-
.exec();
|
|
390
|
-
|
|
391
|
-
const hasExcluded = results.some(e => e.id === entity2.id);
|
|
392
|
-
expect(hasExcluded).toBe(false);
|
|
393
|
-
});
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
describe('eagerLoadComponents()', () => {
|
|
397
|
-
test('preloads specified components', async () => {
|
|
398
|
-
const entity = ctx.tracker.create();
|
|
399
|
-
entity.add(TestUser, { name: 'Eager', email: 'eager@example.com', age: 30 });
|
|
400
|
-
entity.add(TestProduct, { sku: 'EAGER', name: 'Eager Product', price: 20, inStock: true });
|
|
401
|
-
await entity.save();
|
|
402
|
-
|
|
403
|
-
const results = await new Query()
|
|
404
|
-
.with(TestUser)
|
|
405
|
-
.eagerLoadComponents([TestProduct])
|
|
406
|
-
.exec();
|
|
407
|
-
|
|
408
|
-
const found = results.find(e => e.id === entity.id);
|
|
409
|
-
expect(found?.hasInMemory(TestProduct)).toBe(true);
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
describe('sum()', () => {
|
|
414
|
-
test('returns correct sum of numeric field', async () => {
|
|
415
|
-
// Create entities with known prices
|
|
416
|
-
const prices = [10, 20, 30, 40];
|
|
417
|
-
const expectedSum = prices.reduce((a, b) => a + b, 0); // 100
|
|
418
|
-
|
|
419
|
-
for (let i = 0; i < prices.length; i++) {
|
|
420
|
-
const entity = ctx.tracker.create();
|
|
421
|
-
entity.add(TestProduct, {
|
|
422
|
-
sku: `SUM_TEST_${i}_${Date.now()}`,
|
|
423
|
-
name: `Sum Product ${i}`,
|
|
424
|
-
price: prices[i]!,
|
|
425
|
-
inStock: true
|
|
426
|
-
});
|
|
427
|
-
await entity.save();
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
const sum = await new Query()
|
|
431
|
-
.with(TestProduct, {
|
|
432
|
-
filters: [Query.filter('sku', FilterOp.LIKE, 'SUM_TEST_%')]
|
|
433
|
-
})
|
|
434
|
-
.sum(TestProduct, 'price');
|
|
435
|
-
|
|
436
|
-
expect(sum).toBe(expectedSum);
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
test('returns 0 when no entities match', async () => {
|
|
440
|
-
const sum = await new Query()
|
|
441
|
-
.with(TestProduct, {
|
|
442
|
-
filters: [Query.filter('sku', FilterOp.EQ, 'NONEXISTENT_SKU_12345')]
|
|
443
|
-
})
|
|
444
|
-
.sum(TestProduct, 'price');
|
|
445
|
-
|
|
446
|
-
expect(sum).toBe(0);
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
test('sum works with filters', async () => {
|
|
450
|
-
// Create entities - some in stock, some not
|
|
451
|
-
const entity1 = ctx.tracker.create();
|
|
452
|
-
entity1.add(TestProduct, {
|
|
453
|
-
sku: `SUM_FILTER_1_${Date.now()}`,
|
|
454
|
-
name: 'In Stock',
|
|
455
|
-
price: 50,
|
|
456
|
-
inStock: true
|
|
457
|
-
});
|
|
458
|
-
await entity1.save();
|
|
459
|
-
|
|
460
|
-
const entity2 = ctx.tracker.create();
|
|
461
|
-
entity2.add(TestProduct, {
|
|
462
|
-
sku: `SUM_FILTER_2_${Date.now()}`,
|
|
463
|
-
name: 'Out of Stock',
|
|
464
|
-
price: 100,
|
|
465
|
-
inStock: false
|
|
466
|
-
});
|
|
467
|
-
await entity2.save();
|
|
468
|
-
|
|
469
|
-
// Sum only in-stock items
|
|
470
|
-
const sum = await new Query()
|
|
471
|
-
.with(TestProduct, {
|
|
472
|
-
filters: [
|
|
473
|
-
Query.filter('sku', FilterOp.LIKE, 'SUM_FILTER_%'),
|
|
474
|
-
Query.filter('inStock', FilterOp.EQ, true)
|
|
475
|
-
]
|
|
476
|
-
})
|
|
477
|
-
.sum(TestProduct, 'price');
|
|
478
|
-
|
|
479
|
-
expect(sum).toBe(50);
|
|
480
|
-
});
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
describe('average()', () => {
|
|
484
|
-
test('returns correct average of numeric field', async () => {
|
|
485
|
-
// Create entities with known prices
|
|
486
|
-
const prices = [10, 20, 30, 40];
|
|
487
|
-
const expectedAvg = prices.reduce((a, b) => a + b, 0) / prices.length; // 25
|
|
488
|
-
|
|
489
|
-
for (let i = 0; i < prices.length; i++) {
|
|
490
|
-
const entity = ctx.tracker.create();
|
|
491
|
-
entity.add(TestProduct, {
|
|
492
|
-
sku: `AVG_TEST_${i}_${Date.now()}`,
|
|
493
|
-
name: `Avg Product ${i}`,
|
|
494
|
-
price: prices[i]!,
|
|
495
|
-
inStock: true
|
|
496
|
-
});
|
|
497
|
-
await entity.save();
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const avg = await new Query()
|
|
501
|
-
.with(TestProduct, {
|
|
502
|
-
filters: [Query.filter('sku', FilterOp.LIKE, 'AVG_TEST_%')]
|
|
503
|
-
})
|
|
504
|
-
.average(TestProduct, 'price');
|
|
505
|
-
|
|
506
|
-
expect(avg).toBe(expectedAvg);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
test('returns 0 when no entities match', async () => {
|
|
510
|
-
const avg = await new Query()
|
|
511
|
-
.with(TestProduct, {
|
|
512
|
-
filters: [Query.filter('sku', FilterOp.EQ, 'NONEXISTENT_SKU_67890')]
|
|
513
|
-
})
|
|
514
|
-
.average(TestProduct, 'price');
|
|
515
|
-
|
|
516
|
-
expect(avg).toBe(0);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
test('average handles decimal results', async () => {
|
|
520
|
-
// Create entities with prices that result in decimal average
|
|
521
|
-
const prices = [10, 20, 30]; // avg = 20
|
|
522
|
-
|
|
523
|
-
for (let i = 0; i < prices.length; i++) {
|
|
524
|
-
const entity = ctx.tracker.create();
|
|
525
|
-
entity.add(TestProduct, {
|
|
526
|
-
sku: `AVG_DEC_${i}_${Date.now()}`,
|
|
527
|
-
name: `Decimal Avg ${i}`,
|
|
528
|
-
price: prices[i]!,
|
|
529
|
-
inStock: true
|
|
530
|
-
});
|
|
531
|
-
await entity.save();
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const avg = await new Query()
|
|
535
|
-
.with(TestProduct, {
|
|
536
|
-
filters: [Query.filter('sku', FilterOp.LIKE, 'AVG_DEC_%')]
|
|
537
|
-
})
|
|
538
|
-
.average(TestProduct, 'price');
|
|
539
|
-
|
|
540
|
-
expect(avg).toBe(20);
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
test('average works with filters', async () => {
|
|
544
|
-
// Create entities - some expensive, some cheap
|
|
545
|
-
const entity1 = ctx.tracker.create();
|
|
546
|
-
entity1.add(TestProduct, {
|
|
547
|
-
sku: `AVG_FILTER_CHEAP_${Date.now()}`,
|
|
548
|
-
name: 'Cheap Product',
|
|
549
|
-
price: 10,
|
|
550
|
-
inStock: true
|
|
551
|
-
});
|
|
552
|
-
await entity1.save();
|
|
553
|
-
|
|
554
|
-
const entity2 = ctx.tracker.create();
|
|
555
|
-
entity2.add(TestProduct, {
|
|
556
|
-
sku: `AVG_FILTER_EXPENSIVE_${Date.now()}`,
|
|
557
|
-
name: 'Expensive Product',
|
|
558
|
-
price: 1000,
|
|
559
|
-
inStock: true
|
|
560
|
-
});
|
|
561
|
-
await entity2.save();
|
|
562
|
-
|
|
563
|
-
// Average only cheap items (price < 100)
|
|
564
|
-
const avg = await new Query()
|
|
565
|
-
.with(TestProduct, {
|
|
566
|
-
filters: [
|
|
567
|
-
Query.filter('sku', FilterOp.LIKE, 'AVG_FILTER_%'),
|
|
568
|
-
Query.filter('price', FilterOp.LT, 100)
|
|
569
|
-
]
|
|
570
|
-
})
|
|
571
|
-
.average(TestProduct, 'price');
|
|
572
|
-
|
|
573
|
-
expect(avg).toBe(10);
|
|
574
|
-
});
|
|
575
|
-
});
|
|
576
|
-
});
|