bunsane 0.1.4 → 0.2.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/.claude/settings.local.json +47 -0
- package/.claude/skills/update-memory.md +74 -0
- package/.prettierrc +4 -0
- package/.serena/memories/architectural-decision-no-dependency-injection.md +76 -0
- package/.serena/memories/architecture.md +154 -0
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +165 -0
- package/.serena/memories/code_style_and_conventions.md +76 -0
- package/.serena/memories/project_overview.md +43 -0
- package/.serena/memories/schema-dsl-plan.md +107 -0
- package/.serena/memories/suggested_commands.md +80 -0
- package/.serena/memories/typescript-compilation-status.md +54 -0
- package/.serena/project.yml +114 -0
- package/TODO.md +1 -7
- package/bun.lock +150 -4
- package/bunfig.toml +10 -0
- package/config/cache.config.ts +77 -0
- package/config/upload.config.ts +4 -5
- package/core/App.ts +870 -123
- package/core/ArcheType.ts +2268 -377
- package/core/BatchLoader.ts +181 -71
- package/core/Config.ts +153 -0
- package/core/Decorators.ts +4 -1
- package/core/Entity.ts +621 -92
- package/core/EntityHookManager.ts +1 -1
- package/core/EntityInterface.ts +3 -1
- package/core/EntityManager.ts +1 -13
- package/core/ErrorHandler.ts +8 -2
- package/core/Logger.ts +9 -0
- package/core/Middleware.ts +34 -0
- package/core/RequestContext.ts +5 -1
- package/core/RequestLoaders.ts +227 -93
- package/core/SchedulerManager.ts +193 -52
- package/core/cache/CacheAnalytics.ts +399 -0
- package/core/cache/CacheFactory.ts +145 -0
- package/core/cache/CacheManager.ts +520 -0
- package/core/cache/CacheProvider.ts +34 -0
- package/core/cache/CacheWarmer.ts +157 -0
- package/core/cache/CompressionUtils.ts +110 -0
- package/core/cache/MemoryCache.ts +251 -0
- package/core/cache/MultiLevelCache.ts +180 -0
- package/core/cache/NoOpCache.ts +53 -0
- package/core/cache/RedisCache.ts +464 -0
- package/core/cache/TTLStrategy.ts +254 -0
- package/core/cache/index.ts +6 -0
- package/core/components/BaseComponent.ts +120 -0
- package/core/{ComponentRegistry.ts → components/ComponentRegistry.ts} +148 -54
- package/core/components/Decorators.ts +88 -0
- package/core/components/Interfaces.ts +7 -0
- package/core/components/index.ts +5 -0
- package/core/decorators/EntityHooks.ts +0 -3
- package/core/decorators/IndexedField.ts +26 -0
- package/core/decorators/ScheduledTask.ts +0 -47
- package/core/events/EntityLifecycleEvents.ts +1 -1
- package/core/health.ts +112 -0
- package/core/metadata/definitions/ArcheType.ts +14 -0
- package/core/metadata/definitions/Component.ts +9 -0
- package/core/metadata/definitions/gqlObject.ts +1 -1
- package/core/metadata/index.ts +42 -1
- package/core/metadata/metadata-storage.ts +28 -2
- package/core/middleware/AccessLog.ts +59 -0
- package/core/middleware/RequestId.ts +38 -0
- package/core/middleware/SecurityHeaders.ts +62 -0
- package/core/middleware/index.ts +3 -0
- package/core/scheduler/DistributedLock.ts +266 -0
- package/core/scheduler/index.ts +15 -0
- package/core/validateEnv.ts +92 -0
- package/database/DatabaseHelper.ts +416 -40
- package/database/IndexingStrategy.ts +342 -0
- package/database/PreparedStatementCache.ts +226 -0
- package/database/index.ts +32 -7
- package/database/sqlHelpers.ts +14 -2
- package/endpoints/archetypes.ts +362 -0
- package/endpoints/components.ts +58 -0
- package/endpoints/entity.ts +80 -0
- package/endpoints/index.ts +27 -0
- package/endpoints/query.ts +93 -0
- package/endpoints/stats.ts +76 -0
- package/endpoints/tables.ts +212 -0
- package/endpoints/types.ts +155 -0
- package/gql/ArchetypeOperations.ts +32 -86
- package/gql/Generator.ts +27 -315
- package/gql/GeneratorV2.ts +37 -0
- package/gql/builders/InputTypeBuilder.ts +99 -0
- package/gql/builders/ResolverBuilder.ts +234 -0
- package/gql/builders/TypeDefBuilder.ts +105 -0
- package/gql/builders/index.ts +3 -0
- package/gql/decorators/Upload.ts +1 -1
- package/gql/depthLimit.ts +85 -0
- package/gql/graph/GraphNode.ts +224 -0
- package/gql/graph/SchemaGraph.ts +278 -0
- package/gql/helpers.ts +8 -2
- package/gql/index.ts +56 -4
- package/gql/middleware.ts +79 -0
- package/gql/orchestration/GraphQLSchemaOrchestrator.ts +241 -0
- package/gql/orchestration/index.ts +1 -0
- package/gql/scanner/ServiceScanner.ts +347 -0
- package/gql/schema/index.ts +458 -0
- package/gql/strategies/TypeGenerationStrategy.ts +329 -0
- package/gql/types.ts +1 -0
- package/gql/utils/TypeSignature.ts +220 -0
- package/gql/utils/index.ts +1 -0
- package/gql/visitors/ArchetypePreprocessorVisitor.ts +80 -0
- package/gql/visitors/DeduplicationVisitor.ts +82 -0
- package/gql/visitors/GraphVisitor.ts +78 -0
- package/gql/visitors/ResolverGeneratorVisitor.ts +122 -0
- package/gql/visitors/SchemaGeneratorVisitor.ts +851 -0
- package/gql/visitors/TypeCollectorVisitor.ts +79 -0
- package/gql/visitors/VisitorComposer.ts +96 -0
- package/gql/visitors/index.ts +7 -0
- package/package.json +59 -37
- package/plugins/index.ts +2 -2
- package/query/CTENode.ts +97 -0
- package/query/ComponentInclusionNode.ts +689 -0
- package/query/FilterBuilder.ts +127 -0
- package/query/FilterBuilderRegistry.ts +202 -0
- package/query/OrNode.ts +517 -0
- package/query/OrQuery.ts +42 -0
- package/query/Query.ts +1022 -0
- package/query/QueryContext.ts +170 -0
- package/query/QueryDAG.ts +122 -0
- package/query/QueryNode.ts +65 -0
- package/query/SourceNode.ts +53 -0
- package/query/builders/FullTextSearchBuilder.ts +236 -0
- package/query/index.ts +21 -0
- package/scheduler/index.ts +40 -8
- package/service/Service.ts +2 -1
- package/service/ServiceRegistry.ts +6 -5
- package/{core/storage → storage}/LocalStorageProvider.ts +2 -2
- package/storage/S3StorageProvider.ts +316 -0
- package/{core/storage → storage}/StorageProvider.ts +7 -3
- package/studio/bun.lock +482 -0
- package/studio/index.html +13 -0
- package/studio/package.json +39 -0
- package/studio/postcss.config.js +6 -0
- package/studio/src/components/DataTable.tsx +211 -0
- package/studio/src/components/Layout.tsx +13 -0
- package/studio/src/components/PageContainer.tsx +9 -0
- package/studio/src/components/PageHeader.tsx +13 -0
- package/studio/src/components/SearchBar.tsx +57 -0
- package/studio/src/components/Sidebar.tsx +294 -0
- package/studio/src/components/ui/button.tsx +56 -0
- package/studio/src/components/ui/checkbox.tsx +26 -0
- package/studio/src/components/ui/input.tsx +25 -0
- package/studio/src/hooks/useDataTable.ts +131 -0
- package/studio/src/index.css +36 -0
- package/studio/src/lib/api.ts +186 -0
- package/studio/src/lib/utils.ts +13 -0
- package/studio/src/main.tsx +17 -0
- package/studio/src/pages/ArcheType.tsx +239 -0
- package/studio/src/pages/Components.tsx +124 -0
- package/studio/src/pages/EntityInspector.tsx +302 -0
- package/studio/src/pages/QueryRunner.tsx +246 -0
- package/studio/src/pages/Table.tsx +94 -0
- package/studio/src/pages/Welcome.tsx +241 -0
- package/studio/src/routes.tsx +45 -0
- package/studio/src/store/archeTypeSettings.ts +30 -0
- package/studio/src/store/studio.ts +65 -0
- package/studio/src/utils/columnHelpers.tsx +114 -0
- package/studio/studio-instructions.md +81 -0
- package/studio/tailwind.config.js +77 -0
- package/studio/tsconfig.json +24 -0
- package/studio/utils.ts +54 -0
- package/studio/vite.config.js +19 -0
- package/swagger/generator.ts +1 -1
- package/tests/e2e/http.test.ts +126 -0
- package/tests/fixtures/archetypes/TestUserArchetype.ts +21 -0
- package/tests/fixtures/components/TestOrder.ts +23 -0
- package/tests/fixtures/components/TestProduct.ts +23 -0
- package/tests/fixtures/components/TestUser.ts +20 -0
- package/tests/fixtures/components/index.ts +6 -0
- package/tests/graphql/SchemaGeneration.test.ts +90 -0
- package/tests/graphql/builders/ResolverBuilder.test.ts +223 -0
- package/tests/graphql/builders/TypeDefBuilder.test.ts +153 -0
- package/tests/integration/archetype/ArcheType.persistence.test.ts +241 -0
- package/tests/integration/cache/CacheInvalidation.test.ts +259 -0
- package/tests/integration/entity/Entity.persistence.test.ts +333 -0
- package/tests/integration/query/Query.exec.test.ts +523 -0
- package/tests/pglite-setup.ts +61 -0
- package/tests/setup.ts +164 -0
- package/tests/stress/BenchmarkRunner.ts +203 -0
- package/tests/stress/DataSeeder.ts +190 -0
- package/tests/stress/StressTestReporter.ts +229 -0
- package/tests/stress/cursor-perf-test.ts +171 -0
- package/tests/stress/fixtures/StressTestComponents.ts +58 -0
- package/tests/stress/index.ts +7 -0
- package/tests/stress/scenarios/query-benchmarks.test.ts +285 -0
- package/tests/unit/BatchLoader.test.ts +82 -0
- package/tests/unit/archetype/ArcheType.test.ts +107 -0
- package/tests/unit/cache/CacheManager.test.ts +347 -0
- package/tests/unit/cache/MemoryCache.test.ts +260 -0
- package/tests/unit/cache/RedisCache.test.ts +411 -0
- package/tests/unit/entity/Entity.components.test.ts +244 -0
- package/tests/unit/entity/Entity.test.ts +345 -0
- package/tests/unit/gql/depthLimit.test.ts +203 -0
- package/tests/unit/gql/operationMiddleware.test.ts +293 -0
- package/tests/unit/health/Health.test.ts +129 -0
- package/tests/unit/middleware/AccessLog.test.ts +37 -0
- package/tests/unit/middleware/Middleware.test.ts +98 -0
- package/tests/unit/middleware/RequestId.test.ts +54 -0
- package/tests/unit/middleware/SecurityHeaders.test.ts +66 -0
- package/tests/unit/query/FilterBuilder.test.ts +111 -0
- package/tests/unit/query/Query.test.ts +308 -0
- package/tests/unit/scheduler/DistributedLock.test.ts +274 -0
- package/tests/unit/schema/schema-integration.test.ts +426 -0
- package/tests/unit/schema/schema.test.ts +580 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +571 -0
- package/tests/unit/upload/RestUpload.test.ts +267 -0
- package/tests/unit/validateEnv.test.ts +82 -0
- package/tests/utils/entity-tracker.ts +57 -0
- package/tests/utils/index.ts +13 -0
- package/tests/utils/test-context.ts +149 -0
- package/tsconfig.json +5 -1
- package/types/archetype.types.ts +6 -0
- package/types/hooks.types.ts +1 -1
- package/types/query.types.ts +110 -0
- package/types/scheduler.types.ts +68 -7
- package/types/upload.types.ts +1 -0
- package/{core → upload}/FileValidator.ts +10 -1
- package/upload/RestUpload.ts +130 -0
- package/{core/components → upload}/UploadComponent.ts +11 -11
- package/{core → upload}/UploadManager.ts +3 -3
- package/upload/index.ts +23 -7
- package/utils/UploadHelper.ts +27 -6
- package/utils/cronParser.ts +16 -6
- package/.github/workflows/deploy-docs.yml +0 -57
- package/core/Components.ts +0 -202
- package/core/EntityCache.ts +0 -15
- package/core/Query.ts +0 -880
- package/docs/README.md +0 -149
- package/docs/_coverpage.md +0 -36
- package/docs/_sidebar.md +0 -23
- package/docs/api/core.md +0 -568
- package/docs/api/hooks.md +0 -554
- package/docs/api/index.md +0 -222
- package/docs/api/query.md +0 -678
- package/docs/api/service.md +0 -744
- package/docs/core-concepts/archetypes.md +0 -512
- package/docs/core-concepts/components.md +0 -498
- package/docs/core-concepts/entity.md +0 -314
- package/docs/core-concepts/hooks.md +0 -683
- package/docs/core-concepts/query.md +0 -588
- package/docs/core-concepts/services.md +0 -647
- package/docs/examples/code-examples.md +0 -425
- package/docs/getting-started.md +0 -337
- package/docs/index.html +0 -97
- package/tests/bench/insert.bench.ts +0 -60
- package/tests/bench/relations.bench.ts +0 -270
- package/tests/bench/sorting.bench.ts +0 -416
- package/tests/component-hooks-simple.test.ts +0 -117
- package/tests/component-hooks.test.ts +0 -1461
- package/tests/component.test.ts +0 -339
- package/tests/errorHandling.test.ts +0 -155
- package/tests/hooks.test.ts +0 -667
- package/tests/query-sorting.test.ts +0 -101
- package/tests/query.test.ts +0 -81
- package/tests/relations.test.ts +0 -170
- package/tests/scheduler.test.ts +0 -724
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { QueryNode } from "./QueryNode";
|
|
2
|
+
import type { QueryResult } from "./QueryNode";
|
|
3
|
+
import { QueryContext } from "./QueryContext";
|
|
4
|
+
import { shouldUseLateralJoins, shouldUseDirectPartition } from "../core/Config";
|
|
5
|
+
import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
|
|
6
|
+
import {ComponentRegistry} from "../core/components";
|
|
7
|
+
|
|
8
|
+
export class ComponentInclusionNode extends QueryNode {
|
|
9
|
+
private getComponentTableName(compId: string): string {
|
|
10
|
+
if (shouldUseDirectPartition()) {
|
|
11
|
+
return ComponentRegistry.getPartitionTableName(compId) || 'components';
|
|
12
|
+
}
|
|
13
|
+
return 'components';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public execute(context: QueryContext): QueryResult {
|
|
17
|
+
const componentIds = Array.from(context.componentIds);
|
|
18
|
+
const excludedIds = Array.from(context.excludedComponentIds);
|
|
19
|
+
|
|
20
|
+
if (componentIds.length === 0) {
|
|
21
|
+
// No components required, return the input as-is
|
|
22
|
+
return {
|
|
23
|
+
sql: "",
|
|
24
|
+
params: context.params,
|
|
25
|
+
context
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let sql = "";
|
|
30
|
+
const componentCount = componentIds.length;
|
|
31
|
+
const useLateralJoins = Boolean(shouldUseLateralJoins());
|
|
32
|
+
|
|
33
|
+
// Check if CTE is available and use it to avoid redundant entity_components scans
|
|
34
|
+
const useCTE = Boolean(context.hasCTE && context.cteName);
|
|
35
|
+
|
|
36
|
+
// Collect LATERAL join fragments if using LATERAL joins
|
|
37
|
+
const lateralJoins: string[] = [];
|
|
38
|
+
const lateralConditions: string[] = [];
|
|
39
|
+
|
|
40
|
+
// Check if we need custom sorting (sortOrders specified)
|
|
41
|
+
const hasSortOrders = context.sortOrders.length > 0;
|
|
42
|
+
|
|
43
|
+
if (componentCount === 1) {
|
|
44
|
+
// Single component case
|
|
45
|
+
const componentId = componentIds[0]!;
|
|
46
|
+
|
|
47
|
+
// Check if we can use single-pass optimization (filter + sort on same component)
|
|
48
|
+
// This must be checked BEFORE adding any params to avoid orphan params
|
|
49
|
+
const canUseSinglePass = hasSortOrders &&
|
|
50
|
+
context.sortOrders.length === 1 &&
|
|
51
|
+
context.componentFilters.size > 0 &&
|
|
52
|
+
!context.withId &&
|
|
53
|
+
excludedIds.length === 0 &&
|
|
54
|
+
context.excludedEntityIds.size === 0 &&
|
|
55
|
+
!useCTE;
|
|
56
|
+
|
|
57
|
+
if (canUseSinglePass) {
|
|
58
|
+
const singlePass = this.applySinglePassFilterSort(context);
|
|
59
|
+
if (singlePass) {
|
|
60
|
+
// Single-pass handles filters, sort, and pagination all in one query
|
|
61
|
+
return { sql: singlePass, params: context.params, context };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (useCTE) {
|
|
66
|
+
// Use CTE for base entity filtering
|
|
67
|
+
sql = `SELECT DISTINCT ${context.cteName}.entity_id as id FROM ${context.cteName}`;
|
|
68
|
+
|
|
69
|
+
// Filter by the specific component type if not already in CTE
|
|
70
|
+
if (!componentIds.some(id => context.componentIds.has(id))) {
|
|
71
|
+
sql += ` WHERE EXISTS (
|
|
72
|
+
SELECT 1 FROM entity_components ec
|
|
73
|
+
WHERE ec.entity_id = ${context.cteName}.entity_id
|
|
74
|
+
AND ec.type_id = $${context.addParam(componentId)}::text
|
|
75
|
+
AND ec.deleted_at IS NULL
|
|
76
|
+
)`;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
sql = `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id = $${context.addParam(componentId)}::text AND ec.deleted_at IS NULL`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (context.withId) {
|
|
83
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
84
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
85
|
+
sql += ` ${whereKeyword} ${tableAlias}.entity_id = $${context.addParam(context.withId)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Add exclusions
|
|
89
|
+
if (excludedIds.length > 0) {
|
|
90
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
91
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
92
|
+
const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
93
|
+
sql += ` ${whereKeyword} NOT EXISTS (
|
|
94
|
+
SELECT 1 FROM entity_components ec_ex
|
|
95
|
+
WHERE ec_ex.entity_id = ${tableAlias}.entity_id
|
|
96
|
+
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
97
|
+
AND ec_ex.deleted_at IS NULL
|
|
98
|
+
)`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add entity exclusions
|
|
102
|
+
if (context.excludedEntityIds.size > 0) {
|
|
103
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
104
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
105
|
+
const entityExcludedIds = Array.from(context.excludedEntityIds);
|
|
106
|
+
const entityPlaceholders = entityExcludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
107
|
+
sql += ` ${whereKeyword} ${tableAlias}.entity_id NOT IN (${entityPlaceholders})`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Apply component filters for single component (normal path)
|
|
111
|
+
sql = this.applyComponentFilters(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, new Map());
|
|
112
|
+
|
|
113
|
+
// Apply sorting with component data joins if sortOrders are specified
|
|
114
|
+
if (hasSortOrders) {
|
|
115
|
+
sql = this.applySortingWithComponentJoins(sql, context);
|
|
116
|
+
} else {
|
|
117
|
+
// Default: order by entity_id
|
|
118
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
119
|
+
const idColumn = useCTE ? `${context.cteName}.entity_id` : `${tableAlias}.entity_id`;
|
|
120
|
+
|
|
121
|
+
// Apply cursor-based pagination if cursor is set (more efficient than OFFSET)
|
|
122
|
+
if (context.cursorId !== null && !context.paginationAppliedInCTE) {
|
|
123
|
+
const operator = context.cursorDirection === 'after' ? '>' : '<';
|
|
124
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
125
|
+
sql += ` ${whereKeyword} ${idColumn} ${operator} $${context.addParam(context.cursorId)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Order direction depends on cursor direction
|
|
129
|
+
const orderDirection = context.cursorDirection === 'before' ? 'DESC' : 'ASC';
|
|
130
|
+
sql += ` ORDER BY ${idColumn} ${orderDirection}`;
|
|
131
|
+
|
|
132
|
+
// Add LIMIT and OFFSET only if not already applied in CTE
|
|
133
|
+
// When pagination is applied at CTE level, skip it here to avoid double pagination
|
|
134
|
+
if (!context.paginationAppliedInCTE) {
|
|
135
|
+
if (context.limit !== null) {
|
|
136
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
137
|
+
}
|
|
138
|
+
// Only add OFFSET when not using cursor-based pagination
|
|
139
|
+
if (context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
140
|
+
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// Multiple components case
|
|
146
|
+
// Create parameter indices for component IDs to avoid duplicates
|
|
147
|
+
const componentParamIndices: Map<string, number> = new Map();
|
|
148
|
+
const componentPlaceholders = componentIds.map((id) => {
|
|
149
|
+
if (!componentParamIndices.has(id)) {
|
|
150
|
+
componentParamIndices.set(id, context.addParam(id));
|
|
151
|
+
}
|
|
152
|
+
return `$${componentParamIndices.get(id)}::text`;
|
|
153
|
+
}).join(', ');
|
|
154
|
+
|
|
155
|
+
if (useCTE) {
|
|
156
|
+
// Use CTE for base entity filtering
|
|
157
|
+
sql = `SELECT DISTINCT ${context.cteName}.entity_id as id FROM ${context.cteName}`;
|
|
158
|
+
|
|
159
|
+
// Ensure all required components are present
|
|
160
|
+
sql += ` WHERE (`;
|
|
161
|
+
const componentChecks = componentIds.map(compId => {
|
|
162
|
+
if (!componentParamIndices.has(compId)) {
|
|
163
|
+
componentParamIndices.set(compId, context.addParam(compId));
|
|
164
|
+
}
|
|
165
|
+
return `EXISTS (
|
|
166
|
+
SELECT 1 FROM entity_components ec
|
|
167
|
+
WHERE ec.entity_id = ${context.cteName}.entity_id
|
|
168
|
+
AND ec.type_id = $${componentParamIndices.get(compId)}::text
|
|
169
|
+
AND ec.deleted_at IS NULL
|
|
170
|
+
)`;
|
|
171
|
+
});
|
|
172
|
+
sql += componentChecks.join(' AND ') + `)`;
|
|
173
|
+
} else {
|
|
174
|
+
sql = `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id IN (${componentPlaceholders}) AND ec.deleted_at IS NULL`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (context.withId) {
|
|
178
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
179
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
180
|
+
sql += ` ${whereKeyword} ${tableAlias}.entity_id = $${context.addParam(context.withId)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Add exclusions
|
|
184
|
+
if (excludedIds.length > 0) {
|
|
185
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
186
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
187
|
+
const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
188
|
+
sql += ` ${whereKeyword} NOT EXISTS (
|
|
189
|
+
SELECT 1 FROM entity_components ec_ex
|
|
190
|
+
WHERE ec_ex.entity_id = ${tableAlias}.entity_id
|
|
191
|
+
AND ec_ex.type_id IN (${excludedPlaceholders})
|
|
192
|
+
AND ec_ex.deleted_at IS NULL
|
|
193
|
+
)`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Add entity exclusions
|
|
197
|
+
if (context.excludedEntityIds.size > 0) {
|
|
198
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
199
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
200
|
+
const entityExcludedIds = Array.from(context.excludedEntityIds);
|
|
201
|
+
const entityPlaceholders = entityExcludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
|
|
202
|
+
sql += ` ${whereKeyword} ${tableAlias}.entity_id NOT IN (${entityPlaceholders})`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Apply component filters for multiple components
|
|
206
|
+
sql = this.applyComponentFilters(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, componentParamIndices);
|
|
207
|
+
|
|
208
|
+
if (!useCTE) {
|
|
209
|
+
sql += ` GROUP BY ec.entity_id HAVING COUNT(DISTINCT ec.type_id) = $${context.addParam(componentCount)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Apply sorting with component data joins if sortOrders are specified
|
|
213
|
+
if (hasSortOrders) {
|
|
214
|
+
sql = this.applySortingWithComponentJoins(sql, context);
|
|
215
|
+
} else {
|
|
216
|
+
// Default: order by entity_id
|
|
217
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
218
|
+
const idColumn = useCTE ? `${context.cteName}.entity_id` : `${tableAlias}.entity_id`;
|
|
219
|
+
|
|
220
|
+
// Apply cursor-based pagination if cursor is set (more efficient than OFFSET)
|
|
221
|
+
if (context.cursorId !== null && !context.paginationAppliedInCTE) {
|
|
222
|
+
const operator = context.cursorDirection === 'after' ? '>' : '<';
|
|
223
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
224
|
+
sql += ` ${whereKeyword} ${idColumn} ${operator} $${context.addParam(context.cursorId)}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Order direction depends on cursor direction
|
|
228
|
+
const orderDirection = context.cursorDirection === 'before' ? 'DESC' : 'ASC';
|
|
229
|
+
sql += ` ORDER BY ${idColumn} ${orderDirection}`;
|
|
230
|
+
|
|
231
|
+
// Add LIMIT and OFFSET only if not already applied in CTE
|
|
232
|
+
// When pagination is applied at CTE level, skip it here to avoid double pagination
|
|
233
|
+
if (!context.paginationAppliedInCTE) {
|
|
234
|
+
if (context.limit !== null) {
|
|
235
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
236
|
+
}
|
|
237
|
+
// Only add OFFSET when not using cursor-based pagination
|
|
238
|
+
if (context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
239
|
+
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
sql,
|
|
247
|
+
params: context.params,
|
|
248
|
+
context
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Wrap the base query with sorting joins and apply ORDER BY, LIMIT, OFFSET
|
|
254
|
+
* This ensures that sorting and pagination work together correctly
|
|
255
|
+
*/
|
|
256
|
+
private applySortingWithComponentJoins(baseQuery: string, context: QueryContext): string {
|
|
257
|
+
// Check if we can use the optimized direct partition sort
|
|
258
|
+
if (shouldUseDirectPartition() && context.sortOrders.length === 1) {
|
|
259
|
+
const optimized = this.applySortingOptimized(baseQuery, context);
|
|
260
|
+
if (optimized) return optimized;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Try single-pass optimization when filters and sort are on the same component
|
|
264
|
+
if (context.sortOrders.length === 1) {
|
|
265
|
+
const singlePass = this.applySinglePassFilterSort(context);
|
|
266
|
+
if (singlePass) return singlePass;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Wrap the base query as a subquery to get entity ids
|
|
270
|
+
let sql = `SELECT base_entities.id FROM (${baseQuery}) AS base_entities`;
|
|
271
|
+
|
|
272
|
+
// Build LEFT JOINs for each sort order to access component data
|
|
273
|
+
const sortJoins: string[] = [];
|
|
274
|
+
const orderByClauses: string[] = [];
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < context.sortOrders.length; i++) {
|
|
277
|
+
const sortOrder = context.sortOrders[i]!;
|
|
278
|
+
const sortAlias = `sort_${i}`;
|
|
279
|
+
const compAlias = `comp_${i}`;
|
|
280
|
+
|
|
281
|
+
// Get the component type ID for this sort order
|
|
282
|
+
const typeId = ComponentRegistry.getComponentId(sortOrder.component);
|
|
283
|
+
if (!typeId) {
|
|
284
|
+
continue; // Skip if component not registered
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// LEFT JOIN to entity_components and components to get the sort data
|
|
288
|
+
const sortComponentTableName = this.getComponentTableName(typeId);
|
|
289
|
+
sortJoins.push(`
|
|
290
|
+
LEFT JOIN entity_components ${sortAlias}
|
|
291
|
+
ON ${sortAlias}.entity_id = base_entities.id
|
|
292
|
+
AND ${sortAlias}.type_id = $${context.addParam(typeId)}::text
|
|
293
|
+
AND ${sortAlias}.deleted_at IS NULL
|
|
294
|
+
LEFT JOIN ${sortComponentTableName} ${compAlias}
|
|
295
|
+
ON ${compAlias}.id = ${sortAlias}.component_id
|
|
296
|
+
AND ${compAlias}.deleted_at IS NULL`);
|
|
297
|
+
|
|
298
|
+
// Build ORDER BY clause for this sort order
|
|
299
|
+
// Access the property from JSONB data
|
|
300
|
+
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
301
|
+
orderByClauses.push(`${compAlias}.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Combine joins
|
|
305
|
+
sql += sortJoins.join('');
|
|
306
|
+
|
|
307
|
+
// Add ORDER BY clause
|
|
308
|
+
if (orderByClauses.length > 0) {
|
|
309
|
+
sql += ` ORDER BY ${orderByClauses.join(', ')}`;
|
|
310
|
+
} else {
|
|
311
|
+
// Fallback to entity id if no valid sort orders
|
|
312
|
+
sql += ` ORDER BY base_entities.id`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Add LIMIT and OFFSET only if not already applied in CTE
|
|
316
|
+
// When pagination is applied at CTE level, skip it here to avoid double pagination
|
|
317
|
+
if (!context.paginationAppliedInCTE) {
|
|
318
|
+
if (context.limit !== null) {
|
|
319
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
320
|
+
}
|
|
321
|
+
// Only add OFFSET when not using cursor-based pagination
|
|
322
|
+
if (context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
323
|
+
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return sql;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Single-pass optimization when all filters and sort are on the same component.
|
|
332
|
+
* Instead of: CTE -> EXISTS filters -> subquery -> JOIN for sort -> LIMIT
|
|
333
|
+
* We do: JOIN once -> filter + sort in same query -> LIMIT
|
|
334
|
+
*
|
|
335
|
+
* This is dramatically faster because PostgreSQL can use indexes to find
|
|
336
|
+
* the top N matching rows directly instead of finding ALL matches first.
|
|
337
|
+
*/
|
|
338
|
+
private applySinglePassFilterSort(context: QueryContext): string | null {
|
|
339
|
+
if (context.sortOrders.length !== 1) return null;
|
|
340
|
+
|
|
341
|
+
const sortOrder = context.sortOrders[0]!;
|
|
342
|
+
const sortTypeId = ComponentRegistry.getComponentId(sortOrder.component);
|
|
343
|
+
if (!sortTypeId) return null;
|
|
344
|
+
|
|
345
|
+
// Check if all filters are on the same component as the sort
|
|
346
|
+
const filterComponentIds = Array.from(context.componentFilters.keys());
|
|
347
|
+
if (filterComponentIds.length === 0) return null;
|
|
348
|
+
if (filterComponentIds.length > 1) return null; // Multiple components - can't optimize
|
|
349
|
+
if (filterComponentIds[0] !== sortTypeId) return null; // Filter and sort on different components
|
|
350
|
+
|
|
351
|
+
// All filters and sort are on the same component - use single-pass optimization
|
|
352
|
+
const filters = context.componentFilters.get(sortTypeId) || [];
|
|
353
|
+
if (filters.length === 0) return null;
|
|
354
|
+
|
|
355
|
+
const componentTableName = this.getComponentTableName(sortTypeId);
|
|
356
|
+
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
357
|
+
|
|
358
|
+
// Build filter conditions
|
|
359
|
+
const filterConditions: string[] = [];
|
|
360
|
+
for (const filter of filters) {
|
|
361
|
+
// Build JSON path
|
|
362
|
+
let jsonPath: string;
|
|
363
|
+
if (filter.field.includes('.')) {
|
|
364
|
+
const parts = filter.field.split('.');
|
|
365
|
+
const lastPart = parts.pop()!;
|
|
366
|
+
const nestedPath = parts.map(p => `'${p}'`).join('->');
|
|
367
|
+
jsonPath = `c.data->${nestedPath}->>'${lastPart}'`;
|
|
368
|
+
} else {
|
|
369
|
+
jsonPath = `c.data->>'${filter.field}'`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Build condition based on type
|
|
373
|
+
let condition: string;
|
|
374
|
+
if (typeof filter.value === 'number') {
|
|
375
|
+
condition = `(${jsonPath})::numeric ${filter.operator} $${context.addParam(filter.value)}::numeric`;
|
|
376
|
+
} else if (typeof filter.value === 'boolean') {
|
|
377
|
+
condition = `(${jsonPath})::boolean ${filter.operator} $${context.addParam(filter.value)}`;
|
|
378
|
+
} else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
|
|
379
|
+
if (Array.isArray(filter.value)) {
|
|
380
|
+
const placeholders = filter.value.map((v: any) => `$${context.addParam(v)}`).join(', ');
|
|
381
|
+
condition = `${jsonPath} ${filter.operator} (${placeholders})`;
|
|
382
|
+
} else {
|
|
383
|
+
return null; // Invalid - fall back to normal path
|
|
384
|
+
}
|
|
385
|
+
} else if (filter.operator === 'LIKE' || filter.operator === 'NOT LIKE' || filter.operator === 'ILIKE') {
|
|
386
|
+
condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}::text`;
|
|
387
|
+
} else {
|
|
388
|
+
condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}::text`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
filterConditions.push(condition);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
395
|
+
|
|
396
|
+
let sql: string;
|
|
397
|
+
if (useDirectPartition) {
|
|
398
|
+
// Direct partition access - most efficient
|
|
399
|
+
// No DISTINCT needed since each entity has one component of this type
|
|
400
|
+
sql = `SELECT c.entity_id as id FROM ${componentTableName} c
|
|
401
|
+
WHERE c.type_id = $${context.addParam(sortTypeId)}::text
|
|
402
|
+
AND c.deleted_at IS NULL
|
|
403
|
+
AND ${filterConditions.join(' AND ')}
|
|
404
|
+
ORDER BY c.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`;
|
|
405
|
+
} else {
|
|
406
|
+
// Use entity_components junction
|
|
407
|
+
// No DISTINCT needed since each entity has one component of this type
|
|
408
|
+
sql = `SELECT ec.entity_id as id FROM entity_components ec
|
|
409
|
+
JOIN ${componentTableName} c ON c.id = ec.component_id AND c.deleted_at IS NULL
|
|
410
|
+
WHERE ec.type_id = $${context.addParam(sortTypeId)}::text
|
|
411
|
+
AND ec.deleted_at IS NULL
|
|
412
|
+
AND ${filterConditions.join(' AND ')}
|
|
413
|
+
ORDER BY c.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Add pagination
|
|
417
|
+
if (!context.paginationAppliedInCTE) {
|
|
418
|
+
if (context.limit !== null) {
|
|
419
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
420
|
+
}
|
|
421
|
+
if (context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
422
|
+
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return sql;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Optimized sorting for direct partition access
|
|
431
|
+
* Queries the partition table directly without going through entity_components for the sort join
|
|
432
|
+
*/
|
|
433
|
+
private applySortingOptimized(baseQuery: string, context: QueryContext): string | null {
|
|
434
|
+
if (context.sortOrders.length !== 1) return null;
|
|
435
|
+
|
|
436
|
+
const sortOrder = context.sortOrders[0]!;
|
|
437
|
+
const typeId = ComponentRegistry.getComponentId(sortOrder.component);
|
|
438
|
+
if (!typeId) return null;
|
|
439
|
+
|
|
440
|
+
const partitionTable = ComponentRegistry.getPartitionTableName(typeId);
|
|
441
|
+
if (!partitionTable) return null;
|
|
442
|
+
|
|
443
|
+
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
444
|
+
|
|
445
|
+
// Optimized query: Direct join to partition table, skip entity_components for sort
|
|
446
|
+
// This is faster because we go directly to the partition table
|
|
447
|
+
let sql = `SELECT base.id FROM (${baseQuery}) AS base
|
|
448
|
+
JOIN ${partitionTable} c ON c.entity_id = base.id
|
|
449
|
+
AND c.type_id = $${context.addParam(typeId)}::text
|
|
450
|
+
AND c.deleted_at IS NULL
|
|
451
|
+
ORDER BY c.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`;
|
|
452
|
+
|
|
453
|
+
// Add LIMIT and OFFSET only if not already applied in CTE
|
|
454
|
+
// When pagination is applied at CTE level, skip it here to avoid double pagination
|
|
455
|
+
if (!context.paginationAppliedInCTE) {
|
|
456
|
+
if (context.limit !== null) {
|
|
457
|
+
sql += ` LIMIT $${context.addParam(context.limit)}`;
|
|
458
|
+
}
|
|
459
|
+
// Only add OFFSET when not using cursor-based pagination
|
|
460
|
+
if (context.cursorId === null && (context.offsetValue > 0 || context.limit !== null)) {
|
|
461
|
+
sql += ` OFFSET $${context.addParam(context.offsetValue)}`;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return sql;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Apply component filters using either EXISTS subqueries or LATERAL joins
|
|
470
|
+
*/
|
|
471
|
+
private applyComponentFilters(
|
|
472
|
+
context: QueryContext,
|
|
473
|
+
componentIds: string[],
|
|
474
|
+
useCTE: boolean,
|
|
475
|
+
useLateralJoins: boolean,
|
|
476
|
+
lateralJoins: string[],
|
|
477
|
+
lateralConditions: string[],
|
|
478
|
+
sql: string,
|
|
479
|
+
componentParamIndices: Map<string, number>
|
|
480
|
+
): string {
|
|
481
|
+
for (const [compId, filters] of context.componentFilters) {
|
|
482
|
+
for (const filter of filters) {
|
|
483
|
+
let condition: string;
|
|
484
|
+
|
|
485
|
+
// Check for custom filter builder first
|
|
486
|
+
if (FilterBuilderRegistry.has(filter.operator)) {
|
|
487
|
+
// Validate filter if validator is provided
|
|
488
|
+
const options = FilterBuilderRegistry.getOptions(filter.operator);
|
|
489
|
+
if (options?.validate && !options.validate(filter)) {
|
|
490
|
+
throw new Error(`Invalid filter value for operator '${filter.operator}': ${JSON.stringify(filter.value)}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const customBuilder = FilterBuilderRegistry.get(filter.operator)!;
|
|
494
|
+
const result = customBuilder(filter, "c", context);
|
|
495
|
+
condition = result.sql;
|
|
496
|
+
// Note: custom builder is responsible for adding parameters via context.addParam()
|
|
497
|
+
} else {
|
|
498
|
+
// Default filter logic
|
|
499
|
+
// Validate filter value to prevent PostgreSQL UUID parsing errors
|
|
500
|
+
if (filter.value === '' || (typeof filter.value === 'string' && filter.value.trim() === '')) {
|
|
501
|
+
throw new Error(`Filter value for field "${filter.field}" is an empty string. This would cause PostgreSQL UUID parsing errors.`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Check if value looks like a UUID (case-insensitive, with or without hyphens)
|
|
505
|
+
const valueStr = String(filter.value);
|
|
506
|
+
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);
|
|
507
|
+
|
|
508
|
+
// Debug logging
|
|
509
|
+
// console.log('[ComponentInclusionNode] Filter:', {
|
|
510
|
+
// field: filter.field,
|
|
511
|
+
// operator: filter.operator,
|
|
512
|
+
// value: filter.value,
|
|
513
|
+
// valueStr,
|
|
514
|
+
// isUUID
|
|
515
|
+
// });
|
|
516
|
+
|
|
517
|
+
// Build JSON path for nested fields (e.g., "device.unique_id" -> "c.data->'device'->>'unique_id'")
|
|
518
|
+
let jsonPath: string;
|
|
519
|
+
if (filter.field.includes('.')) {
|
|
520
|
+
const parts = filter.field.split('.');
|
|
521
|
+
const lastPart = parts.pop()!;
|
|
522
|
+
const nestedPath = parts.map(p => `'${p}'`).join('->');
|
|
523
|
+
jsonPath = `c.data->${nestedPath}->>'${lastPart}'`;
|
|
524
|
+
} else {
|
|
525
|
+
jsonPath = `c.data->>'${filter.field}'`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (isUUID && filter.operator === '=') {
|
|
529
|
+
// UUID equality comparison - only cast the parameter, compare as text
|
|
530
|
+
// This allows matching UUID parameter against both UUID and text fields
|
|
531
|
+
condition = `${jsonPath} = $${context.addParam(filter.value)}`;
|
|
532
|
+
} else if (filter.operator === 'LIKE' || filter.operator === 'NOT LIKE' || filter.operator === 'ILIKE') {
|
|
533
|
+
// String LIKE/ILIKE comparison - no casting
|
|
534
|
+
condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
|
|
535
|
+
} else if (filter.operator === 'IN' || filter.operator === 'NOT IN') {
|
|
536
|
+
// IN/NOT IN comparison - handle arrays properly
|
|
537
|
+
if (Array.isArray(filter.value)) {
|
|
538
|
+
const placeholders = Array.from({length: filter.value.length}, (_, i) => `$${context.addParam(filter.value[i])}`).join(', ');
|
|
539
|
+
condition = `${jsonPath} ${filter.operator} (${placeholders})`;
|
|
540
|
+
} else {
|
|
541
|
+
throw new Error(`${filter.operator} operator requires an array of values`);
|
|
542
|
+
}
|
|
543
|
+
} else if (typeof filter.value === 'number') {
|
|
544
|
+
// Only treat as numeric if the value is actually a number type, not a string
|
|
545
|
+
condition = `(${jsonPath})::numeric ${filter.operator} $${context.addParam(filter.value)}::numeric`;
|
|
546
|
+
} else if (typeof filter.value === 'boolean') {
|
|
547
|
+
// Boolean comparison - cast JSON text to boolean
|
|
548
|
+
condition = `(${jsonPath})::boolean ${filter.operator} $${context.addParam(filter.value)}`;
|
|
549
|
+
} else {
|
|
550
|
+
// Default: text comparison without casting
|
|
551
|
+
condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// console.log('[ComponentInclusionNode] Condition:', condition);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const tableAlias = useCTE ? context.cteName : "ec";
|
|
558
|
+
const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
|
|
559
|
+
|
|
560
|
+
if (useLateralJoins) {
|
|
561
|
+
// Use LATERAL join approach
|
|
562
|
+
// Create a short, unique alias (PostgreSQL has 63 char limit)
|
|
563
|
+
// Use first 8 chars of component ID + field name + index
|
|
564
|
+
const compIdShort = compId.substring(0, 8);
|
|
565
|
+
const fieldShort = filter.field.replace(/\./g, '_').substring(0, 20);
|
|
566
|
+
const lateralAlias = `lat_${compIdShort}_${fieldShort}_${lateralJoins.length}`;
|
|
567
|
+
|
|
568
|
+
const componentTableName = this.getComponentTableName(compId);
|
|
569
|
+
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
570
|
+
|
|
571
|
+
if (useDirectPartition) {
|
|
572
|
+
// Direct partition access - query partition table directly by entity_id
|
|
573
|
+
lateralJoins.push(
|
|
574
|
+
`CROSS JOIN LATERAL (
|
|
575
|
+
SELECT 1 FROM ${componentTableName} c
|
|
576
|
+
WHERE c.entity_id = ${tableAlias}.entity_id
|
|
577
|
+
AND c.type_id = $${componentParamIndices.has(compId) ? componentParamIndices.get(compId) : context.addParam(compId)}::text
|
|
578
|
+
AND ${condition}
|
|
579
|
+
AND c.deleted_at IS NULL
|
|
580
|
+
LIMIT 1
|
|
581
|
+
) AS ${lateralAlias}`
|
|
582
|
+
);
|
|
583
|
+
} else {
|
|
584
|
+
// Use entity_components junction table
|
|
585
|
+
lateralJoins.push(
|
|
586
|
+
`CROSS JOIN LATERAL (
|
|
587
|
+
SELECT 1 FROM entity_components ec_f
|
|
588
|
+
JOIN ${componentTableName} c ON ec_f.component_id = c.id
|
|
589
|
+
WHERE ec_f.entity_id = ${tableAlias}.entity_id
|
|
590
|
+
AND ec_f.type_id = $${componentParamIndices.has(compId) ? componentParamIndices.get(compId) : context.addParam(compId)}::text
|
|
591
|
+
AND ${condition}
|
|
592
|
+
AND ec_f.deleted_at IS NULL
|
|
593
|
+
AND c.deleted_at IS NULL
|
|
594
|
+
LIMIT 1
|
|
595
|
+
) AS ${lateralAlias}`
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
lateralConditions.push(`${lateralAlias} IS NOT NULL`);
|
|
599
|
+
} else {
|
|
600
|
+
// Use traditional EXISTS subquery
|
|
601
|
+
const componentTableName = this.getComponentTableName(compId);
|
|
602
|
+
const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
|
|
603
|
+
|
|
604
|
+
if (useDirectPartition) {
|
|
605
|
+
// Direct partition access - query partition table directly by entity_id
|
|
606
|
+
sql += ` ${whereKeyword} EXISTS (
|
|
607
|
+
SELECT 1 FROM ${componentTableName} c
|
|
608
|
+
WHERE c.entity_id = ${tableAlias}.entity_id
|
|
609
|
+
AND c.type_id = $${componentParamIndices.has(compId) ? componentParamIndices.get(compId) : context.addParam(compId)}::text
|
|
610
|
+
AND ${condition}
|
|
611
|
+
AND c.deleted_at IS NULL
|
|
612
|
+
)`;
|
|
613
|
+
} else {
|
|
614
|
+
// Use entity_components junction table
|
|
615
|
+
sql += ` ${whereKeyword} EXISTS (
|
|
616
|
+
SELECT 1 FROM entity_components ec_f
|
|
617
|
+
JOIN ${componentTableName} c ON ec_f.component_id = c.id
|
|
618
|
+
WHERE ec_f.entity_id = ${tableAlias}.entity_id
|
|
619
|
+
AND ec_f.type_id = $${componentParamIndices.has(compId) ? componentParamIndices.get(compId) : context.addParam(compId)}::text
|
|
620
|
+
AND ${condition}
|
|
621
|
+
AND ec_f.deleted_at IS NULL
|
|
622
|
+
AND c.deleted_at IS NULL
|
|
623
|
+
)`;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// If using LATERAL joins, add them to the FROM clause and conditions to WHERE
|
|
630
|
+
if (useLateralJoins && lateralJoins.length > 0) {
|
|
631
|
+
// Add LATERAL conditions to WHERE clause FIRST (before inserting LATERAL joins)
|
|
632
|
+
let whereClause = '';
|
|
633
|
+
if (lateralConditions.length > 0) {
|
|
634
|
+
const conditionsString = lateralConditions.join(' AND ');
|
|
635
|
+
|
|
636
|
+
// Find ORDER BY or GROUP BY to determine WHERE insertion point
|
|
637
|
+
const orderByMatch = sql.match(/\s+(ORDER\s+BY)/i);
|
|
638
|
+
const groupByMatch = sql.match(/\s+(GROUP\s+BY)/i);
|
|
639
|
+
|
|
640
|
+
let insertIndex = -1;
|
|
641
|
+
if (orderByMatch) {
|
|
642
|
+
insertIndex = orderByMatch.index!;
|
|
643
|
+
} else if (groupByMatch) {
|
|
644
|
+
insertIndex = groupByMatch.index!;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Check if WHERE already exists in the query (before ORDER BY/GROUP BY)
|
|
648
|
+
const beforeClause = insertIndex !== -1 ? sql.substring(0, insertIndex) : sql;
|
|
649
|
+
const hasWhere = beforeClause.includes(' WHERE ');
|
|
650
|
+
const whereKeyword = hasWhere ? ' AND' : ' WHERE';
|
|
651
|
+
whereClause = `${whereKeyword} ${conditionsString}`;
|
|
652
|
+
|
|
653
|
+
if (insertIndex !== -1) {
|
|
654
|
+
// Insert before ORDER BY or GROUP BY
|
|
655
|
+
sql = sql.substring(0, insertIndex) + whereClause + sql.substring(insertIndex);
|
|
656
|
+
} else {
|
|
657
|
+
// No ORDER BY or GROUP BY, append at end
|
|
658
|
+
sql += whereClause;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Now find the FROM clause and add LATERAL joins after the table name
|
|
663
|
+
const fromIndex = sql.indexOf(' FROM ');
|
|
664
|
+
if (fromIndex !== -1) {
|
|
665
|
+
const afterFromStart = fromIndex + 6; // Position after "FROM "
|
|
666
|
+
const afterFromPart = sql.substring(afterFromStart);
|
|
667
|
+
|
|
668
|
+
// Find the end of the table name/alias (before WHERE, ORDER BY, or GROUP BY)
|
|
669
|
+
let tableEndIndex = afterFromPart.search(/\s+(WHERE|AND|ORDER\s+BY|GROUP\s+BY)/i);
|
|
670
|
+
if (tableEndIndex === -1) {
|
|
671
|
+
tableEndIndex = afterFromPart.length;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const tableName = afterFromPart.substring(0, tableEndIndex).trim();
|
|
675
|
+
const restOfQuery = afterFromPart.substring(tableEndIndex);
|
|
676
|
+
|
|
677
|
+
const beforeFrom = sql.substring(0, afterFromStart);
|
|
678
|
+
const lateralSql = lateralJoins.join(' ');
|
|
679
|
+
sql = beforeFrom + tableName + ' ' + lateralSql + restOfQuery;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return sql;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
public getNodeType(): string {
|
|
687
|
+
return "ComponentInclusionNode";
|
|
688
|
+
}
|
|
689
|
+
}
|