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.
Files changed (82) hide show
  1. package/.github/workflows/deploy-docs.yml +57 -0
  2. package/LICENSE.md +1 -1
  3. package/README.md +2 -28
  4. package/TODO.md +8 -1
  5. package/bun.lock +3 -0
  6. package/config/upload.config.ts +135 -0
  7. package/core/App.ts +168 -4
  8. package/core/ArcheType.ts +122 -0
  9. package/core/BatchLoader.ts +100 -0
  10. package/core/ComponentRegistry.ts +4 -3
  11. package/core/Components.ts +2 -2
  12. package/core/Decorators.ts +15 -8
  13. package/core/Entity.ts +193 -14
  14. package/core/EntityCache.ts +15 -0
  15. package/core/EntityHookManager.ts +855 -0
  16. package/core/EntityManager.ts +12 -2
  17. package/core/ErrorHandler.ts +64 -7
  18. package/core/FileValidator.ts +284 -0
  19. package/core/Query.ts +503 -85
  20. package/core/RequestContext.ts +24 -0
  21. package/core/RequestLoaders.ts +89 -0
  22. package/core/SchedulerManager.ts +710 -0
  23. package/core/UploadManager.ts +261 -0
  24. package/core/components/UploadComponent.ts +206 -0
  25. package/core/decorators/EntityHooks.ts +190 -0
  26. package/core/decorators/ScheduledTask.ts +83 -0
  27. package/core/events/EntityLifecycleEvents.ts +177 -0
  28. package/core/processors/ImageProcessor.ts +423 -0
  29. package/core/storage/LocalStorageProvider.ts +290 -0
  30. package/core/storage/StorageProvider.ts +112 -0
  31. package/database/DatabaseHelper.ts +183 -58
  32. package/database/index.ts +5 -5
  33. package/database/sqlHelpers.ts +7 -0
  34. package/docs/README.md +149 -0
  35. package/docs/_coverpage.md +36 -0
  36. package/docs/_sidebar.md +23 -0
  37. package/docs/api/core.md +568 -0
  38. package/docs/api/hooks.md +554 -0
  39. package/docs/api/index.md +222 -0
  40. package/docs/api/query.md +678 -0
  41. package/docs/api/service.md +744 -0
  42. package/docs/core-concepts/archetypes.md +512 -0
  43. package/docs/core-concepts/components.md +498 -0
  44. package/docs/core-concepts/entity.md +314 -0
  45. package/docs/core-concepts/hooks.md +683 -0
  46. package/docs/core-concepts/query.md +588 -0
  47. package/docs/core-concepts/services.md +647 -0
  48. package/docs/examples/code-examples.md +425 -0
  49. package/docs/getting-started.md +337 -0
  50. package/docs/index.html +97 -0
  51. package/gql/Generator.ts +58 -35
  52. package/gql/decorators/Upload.ts +176 -0
  53. package/gql/helpers.ts +67 -0
  54. package/gql/index.ts +65 -31
  55. package/gql/types.ts +1 -1
  56. package/index.ts +79 -11
  57. package/package.json +19 -10
  58. package/rest/Generator.ts +3 -0
  59. package/rest/index.ts +22 -0
  60. package/service/Service.ts +1 -1
  61. package/service/ServiceRegistry.ts +10 -6
  62. package/service/index.ts +12 -1
  63. package/tests/bench/insert.bench.ts +59 -0
  64. package/tests/bench/relations.bench.ts +269 -0
  65. package/tests/bench/sorting.bench.ts +415 -0
  66. package/tests/component-hooks.test.ts +1409 -0
  67. package/tests/component.test.ts +338 -0
  68. package/tests/errorHandling.test.ts +155 -0
  69. package/tests/hooks.test.ts +666 -0
  70. package/tests/query-sorting.test.ts +101 -0
  71. package/tests/relations.test.ts +169 -0
  72. package/tests/scheduler.test.ts +724 -0
  73. package/tsconfig.json +35 -34
  74. package/types/graphql.types.ts +87 -0
  75. package/types/hooks.types.ts +141 -0
  76. package/types/scheduler.types.ts +165 -0
  77. package/types/upload.types.ts +184 -0
  78. package/upload/index.ts +140 -0
  79. package/utils/UploadHelper.ts +305 -0
  80. package/utils/cronParser.ts +366 -0
  81. package/utils/errorMessages.ts +151 -0
  82. 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 type QueryFilterOptions = {
17
- filters?: QueryFilter[];
18
- };
29
+ export interface QueryFilterOptions {
30
+ filters: QueryFilter[];
31
+ }
19
32
 
20
- function wrapLog(str: string) {
21
- // console.log(str);
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 `data->>'${field}' ${operator} '${value}'`;
132
+ return { sql: `${jsonPath} ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
74
133
  } else {
75
- return `(data->>'${field}')::numeric ${operator} ${value}`;
134
+ return { sql: `(${jsonPath})::numeric ${operator} $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
76
135
  }
77
136
  case "LIKE":
78
- return `data->>'${field}' LIKE '${value}'`;
137
+ return { sql: `${jsonPath} LIKE $${paramIndex}`, params: [value], newParamIndex: paramIndex + 1 };
79
138
  case "IN":
80
139
  if (Array.isArray(value)) {
81
- const valueList = value.map(v => typeof v === "string" ? `'${v}'` : v).join(", ");
82
- return `data->>'${field}' IN (${valueList})`;
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 valueList = value.map(v => typeof v === "string" ? `'${v}'` : v).join(", ");
88
- return `data->>'${field}' NOT IN (${valueList})`;
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
- private buildFilterWhereClause(typeId: string, filters: QueryFilter[]): string {
97
- if (filters.length === 0) return "";
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 = filters.map(filter => this.buildFilterCondition(filter));
100
- return conditions.join(" AND ");
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
- @timed
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
- const result = await db`SELECT id FROM entities WHERE id = ${this.withId!}`;
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.map(id => `'${id}'`).join(', ');
142
- const excludedIdsString = excludedIds.map(id => `'${id}'`).join(', ');
143
- const excludedQuery = `
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 (${componentIdsString})
147
- ${this.withId ? `AND ec.entity_id = '${this.withId}'` : ''}
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 (${excludedIdsString})
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
- wrapLog(`Executing query: ${excludedQuery}`);
156
- const excludedQueryResult = await db.unsafe(excludedQuery);
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: string;
339
+ let queryStr: any;
164
340
  let requiredOnlyQueryResult: any;
165
341
  if (componentCount === 1) {
166
- // Optimize for single component: no need for GROUP BY, HAVING, or DISTINCT
167
- queryStr = `SELECT entity_id as id FROM entity_components WHERE type_id = '${componentIds[0]}' ${this.withId ? `AND entity_id = '${this.withId}'` : ''}`;
168
- wrapLog(`Executing optimized query: ${queryStr}`);
169
- requiredOnlyQueryResult = await db.unsafe(queryStr);
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
- queryStr = `SELECT DISTINCT entity_id as id FROM entity_components WHERE type_id IN (${componentIds.map(id => `'${id}'`).join(', ')}) ${this.withId ? `AND entity_id = '${this.withId}'` : ''} GROUP BY entity_id HAVING COUNT(DISTINCT type_id) = ${componentCount}`;
172
- wrapLog(`Executing query: ${queryStr}`);
173
- requiredOnlyQueryResult = await db`
174
- SELECT DISTINCT entity_id as id FROM entity_components
175
- WHERE type_id IN ${sql(componentIds)}
176
- ${this.withId ? sql`AND entity_id = ${this.withId}` : sql``}
177
- GROUP BY entity_id
178
- HAVING COUNT(DISTINCT type_id) = ${componentCount}
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.map(id => `'${id}'`).join(', ');
185
- const onlyExcludedQuery = `
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 = '${this.withId}' AND ` : ''} NOT EXISTS (
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 (${onlyExcludedIdsString})
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
- wrapLog(`Executing query: ${onlyExcludedQuery}`);
194
- const onlyExcludedQueryResult = await db.unsafe(onlyExcludedQuery);
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 async getIdsWithFilters(componentIds: string[], componentCount: number): Promise<string[]> {
237
- let query = `
238
- SELECT DISTINCT ec.entity_id as id
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 = '${typeId}'`);
253
-
254
- const filterCondition = this.buildFilterWhereClause(typeId, filters);
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
- private async getIdsWithFiltersAndExclusions(componentIds: string[], excludedIds: string[], componentCount: number): Promise<string[]> {
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 idsString = entityIds.map(id => `'${id}'`).join(', ');
279
- const excludedString = excludedIds.map(id => `'${id}'`).join(', ');
280
- const query = `
689
+ const idsList = sql(entityIds);
690
+ const excludedList = inList(excludedIds, 1);
691
+ let query = db`
281
692
  WITH entity_list AS (
282
- SELECT unnest(ARRAY[${idsString}]) as id
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 (${excludedString})
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
- const exclusionResult = await db.unsafe(query);
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
  }