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
package/query/Query.ts
ADDED
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
import {ComponentRegistry , type BaseComponent, type ComponentDataType } from "../core/components";
|
|
2
|
+
import { Entity } from "../core/Entity";
|
|
3
|
+
import { logger } from "../core/Logger";
|
|
4
|
+
import db from "../database";
|
|
5
|
+
import { timed } from "../core/Decorators";
|
|
6
|
+
import { inList } from "../database/sqlHelpers";
|
|
7
|
+
import { QueryContext, QueryDAG, SourceNode, ComponentInclusionNode } from "./index";
|
|
8
|
+
import { OrQuery } from "./OrQuery";
|
|
9
|
+
import { OrNode } from "./OrNode";
|
|
10
|
+
import { preparedStatementCache } from "../database/PreparedStatementCache";
|
|
11
|
+
import { getMetadataStorage } from "../core/metadata";
|
|
12
|
+
import { shouldUseDirectPartition } from "../core/Config";
|
|
13
|
+
import type { SQL } from "bun";
|
|
14
|
+
import type { ComponentConstructor, TypedEntity, ComponentRecord } from "../types/query.types";
|
|
15
|
+
|
|
16
|
+
export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "ILIKE" | "IN" | "NOT IN" | string;
|
|
17
|
+
|
|
18
|
+
export const FilterOp = {
|
|
19
|
+
EQ: "=" as FilterOperator,
|
|
20
|
+
GT: ">" as FilterOperator,
|
|
21
|
+
LT: "<" as FilterOperator,
|
|
22
|
+
GTE: ">=" as FilterOperator,
|
|
23
|
+
LTE: "<=" as FilterOperator,
|
|
24
|
+
NEQ: "!=" as FilterOperator,
|
|
25
|
+
LIKE: "LIKE" as FilterOperator,
|
|
26
|
+
ILIKE: "ILIKE" as FilterOperator,
|
|
27
|
+
IN: "IN" as FilterOperator,
|
|
28
|
+
NOT_IN: "NOT IN" as FilterOperator
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QueryFilter {
|
|
32
|
+
field: string;
|
|
33
|
+
operator: FilterOperator;
|
|
34
|
+
value: any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface QueryFilterOptions {
|
|
38
|
+
filters: QueryFilter[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SortDirection = "ASC" | "DESC";
|
|
42
|
+
|
|
43
|
+
export interface SortOrder {
|
|
44
|
+
component: string;
|
|
45
|
+
property: string;
|
|
46
|
+
direction: SortDirection;
|
|
47
|
+
nullsFirst?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ComponentWithFilters {
|
|
51
|
+
component: new (...args: any[]) => BaseComponent;
|
|
52
|
+
filters?: QueryFilter[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface QueryCacheOptions {
|
|
56
|
+
preparedStatement?: boolean;
|
|
57
|
+
component?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* New Query class that uses DAG internally for better modularity and extensibility.
|
|
62
|
+
*
|
|
63
|
+
* Generic type parameter `TComponents` tracks component types added via `.with()`,
|
|
64
|
+
* enabling type-safe access to component data after query execution.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const entities = await new Query()
|
|
69
|
+
* .with(Position)
|
|
70
|
+
* .with(Velocity)
|
|
71
|
+
* .exec();
|
|
72
|
+
* // entities is TypedEntity<[typeof Position, typeof Velocity]>[]
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
class Query<TComponents extends readonly ComponentConstructor[] = []> {
|
|
76
|
+
private context: QueryContext;
|
|
77
|
+
private debug: boolean = false;
|
|
78
|
+
private orQuery: OrQuery | null = null;
|
|
79
|
+
private shouldPopulate: boolean = false;
|
|
80
|
+
private trx: SQL | undefined;
|
|
81
|
+
private skipPreparedCache: boolean = false;
|
|
82
|
+
private skipComponentCache: boolean = false;
|
|
83
|
+
|
|
84
|
+
/** Component constructors added to this query for type-safe access */
|
|
85
|
+
private _componentCtors: ComponentConstructor[] = [];
|
|
86
|
+
|
|
87
|
+
constructor(trx?: SQL) {
|
|
88
|
+
this.trx = trx;
|
|
89
|
+
this.context = new QueryContext(trx);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get the database connection to use (transaction or default db)
|
|
94
|
+
*/
|
|
95
|
+
private getDb(): SQL {
|
|
96
|
+
return this.trx ?? db;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public findById(id: string) {
|
|
100
|
+
// Validate ID to prevent PostgreSQL UUID parsing errors
|
|
101
|
+
if (!id || typeof id !== 'string' || id.trim() === '') {
|
|
102
|
+
throw new Error(`Query.findById called with invalid id: "${id}"`);
|
|
103
|
+
}
|
|
104
|
+
this.context.withId = id;
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public async findOneById(id: string): Promise<TypedEntity<TComponents> | null> {
|
|
109
|
+
// Validate ID to prevent PostgreSQL UUID parsing errors
|
|
110
|
+
if (!id || typeof id !== 'string' || id.trim() === '') {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const entities = await this.findById(id).exec();
|
|
114
|
+
return entities.length > 0 ? entities[0]! : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Add a component requirement to the query with type accumulation.
|
|
119
|
+
* The returned Query tracks all component types for type-safe access after exec().
|
|
120
|
+
*/
|
|
121
|
+
public with<T extends BaseComponent>(
|
|
122
|
+
componentCtor: new (...args: any[]) => T,
|
|
123
|
+
options?: QueryFilterOptions
|
|
124
|
+
): Query<readonly [...TComponents, new (...args: any[]) => T]>;
|
|
125
|
+
public with(components: ComponentWithFilters[]): this;
|
|
126
|
+
public with(orQuery: OrQuery): this;
|
|
127
|
+
public with<T extends BaseComponent>(
|
|
128
|
+
componentCtorOrComponentsOrOrQuery: (new (...args: any[]) => T) | ComponentWithFilters[] | OrQuery,
|
|
129
|
+
options?: QueryFilterOptions
|
|
130
|
+
): Query<readonly [...TComponents, new (...args: any[]) => T]> | this {
|
|
131
|
+
if (componentCtorOrComponentsOrOrQuery instanceof OrQuery) {
|
|
132
|
+
// Handle OR query
|
|
133
|
+
this.orQuery = componentCtorOrComponentsOrOrQuery;
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (Array.isArray(componentCtorOrComponentsOrOrQuery)) {
|
|
138
|
+
// Handle array of components with filters
|
|
139
|
+
for (const item of componentCtorOrComponentsOrOrQuery) {
|
|
140
|
+
const typeId = this.context.getComponentId(item.component);
|
|
141
|
+
if (!typeId) {
|
|
142
|
+
throw new Error(`Component ${item.component.name} is not registered.`);
|
|
143
|
+
}
|
|
144
|
+
this.context.componentIds.add(typeId);
|
|
145
|
+
this._componentCtors.push(item.component);
|
|
146
|
+
|
|
147
|
+
if (item.filters && item.filters.length > 0) {
|
|
148
|
+
this.context.componentFilters.set(typeId, item.filters);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// Handle single component
|
|
153
|
+
const typeId = this.context.getComponentId(componentCtorOrComponentsOrOrQuery);
|
|
154
|
+
if (!typeId) {
|
|
155
|
+
throw new Error(`Component ${componentCtorOrComponentsOrOrQuery.name} is not registered.`);
|
|
156
|
+
}
|
|
157
|
+
this.context.componentIds.add(typeId);
|
|
158
|
+
this._componentCtors.push(componentCtorOrComponentsOrOrQuery);
|
|
159
|
+
|
|
160
|
+
if (options?.filters && options.filters.length > 0) {
|
|
161
|
+
this.context.componentFilters.set(typeId, options.filters);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return this as unknown as Query<readonly [...TComponents, new (...args: any[]) => T]>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public without<T extends BaseComponent>(ctor: new (...args: any[]) => T) {
|
|
169
|
+
const type_id = this.context.getComponentId(ctor);
|
|
170
|
+
if (!type_id) {
|
|
171
|
+
throw new Error(`Component ${ctor.name} is not registered.`);
|
|
172
|
+
}
|
|
173
|
+
this.context.excludedComponentIds.add(type_id);
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public excludeEntityId(entityId: string): this {
|
|
178
|
+
this.context.excludedEntityIds.add(entityId);
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public populate(): this {
|
|
183
|
+
this.shouldPopulate = true;
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Eagerly load specific components after query execution.
|
|
189
|
+
* This preloads components into entities to avoid N+1 queries when accessing them later.
|
|
190
|
+
* @param ctors Array of component constructors to eagerly load
|
|
191
|
+
*/
|
|
192
|
+
public eagerLoadComponents(ctors: Array<new () => BaseComponent>): this {
|
|
193
|
+
for (const ctor of ctors) {
|
|
194
|
+
const type_id = this.context.getComponentId(ctor);
|
|
195
|
+
if (!type_id) {
|
|
196
|
+
throw new Error(`Component ${ctor.name} is not registered.`);
|
|
197
|
+
}
|
|
198
|
+
this.context.eagerComponents.add(type_id);
|
|
199
|
+
}
|
|
200
|
+
return this;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Alias for eagerLoadComponents for backward compatibility
|
|
205
|
+
*/
|
|
206
|
+
public eagerLoad<T extends BaseComponent>(ctors: (new (...args: any[]) => T)[]): this {
|
|
207
|
+
return this.eagerLoadComponents(ctors);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
public take(limit: number): this {
|
|
211
|
+
this.context.limit = limit;
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public offset(offset: number): this {
|
|
216
|
+
this.context.offsetValue = offset;
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Use cursor-based pagination instead of OFFSET.
|
|
222
|
+
* Much more efficient for large datasets - O(1) instead of O(offset).
|
|
223
|
+
*
|
|
224
|
+
* @param cursorId - The entity ID to paginate from (exclusive)
|
|
225
|
+
* @param direction - 'after' for next page (default), 'before' for previous page
|
|
226
|
+
* @returns this for chaining
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* // Get first page
|
|
230
|
+
* const page1 = await new Query().with(User).take(100).exec();
|
|
231
|
+
*
|
|
232
|
+
* // Get next page using cursor
|
|
233
|
+
* const lastId = page1[page1.length - 1].id;
|
|
234
|
+
* const page2 = await new Query().with(User).take(100).cursor(lastId).exec();
|
|
235
|
+
*/
|
|
236
|
+
public cursor(cursorId: string, direction: 'after' | 'before' = 'after'): this {
|
|
237
|
+
this.context.cursorId = cursorId;
|
|
238
|
+
this.context.cursorDirection = direction;
|
|
239
|
+
// Clear offset when using cursor-based pagination
|
|
240
|
+
this.context.offsetValue = 0;
|
|
241
|
+
return this;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public sortBy<T extends BaseComponent>(
|
|
245
|
+
componentCtor: new (...args: any[]) => T,
|
|
246
|
+
property: keyof ComponentDataType<T>,
|
|
247
|
+
direction: SortDirection = "ASC",
|
|
248
|
+
nullsFirst: boolean = false
|
|
249
|
+
): this {
|
|
250
|
+
const componentName = componentCtor.name;
|
|
251
|
+
const typeId = this.context.getComponentId(componentCtor);
|
|
252
|
+
|
|
253
|
+
if (!typeId) {
|
|
254
|
+
throw new Error(`Component ${componentName} is not registered.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Validate that the component is required in this query
|
|
258
|
+
if (!this.context.componentIds.has(typeId)) {
|
|
259
|
+
throw new Error(`Cannot sort by component ${componentName} that is not included in the query. Use .with(${componentName}) first.`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.context.sortOrders.push({
|
|
263
|
+
component: componentName,
|
|
264
|
+
property: property as string,
|
|
265
|
+
direction,
|
|
266
|
+
nullsFirst
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return this;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
public debugMode(enabled: boolean = true): this {
|
|
273
|
+
this.debug = enabled;
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Bypass cache for this query.
|
|
279
|
+
* @param options Cache options to bypass. If not provided, bypasses prepared statement cache.
|
|
280
|
+
*/
|
|
281
|
+
public noCache(): this;
|
|
282
|
+
public noCache(options: QueryCacheOptions): this;
|
|
283
|
+
public noCache(options?: QueryCacheOptions): this {
|
|
284
|
+
if (!options) {
|
|
285
|
+
// Default behavior: bypass prepared statement cache
|
|
286
|
+
this.skipPreparedCache = true;
|
|
287
|
+
} else {
|
|
288
|
+
if (options.preparedStatement === true) {
|
|
289
|
+
this.skipPreparedCache = true;
|
|
290
|
+
}
|
|
291
|
+
if (options.component === true) {
|
|
292
|
+
this.skipComponentCache = true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
public count(): Promise<number> {
|
|
299
|
+
return new Promise<number>((resolve, reject) => {
|
|
300
|
+
const timeout = setTimeout(() => {
|
|
301
|
+
logger.error(`Query count execution timeout`);
|
|
302
|
+
reject(new Error(`Query count execution timeout after 30 seconds`));
|
|
303
|
+
}, 30000);
|
|
304
|
+
this.doCount()
|
|
305
|
+
.then(result => {
|
|
306
|
+
clearTimeout(timeout);
|
|
307
|
+
resolve(result);
|
|
308
|
+
})
|
|
309
|
+
.catch(error => {
|
|
310
|
+
clearTimeout(timeout);
|
|
311
|
+
reject(error);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get an estimated count using PostgreSQL statistics.
|
|
318
|
+
* Much faster than exact count() for large tables - O(1) instead of O(n).
|
|
319
|
+
*
|
|
320
|
+
* Note: Returns approximate count based on PostgreSQL's statistics.
|
|
321
|
+
* Run ANALYZE on the table for more accurate estimates.
|
|
322
|
+
*
|
|
323
|
+
* @param component - The component class to count (uses its partition table)
|
|
324
|
+
* @returns Estimated count (may be up to 10% off for recently modified tables)
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* // Fast approximate count
|
|
328
|
+
* const approxCount = await new Query().with(User).estimatedCount(User);
|
|
329
|
+
* console.log(`Approximately ${approxCount} users`);
|
|
330
|
+
*/
|
|
331
|
+
public async estimatedCount(component: new (...args: any[]) => BaseComponent): Promise<number> {
|
|
332
|
+
const typeId = ComponentRegistry.getComponentId(component.name);
|
|
333
|
+
if (!typeId) {
|
|
334
|
+
throw new Error(`Component ${component.name} not registered`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const tableName = ComponentRegistry.getPartitionTableName(typeId);
|
|
338
|
+
const dbConn = this.getDb();
|
|
339
|
+
|
|
340
|
+
// Use PostgreSQL's statistics for fast count estimate
|
|
341
|
+
// This queries pg_class which is O(1) instead of scanning the table
|
|
342
|
+
const sql = tableName && tableName !== 'components'
|
|
343
|
+
? `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`
|
|
344
|
+
: `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
|
|
345
|
+
|
|
346
|
+
const result = await dbConn.unsafe(sql, [tableName || 'entity_components']);
|
|
347
|
+
|
|
348
|
+
if (!result || result.length === 0 || result[0].estimate === null) {
|
|
349
|
+
// Fallback to exact count if statistics not available
|
|
350
|
+
return this.count();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return Number(result[0].estimate);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private async doCount(): Promise<number> {
|
|
357
|
+
// Build the DAG
|
|
358
|
+
const dag = new QueryDAG();
|
|
359
|
+
|
|
360
|
+
// Check if we have an OR query
|
|
361
|
+
if (this.orQuery) {
|
|
362
|
+
// For OR queries, we need to ensure entities have all required components first
|
|
363
|
+
if (this.context.componentIds.size > 0) {
|
|
364
|
+
// ComponentInclusionNode is the root, OrNode is the leaf
|
|
365
|
+
const componentNode = new ComponentInclusionNode();
|
|
366
|
+
dag.setRootNode(componentNode);
|
|
367
|
+
|
|
368
|
+
// OrNode filters on top of the base requirements
|
|
369
|
+
const orNode = new OrNode(this.orQuery);
|
|
370
|
+
orNode.addDependency(componentNode);
|
|
371
|
+
dag.addNode(orNode);
|
|
372
|
+
} else {
|
|
373
|
+
// No base requirements, OrNode is both root and leaf
|
|
374
|
+
const orNode = new OrNode(this.orQuery);
|
|
375
|
+
dag.setRootNode(orNode);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
// Use buildBasicQuery for regular AND logic (includes CTE optimization)
|
|
379
|
+
const optimizedDag = QueryDAG.buildBasicQuery(this.context);
|
|
380
|
+
// Copy nodes from optimized DAG to our DAG
|
|
381
|
+
for (const node of optimizedDag.getNodes()) {
|
|
382
|
+
dag.addNode(node);
|
|
383
|
+
}
|
|
384
|
+
if (optimizedDag.getRootNode()) {
|
|
385
|
+
dag.setRootNode(optimizedDag.getRootNode()!);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Execute the DAG
|
|
390
|
+
const result = dag.execute(this.context);
|
|
391
|
+
|
|
392
|
+
// Modify SQL for count
|
|
393
|
+
const countSql = `SELECT COUNT(*) as count FROM (${result.sql}) AS subquery`;
|
|
394
|
+
|
|
395
|
+
// Get the database connection (transaction or default)
|
|
396
|
+
const dbConn = this.getDb();
|
|
397
|
+
|
|
398
|
+
let countResult: any[];
|
|
399
|
+
|
|
400
|
+
if (this.skipPreparedCache) {
|
|
401
|
+
// Bypass cache - execute directly
|
|
402
|
+
countResult = await dbConn.unsafe(countSql, result.params);
|
|
403
|
+
} else {
|
|
404
|
+
// Check prepared statement cache
|
|
405
|
+
// Add 'count:' prefix to differentiate count queries from exec queries
|
|
406
|
+
const cacheKey = 'count:' + this.context.generateCacheKey();
|
|
407
|
+
const { statement, isHit } = await preparedStatementCache.getOrCreate(countSql, cacheKey, dbConn);
|
|
408
|
+
countResult = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Debug logging
|
|
412
|
+
if (this.debug) {
|
|
413
|
+
console.log('🔍 Query Count Debug:');
|
|
414
|
+
console.log('SQL:', countSql);
|
|
415
|
+
console.log('Params:', result.params);
|
|
416
|
+
console.log('Prepared Cache Bypass:', this.skipPreparedCache);
|
|
417
|
+
console.log('Component Cache Bypass:', this.skipComponentCache);
|
|
418
|
+
console.log('Using Transaction:', !!this.trx);
|
|
419
|
+
console.log('---');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Validate params before execution to catch UUID errors early
|
|
423
|
+
for (let i = 0; i < result.params.length; i++) {
|
|
424
|
+
const param = result.params[i];
|
|
425
|
+
if (param === '' || (typeof param === 'string' && param.trim() === '')) {
|
|
426
|
+
logger.error(`Empty string parameter detected at position ${i + 1} in count query`);
|
|
427
|
+
throw new Error(`Query count parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors.`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Safely extract count from result - handle undefined/null cases
|
|
432
|
+
if (!countResult || countResult.length === 0 || countResult[0] === undefined) {
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// PostgreSQL COUNT(*) returns a value, handle both string and number
|
|
437
|
+
const count = countResult[0].count;
|
|
438
|
+
if (count === undefined || count === null) {
|
|
439
|
+
return 0;
|
|
440
|
+
}
|
|
441
|
+
return typeof count === 'string' ? parseInt(count, 10) : Number(count);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Calculate the sum of a numeric field across all matching entities.
|
|
446
|
+
* The component must be included in the query via .with().
|
|
447
|
+
* @param componentCtor The component class containing the field
|
|
448
|
+
* @param field The field name to sum (must be numeric)
|
|
449
|
+
* @returns Promise resolving to the sum, or 0 if no matches
|
|
450
|
+
*/
|
|
451
|
+
public sum<T extends BaseComponent>(
|
|
452
|
+
componentCtor: new (...args: any[]) => T,
|
|
453
|
+
field: keyof ComponentDataType<T>
|
|
454
|
+
): Promise<number> {
|
|
455
|
+
return new Promise<number>((resolve, reject) => {
|
|
456
|
+
const timeout = setTimeout(() => {
|
|
457
|
+
logger.error(`Query sum execution timeout`);
|
|
458
|
+
reject(new Error(`Query sum execution timeout after 30 seconds`));
|
|
459
|
+
}, 30000);
|
|
460
|
+
this.doAggregate('SUM', componentCtor, field as string)
|
|
461
|
+
.then(result => {
|
|
462
|
+
clearTimeout(timeout);
|
|
463
|
+
resolve(result);
|
|
464
|
+
})
|
|
465
|
+
.catch(error => {
|
|
466
|
+
clearTimeout(timeout);
|
|
467
|
+
reject(error);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Calculate the average of a numeric field across all matching entities.
|
|
474
|
+
* The component must be included in the query via .with().
|
|
475
|
+
* @param componentCtor The component class containing the field
|
|
476
|
+
* @param field The field name to average (must be numeric)
|
|
477
|
+
* @returns Promise resolving to the average, or 0 if no matches
|
|
478
|
+
*/
|
|
479
|
+
public average<T extends BaseComponent>(
|
|
480
|
+
componentCtor: new (...args: any[]) => T,
|
|
481
|
+
field: keyof ComponentDataType<T>
|
|
482
|
+
): Promise<number> {
|
|
483
|
+
return new Promise<number>((resolve, reject) => {
|
|
484
|
+
const timeout = setTimeout(() => {
|
|
485
|
+
logger.error(`Query average execution timeout`);
|
|
486
|
+
reject(new Error(`Query average execution timeout after 30 seconds`));
|
|
487
|
+
}, 30000);
|
|
488
|
+
this.doAggregate('AVG', componentCtor, field as string)
|
|
489
|
+
.then(result => {
|
|
490
|
+
clearTimeout(timeout);
|
|
491
|
+
resolve(result);
|
|
492
|
+
})
|
|
493
|
+
.catch(error => {
|
|
494
|
+
clearTimeout(timeout);
|
|
495
|
+
reject(error);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Internal method to perform aggregate operations (SUM, AVG) on component fields.
|
|
502
|
+
* Uses an optimized single-pass approach by joining to the component table
|
|
503
|
+
* directly within the CTE-based query.
|
|
504
|
+
*/
|
|
505
|
+
private async doAggregate(
|
|
506
|
+
aggregateType: 'SUM' | 'AVG',
|
|
507
|
+
componentCtor: new (...args: any[]) => BaseComponent,
|
|
508
|
+
field: string
|
|
509
|
+
): Promise<number> {
|
|
510
|
+
// Get the component type ID
|
|
511
|
+
const typeId = this.context.getComponentId(componentCtor);
|
|
512
|
+
if (!typeId) {
|
|
513
|
+
throw new Error(`Component ${componentCtor.name} is not registered.`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Validate that the component is in the query
|
|
517
|
+
if (!this.context.componentIds.has(typeId)) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`Cannot aggregate on component ${componentCtor.name} that is not included in the query. ` +
|
|
520
|
+
`Use .with(${componentCtor.name}) first.`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Reset context for fresh execution
|
|
525
|
+
this.context.reset();
|
|
526
|
+
|
|
527
|
+
// Build the DAG
|
|
528
|
+
const dag = new QueryDAG();
|
|
529
|
+
|
|
530
|
+
// Check if we have an OR query
|
|
531
|
+
if (this.orQuery) {
|
|
532
|
+
if (this.context.componentIds.size > 0) {
|
|
533
|
+
const componentNode = new ComponentInclusionNode();
|
|
534
|
+
dag.setRootNode(componentNode);
|
|
535
|
+
|
|
536
|
+
const orNode = new OrNode(this.orQuery);
|
|
537
|
+
orNode.addDependency(componentNode);
|
|
538
|
+
dag.addNode(orNode);
|
|
539
|
+
} else {
|
|
540
|
+
const orNode = new OrNode(this.orQuery);
|
|
541
|
+
dag.setRootNode(orNode);
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
const optimizedDag = QueryDAG.buildBasicQuery(this.context);
|
|
545
|
+
for (const node of optimizedDag.getNodes()) {
|
|
546
|
+
dag.addNode(node);
|
|
547
|
+
}
|
|
548
|
+
if (optimizedDag.getRootNode()) {
|
|
549
|
+
dag.setRootNode(optimizedDag.getRootNode()!);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Execute the DAG to get the base query
|
|
554
|
+
const result = dag.execute(this.context);
|
|
555
|
+
|
|
556
|
+
// Determine the component table name
|
|
557
|
+
const componentTableName = shouldUseDirectPartition()
|
|
558
|
+
? (ComponentRegistry.getPartitionTableName(typeId) || 'components')
|
|
559
|
+
: 'components';
|
|
560
|
+
|
|
561
|
+
// Build the JSON path for the field
|
|
562
|
+
let jsonPath: string;
|
|
563
|
+
if (field.includes('.')) {
|
|
564
|
+
const parts = field.split('.');
|
|
565
|
+
const lastPart = parts.pop()!;
|
|
566
|
+
const nestedPath = parts.map(p => `'${p}'`).join('->');
|
|
567
|
+
jsonPath = `c.data->${nestedPath}->>'${lastPart}'`;
|
|
568
|
+
} else {
|
|
569
|
+
jsonPath = `c.data->>'${field}'`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Add the type_id parameter for the JOIN condition
|
|
573
|
+
const typeIdParamIndex = this.context.addParam(typeId);
|
|
574
|
+
|
|
575
|
+
// Build aggregate SQL by wrapping the entity query as a subquery
|
|
576
|
+
// This approach works consistently regardless of CTE usage
|
|
577
|
+
// The base query returns entity_id (aliased as 'id'), which we join to components
|
|
578
|
+
const aggregateSql = `
|
|
579
|
+
SELECT ${aggregateType}((${jsonPath})::numeric) as result
|
|
580
|
+
FROM (${result.sql}) AS entity_subq
|
|
581
|
+
JOIN ${componentTableName} c ON c.entity_id = entity_subq.id
|
|
582
|
+
WHERE c.type_id = $${typeIdParamIndex}
|
|
583
|
+
AND c.deleted_at IS NULL`;
|
|
584
|
+
|
|
585
|
+
// Get the database connection
|
|
586
|
+
const dbConn = this.getDb();
|
|
587
|
+
|
|
588
|
+
let aggregateResult: any[];
|
|
589
|
+
|
|
590
|
+
if (this.skipPreparedCache) {
|
|
591
|
+
aggregateResult = await dbConn.unsafe(aggregateSql, result.params);
|
|
592
|
+
} else {
|
|
593
|
+
const cacheKey = `${aggregateType.toLowerCase()}:${typeId}:${field}:` + this.context.generateCacheKey();
|
|
594
|
+
const { statement } = await preparedStatementCache.getOrCreate(aggregateSql, cacheKey, dbConn);
|
|
595
|
+
aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Debug logging
|
|
599
|
+
if (this.debug) {
|
|
600
|
+
console.log(`🔍 Query ${aggregateType} Debug:`);
|
|
601
|
+
console.log('SQL:', aggregateSql);
|
|
602
|
+
console.log('Params:', result.params);
|
|
603
|
+
console.log('Component:', componentCtor.name);
|
|
604
|
+
console.log('Field:', field);
|
|
605
|
+
console.log('---');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Validate params
|
|
609
|
+
for (let i = 0; i < result.params.length; i++) {
|
|
610
|
+
const param = result.params[i];
|
|
611
|
+
if (param === '' || (typeof param === 'string' && param.trim() === '')) {
|
|
612
|
+
logger.error(`Empty string parameter detected at position ${i + 1} in ${aggregateType} query`);
|
|
613
|
+
throw new Error(`Query ${aggregateType} parameter $${i + 1} is an empty string.`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Extract result
|
|
618
|
+
if (!aggregateResult || aggregateResult.length === 0 || aggregateResult[0] === undefined) {
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const value = aggregateResult[0].result;
|
|
623
|
+
if (value === undefined || value === null) {
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
return typeof value === 'string' ? parseFloat(value) : Number(value);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Execute the query and return typed entities.
|
|
631
|
+
*
|
|
632
|
+
* When components are added via `.with()`, the returned entities have:
|
|
633
|
+
* - `getTyped(Ctor)`: Type-safe async getter (returns non-null since query guarantees existence)
|
|
634
|
+
* - `componentData`: Synchronous access to already-loaded component data
|
|
635
|
+
*
|
|
636
|
+
* @returns Promise resolving to array of TypedEntity with accumulated component types
|
|
637
|
+
*/
|
|
638
|
+
@timed("Query.exec")
|
|
639
|
+
public async exec(): Promise<TypedEntity<TComponents>[]> {
|
|
640
|
+
return new Promise<TypedEntity<TComponents>[]>((resolve, reject) => {
|
|
641
|
+
// Add timeout to prevent hanging queries
|
|
642
|
+
const timeout = setTimeout(() => {
|
|
643
|
+
logger.error(`Query execution timeout`);
|
|
644
|
+
reject(new Error(`Query execution timeout after 30 seconds`));
|
|
645
|
+
}, 30000); // 30 second timeout
|
|
646
|
+
|
|
647
|
+
this.doExec()
|
|
648
|
+
.then(result => {
|
|
649
|
+
clearTimeout(timeout);
|
|
650
|
+
// Wrap entities with typed accessors
|
|
651
|
+
const typedEntities = result.map(e => this.wrapTypedEntity(e));
|
|
652
|
+
resolve(typedEntities);
|
|
653
|
+
})
|
|
654
|
+
.catch(error => {
|
|
655
|
+
clearTimeout(timeout);
|
|
656
|
+
reject(error);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Wrap an entity with typed accessors for components in this query.
|
|
663
|
+
* Provides both async getTyped() and synchronous componentData access.
|
|
664
|
+
*/
|
|
665
|
+
private wrapTypedEntity(entity: Entity): TypedEntity<TComponents> {
|
|
666
|
+
const componentCtors = this._componentCtors;
|
|
667
|
+
|
|
668
|
+
// Build synchronous component data record from already-loaded components
|
|
669
|
+
const componentData: Record<string, any> = {};
|
|
670
|
+
for (const ctor of componentCtors) {
|
|
671
|
+
const comp = entity.getInMemory(ctor);
|
|
672
|
+
if (comp) {
|
|
673
|
+
componentData[ctor.name] = (comp as any).data();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Create typed entity wrapper
|
|
678
|
+
const typedEntity = entity as TypedEntity<TComponents>;
|
|
679
|
+
|
|
680
|
+
// Define componentData property
|
|
681
|
+
Object.defineProperty(typedEntity, 'componentData', {
|
|
682
|
+
value: componentData as ComponentRecord<TComponents>,
|
|
683
|
+
writable: false,
|
|
684
|
+
enumerable: true
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// Define _queriedComponents property for runtime reflection
|
|
688
|
+
Object.defineProperty(typedEntity, '_queriedComponents', {
|
|
689
|
+
value: componentCtors as unknown as TComponents,
|
|
690
|
+
writable: false,
|
|
691
|
+
enumerable: false
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// Define getTyped method
|
|
695
|
+
Object.defineProperty(typedEntity, 'getTyped', {
|
|
696
|
+
value: async function<T extends TComponents[number]>(
|
|
697
|
+
ctor: T
|
|
698
|
+
): Promise<T extends ComponentConstructor<infer C> ? C extends BaseComponent ? ComponentDataType<C> : never : never> {
|
|
699
|
+
const data = await entity.get(ctor as any);
|
|
700
|
+
if (!data) {
|
|
701
|
+
throw new Error(`Component ${(ctor as any).name} not found on entity ${entity.id}, but it was expected from query`);
|
|
702
|
+
}
|
|
703
|
+
return data as any;
|
|
704
|
+
},
|
|
705
|
+
writable: false,
|
|
706
|
+
enumerable: false
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
return typedEntity;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private async doExec(): Promise<Entity[]> {
|
|
713
|
+
// Reset context for fresh execution
|
|
714
|
+
this.context.reset();
|
|
715
|
+
|
|
716
|
+
// Build the DAG
|
|
717
|
+
const dag = new QueryDAG();
|
|
718
|
+
|
|
719
|
+
// Check if we have an OR query
|
|
720
|
+
if (this.orQuery) {
|
|
721
|
+
// For OR queries, we need to ensure entities have all required components first
|
|
722
|
+
if (this.context.componentIds.size > 0) {
|
|
723
|
+
// ComponentInclusionNode is the root, OrNode is the leaf
|
|
724
|
+
const componentNode = new ComponentInclusionNode();
|
|
725
|
+
dag.setRootNode(componentNode);
|
|
726
|
+
|
|
727
|
+
// OrNode filters on top of the base requirements
|
|
728
|
+
const orNode = new OrNode(this.orQuery);
|
|
729
|
+
orNode.addDependency(componentNode);
|
|
730
|
+
dag.addNode(orNode);
|
|
731
|
+
} else {
|
|
732
|
+
// No base requirements, OrNode is both root and leaf
|
|
733
|
+
const orNode = new OrNode(this.orQuery);
|
|
734
|
+
dag.setRootNode(orNode);
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
// Use buildBasicQuery for regular AND logic (includes CTE optimization)
|
|
738
|
+
const optimizedDag = QueryDAG.buildBasicQuery(this.context);
|
|
739
|
+
// Copy nodes from optimized DAG to our DAG
|
|
740
|
+
for (const node of optimizedDag.getNodes()) {
|
|
741
|
+
dag.addNode(node);
|
|
742
|
+
}
|
|
743
|
+
if (optimizedDag.getRootNode()) {
|
|
744
|
+
dag.setRootNode(optimizedDag.getRootNode()!);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Execute the DAG
|
|
749
|
+
const result = dag.execute(this.context);
|
|
750
|
+
|
|
751
|
+
// Get the database connection (transaction or default)
|
|
752
|
+
const dbConn = this.getDb();
|
|
753
|
+
|
|
754
|
+
// Debug logging
|
|
755
|
+
if (this.debug) {
|
|
756
|
+
console.log('🔍 Query Debug:');
|
|
757
|
+
console.log('SQL:', result.sql);
|
|
758
|
+
console.log('Params:', result.params);
|
|
759
|
+
console.log('OR Query:', !!this.orQuery);
|
|
760
|
+
console.log('Prepared Cache Bypass:', this.skipPreparedCache);
|
|
761
|
+
console.log('Component Cache Bypass:', this.skipComponentCache);
|
|
762
|
+
console.log('Using Transaction:', !!this.trx);
|
|
763
|
+
console.log('---');
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Validate params before execution to catch UUID errors early
|
|
767
|
+
for (let i = 0; i < result.params.length; i++) {
|
|
768
|
+
const param = result.params[i];
|
|
769
|
+
if (param === '' || (typeof param === 'string' && param.trim() === '')) {
|
|
770
|
+
logger.error(`Empty string parameter detected at position ${i + 1}: SQL=${result.sql.substring(0, 200)}`);
|
|
771
|
+
throw new Error(`Query parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors. SQL: ${result.sql.substring(0, 100)}...`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Validate parameters before execution
|
|
776
|
+
for (let i = 0; i < result.params.length; i++) {
|
|
777
|
+
if (result.params[i] === undefined || result.params[i] === null) {
|
|
778
|
+
console.error(`❌ Query parameter $${i + 1} is undefined/null`);
|
|
779
|
+
console.error(`SQL: ${result.sql}`);
|
|
780
|
+
console.error(`All params: ${JSON.stringify(result.params)}`);
|
|
781
|
+
throw new Error(`Query parameter $${i + 1} is undefined/null. SQL: ${result.sql.substring(0, 100)}...`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
let entities: any[];
|
|
786
|
+
|
|
787
|
+
if (this.orQuery || this.skipPreparedCache) {
|
|
788
|
+
// For OR queries or explicit cache bypass, execute directly
|
|
789
|
+
// This avoids potential parameter type inference issues with Bun's SQL
|
|
790
|
+
entities = await dbConn.unsafe(result.sql, result.params);
|
|
791
|
+
} else {
|
|
792
|
+
// Check prepared statement cache for regular queries
|
|
793
|
+
const cacheKey = this.context.generateCacheKey();
|
|
794
|
+
const { statement, isHit } = await preparedStatementCache.getOrCreate(result.sql, cacheKey, dbConn);
|
|
795
|
+
entities = await preparedStatementCache.execute(statement, result.params, dbConn);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Convert to Entity objects
|
|
799
|
+
const entityIds: string[] = entities.map((row: any) => row.id);
|
|
800
|
+
|
|
801
|
+
if (entityIds.length === 0) {
|
|
802
|
+
return [];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Create Entity objects
|
|
806
|
+
const entityMap = new Map<string, Entity>();
|
|
807
|
+
for (const id of entityIds) {
|
|
808
|
+
const entity = new Entity(id);
|
|
809
|
+
entity.setPersisted(true);
|
|
810
|
+
entity.setDirty(false);
|
|
811
|
+
entityMap.set(id, entity);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Populate entities with components if requested
|
|
815
|
+
if (this.shouldPopulate && this.context.componentIds.size > 0) {
|
|
816
|
+
await this.populateComponents(entityMap);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Eagerly load specific components if requested
|
|
820
|
+
if (this.context.eagerComponents.size > 0) {
|
|
821
|
+
const entitiesArray = Array.from(entityMap.values());
|
|
822
|
+
await Entity.LoadComponents(entitiesArray, Array.from(this.context.eagerComponents), this.skipComponentCache);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Return entities in the same order as the query results
|
|
826
|
+
const finalEntities = entityIds.map(id => entityMap.get(id)!);
|
|
827
|
+
|
|
828
|
+
return finalEntities;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Bulk fetch and attach components to entities
|
|
833
|
+
* @private
|
|
834
|
+
*/
|
|
835
|
+
private async populateComponents(entityMap: Map<string, Entity>): Promise<void> {
|
|
836
|
+
const entityIds = Array.from(entityMap.keys());
|
|
837
|
+
const componentTypeIds = Array.from(this.context.componentIds);
|
|
838
|
+
|
|
839
|
+
if (entityIds.length === 0 || componentTypeIds.length === 0) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Bulk fetch all components for all entities and all requested component types
|
|
844
|
+
const entityIdList = inList(entityIds, 1);
|
|
845
|
+
const typeIdList = inList(componentTypeIds, entityIdList.newParamIndex);
|
|
846
|
+
|
|
847
|
+
// Get the database connection (transaction or default)
|
|
848
|
+
const dbConn = this.getDb();
|
|
849
|
+
|
|
850
|
+
let components: any[];
|
|
851
|
+
if (shouldUseDirectPartition() && componentTypeIds.length === 1) {
|
|
852
|
+
// Single component type - use direct partition if available
|
|
853
|
+
const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
|
|
854
|
+
if (partitionTableName) {
|
|
855
|
+
components = await dbConn.unsafe(`
|
|
856
|
+
SELECT id, entity_id, type_id, data
|
|
857
|
+
FROM ${partitionTableName}
|
|
858
|
+
WHERE entity_id IN ${entityIdList.sql}
|
|
859
|
+
AND type_id IN ${typeIdList.sql}
|
|
860
|
+
AND deleted_at IS NULL
|
|
861
|
+
`, [...entityIdList.params, ...typeIdList.params]);
|
|
862
|
+
} else {
|
|
863
|
+
// Fallback to parent table
|
|
864
|
+
components = await dbConn.unsafe(`
|
|
865
|
+
SELECT id, entity_id, type_id, data
|
|
866
|
+
FROM components
|
|
867
|
+
WHERE entity_id IN ${entityIdList.sql}
|
|
868
|
+
AND type_id IN ${typeIdList.sql}
|
|
869
|
+
AND deleted_at IS NULL
|
|
870
|
+
`, [...entityIdList.params, ...typeIdList.params]);
|
|
871
|
+
}
|
|
872
|
+
} else {
|
|
873
|
+
// Multiple types or direct partition disabled - use parent table
|
|
874
|
+
components = await dbConn.unsafe(`
|
|
875
|
+
SELECT id, entity_id, type_id, data
|
|
876
|
+
FROM components
|
|
877
|
+
WHERE entity_id IN ${entityIdList.sql}
|
|
878
|
+
AND type_id IN ${typeIdList.sql}
|
|
879
|
+
AND deleted_at IS NULL
|
|
880
|
+
`, [...entityIdList.params, ...typeIdList.params]);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Get metadata storage for Date deserialization
|
|
884
|
+
const storage = getMetadataStorage();
|
|
885
|
+
|
|
886
|
+
// Group components by entity_id and attach them to entities
|
|
887
|
+
for (const row of components) {
|
|
888
|
+
const entity = entityMap.get(row.entity_id);
|
|
889
|
+
if (!entity) continue;
|
|
890
|
+
|
|
891
|
+
// Get the component constructor from registry
|
|
892
|
+
const ComponentCtor = ComponentRegistry.getConstructor(row.type_id);
|
|
893
|
+
if (!ComponentCtor) {
|
|
894
|
+
logger.warn(`Component constructor not found for type_id: ${row.type_id}`);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Create component instance
|
|
899
|
+
const component = new ComponentCtor();
|
|
900
|
+
|
|
901
|
+
// Parse and assign component data
|
|
902
|
+
const componentData = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
903
|
+
Object.assign(component, componentData);
|
|
904
|
+
|
|
905
|
+
// Deserialize Date properties
|
|
906
|
+
const props = storage.componentProperties.get(row.type_id);
|
|
907
|
+
if (props) {
|
|
908
|
+
for (const prop of props) {
|
|
909
|
+
if (prop.propertyType === Date && typeof (component as any)[prop.propertyKey] === 'string') {
|
|
910
|
+
(component as any)[prop.propertyKey] = new Date((component as any)[prop.propertyKey]);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Set component metadata
|
|
916
|
+
component.id = row.id;
|
|
917
|
+
component.setPersisted(true);
|
|
918
|
+
component.setDirty(false);
|
|
919
|
+
|
|
920
|
+
// Add component to entity (using protected method)
|
|
921
|
+
(entity as any).addComponent(component);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Execute query with EXPLAIN ANALYZE for performance debugging
|
|
927
|
+
* Returns the query plan and execution statistics
|
|
928
|
+
*/
|
|
929
|
+
public async explainAnalyze(buffers: boolean = true): Promise<string> {
|
|
930
|
+
// Reset context for fresh execution
|
|
931
|
+
this.context.reset();
|
|
932
|
+
|
|
933
|
+
// Build the DAG (same as exec)
|
|
934
|
+
const dag = new QueryDAG();
|
|
935
|
+
|
|
936
|
+
if (this.orQuery) {
|
|
937
|
+
if (this.context.componentIds.size > 0) {
|
|
938
|
+
const componentNode = new ComponentInclusionNode();
|
|
939
|
+
dag.setRootNode(componentNode);
|
|
940
|
+
|
|
941
|
+
const orNode = new OrNode(this.orQuery);
|
|
942
|
+
orNode.addDependency(componentNode);
|
|
943
|
+
dag.addNode(orNode);
|
|
944
|
+
} else {
|
|
945
|
+
const orNode = new OrNode(this.orQuery);
|
|
946
|
+
dag.setRootNode(orNode);
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
const optimizedDag = QueryDAG.buildBasicQuery(this.context);
|
|
950
|
+
for (const node of optimizedDag.getNodes()) {
|
|
951
|
+
dag.addNode(node);
|
|
952
|
+
}
|
|
953
|
+
if (optimizedDag.getRootNode()) {
|
|
954
|
+
dag.setRootNode(optimizedDag.getRootNode()!);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Execute the DAG
|
|
959
|
+
const result = dag.execute(this.context);
|
|
960
|
+
|
|
961
|
+
// Create EXPLAIN ANALYZE query
|
|
962
|
+
const explainSql = `EXPLAIN (ANALYZE${buffers ? ', BUFFERS' : ''}) ${result.sql}`;
|
|
963
|
+
|
|
964
|
+
// Get the database connection (transaction or default)
|
|
965
|
+
const dbConn = this.getDb();
|
|
966
|
+
|
|
967
|
+
// Debug logging
|
|
968
|
+
if (this.debug) {
|
|
969
|
+
console.log('🔍 Query EXPLAIN ANALYZE Debug:');
|
|
970
|
+
console.log('SQL:', explainSql);
|
|
971
|
+
console.log('Params:', result.params);
|
|
972
|
+
console.log('Using Transaction:', !!this.trx);
|
|
973
|
+
console.log('---');
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Execute the EXPLAIN ANALYZE query
|
|
977
|
+
const explainResult = await dbConn.unsafe(explainSql, result.params);
|
|
978
|
+
|
|
979
|
+
// Format the result
|
|
980
|
+
return explainResult.map((row: any) => row['QUERY PLAN']).join('\n');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Get prepared statement cache statistics
|
|
985
|
+
*/
|
|
986
|
+
public static getCacheStats() {
|
|
987
|
+
return preparedStatementCache.getStats();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
static filterOp = FilterOp;
|
|
991
|
+
|
|
992
|
+
public static filter(field: string, operator: FilterOperator, value: any): QueryFilter {
|
|
993
|
+
// Validate value to catch empty strings early
|
|
994
|
+
if (value === '' || (typeof value === 'string' && value.trim() === '')) {
|
|
995
|
+
throw new Error(`Query.filter: Cannot create filter for field "${field}" with empty string value. This would cause PostgreSQL UUID parsing errors.`);
|
|
996
|
+
}
|
|
997
|
+
return { field, operator, value };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
public static typedFilter<T extends BaseComponent>(
|
|
1001
|
+
componentCtor: new (...args: any[]) => T,
|
|
1002
|
+
field: keyof ComponentDataType<T>,
|
|
1003
|
+
operator: FilterOperator,
|
|
1004
|
+
value: any
|
|
1005
|
+
): QueryFilter {
|
|
1006
|
+
return { field: field as string, operator, value };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
public static filters(...filters: QueryFilter[]): QueryFilterOptions {
|
|
1010
|
+
return { filters };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* OR function for combining component filters
|
|
1016
|
+
* Creates an OrQuery that matches entities satisfying ANY of the branches
|
|
1017
|
+
*/
|
|
1018
|
+
export function or(branches: ComponentWithFilters[]): OrQuery {
|
|
1019
|
+
return new OrQuery(branches);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export { Query };
|