bunsane 0.1.0 → 0.1.2
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/.github/workflows/deploy-docs.yml +57 -0
- package/LICENSE.md +1 -1
- package/README.md +2 -28
- package/TODO.md +8 -1
- package/bun.lock +3 -0
- package/config/upload.config.ts +135 -0
- package/core/App.ts +168 -4
- package/core/ArcheType.ts +122 -0
- package/core/BatchLoader.ts +100 -0
- package/core/ComponentRegistry.ts +4 -3
- package/core/Components.ts +2 -2
- package/core/Decorators.ts +15 -8
- package/core/Entity.ts +193 -14
- package/core/EntityCache.ts +15 -0
- package/core/EntityHookManager.ts +855 -0
- package/core/EntityManager.ts +12 -2
- package/core/ErrorHandler.ts +64 -7
- package/core/FileValidator.ts +284 -0
- package/core/Query.ts +503 -85
- package/core/RequestContext.ts +24 -0
- package/core/RequestLoaders.ts +89 -0
- package/core/SchedulerManager.ts +710 -0
- package/core/UploadManager.ts +261 -0
- package/core/components/UploadComponent.ts +206 -0
- package/core/decorators/EntityHooks.ts +190 -0
- package/core/decorators/ScheduledTask.ts +83 -0
- package/core/events/EntityLifecycleEvents.ts +177 -0
- package/core/processors/ImageProcessor.ts +423 -0
- package/core/storage/LocalStorageProvider.ts +290 -0
- package/core/storage/StorageProvider.ts +112 -0
- package/database/DatabaseHelper.ts +183 -58
- package/database/index.ts +5 -5
- package/database/sqlHelpers.ts +7 -0
- package/docs/README.md +149 -0
- package/docs/_coverpage.md +36 -0
- package/docs/_sidebar.md +23 -0
- package/docs/api/core.md +568 -0
- package/docs/api/hooks.md +554 -0
- package/docs/api/index.md +222 -0
- package/docs/api/query.md +678 -0
- package/docs/api/service.md +744 -0
- package/docs/core-concepts/archetypes.md +512 -0
- package/docs/core-concepts/components.md +498 -0
- package/docs/core-concepts/entity.md +314 -0
- package/docs/core-concepts/hooks.md +683 -0
- package/docs/core-concepts/query.md +588 -0
- package/docs/core-concepts/services.md +647 -0
- package/docs/examples/code-examples.md +425 -0
- package/docs/getting-started.md +337 -0
- package/docs/index.html +97 -0
- package/gql/Generator.ts +58 -35
- package/gql/decorators/Upload.ts +176 -0
- package/gql/helpers.ts +67 -0
- package/gql/index.ts +65 -31
- package/gql/types.ts +1 -1
- package/index.ts +79 -11
- package/package.json +19 -10
- package/rest/Generator.ts +3 -0
- package/rest/index.ts +22 -0
- package/service/Service.ts +1 -1
- package/service/ServiceRegistry.ts +10 -6
- package/service/index.ts +12 -1
- package/tests/bench/insert.bench.ts +59 -0
- package/tests/bench/relations.bench.ts +269 -0
- package/tests/bench/sorting.bench.ts +415 -0
- package/tests/component-hooks.test.ts +1409 -0
- package/tests/component.test.ts +338 -0
- package/tests/errorHandling.test.ts +155 -0
- package/tests/hooks.test.ts +666 -0
- package/tests/query-sorting.test.ts +101 -0
- package/tests/relations.test.ts +169 -0
- package/tests/scheduler.test.ts +724 -0
- package/tsconfig.json +35 -34
- package/types/graphql.types.ts +87 -0
- package/types/hooks.types.ts +141 -0
- package/types/scheduler.types.ts +165 -0
- package/types/upload.types.ts +184 -0
- package/upload/index.ts +140 -0
- package/utils/UploadHelper.ts +305 -0
- package/utils/cronParser.ts +366 -0
- package/utils/errorMessages.ts +151 -0
- package/core/Events.ts +0 -0
package/core/Query.ts
CHANGED
|
@@ -1,31 +1,56 @@
|
|
|
1
|
-
import type { BaseComponent } from "./Components";
|
|
1
|
+
import type { BaseComponent, ComponentDataType } from "./Components";
|
|
2
2
|
import { Entity } from "./Entity";
|
|
3
3
|
import ComponentRegistry from "./ComponentRegistry";
|
|
4
4
|
import { logger } from "./Logger";
|
|
5
5
|
import { sql } from "bun";
|
|
6
6
|
import db from "database";
|
|
7
7
|
import { timed } from "./Decorators";
|
|
8
|
+
import { inList } from "../database/sqlHelpers";
|
|
8
9
|
|
|
9
10
|
export type FilterOperator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "LIKE" | "IN" | "NOT IN";
|
|
11
|
+
|
|
12
|
+
export const FilterOp = {
|
|
13
|
+
EQ: "=" as FilterOperator,
|
|
14
|
+
GT: ">" as FilterOperator,
|
|
15
|
+
LT: "<" as FilterOperator,
|
|
16
|
+
GTE: ">=" as FilterOperator,
|
|
17
|
+
LTE: "<=" as FilterOperator,
|
|
18
|
+
NEQ: "!=" as FilterOperator,
|
|
19
|
+
LIKE: "LIKE" as FilterOperator,
|
|
20
|
+
IN: "IN" as FilterOperator,
|
|
21
|
+
NOT_IN: "NOT IN" as FilterOperator
|
|
22
|
+
}
|
|
10
23
|
export interface QueryFilter {
|
|
11
24
|
field: string;
|
|
12
25
|
operator: FilterOperator;
|
|
13
26
|
value: any;
|
|
14
27
|
}
|
|
15
28
|
|
|
16
|
-
export
|
|
17
|
-
filters
|
|
18
|
-
}
|
|
29
|
+
export interface QueryFilterOptions {
|
|
30
|
+
filters: QueryFilter[];
|
|
31
|
+
}
|
|
19
32
|
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
export type SortDirection = "ASC" | "DESC";
|
|
34
|
+
|
|
35
|
+
export interface SortOrder {
|
|
36
|
+
component: string;
|
|
37
|
+
property: string;
|
|
38
|
+
direction: SortDirection;
|
|
39
|
+
nullsFirst?: boolean;
|
|
22
40
|
}
|
|
41
|
+
|
|
23
42
|
class Query {
|
|
24
43
|
private requiredComponents: Set<string> = new Set<string>();
|
|
25
44
|
private excludedComponents: Set<string> = new Set<string>();
|
|
26
45
|
private componentFilters: Map<string, QueryFilter[]> = new Map();
|
|
27
46
|
private populateComponents: boolean = false;
|
|
28
47
|
private withId: string | null = null;
|
|
48
|
+
private limit: number | null = null;
|
|
49
|
+
private offsetValue: number = 0;
|
|
50
|
+
private eagerComponents: Set<string> = new Set<string>();
|
|
51
|
+
private sortOrders: SortOrder[] = [];
|
|
52
|
+
|
|
53
|
+
static filterOp = FilterOp;
|
|
29
54
|
|
|
30
55
|
public findById(id: string) {
|
|
31
56
|
this.withId = id;
|
|
@@ -51,17 +76,51 @@ class Query {
|
|
|
51
76
|
return this;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
|
-
|
|
79
|
+
public eagerLoad<T extends BaseComponent>(ctors: (new (...args: any[]) => T)[]): this {
|
|
80
|
+
for (const ctor of ctors) {
|
|
81
|
+
const type_id = ComponentRegistry.getComponentId(ctor.name);
|
|
82
|
+
if (!type_id) {
|
|
83
|
+
throw new Error(`Component ${ctor.name} is not registered.`);
|
|
84
|
+
}
|
|
85
|
+
this.eagerComponents.add(type_id);
|
|
86
|
+
}
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public eagerLoadComponents(ctors: Array<new () => BaseComponent>): this {
|
|
91
|
+
for (const ctor of ctors) {
|
|
92
|
+
const type_id = ComponentRegistry.getComponentId(ctor.name);
|
|
93
|
+
if (!type_id) {
|
|
94
|
+
throw new Error(`Component ${ctor.name} is not registered.`);
|
|
95
|
+
}
|
|
96
|
+
this.eagerComponents.add(type_id);
|
|
97
|
+
}
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
55
101
|
public static filter(field: string, operator: FilterOperator, value: any): QueryFilter {
|
|
56
102
|
return { field, operator, value };
|
|
57
103
|
}
|
|
58
104
|
|
|
105
|
+
public static typedFilter<T extends BaseComponent>(
|
|
106
|
+
componentCtor: new (...args: any[]) => T,
|
|
107
|
+
field: keyof ComponentDataType<T>,
|
|
108
|
+
operator: FilterOperator,
|
|
109
|
+
value: any
|
|
110
|
+
): QueryFilter {
|
|
111
|
+
return { field: field as string, operator, value };
|
|
112
|
+
}
|
|
113
|
+
|
|
59
114
|
public static filters(...filters: QueryFilter[]): QueryFilterOptions {
|
|
60
115
|
return { filters };
|
|
61
116
|
}
|
|
62
117
|
|
|
63
|
-
private buildFilterCondition(filter: QueryFilter): string {
|
|
118
|
+
private buildFilterCondition(filter: QueryFilter, alias: string, paramIndex: number): { sql: string, params: any[], newParamIndex: number } {
|
|
64
119
|
const { field, operator, value } = filter;
|
|
120
|
+
|
|
121
|
+
// Build JSON path for nested properties (e.g., "parent.child" -> data->'parent'->>'child')
|
|
122
|
+
const jsonPath = this.buildJsonPath(field, alias);
|
|
123
|
+
|
|
65
124
|
switch (operator) {
|
|
66
125
|
case "=":
|
|
67
126
|
case ">":
|
|
@@ -70,22 +129,22 @@ class Query {
|
|
|
70
129
|
case "<=":
|
|
71
130
|
case "!=":
|
|
72
131
|
if (typeof value === "string") {
|
|
73
|
-
return
|
|
132
|
+
return { sql: `${jsonPath} ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
|
|
74
133
|
} else {
|
|
75
|
-
return `(
|
|
134
|
+
return { sql: `(${jsonPath})::numeric ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
|
|
76
135
|
}
|
|
77
136
|
case "LIKE":
|
|
78
|
-
return
|
|
137
|
+
return { sql: `${jsonPath} LIKE $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
|
|
79
138
|
case "IN":
|
|
80
139
|
if (Array.isArray(value)) {
|
|
81
|
-
const
|
|
82
|
-
return
|
|
140
|
+
const placeholders = Array.from({length: value.length}, (_, i) => `$${paramIndex + i}`).join(', ');
|
|
141
|
+
return { sql: `${jsonPath} IN (${placeholders})`, params: value, newParamIndex: paramIndex + value.length };
|
|
83
142
|
}
|
|
84
143
|
throw new Error("IN operator requires an array of values");
|
|
85
144
|
case "NOT IN":
|
|
86
145
|
if (Array.isArray(value)) {
|
|
87
|
-
const
|
|
88
|
-
return
|
|
146
|
+
const placeholders = Array.from({length: value.length}, (_, i) => `$${paramIndex + i}`).join(', ');
|
|
147
|
+
return { sql: `${jsonPath} NOT IN (${placeholders})`, params: value, newParamIndex: paramIndex + value.length };
|
|
89
148
|
}
|
|
90
149
|
throw new Error("NOT IN operator requires an array of values");
|
|
91
150
|
default:
|
|
@@ -93,11 +152,41 @@ class Query {
|
|
|
93
152
|
}
|
|
94
153
|
}
|
|
95
154
|
|
|
96
|
-
|
|
97
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Build PostgreSQL JSON path expression for nested properties
|
|
157
|
+
* @param field Field path (e.g., "parent.child.grandchild")
|
|
158
|
+
* @param alias Table alias for the components table
|
|
159
|
+
* @returns PostgreSQL JSON path expression
|
|
160
|
+
*/
|
|
161
|
+
private buildJsonPath(field: string, alias: string): string {
|
|
162
|
+
const parts = field.split('.');
|
|
163
|
+
|
|
164
|
+
if (parts.length === 1) {
|
|
165
|
+
// Single level: data->>'field'
|
|
166
|
+
return `${alias}.data->>'${field}'`;
|
|
167
|
+
} else {
|
|
168
|
+
// Nested levels: data->'parent'->'child'->>'grandchild'
|
|
169
|
+
const pathParts = parts.slice(0, -1).map(part => `'${part}'`);
|
|
170
|
+
const lastPart = parts[parts.length - 1];
|
|
171
|
+
|
|
172
|
+
return `${alias}.data->${pathParts.join('->')}->>'${lastPart}'`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private buildFilterWhereClause(typeId: string, filters: QueryFilter[], alias: string, paramIndex: number): { sql: string, params: any[], newParamIndex: number } {
|
|
177
|
+
if (filters.length === 0) return { sql: '', params: [], newParamIndex: paramIndex };
|
|
98
178
|
|
|
99
|
-
const conditions =
|
|
100
|
-
|
|
179
|
+
const conditions: string[] = [];
|
|
180
|
+
const allParams: any[] = [];
|
|
181
|
+
let currentIndex = paramIndex;
|
|
182
|
+
for (const filter of filters) {
|
|
183
|
+
const { sql, params, newParamIndex } = this.buildFilterCondition(filter, alias, currentIndex);
|
|
184
|
+
conditions.push(sql);
|
|
185
|
+
allParams.push(...params);
|
|
186
|
+
currentIndex = newParamIndex;
|
|
187
|
+
}
|
|
188
|
+
const sql = conditions.join(' AND ');
|
|
189
|
+
return { sql, params: allParams, newParamIndex: currentIndex };
|
|
101
190
|
}
|
|
102
191
|
|
|
103
192
|
|
|
@@ -115,8 +204,82 @@ class Query {
|
|
|
115
204
|
return this;
|
|
116
205
|
}
|
|
117
206
|
|
|
118
|
-
|
|
207
|
+
public take(limit: number): this {
|
|
208
|
+
this.limit = limit;
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public offset(offset: number): this {
|
|
213
|
+
this.offsetValue = offset;
|
|
214
|
+
return this;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public sortBy<T extends BaseComponent>(
|
|
218
|
+
componentCtor: new (...args: any[]) => T,
|
|
219
|
+
property: keyof ComponentDataType<T>,
|
|
220
|
+
direction: SortDirection = "ASC",
|
|
221
|
+
nullsFirst: boolean = false
|
|
222
|
+
): this {
|
|
223
|
+
const componentName = componentCtor.name;
|
|
224
|
+
const typeId = ComponentRegistry.getComponentId(componentName);
|
|
225
|
+
|
|
226
|
+
if (!typeId) {
|
|
227
|
+
throw new Error(`Component ${componentName} is not registered.`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Validate that the component is required in this query
|
|
231
|
+
if (!this.requiredComponents.has(typeId)) {
|
|
232
|
+
throw new Error(`Cannot sort by component ${componentName} that is not included in the query. Use .with(${componentName}) first.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.sortOrders.push({
|
|
236
|
+
component: componentName,
|
|
237
|
+
property: property as string,
|
|
238
|
+
direction,
|
|
239
|
+
nullsFirst
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return this;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public orderBy(orders: SortOrder[]): this {
|
|
246
|
+
// Validate each sort order
|
|
247
|
+
for (const order of orders) {
|
|
248
|
+
const typeId = ComponentRegistry.getComponentId(order.component);
|
|
249
|
+
if (!typeId) {
|
|
250
|
+
throw new Error(`Component ${order.component} is not registered.`);
|
|
251
|
+
}
|
|
252
|
+
if (!this.requiredComponents.has(typeId)) {
|
|
253
|
+
throw new Error(`Cannot sort by component ${order.component} that is not included in the query. Use .with(${order.component}) first.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.sortOrders = orders;
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@timed("Query.exec")
|
|
119
262
|
public async exec(): Promise<Entity[]> {
|
|
263
|
+
return new Promise<Entity[]>((resolve, reject) => {
|
|
264
|
+
// Add timeout to prevent hanging queries
|
|
265
|
+
const timeout = setTimeout(() => {
|
|
266
|
+
logger.error(`Query execution timeout`);
|
|
267
|
+
reject(new Error(`Query execution timeout after 30 seconds`));
|
|
268
|
+
}, 30000); // 30 second timeout
|
|
269
|
+
|
|
270
|
+
this.doExec()
|
|
271
|
+
.then(result => {
|
|
272
|
+
clearTimeout(timeout);
|
|
273
|
+
resolve(result);
|
|
274
|
+
})
|
|
275
|
+
.catch(error => {
|
|
276
|
+
clearTimeout(timeout);
|
|
277
|
+
reject(error);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private async doExec(): Promise<Entity[]> {
|
|
120
283
|
const componentIds = Array.from(this.requiredComponents);
|
|
121
284
|
const excludedIds = Array.from(this.excludedComponents);
|
|
122
285
|
const componentCount = componentIds.length;
|
|
@@ -131,67 +294,120 @@ class Query {
|
|
|
131
294
|
case !hasRequired && !hasExcluded && !hasWithId:
|
|
132
295
|
return [];
|
|
133
296
|
case !hasRequired && !hasExcluded && hasWithId:
|
|
134
|
-
|
|
297
|
+
let query = db`SELECT id FROM entities WHERE id = ${this.withId} AND deleted_at IS NULL ORDER BY id`;
|
|
298
|
+
if (this.limit !== null) {
|
|
299
|
+
query = db`${query} LIMIT ${this.limit}`;
|
|
300
|
+
}
|
|
301
|
+
if (this.offsetValue > 0) {
|
|
302
|
+
query = db`${query} OFFSET ${this.offsetValue}`;
|
|
303
|
+
}
|
|
304
|
+
const result = await query;
|
|
135
305
|
ids = result.map((row: any) => row.id);
|
|
136
306
|
break;
|
|
137
307
|
case hasRequired && hasExcluded && hasFilters:
|
|
138
|
-
ids = await this.getIdsWithFiltersAndExclusions(componentIds, excludedIds, componentCount);
|
|
308
|
+
ids = await this.getIdsWithFiltersAndExclusions(componentIds, excludedIds, componentCount, this.limit, this.offsetValue);
|
|
139
309
|
break;
|
|
140
310
|
case hasRequired && hasExcluded:
|
|
141
|
-
const componentIdsString = componentIds
|
|
142
|
-
const excludedIdsString = excludedIds
|
|
143
|
-
|
|
311
|
+
const componentIdsString = inList(componentIds, 1);
|
|
312
|
+
const excludedIdsString = inList(excludedIds, componentIdsString.newParamIndex);
|
|
313
|
+
let excludedQuery = db`
|
|
144
314
|
SELECT ec.entity_id as id
|
|
145
315
|
FROM entity_components ec
|
|
146
|
-
WHERE ec.type_id IN
|
|
147
|
-
${this.withId ? `AND ec.entity_id =
|
|
316
|
+
WHERE ec.type_id IN ${db.unsafe(componentIdsString.sql, componentIdsString.params)} AND ec.deleted_at IS NULL
|
|
317
|
+
${this.withId ? db`AND ec.entity_id = ${this.withId}` : db``}
|
|
148
318
|
AND NOT EXISTS (
|
|
149
319
|
SELECT 1 FROM entity_components ec_ex
|
|
150
|
-
WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN
|
|
320
|
+
WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN ${db.unsafe(excludedIdsString.sql, excludedIdsString.params)} AND ec_ex.deleted_at IS NULL
|
|
151
321
|
)
|
|
152
322
|
GROUP BY ec.entity_id
|
|
153
323
|
HAVING COUNT(DISTINCT ec.type_id) = ${componentCount}
|
|
324
|
+
ORDER BY ec.entity_id
|
|
154
325
|
`;
|
|
155
|
-
|
|
156
|
-
|
|
326
|
+
if (this.limit !== null) {
|
|
327
|
+
excludedQuery = db`${excludedQuery} LIMIT ${this.limit}`;
|
|
328
|
+
}
|
|
329
|
+
if (this.offsetValue > 0) {
|
|
330
|
+
excludedQuery = db`${excludedQuery} OFFSET ${this.offsetValue}`;
|
|
331
|
+
}
|
|
332
|
+
const excludedQueryResult = await excludedQuery;
|
|
157
333
|
ids = excludedQueryResult.map((row: any) => row.id);
|
|
158
334
|
break;
|
|
159
335
|
case hasRequired && hasFilters:
|
|
160
|
-
ids = await this.getIdsWithFilters(componentIds, componentCount);
|
|
336
|
+
ids = await this.getIdsWithFilters(componentIds, componentCount, this.limit, this.offsetValue);
|
|
161
337
|
break;
|
|
162
338
|
case hasRequired:
|
|
163
|
-
let queryStr:
|
|
339
|
+
let queryStr: any;
|
|
164
340
|
let requiredOnlyQueryResult: any;
|
|
165
341
|
if (componentCount === 1) {
|
|
166
|
-
// Optimize
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
342
|
+
// Phase 2A: Optimize single component sorting with JOIN
|
|
343
|
+
if (this.sortOrders.length > 0) {
|
|
344
|
+
const typeId = componentIds[0]!;
|
|
345
|
+
const sortExpression = this.buildSortExpressionForSingleComponent(typeId, "c");
|
|
346
|
+
queryStr = db`SELECT DISTINCT ec.entity_id as id ${db.unsafe(sortExpression.select)} FROM entity_components ec JOIN components c ON ec.entity_id = c.entity_id AND c.type_id = ${typeId} AND c.deleted_at IS NULL WHERE ec.type_id = ${typeId} ${this.withId ? db`AND ec.entity_id = ${this.withId}` : db``} AND ec.deleted_at IS NULL ${db.unsafe(sortExpression.orderBy)}`;
|
|
347
|
+
} else {
|
|
348
|
+
queryStr = db`SELECT entity_id as id FROM entity_components WHERE type_id = ${componentIds[0]} ${this.withId ? db`AND entity_id = ${this.withId}` : db``} AND deleted_at IS NULL ORDER BY entity_id`;
|
|
349
|
+
}
|
|
350
|
+
if (this.limit !== null) {
|
|
351
|
+
queryStr = db`${queryStr} LIMIT ${this.limit}`;
|
|
352
|
+
}
|
|
353
|
+
if (this.offsetValue > 0) {
|
|
354
|
+
queryStr = db`${queryStr} OFFSET ${this.offsetValue}`;
|
|
355
|
+
}
|
|
356
|
+
requiredOnlyQueryResult = await queryStr;
|
|
170
357
|
} else {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
358
|
+
// Phase 2A: Optimize multi-component sorting with JOINs instead of subqueries
|
|
359
|
+
if (this.sortOrders.length > 0) {
|
|
360
|
+
const compIds = inList(componentIds, 1);
|
|
361
|
+
let orderByClause = "ORDER BY ";
|
|
362
|
+
const orderClauses: string[] = [];
|
|
363
|
+
|
|
364
|
+
for (const order of this.sortOrders) {
|
|
365
|
+
const typeId = ComponentRegistry.getComponentId(order.component);
|
|
366
|
+
if (typeId && componentIds.includes(typeId)) {
|
|
367
|
+
const direction = order.direction.toUpperCase();
|
|
368
|
+
const nullsClause = order.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
|
|
369
|
+
// Use JOIN-based sorting instead of subquery
|
|
370
|
+
const subquery = `(SELECT (c.data->>'${order.property}')::numeric FROM components c WHERE c.entity_id = base_query.id AND c.type_id = '${typeId}' AND c.deleted_at IS NULL LIMIT 1)`;
|
|
371
|
+
orderClauses.push(`${subquery} ${direction} ${nullsClause}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
orderClauses.push("base_query.id ASC");
|
|
375
|
+
orderByClause += orderClauses.join(", ");
|
|
376
|
+
|
|
377
|
+
queryStr = db`SELECT * FROM (SELECT DISTINCT entity_id as id FROM entity_components WHERE type_id IN ${db.unsafe(compIds.sql, compIds.params)} ${this.withId ? db`AND entity_id = ${this.withId}` : db``} AND deleted_at IS NULL GROUP BY entity_id HAVING COUNT(DISTINCT type_id) = ${componentCount}) base_query ${db.unsafe(orderByClause)}`;
|
|
378
|
+
} else {
|
|
379
|
+
const compIds = inList(componentIds, 1);
|
|
380
|
+
queryStr = db`SELECT DISTINCT entity_id as id FROM entity_components WHERE type_id IN ${db.unsafe(compIds.sql, compIds.params)} ${this.withId ? db`AND entity_id = ${this.withId}` : db``} AND deleted_at IS NULL GROUP BY entity_id HAVING COUNT(DISTINCT type_id) = ${componentCount} ORDER BY entity_id`;
|
|
381
|
+
}
|
|
382
|
+
if (this.limit !== null) {
|
|
383
|
+
queryStr = db`${queryStr} LIMIT ${this.limit}`;
|
|
384
|
+
}
|
|
385
|
+
if (this.offsetValue > 0) {
|
|
386
|
+
queryStr = db`${queryStr} OFFSET ${this.offsetValue}`;
|
|
387
|
+
}
|
|
388
|
+
requiredOnlyQueryResult = await queryStr;
|
|
180
389
|
}
|
|
181
390
|
ids = requiredOnlyQueryResult.map((row: any) => row.id);
|
|
182
391
|
break;
|
|
183
392
|
case hasExcluded:
|
|
184
|
-
const onlyExcludedIdsString = excludedIds
|
|
185
|
-
|
|
393
|
+
const onlyExcludedIdsString = inList(excludedIds, 1);
|
|
394
|
+
let onlyExcludedQuery = db`
|
|
186
395
|
SELECT DISTINCT ec.entity_id as id
|
|
187
|
-
FROM entity_components ec
|
|
188
|
-
WHERE ${this.withId ? `ec.entity_id =
|
|
396
|
+
FROM entity_components ec
|
|
397
|
+
WHERE ${this.withId ? db`ec.entity_id = ${this.withId} AND ` : db``} NOT EXISTS (
|
|
189
398
|
SELECT 1 FROM entity_components ec_ex
|
|
190
|
-
WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN
|
|
399
|
+
WHERE ec_ex.entity_id = ec.entity_id AND ec_ex.type_id IN ${db.unsafe(onlyExcludedIdsString.sql, onlyExcludedIdsString.params)} AND ec_ex.deleted_at IS NULL
|
|
191
400
|
)
|
|
401
|
+
AND ec.deleted_at IS NULL
|
|
402
|
+
ORDER BY ec.entity_id
|
|
192
403
|
`;
|
|
193
|
-
|
|
194
|
-
|
|
404
|
+
if (this.limit !== null) {
|
|
405
|
+
onlyExcludedQuery = db`${onlyExcludedQuery} LIMIT ${this.limit}`;
|
|
406
|
+
}
|
|
407
|
+
if (this.offsetValue > 0) {
|
|
408
|
+
onlyExcludedQuery = db`${onlyExcludedQuery} OFFSET ${this.offsetValue}`;
|
|
409
|
+
}
|
|
410
|
+
const onlyExcludedQueryResult = await onlyExcludedQuery;
|
|
195
411
|
ids = onlyExcludedQueryResult.map((row: any) => row.id);
|
|
196
412
|
break;
|
|
197
413
|
default:
|
|
@@ -229,66 +445,268 @@ class Query {
|
|
|
229
445
|
entities[i + 3] = entity;
|
|
230
446
|
}
|
|
231
447
|
}
|
|
448
|
+
if (this.eagerComponents.size > 0) {
|
|
449
|
+
await Entity.LoadComponents(entities, Array.from(this.eagerComponents));
|
|
450
|
+
}
|
|
232
451
|
return entities;
|
|
233
452
|
}
|
|
234
453
|
}
|
|
235
454
|
|
|
236
|
-
private
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
FROM entity_components ec
|
|
240
|
-
`;
|
|
241
|
-
|
|
242
|
-
const joins: string[] = [];
|
|
243
|
-
const whereConditions: string[] = [`ec.type_id IN (${componentIds.map(id => `'${id}'`).join(', ')})`];
|
|
244
|
-
if (this.withId) {
|
|
245
|
-
whereConditions.push(`ec.entity_id = '${this.withId}'`);
|
|
455
|
+
private buildOrderByClause(): string {
|
|
456
|
+
if (this.sortOrders.length === 0) {
|
|
457
|
+
return 'ORDER BY ec.entity_id';
|
|
246
458
|
}
|
|
247
|
-
|
|
459
|
+
|
|
460
|
+
const orderClauses: string[] = [];
|
|
461
|
+
for (const order of this.sortOrders) {
|
|
462
|
+
const typeId = ComponentRegistry.getComponentId(order.component);
|
|
463
|
+
if (!typeId) continue;
|
|
464
|
+
|
|
465
|
+
// For now, assume we have a component alias. In practice, we'd need to map component types to aliases
|
|
466
|
+
// This is a simplified implementation - in a full implementation, we'd need to track aliases per component
|
|
467
|
+
const componentAlias = `c_${typeId}`;
|
|
468
|
+
const direction = order.direction.toUpperCase();
|
|
469
|
+
const nulls = order.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
470
|
+
|
|
471
|
+
// Use buildJsonPath for nested property support
|
|
472
|
+
const jsonPath = this.buildJsonPath(order.property, componentAlias);
|
|
473
|
+
orderClauses.push(`(${jsonPath})::text ${direction} ${nulls}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Always include entity_id as final tiebreaker for consistent ordering
|
|
477
|
+
orderClauses.push('ec.entity_id ASC');
|
|
478
|
+
|
|
479
|
+
return `ORDER BY ${orderClauses.join(', ')}`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private buildOrderByClauseWithJoinData(componentIds: string[], joinCount: number): string {
|
|
483
|
+
if (this.sortOrders.length === 0) {
|
|
484
|
+
return `ORDER BY id`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const orderClauses: string[] = [];
|
|
488
|
+
|
|
489
|
+
for (let i = 0; i < this.sortOrders.length; i++) {
|
|
490
|
+
const order = this.sortOrders[i];
|
|
491
|
+
if (!order) continue;
|
|
492
|
+
|
|
493
|
+
const typeId = ComponentRegistry.getComponentId(order.component);
|
|
494
|
+
if (!typeId || !componentIds.includes(typeId)) {
|
|
495
|
+
continue; // Skip if component not in query
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const direction = order.direction.toUpperCase();
|
|
499
|
+
const nullsClause = order.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
|
|
500
|
+
|
|
501
|
+
// For subquery approach, use correlated subquery for sorting
|
|
502
|
+
const sortExpression = `(SELECT (c.data->>'${order.property}')::numeric FROM components c WHERE c.entity_id = filtered_entities.id AND c.type_id = '${typeId}' AND c.deleted_at IS NULL LIMIT 1)`;
|
|
503
|
+
|
|
504
|
+
orderClauses.push(`${sortExpression} ${direction} ${nullsClause}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Always include entity_id as final tiebreaker for consistent ordering
|
|
508
|
+
orderClauses.push(`id ASC`);
|
|
509
|
+
|
|
510
|
+
return `ORDER BY ${orderClauses.join(", ")}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private buildSortExpressionForSingleComponent(typeId: string, alias: string): { select: string, orderBy: string } {
|
|
514
|
+
if (this.sortOrders.length === 0) {
|
|
515
|
+
return { select: "", orderBy: "ORDER BY ec.entity_id" };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const order = this.sortOrders[0]; // For single component, we only support single sort
|
|
519
|
+
if (!order) {
|
|
520
|
+
return { select: "", orderBy: "ORDER BY ec.entity_id" };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const direction = order.direction.toUpperCase();
|
|
524
|
+
const nullsClause = order.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
|
|
525
|
+
|
|
526
|
+
// Use buildJsonPath for nested property support
|
|
527
|
+
const jsonPath = this.buildJsonPath(order.property, alias);
|
|
528
|
+
const selectExpr = `, (${jsonPath})::numeric as sort_val`;
|
|
529
|
+
const orderByExpr = `ORDER BY sort_val ${direction} ${nullsClause}, ec.entity_id ASC`;
|
|
530
|
+
|
|
531
|
+
return { select: selectExpr, orderBy: orderByExpr };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async getIdsWithFilters(componentIds: string[], componentCount: number, limit?: number | null, offset?: number): Promise<string[]> {
|
|
535
|
+
let params: any[] = [];
|
|
536
|
+
let paramIndex = 1;
|
|
537
|
+
const compIds = inList(componentIds, paramIndex);
|
|
538
|
+
params.push(...compIds.params);
|
|
539
|
+
paramIndex = compIds.newParamIndex;
|
|
540
|
+
|
|
541
|
+
const joins: string[] = [];
|
|
248
542
|
let joinIndex = 0;
|
|
249
543
|
for (const [typeId, filters] of this.componentFilters.entries()) {
|
|
250
544
|
if (componentIds.includes(typeId)) {
|
|
251
545
|
const alias = `c${joinIndex}`;
|
|
252
|
-
joins.push(`JOIN components ${alias} ON ec.entity_id = ${alias}.entity_id AND ${alias}.type_id =
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (filterCondition) {
|
|
256
|
-
whereConditions.push(filterCondition.replace(/data->/g, `${alias}.data->`));
|
|
257
|
-
}
|
|
546
|
+
joins.push(`JOIN components ${alias} ON ec.entity_id = ${alias}.entity_id AND ${alias}.type_id = $${paramIndex} AND ${alias}.deleted_at IS NULL`);
|
|
547
|
+
params.push(typeId);
|
|
548
|
+
paramIndex++;
|
|
258
549
|
joinIndex++;
|
|
259
550
|
}
|
|
260
551
|
}
|
|
261
|
-
|
|
262
|
-
query += joins.join(' ');
|
|
263
|
-
query += ` WHERE ${whereConditions.join(' AND ')}`;
|
|
264
|
-
query += ` GROUP BY ec.entity_id HAVING COUNT(DISTINCT ec.type_id) = ${componentCount}`;
|
|
265
|
-
|
|
266
|
-
wrapLog(`Executing filtered query: ${query}`);
|
|
267
|
-
const filteredResult = await db.unsafe(query);
|
|
268
|
-
return filteredResult.map((row: any) => row.id);
|
|
269
|
-
}
|
|
270
552
|
|
|
271
|
-
|
|
553
|
+
let sql: string;
|
|
554
|
+
|
|
555
|
+
// For sorting, use a CTE approach to avoid GROUP BY conflicts
|
|
556
|
+
if (this.sortOrders.length > 0) {
|
|
557
|
+
let selectColumns = `ec.entity_id as id`;
|
|
558
|
+
|
|
559
|
+
// Add sort columns using window functions
|
|
560
|
+
const sortColumns: string[] = [];
|
|
561
|
+
for (let i = 0; i < this.sortOrders.length; i++) {
|
|
562
|
+
const order = this.sortOrders[i];
|
|
563
|
+
if (!order) continue;
|
|
564
|
+
|
|
565
|
+
const typeId = ComponentRegistry.getComponentId(order.component);
|
|
566
|
+
if (!typeId || !componentIds.includes(typeId)) {
|
|
567
|
+
continue; // Skip if component not in query
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Find the join alias for this component
|
|
571
|
+
let sortAlias = '';
|
|
572
|
+
let aliasIndex = 0;
|
|
573
|
+
for (const [filterTypeId, filters] of Array.from(this.componentFilters.entries())) {
|
|
574
|
+
if (componentIds.includes(filterTypeId) && filterTypeId === typeId) {
|
|
575
|
+
sortAlias = `c${aliasIndex}`;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
if (componentIds.includes(filterTypeId)) {
|
|
579
|
+
aliasIndex++;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (sortAlias) {
|
|
584
|
+
sortColumns.push(`FIRST_VALUE((${sortAlias}.data->>'${order.property}')::numeric) OVER (PARTITION BY ec.entity_id ORDER BY ${sortAlias}.created_at DESC) as sort_val_${i}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (sortColumns.length > 0) {
|
|
588
|
+
selectColumns += `, ${sortColumns.join(', ')}`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Use CTE to get filtered entities with sort values
|
|
592
|
+
sql = `WITH filtered_entities AS (
|
|
593
|
+
SELECT ${selectColumns}
|
|
594
|
+
FROM entity_components ec ${joins.join(' ')}
|
|
595
|
+
WHERE ec.type_id IN ${compIds.sql} AND ec.deleted_at IS NULL`;
|
|
596
|
+
|
|
597
|
+
if (this.withId) {
|
|
598
|
+
sql += ` AND ec.entity_id = $${paramIndex}`;
|
|
599
|
+
params.push(this.withId);
|
|
600
|
+
paramIndex++;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
joinIndex = 0;
|
|
604
|
+
for (const [typeId, filters] of this.componentFilters.entries()) {
|
|
605
|
+
if (componentIds.includes(typeId)) {
|
|
606
|
+
const alias = `c${joinIndex}`;
|
|
607
|
+
const filterConditions = this.buildFilterWhereClause(typeId, filters, alias, paramIndex);
|
|
608
|
+
if (filterConditions.sql) {
|
|
609
|
+
sql += ` AND ${filterConditions.sql}`;
|
|
610
|
+
params.push(...filterConditions.params);
|
|
611
|
+
paramIndex = filterConditions.newParamIndex;
|
|
612
|
+
}
|
|
613
|
+
joinIndex++;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
sql += `
|
|
618
|
+
)
|
|
619
|
+
SELECT DISTINCT fe.id, ${sortColumns.length > 0 ? sortColumns.map((_, i) => `fe.sort_val_${i}`).join(', ') : ''}
|
|
620
|
+
FROM filtered_entities fe
|
|
621
|
+
WHERE (SELECT COUNT(DISTINCT ec.type_id) FROM entity_components ec WHERE ec.entity_id = fe.id AND ec.deleted_at IS NULL) = $${paramIndex}`;
|
|
622
|
+
|
|
623
|
+
params.push(componentCount);
|
|
624
|
+
paramIndex++;
|
|
625
|
+
|
|
626
|
+
// Build ORDER BY clause using the selected sort values
|
|
627
|
+
const orderClauses: string[] = [];
|
|
628
|
+
for (let i = 0; i < this.sortOrders.length; i++) {
|
|
629
|
+
const order = this.sortOrders[i];
|
|
630
|
+
if (!order) continue;
|
|
631
|
+
|
|
632
|
+
const direction = order.direction.toUpperCase();
|
|
633
|
+
const nullsClause = order.nullsFirst ? "NULLS FIRST" : "NULLS LAST";
|
|
634
|
+
orderClauses.push(`sort_val_${i} ${direction} ${nullsClause}`);
|
|
635
|
+
}
|
|
636
|
+
// Always include entity_id as final tiebreaker for consistent ordering
|
|
637
|
+
orderClauses.push(`id ASC`);
|
|
638
|
+
sql += ` ORDER BY ${orderClauses.join(", ")}`;
|
|
639
|
+
} else {
|
|
640
|
+
// No sorting - use simpler approach
|
|
641
|
+
sql = `SELECT DISTINCT ec.entity_id as id FROM entity_components ec ${joins.join(' ')} WHERE ec.type_id IN ${compIds.sql} AND ec.deleted_at IS NULL`;
|
|
642
|
+
|
|
643
|
+
if (this.withId) {
|
|
644
|
+
sql += ` AND ec.entity_id = $${paramIndex}`;
|
|
645
|
+
params.push(this.withId);
|
|
646
|
+
paramIndex++;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
joinIndex = 0;
|
|
650
|
+
for (const [typeId, filters] of this.componentFilters.entries()) {
|
|
651
|
+
if (componentIds.includes(typeId)) {
|
|
652
|
+
const alias = `c${joinIndex}`;
|
|
653
|
+
const filterConditions = this.buildFilterWhereClause(typeId, filters, alias, paramIndex);
|
|
654
|
+
if (filterConditions.sql) {
|
|
655
|
+
sql += ` AND ${filterConditions.sql}`;
|
|
656
|
+
params.push(...filterConditions.params);
|
|
657
|
+
paramIndex = filterConditions.newParamIndex;
|
|
658
|
+
}
|
|
659
|
+
joinIndex++;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
sql += ` GROUP BY ec.entity_id HAVING COUNT(DISTINCT ec.type_id) = $${paramIndex}`;
|
|
664
|
+
params.push(componentCount);
|
|
665
|
+
paramIndex++;
|
|
666
|
+
sql += ` ORDER BY ec.entity_id`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (limit !== null && limit !== undefined) {
|
|
670
|
+
sql += ` LIMIT $${paramIndex}`;
|
|
671
|
+
params.push(limit);
|
|
672
|
+
paramIndex++;
|
|
673
|
+
}
|
|
674
|
+
if (offset && offset > 0) {
|
|
675
|
+
sql += ` OFFSET $${paramIndex}`;
|
|
676
|
+
params.push(offset);
|
|
677
|
+
paramIndex++;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const filteredResult = await db.unsafe(sql, params);
|
|
681
|
+
return filteredResult.map((row: any) => row.id);
|
|
682
|
+
} private async getIdsWithFiltersAndExclusions(componentIds: string[], excludedIds: string[], componentCount: number, limit?: number | null, offset?: number): Promise<string[]> {
|
|
272
683
|
const entityIds = await this.getIdsWithFilters(componentIds, componentCount);
|
|
273
684
|
|
|
274
685
|
if (entityIds.length === 0) {
|
|
275
686
|
return [];
|
|
276
687
|
}
|
|
277
688
|
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
689
|
+
const idsList = sql(entityIds);
|
|
690
|
+
const excludedList = inList(excludedIds, 1);
|
|
691
|
+
let query = db`
|
|
281
692
|
WITH entity_list AS (
|
|
282
|
-
SELECT unnest(
|
|
693
|
+
SELECT unnest(${idsList}) as id
|
|
283
694
|
)
|
|
284
695
|
SELECT el.id
|
|
285
696
|
FROM entity_list el
|
|
286
697
|
WHERE NOT EXISTS (
|
|
287
698
|
SELECT 1 FROM entity_components ec
|
|
288
|
-
WHERE ec.entity_id = el.id AND ec.type_id IN
|
|
699
|
+
WHERE ec.entity_id = el.id AND ec.type_id IN ${db.unsafe(excludedList.sql, excludedList.params)} AND ec.deleted_at IS NULL
|
|
289
700
|
)
|
|
701
|
+
ORDER BY el.id
|
|
290
702
|
`;
|
|
291
|
-
|
|
703
|
+
if (limit !== null && limit !== undefined) {
|
|
704
|
+
query = db`${query} LIMIT ${limit}`;
|
|
705
|
+
}
|
|
706
|
+
if (offset && offset > 0) {
|
|
707
|
+
query = db`${query} OFFSET ${offset}`;
|
|
708
|
+
}
|
|
709
|
+
const exclusionResult = await query;
|
|
292
710
|
return exclusionResult.map((row: any) => row.id);
|
|
293
711
|
}
|
|
294
712
|
}
|