bunsane 0.3.2 → 0.5.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 +471 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +93 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +8 -7
- 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 +25 -10
- 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 +383 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +375 -0
- package/core/health.ts +93 -4
- 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/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/StreamConsumer.ts +535 -535
- 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/core/validateEnv.ts +10 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +29 -3
- package/database/instrumentedDb.ts +141 -141
- package/database/sqlHelpers.ts +3 -1
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/schema/index.ts +15 -4
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +245 -14
- package/query/OrNode.ts +8 -19
- package/query/Query.ts +208 -79
- 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/scheduled_tasks.lock +0 -1
- 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/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- 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/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- 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 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- 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
|
@@ -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
|
|
@@ -637,7 +862,11 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
637
862
|
} else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
|
|
638
863
|
// IN/NOT IN comparison - handle arrays properly
|
|
639
864
|
if (Array.isArray(filter.value) && filter.value.length > 0) {
|
|
640
|
-
|
|
865
|
+
let placeholders = '';
|
|
866
|
+
for (let i = 0; i < filter.value.length; i++) {
|
|
867
|
+
if (i) placeholders += ', ';
|
|
868
|
+
placeholders += '$' + context.addParam(filter.value[i]);
|
|
869
|
+
}
|
|
641
870
|
condition = `${jsonPath} ${filter.operator} (${placeholders})`;
|
|
642
871
|
} else if (Array.isArray(filter.value) && filter.value.length === 0) {
|
|
643
872
|
// Empty array: IN () is always false, NOT IN () is always true
|
|
@@ -673,8 +902,9 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
673
902
|
const componentTableName = this.getComponentTableName(compId);
|
|
674
903
|
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
675
904
|
|
|
676
|
-
if (useDirectPartition) {
|
|
677
|
-
//
|
|
905
|
+
if (useDirectPartition || !getMembershipSource().isLegacy) {
|
|
906
|
+
// Single-table predicate on the component (partition)
|
|
907
|
+
// table — membership is the same row, no junction join.
|
|
678
908
|
lateralJoins.push(
|
|
679
909
|
`CROSS JOIN LATERAL (
|
|
680
910
|
SELECT 1 FROM ${componentTableName} c
|
|
@@ -706,8 +936,9 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
706
936
|
const componentTableName = this.getComponentTableName(compId);
|
|
707
937
|
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
708
938
|
|
|
709
|
-
if (useDirectPartition) {
|
|
710
|
-
//
|
|
939
|
+
if (useDirectPartition || !getMembershipSource().isLegacy) {
|
|
940
|
+
// Single-table predicate on the component (partition)
|
|
941
|
+
// table — membership is the same row, no junction join.
|
|
711
942
|
sql += ` ${whereKeyword} EXISTS (
|
|
712
943
|
SELECT 1 FROM ${componentTableName} c
|
|
713
944
|
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
|
|
|
@@ -356,30 +357,18 @@ export class OrNode extends QueryNode {
|
|
|
356
357
|
WHERE EXISTS (
|
|
357
358
|
SELECT 1 FROM ${componentTableName} c
|
|
358
359
|
WHERE c.entity_id = base.id
|
|
359
|
-
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
|
|
360
|
-
AND c.created_at = (
|
|
361
|
-
SELECT MAX(c2.created_at)
|
|
362
|
-
FROM ${componentTableName} c2
|
|
363
|
-
WHERE c2.entity_id = c.entity_id
|
|
364
|
-
AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
|
|
365
|
-
)`;
|
|
360
|
+
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
|
|
366
361
|
} else {
|
|
367
362
|
// Use original query without base
|
|
368
363
|
const componentTableName = this.getComponentTableName(componentId);
|
|
369
364
|
branchSql = `
|
|
370
365
|
SELECT ec.entity_id
|
|
371
|
-
FROM
|
|
366
|
+
FROM ${getMembershipTable()} ec
|
|
372
367
|
WHERE ec.type_id = $${componentIdParamIndex} AND ec.deleted_at IS NULL
|
|
373
368
|
AND EXISTS (
|
|
374
369
|
SELECT 1 FROM ${componentTableName} c
|
|
375
370
|
WHERE c.entity_id = ec.entity_id
|
|
376
|
-
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL
|
|
377
|
-
AND c.created_at = (
|
|
378
|
-
SELECT MAX(c2.created_at)
|
|
379
|
-
FROM ${componentTableName} c2
|
|
380
|
-
WHERE c2.entity_id = c.entity_id
|
|
381
|
-
AND c2.type_id = $${componentIdParamIndex} AND c2.deleted_at IS NULL
|
|
382
|
-
)`;
|
|
371
|
+
AND c.type_id = $${componentIdParamIndex} AND c.deleted_at IS NULL`;
|
|
383
372
|
}
|
|
384
373
|
|
|
385
374
|
context.params.push(componentId);
|
|
@@ -458,7 +447,7 @@ export class OrNode extends QueryNode {
|
|
|
458
447
|
for (const componentType of allComponentTypes) {
|
|
459
448
|
const componentId = ComponentRegistry.getComponentId(componentType);
|
|
460
449
|
if (componentId) {
|
|
461
|
-
componentConditions.push(`EXISTS (SELECT 1 FROM
|
|
450
|
+
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
451
|
context.params.push(componentId);
|
|
463
452
|
paramIndex++;
|
|
464
453
|
}
|
|
@@ -480,7 +469,7 @@ export class OrNode extends QueryNode {
|
|
|
480
469
|
if (context.excludedComponentIds.size > 0) {
|
|
481
470
|
const excludedTypes = Array.from(context.excludedComponentIds);
|
|
482
471
|
const placeholders = excludedTypes.map(() => `$${paramIndex++}`).join(', ');
|
|
483
|
-
conditions.push(`NOT EXISTS (SELECT 1 FROM
|
|
472
|
+
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
473
|
context.params.push(...excludedTypes);
|
|
485
474
|
}
|
|
486
475
|
|