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
package/endpoints/archetypes.ts
CHANGED
|
@@ -319,19 +319,13 @@ export async function handleStudioArcheTypeDeleteRequest(
|
|
|
319
319
|
.join(", ");
|
|
320
320
|
|
|
321
321
|
// Delete in correct order to avoid foreign key constraint violations
|
|
322
|
-
// 1. Delete from
|
|
323
|
-
await db.unsafe(
|
|
324
|
-
`DELETE FROM entity_components WHERE entity_id IN (${idPlaceholders})`,
|
|
325
|
-
entityIds
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
// 2. Delete from components
|
|
322
|
+
// 1. Delete from components (membership source of truth)
|
|
329
323
|
await db.unsafe(
|
|
330
324
|
`DELETE FROM components WHERE entity_id IN (${idPlaceholders})`,
|
|
331
325
|
entityIds
|
|
332
326
|
);
|
|
333
327
|
|
|
334
|
-
//
|
|
328
|
+
// 2. Delete from entities
|
|
335
329
|
await db.unsafe(
|
|
336
330
|
`DELETE FROM entities WHERE id IN (${idPlaceholders})`,
|
|
337
331
|
entityIds
|
package/endpoints/tables.ts
CHANGED
|
@@ -179,7 +179,12 @@ export async function handleStudioTableDeleteRequest(
|
|
|
179
179
|
|
|
180
180
|
export async function handleGetTables(): Promise<Response> {
|
|
181
181
|
try {
|
|
182
|
-
//
|
|
182
|
+
// Exclude framework-internal tables and the legacy entity_components table.
|
|
183
|
+
// entity_components is no longer written by the framework (Phase 3 of
|
|
184
|
+
// docs/ENTITY_COMPONENTS_REMOVAL_PLAN.md) but may still exist as an orphan
|
|
185
|
+
// in upgraded databases. Keeping it out of the Studio listing avoids
|
|
186
|
+
// exposing a confusingly schema'd legacy table with no ECS UI support.
|
|
187
|
+
// Users are directed to drop it via the startup orphan-notice log.
|
|
183
188
|
const ecsTables = ['components', 'entities', 'entity_components', 'spatial_ref_sys'];
|
|
184
189
|
const ecsTablePlaceholders = ecsTables.map((_, index) => `$${index + 1}`).join(", ");
|
|
185
190
|
|
package/gql/index.ts
CHANGED
|
@@ -116,7 +116,7 @@ const maskError = (error: any, message: string): GraphQLError => {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
// Pass through known application-level GraphQL error codes
|
|
119
|
-
const isGQLError = (e: any): e is
|
|
119
|
+
const isGQLError = (e: any): e is { message: string; extensions?: Record<string, unknown> } =>
|
|
120
120
|
e instanceof GraphQLError ||
|
|
121
121
|
(e !== null && typeof e === 'object' && 'extensions' in e && 'message' in e && typeof e.message === 'string');
|
|
122
122
|
const knownCodes = ['FORBIDDEN', 'NOT_FOUND', 'BAD_USER_INPUT', 'BAD_REQUEST'];
|
|
@@ -18,24 +18,45 @@ export class ResolverGeneratorVisitor extends GraphVisitor {
|
|
|
18
18
|
this.services = services;
|
|
19
19
|
this.resolverBuilder = new ResolverBuilder();
|
|
20
20
|
|
|
21
|
-
// Add Date scalar resolver
|
|
21
|
+
// Add Date scalar resolver.
|
|
22
|
+
// Safety net: gqloom's `z.date()` currently maps to GraphQLString
|
|
23
|
+
// (see @gqloom/zod isZodDate → GraphQLString), so this custom Date
|
|
24
|
+
// scalar is rarely wired by the auto-generated archetype schema.
|
|
25
|
+
// Component-prop leaf resolvers normalize Date → ISO string upstream
|
|
26
|
+
// (core/archetype/fieldResolvers.ts) so GraphQLString coercion does
|
|
27
|
+
// not call Date.valueOf() and emit epoch ms. We still harden this
|
|
28
|
+
// serializer to accept Date, number, and numeric-string inputs in
|
|
29
|
+
// case a downstream user types a field as the `Date` scalar
|
|
30
|
+
// directly.
|
|
22
31
|
this.resolverBuilder.addScalarResolver('Date', {
|
|
23
32
|
serialize: (value: any) => {
|
|
24
|
-
if (value
|
|
25
|
-
|
|
33
|
+
if (value === null || value === undefined) return value;
|
|
34
|
+
if (value instanceof Date) return value.toISOString();
|
|
35
|
+
if (typeof value === 'number') return new Date(value).toISOString();
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
if (/^\d+$/.test(value)) {
|
|
38
|
+
return new Date(Number(value)).toISOString();
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
26
41
|
}
|
|
27
|
-
|
|
42
|
+
throw new Error(`Date scalar cannot serialize ${typeof value}`);
|
|
28
43
|
},
|
|
29
44
|
parseValue: (value: any) => {
|
|
30
45
|
if (typeof value === 'string') {
|
|
31
46
|
return new Date(value);
|
|
32
47
|
}
|
|
48
|
+
if (typeof value === 'number') {
|
|
49
|
+
return new Date(value);
|
|
50
|
+
}
|
|
33
51
|
return value;
|
|
34
52
|
},
|
|
35
53
|
parseLiteral: (ast: any) => {
|
|
36
54
|
if (ast.kind === 'StringValue') {
|
|
37
55
|
return new Date(ast.value);
|
|
38
56
|
}
|
|
57
|
+
if (ast.kind === 'IntValue') {
|
|
58
|
+
return new Date(Number(ast.value));
|
|
59
|
+
}
|
|
39
60
|
return null;
|
|
40
61
|
}
|
|
41
62
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunsane",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"author": {
|
|
5
5
|
"name": "yaaruu"
|
|
6
6
|
},
|
|
@@ -14,6 +14,27 @@
|
|
|
14
14
|
],
|
|
15
15
|
"module": "index.ts",
|
|
16
16
|
"type": "module",
|
|
17
|
+
"files": [
|
|
18
|
+
"index.ts",
|
|
19
|
+
"config",
|
|
20
|
+
"core",
|
|
21
|
+
"database",
|
|
22
|
+
"endpoints",
|
|
23
|
+
"gql",
|
|
24
|
+
"plugins",
|
|
25
|
+
"query",
|
|
26
|
+
"rest",
|
|
27
|
+
"scheduler",
|
|
28
|
+
"service",
|
|
29
|
+
"storage",
|
|
30
|
+
"studio/dist",
|
|
31
|
+
"swagger",
|
|
32
|
+
"types",
|
|
33
|
+
"upload",
|
|
34
|
+
"utils",
|
|
35
|
+
"tsconfig.json",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
17
38
|
"scripts": {
|
|
18
39
|
"build": "bun run build:studio && tsc",
|
|
19
40
|
"build:studio": "cd studio && bun install && bun run build",
|
package/query/CTENode.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { QueryNode } from "./QueryNode";
|
|
2
2
|
import type { QueryResult } from "./QueryNode";
|
|
3
3
|
import { QueryContext } from "./QueryContext";
|
|
4
|
+
import { getMembershipTable } from "./membershipSource";
|
|
4
5
|
|
|
5
6
|
export class CTENode extends QueryNode {
|
|
6
7
|
public execute(context: QueryContext): QueryResult {
|
|
7
8
|
// Generate CTE for base entity filtering
|
|
8
9
|
const componentIds = Array.from(context.componentIds);
|
|
9
10
|
const excludedIds = Array.from(context.excludedComponentIds);
|
|
11
|
+
const membershipTable = getMembershipTable();
|
|
10
12
|
|
|
11
13
|
if (componentIds.length === 0) {
|
|
12
14
|
throw new Error("CTENode requires at least one component type to filter on");
|
|
@@ -26,7 +28,7 @@ export class CTENode extends QueryNode {
|
|
|
26
28
|
if (excludedIds.length > 0) {
|
|
27
29
|
const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
28
30
|
exclusionCondition = ` AND NOT EXISTS (
|
|
29
|
-
SELECT 1 FROM
|
|
31
|
+
SELECT 1 FROM ${membershipTable} ec_ex
|
|
30
32
|
WHERE ec_ex.entity_id = ec.entity_id
|
|
31
33
|
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
32
34
|
AND ec_ex.deleted_at IS NULL
|
|
@@ -45,7 +47,7 @@ export class CTENode extends QueryNode {
|
|
|
45
47
|
// Single component - simple query, no INTERSECT needed
|
|
46
48
|
const paramIdx = context.addParam(componentIds[0]);
|
|
47
49
|
cteSql += ` SELECT DISTINCT ec.entity_id\n`;
|
|
48
|
-
cteSql += ` FROM
|
|
50
|
+
cteSql += ` FROM ${membershipTable} ec\n`;
|
|
49
51
|
cteSql += ` WHERE ec.type_id = $${paramIdx}::text\n`;
|
|
50
52
|
cteSql += ` AND ec.deleted_at IS NULL\n`;
|
|
51
53
|
if (cursorCondition) cteSql += ` ${cursorCondition.trim()}\n`;
|
|
@@ -57,7 +59,7 @@ export class CTENode extends QueryNode {
|
|
|
57
59
|
// then efficiently merge results, avoiding Cartesian product explosion
|
|
58
60
|
const intersectQueries = componentIds.map((compId) => {
|
|
59
61
|
const paramIdx = context.addParam(compId);
|
|
60
|
-
let subquery = `SELECT ec.entity_id FROM
|
|
62
|
+
let subquery = `SELECT ec.entity_id FROM ${membershipTable} ec WHERE ec.type_id = $${paramIdx}::text AND ec.deleted_at IS NULL`;
|
|
61
63
|
// Add cursor/exclusion conditions to each subquery for efficiency
|
|
62
64
|
if (cursorCondition) subquery += cursorCondition;
|
|
63
65
|
if (exclusionCondition) subquery += exclusionCondition;
|
|
@@ -6,6 +6,7 @@ import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
|
|
|
6
6
|
import { ComponentRegistry } from "../core/components";
|
|
7
7
|
import { getMetadataStorage } from "../core/metadata";
|
|
8
8
|
import { assertIdentifier } from "./SqlIdentifier";
|
|
9
|
+
import { getMembershipSource, getMembershipTable } from "./membershipSource";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Check if a component property is numeric based on metadata
|
|
@@ -40,6 +41,209 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
40
41
|
return 'components';
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Whether the multi-component sort-driven scan applies. Must be pure
|
|
46
|
+
* (no param side effects) — QueryDAG consults it to skip CTE planning
|
|
47
|
+
* and execute() consults it before building any SQL.
|
|
48
|
+
*
|
|
49
|
+
* Eligible shape: exactly one sort order on a required component, two or
|
|
50
|
+
* more required components, no findById, no cursor pagination. Filters
|
|
51
|
+
* on any component are supported (applied inline / via EXISTS).
|
|
52
|
+
*/
|
|
53
|
+
public static canUseSortDrivenScan(context: QueryContext): boolean {
|
|
54
|
+
if (context.sortOrders.length !== 1) return false;
|
|
55
|
+
if (context.componentIds.size < 2) return false;
|
|
56
|
+
if (context.withId) return false;
|
|
57
|
+
if (context.cursorId !== null) return false;
|
|
58
|
+
if (context.hasOrQuery) return false;
|
|
59
|
+
const sortTypeId = ComponentRegistry.getComponentId(context.sortOrders[0]!.component);
|
|
60
|
+
if (!sortTypeId || !context.componentIds.has(sortTypeId)) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a filter condition against `<alias>.data`. Mirrors the default
|
|
66
|
+
* logic in applyComponentFiltersWithState but with a configurable alias
|
|
67
|
+
* so the sort-driven scan can filter the driving table (`s`) and EXISTS
|
|
68
|
+
* probes (`cf`) without string surgery.
|
|
69
|
+
*/
|
|
70
|
+
private buildFilterCondition(filter: { field: string; operator: string; value: any }, alias: string, context: QueryContext): string {
|
|
71
|
+
if (FilterBuilderRegistry.has(filter.operator)) {
|
|
72
|
+
const options = FilterBuilderRegistry.getOptions(filter.operator);
|
|
73
|
+
if (options?.validate && !options.validate(filter as any)) {
|
|
74
|
+
throw new Error(`Invalid filter value for operator '${filter.operator}': ${JSON.stringify(filter.value)}`);
|
|
75
|
+
}
|
|
76
|
+
return FilterBuilderRegistry.get(filter.operator)!(filter as any, alias, context).sql;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let jsonPath: string;
|
|
80
|
+
if (filter.field.includes('.')) {
|
|
81
|
+
const parts = filter.field.split('.');
|
|
82
|
+
const lastPart = parts.pop()!;
|
|
83
|
+
const nestedPath = parts.map(p => `'${p}'`).join('->');
|
|
84
|
+
jsonPath = `${alias}.data->${nestedPath}->>'${lastPart}'`;
|
|
85
|
+
} else {
|
|
86
|
+
jsonPath = `${alias}.data->>'${filter.field}'`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const valueStr = String(filter.value);
|
|
90
|
+
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(valueStr);
|
|
91
|
+
|
|
92
|
+
if (isUUID && filter.operator === '=') {
|
|
93
|
+
return `${jsonPath} = $${context.addParam(filter.value)}`;
|
|
94
|
+
} else if (filter.operator === 'LIKE' || filter.operator === 'NOT LIKE' || filter.operator === 'ILIKE') {
|
|
95
|
+
return `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
|
|
96
|
+
} else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
|
|
97
|
+
if (Array.isArray(filter.value) && filter.value.length > 0) {
|
|
98
|
+
const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
|
|
99
|
+
return `${jsonPath} ${filter.operator} (${placeholders})`;
|
|
100
|
+
} else if (Array.isArray(filter.value) && filter.value.length === 0) {
|
|
101
|
+
return filter.operator === 'IN' ? 'FALSE' : 'TRUE';
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`${filter.operator} operator requires an array of values`);
|
|
104
|
+
} else if (typeof filter.value === 'number') {
|
|
105
|
+
return `(${jsonPath})::numeric ${filter.operator} $${context.addParam(filter.value)}::numeric`;
|
|
106
|
+
} else if (typeof filter.value === 'boolean') {
|
|
107
|
+
return `(${jsonPath})::boolean ${filter.operator} $${context.addParam(filter.value)}`;
|
|
108
|
+
}
|
|
109
|
+
return `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sort-driven scan for multi-component sorted queries.
|
|
114
|
+
*
|
|
115
|
+
* The previous shape (INTERSECT/CTE base set + correlated scalar
|
|
116
|
+
* subquery ORDER BY) forces PostgreSQL to materialize EVERY matching
|
|
117
|
+
* entity, run one correlated lookup per row for the sort key, sort the
|
|
118
|
+
* whole set, then apply LIMIT. This shape instead drives the scan from
|
|
119
|
+
* the sort component's table so the planner can walk the functional
|
|
120
|
+
* index on the sort expression and stop after LIMIT rows, probing the
|
|
121
|
+
* other component requirements with cheap EXISTS lookups per visited
|
|
122
|
+
* row:
|
|
123
|
+
*
|
|
124
|
+
* SELECT s.entity_id AS id FROM <sort component table> s
|
|
125
|
+
* WHERE s.type_id = $1 AND s.deleted_at IS NULL
|
|
126
|
+
* AND <filters on sort component (inline on s)>
|
|
127
|
+
* AND EXISTS (... other required component ...) -- per component
|
|
128
|
+
* AND EXISTS (... other component filter ...) -- per filter
|
|
129
|
+
* ORDER BY (s.data->>'prop')::numeric ASC NULLS LAST
|
|
130
|
+
* LIMIT $n OFFSET $m
|
|
131
|
+
*
|
|
132
|
+
* The form leaves the planner free to fall back to filter-first +
|
|
133
|
+
* sort when the predicate is highly selective — unlike the correlated
|
|
134
|
+
* subquery ORDER BY, which can never use an index for ordering.
|
|
135
|
+
*/
|
|
136
|
+
private applySortDrivenScan(context: QueryContext): string | null {
|
|
137
|
+
if (!ComponentInclusionNode.canUseSortDrivenScan(context)) return null;
|
|
138
|
+
|
|
139
|
+
const sortOrder = context.sortOrders[0]!;
|
|
140
|
+
const sortTypeId = ComponentRegistry.getComponentId(sortOrder.component)!;
|
|
141
|
+
const componentIds = Array.from(context.componentIds);
|
|
142
|
+
const otherComponentIds = componentIds.filter(id => id !== sortTypeId);
|
|
143
|
+
|
|
144
|
+
const safeProperty = assertIdentifier(sortOrder.property, 'sortOrder.property');
|
|
145
|
+
const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
146
|
+
const sortExpr = isNumeric
|
|
147
|
+
? `(s.data->>'${safeProperty}')::numeric`
|
|
148
|
+
: `s.data->>'${safeProperty}'`;
|
|
149
|
+
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
150
|
+
|
|
151
|
+
const sortTable = this.getComponentTableName(sortTypeId);
|
|
152
|
+
const driveDirect = shouldUseDirectPartition() && sortTable !== 'components';
|
|
153
|
+
|
|
154
|
+
const conditions: string[] = [];
|
|
155
|
+
|
|
156
|
+
// Filters on the sort component apply inline on the driving table.
|
|
157
|
+
for (const filter of context.componentFilters.get(sortTypeId) ?? []) {
|
|
158
|
+
conditions.push(this.buildFilterCondition(filter, 's', context));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Presence probe per other required component.
|
|
162
|
+
for (const compId of otherComponentIds) {
|
|
163
|
+
conditions.push(`EXISTS (
|
|
164
|
+
SELECT 1 FROM ${getMembershipTable()} ec_r
|
|
165
|
+
WHERE ec_r.entity_id = s.entity_id
|
|
166
|
+
AND ec_r.type_id = $${context.addParam(compId)}::text
|
|
167
|
+
AND ec_r.deleted_at IS NULL
|
|
168
|
+
)`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Filters on other components probe their own table.
|
|
172
|
+
for (const compId of otherComponentIds) {
|
|
173
|
+
const filters = context.componentFilters.get(compId) ?? [];
|
|
174
|
+
for (const filter of filters) {
|
|
175
|
+
const condition = this.buildFilterCondition(filter, 'cf', context);
|
|
176
|
+
const compTable = this.getComponentTableName(compId);
|
|
177
|
+
const filterDirect = shouldUseDirectPartition() && compTable !== 'components';
|
|
178
|
+
if (filterDirect || !getMembershipSource().isLegacy) {
|
|
179
|
+
// Single-table predicate on the component (partition) table:
|
|
180
|
+
// membership lives in the same row, so no junction join.
|
|
181
|
+
conditions.push(`EXISTS (
|
|
182
|
+
SELECT 1 FROM ${compTable} cf
|
|
183
|
+
WHERE cf.entity_id = s.entity_id
|
|
184
|
+
AND cf.type_id = $${context.addParam(compId)}::text
|
|
185
|
+
AND ${condition}
|
|
186
|
+
AND cf.deleted_at IS NULL
|
|
187
|
+
)`);
|
|
188
|
+
} else {
|
|
189
|
+
conditions.push(`EXISTS (
|
|
190
|
+
SELECT 1 FROM entity_components ec_f
|
|
191
|
+
JOIN ${compTable} cf ON ec_f.component_id = cf.id
|
|
192
|
+
WHERE ec_f.entity_id = s.entity_id
|
|
193
|
+
AND ec_f.type_id = $${context.addParam(compId)}::text
|
|
194
|
+
AND ${condition}
|
|
195
|
+
AND ec_f.deleted_at IS NULL
|
|
196
|
+
AND cf.deleted_at IS NULL
|
|
197
|
+
)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Excluded components / entities.
|
|
203
|
+
if (context.excludedComponentIds.size > 0) {
|
|
204
|
+
const excludedPlaceholders = Array.from(context.excludedComponentIds)
|
|
205
|
+
.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
206
|
+
conditions.push(`NOT EXISTS (
|
|
207
|
+
SELECT 1 FROM ${getMembershipTable()} ec_ex
|
|
208
|
+
WHERE ec_ex.entity_id = s.entity_id
|
|
209
|
+
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
210
|
+
AND ec_ex.deleted_at IS NULL
|
|
211
|
+
)`);
|
|
212
|
+
}
|
|
213
|
+
if (context.excludedEntityIds.size > 0) {
|
|
214
|
+
const entityPlaceholders = Array.from(context.excludedEntityIds)
|
|
215
|
+
.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
216
|
+
conditions.push(`s.entity_id NOT IN (${entityPlaceholders})`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const extraConditions = conditions.length > 0 ? `\n AND ${conditions.join('\n AND ')}` : '';
|
|
220
|
+
|
|
221
|
+
let sql: string;
|
|
222
|
+
if (driveDirect || !getMembershipSource().isLegacy) {
|
|
223
|
+
// Drive directly from the sort component (partition) table —
|
|
224
|
+
// membership and component data are the same row.
|
|
225
|
+
sql = `SELECT s.entity_id as id FROM ${sortTable} s
|
|
226
|
+
WHERE s.type_id = $${context.addParam(sortTypeId)}::text
|
|
227
|
+
AND s.deleted_at IS NULL${extraConditions}
|
|
228
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
229
|
+
} else {
|
|
230
|
+
sql = `SELECT s.entity_id as id FROM entity_components ec
|
|
231
|
+
JOIN ${sortTable} s ON s.id = ec.component_id AND s.deleted_at IS NULL
|
|
232
|
+
WHERE ec.type_id = $${context.addParam(sortTypeId)}::text
|
|
233
|
+
AND ec.deleted_at IS NULL${extraConditions}
|
|
234
|
+
ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (context.limit !== null) {
|
|
238
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
239
|
+
}
|
|
240
|
+
if (context.offsetValue > 0 || context.limit !== null) {
|
|
241
|
+
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return sql;
|
|
245
|
+
}
|
|
246
|
+
|
|
43
247
|
public execute(context: QueryContext): QueryResult {
|
|
44
248
|
const componentIds = Array.from(context.componentIds);
|
|
45
249
|
const excludedIds = Array.from(context.excludedComponentIds);
|
|
@@ -71,6 +275,18 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
71
275
|
// Check if we need custom sorting (sortOrders specified)
|
|
72
276
|
const hasSortOrders = context.sortOrders.length > 0;
|
|
73
277
|
|
|
278
|
+
// Multi-component sorted queries: drive the scan from the sort
|
|
279
|
+
// component so the planner can use the sort-expression index and
|
|
280
|
+
// stop at LIMIT, instead of materializing the full INTERSECT set and
|
|
281
|
+
// sorting it via correlated subqueries. Checked before any params
|
|
282
|
+
// are added so a null fallback leaves the context clean.
|
|
283
|
+
if (!useCTE && hasSortOrders && ComponentInclusionNode.canUseSortDrivenScan(context)) {
|
|
284
|
+
const sortDriven = this.applySortDrivenScan(context);
|
|
285
|
+
if (sortDriven) {
|
|
286
|
+
return { sql: sortDriven, params: context.params, context };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
74
290
|
if (componentCount === 1) {
|
|
75
291
|
// Single component case
|
|
76
292
|
const componentId = componentIds[0]!;
|
|
@@ -100,14 +316,14 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
100
316
|
// Filter by the specific component type if not already in CTE
|
|
101
317
|
if (!componentIds.some(id => context.componentIds.has(id))) {
|
|
102
318
|
sql += ` WHERE EXISTS (
|
|
103
|
-
SELECT 1 FROM
|
|
319
|
+
SELECT 1 FROM ${getMembershipTable()} ec
|
|
104
320
|
WHERE ec.entity_id = ${context.cteName}.entity_id
|
|
105
321
|
AND ec.type_id = $${context.addParam(componentId)}::text
|
|
106
322
|
AND ec.deleted_at IS NULL
|
|
107
323
|
)`;
|
|
108
324
|
}
|
|
109
325
|
} else {
|
|
110
|
-
sql = `SELECT DISTINCT ec.entity_id as id FROM
|
|
326
|
+
sql = `SELECT DISTINCT ec.entity_id as id FROM ${getMembershipTable()} ec WHERE ec.type_id = $${context.addParam(componentId)}::text AND ec.deleted_at IS NULL`;
|
|
111
327
|
}
|
|
112
328
|
|
|
113
329
|
if (context.withId) {
|
|
@@ -122,7 +338,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
122
338
|
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
123
339
|
const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
124
340
|
sql += ` ${whereKeyword} NOT EXISTS (
|
|
125
|
-
SELECT 1 FROM
|
|
341
|
+
SELECT 1 FROM ${getMembershipTable()} ec_ex
|
|
126
342
|
WHERE ec_ex.entity_id = ${tableAlias}.entity_id
|
|
127
343
|
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
128
344
|
AND ec_ex.deleted_at IS NULL
|
|
@@ -191,7 +407,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
191
407
|
componentParamIndices.set(compId, context.addParam(compId));
|
|
192
408
|
}
|
|
193
409
|
return `EXISTS (
|
|
194
|
-
SELECT 1 FROM
|
|
410
|
+
SELECT 1 FROM ${getMembershipTable()} ec
|
|
195
411
|
WHERE ec.entity_id = ${context.cteName}.entity_id
|
|
196
412
|
AND ec.type_id = $${componentParamIndices.get(compId)}::text
|
|
197
413
|
AND ec.deleted_at IS NULL
|
|
@@ -206,7 +422,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
206
422
|
if (!componentParamIndices.has(compId)) {
|
|
207
423
|
componentParamIndices.set(compId, context.addParam(compId));
|
|
208
424
|
}
|
|
209
|
-
return `SELECT ec.entity_id FROM
|
|
425
|
+
return `SELECT ec.entity_id FROM ${getMembershipTable()} ec WHERE ec.type_id = $${componentParamIndices.get(compId)}::text AND ec.deleted_at IS NULL`;
|
|
210
426
|
});
|
|
211
427
|
sql = `SELECT intersected.entity_id as id FROM (${intersectQueries.join(' INTERSECT ')}) AS intersected`;
|
|
212
428
|
}
|
|
@@ -229,7 +445,7 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
229
445
|
const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
|
|
230
446
|
const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
231
447
|
sql += ` ${whereKeyword} NOT EXISTS (
|
|
232
|
-
SELECT 1 FROM
|
|
448
|
+
SELECT 1 FROM ${getMembershipTable()} ec_ex
|
|
233
449
|
WHERE ec_ex.entity_id = ${multiCompAlias}.entity_id
|
|
234
450
|
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
235
451
|
AND ec_ex.deleted_at IS NULL
|
|
@@ -343,7 +559,8 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
343
559
|
? `(sort_c.data->>'${safeProperty}')::numeric`
|
|
344
560
|
: `sort_c.data->>'${safeProperty}'`;
|
|
345
561
|
|
|
346
|
-
const subquery =
|
|
562
|
+
const subquery = getMembershipSource().isLegacy
|
|
563
|
+
? `(
|
|
347
564
|
SELECT ${sortExpr}
|
|
348
565
|
FROM entity_components sort_ec
|
|
349
566
|
JOIN ${sortComponentTableName} sort_c ON sort_c.id = sort_ec.component_id
|
|
@@ -352,6 +569,14 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
352
569
|
AND sort_ec.deleted_at IS NULL
|
|
353
570
|
AND sort_c.deleted_at IS NULL
|
|
354
571
|
LIMIT 1
|
|
572
|
+
)`
|
|
573
|
+
: `(
|
|
574
|
+
SELECT ${sortExpr}
|
|
575
|
+
FROM ${sortComponentTableName} sort_c
|
|
576
|
+
WHERE sort_c.entity_id = base_entities.id
|
|
577
|
+
AND sort_c.type_id = $${context.addParam(typeId)}::text
|
|
578
|
+
AND sort_c.deleted_at IS NULL
|
|
579
|
+
LIMIT 1
|
|
355
580
|
)`;
|
|
356
581
|
|
|
357
582
|
orderByClauses.push(`${subquery} ${sortOrder.direction} ${nullsClause}`);
|
|
@@ -465,8 +690,8 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
465
690
|
: `c.data->>'${safeProperty}'`;
|
|
466
691
|
|
|
467
692
|
let sql: string;
|
|
468
|
-
if (useDirectPartition) {
|
|
469
|
-
// Direct partition
|
|
693
|
+
if (useDirectPartition || !getMembershipSource().isLegacy) {
|
|
694
|
+
// Direct access on the component (partition) table - most efficient.
|
|
470
695
|
// No DISTINCT needed since each entity has one component of this type
|
|
471
696
|
sql = `SELECT c.entity_id as id FROM ${componentTableName} c
|
|
472
697
|
WHERE c.type_id = $${context.addParam(sortTypeId)}::text
|
|
@@ -673,8 +898,9 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
673
898
|
const componentTableName = this.getComponentTableName(compId);
|
|
674
899
|
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
675
900
|
|
|
676
|
-
if (useDirectPartition) {
|
|
677
|
-
//
|
|
901
|
+
if (useDirectPartition || !getMembershipSource().isLegacy) {
|
|
902
|
+
// Single-table predicate on the component (partition)
|
|
903
|
+
// table — membership is the same row, no junction join.
|
|
678
904
|
lateralJoins.push(
|
|
679
905
|
`CROSS JOIN LATERAL (
|
|
680
906
|
SELECT 1 FROM ${componentTableName} c
|
|
@@ -706,8 +932,9 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
706
932
|
const componentTableName = this.getComponentTableName(compId);
|
|
707
933
|
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
708
934
|
|
|
709
|
-
if (useDirectPartition) {
|
|
710
|
-
//
|
|
935
|
+
if (useDirectPartition || !getMembershipSource().isLegacy) {
|
|
936
|
+
// Single-table predicate on the component (partition)
|
|
937
|
+
// table — membership is the same row, no junction join.
|
|
711
938
|
sql += ` ${whereKeyword} EXISTS (
|
|
712
939
|
SELECT 1 FROM ${componentTableName} c
|
|
713
940
|
WHERE c.entity_id = ${tableAlias}.entity_id
|
package/query/OrNode.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { QueryContext } from "./QueryContext";
|
|
|
4
4
|
import { OrQuery } from "./OrQuery";
|
|
5
5
|
import { ComponentRegistry } from "../core/components";
|
|
6
6
|
import { shouldUseDirectPartition } from "../core/Config";
|
|
7
|
+
import { getMembershipTable } from "./membershipSource";
|
|
7
8
|
|
|
8
9
|
export class OrNode extends QueryNode {
|
|
9
10
|
private orQuery: OrQuery;
|
|
@@ -149,7 +150,7 @@ export class OrNode extends QueryNode {
|
|
|
149
150
|
if (context.excludedComponentIds.size > 0) {
|
|
150
151
|
const excludedTypes = Array.from(context.excludedComponentIds);
|
|
151
152
|
const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
|
|
152
|
-
conditions.push(`NOT EXISTS (SELECT 1 FROM
|
|
153
|
+
conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
|
|
153
154
|
context.params.push(...excludedTypes);
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -271,7 +272,7 @@ export class OrNode extends QueryNode {
|
|
|
271
272
|
if (context.excludedComponentIds.size > 0) {
|
|
272
273
|
const excludedTypes = Array.from(context.excludedComponentIds);
|
|
273
274
|
const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
|
|
274
|
-
conditions.push(`NOT EXISTS (SELECT 1 FROM
|
|
275
|
+
conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = ${partitionTable}.entity_id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
|
|
275
276
|
context.params.push(...excludedTypes);
|
|
276
277
|
}
|
|
277
278
|
|
|
@@ -368,7 +369,7 @@ export class OrNode extends QueryNode {
|
|
|
368
369
|
const componentTableName = this.getComponentTableName(componentId);
|
|
369
370
|
branchSql = `
|
|
370
371
|
SELECT ec.entity_id
|
|
371
|
-
FROM
|
|
372
|
+
FROM ${getMembershipTable()} ec
|
|
372
373
|
WHERE ec.type_id = $${componentIdParamIndex} AND ec.deleted_at IS NULL
|
|
373
374
|
AND EXISTS (
|
|
374
375
|
SELECT 1 FROM ${componentTableName} c
|
|
@@ -458,7 +459,7 @@ export class OrNode extends QueryNode {
|
|
|
458
459
|
for (const componentType of allComponentTypes) {
|
|
459
460
|
const componentId = ComponentRegistry.getComponentId(componentType);
|
|
460
461
|
if (componentId) {
|
|
461
|
-
componentConditions.push(`EXISTS (SELECT 1 FROM
|
|
462
|
+
componentConditions.push(`EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_all WHERE ec_all.entity_id = or_results.id AND ec_all.type_id = $${paramIndex} AND ec_all.deleted_at IS NULL)`);
|
|
462
463
|
context.params.push(componentId);
|
|
463
464
|
paramIndex++;
|
|
464
465
|
}
|
|
@@ -480,7 +481,7 @@ export class OrNode extends QueryNode {
|
|
|
480
481
|
if (context.excludedComponentIds.size > 0) {
|
|
481
482
|
const excludedTypes = Array.from(context.excludedComponentIds);
|
|
482
483
|
const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
|
|
483
|
-
conditions.push(`NOT EXISTS (SELECT 1 FROM
|
|
484
|
+
conditions.push(`NOT EXISTS (SELECT 1 FROM ${getMembershipTable()} ec_ex WHERE ec_ex.entity_id = or_results.id AND ec_ex.type_id IN (${placeholders}) AND ec_ex.deleted_at IS NULL)`);
|
|
484
485
|
context.params.push(...excludedTypes);
|
|
485
486
|
}
|
|
486
487
|
|