@0xobelisk/ecs 1.2.0-pre.100

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/src/query.ts ADDED
@@ -0,0 +1,839 @@
1
+ // ECS query system implementation
2
+
3
+ import { DubheGraphqlClient } from '@0xobelisk/graphql-client';
4
+ import { EntityId, ComponentType, QueryOptions, PagedResult, PagedQueryResult } from './types';
5
+ import {
6
+ extractIntersectionFromBatchResult,
7
+ extractUnionFromBatchResult,
8
+ extractPagedQueryResult,
9
+ isValidEntityId,
10
+ isValidComponentType,
11
+ createCacheKey,
12
+ paginateArray
13
+ } from './utils';
14
+ import { ComponentDiscoverer } from './world';
15
+
16
+ /**
17
+ * ECS query system core implementation
18
+ */
19
+ export class ECSQuery {
20
+ private graphqlClient: DubheGraphqlClient;
21
+ private queryCache = new Map<string, { result: EntityId[]; timestamp: number }>();
22
+ private cacheTimeout = 5000; // 5 second cache timeout
23
+ private availableComponents: ComponentType[] = [];
24
+ private componentDiscoverer: ComponentDiscoverer | null = null;
25
+ // Component primary key cache - pre-parsed during initialization
26
+ private componentPrimaryKeys = new Map<ComponentType, string>();
27
+
28
+ constructor(graphqlClient: DubheGraphqlClient, componentDiscoverer?: ComponentDiscoverer) {
29
+ this.graphqlClient = graphqlClient;
30
+ this.componentDiscoverer = componentDiscoverer || null;
31
+ }
32
+
33
+ /**
34
+ * Set available component list
35
+ */
36
+ setAvailableComponents(componentTypes: ComponentType[]): void {
37
+ this.availableComponents = componentTypes;
38
+ }
39
+
40
+ /**
41
+ * Pre-parse and cache all component primary key information
42
+ */
43
+ initializeComponentMetadata(
44
+ componentMetadataList: Array<{ name: ComponentType; primaryKeys: string[] }>
45
+ ) {
46
+ this.componentPrimaryKeys.clear();
47
+
48
+ for (const metadata of componentMetadataList) {
49
+ if (metadata.primaryKeys.length === 1) {
50
+ this.componentPrimaryKeys.set(metadata.name, metadata.primaryKeys[0]);
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Get component's primary key field name (quickly retrieve from cache)
57
+ */
58
+ getComponentPrimaryKeyField(componentType: ComponentType): string {
59
+ return this.componentPrimaryKeys.get(componentType) || 'entityId';
60
+ }
61
+
62
+ /**
63
+ * Set component discoverer
64
+ */
65
+ setComponentDiscoverer(discoverer: ComponentDiscoverer): void {
66
+ this.componentDiscoverer = discoverer;
67
+ }
68
+
69
+ private buildPaginationParams(options?: QueryOptions): {
70
+ first?: number;
71
+ last?: number;
72
+ after?: string;
73
+ before?: string;
74
+ } {
75
+ const params: {
76
+ first?: number;
77
+ last?: number;
78
+ after?: string;
79
+ before?: string;
80
+ } = {};
81
+
82
+ // Priority: new pagination params > legacy params
83
+ if (options?.first !== undefined) {
84
+ params.first = options.first;
85
+ } else if (options?.limit !== undefined) {
86
+ // Backward compatibility: map limit to first
87
+ params.first = options.limit;
88
+ if (options?.offset !== undefined) {
89
+ console.warn(
90
+ 'ECS Query: offset parameter is not supported with GraphQL cursor-based pagination. Use after/before instead.'
91
+ );
92
+ }
93
+ }
94
+
95
+ if (options?.last !== undefined) {
96
+ params.last = options.last;
97
+ }
98
+
99
+ if (options?.after !== undefined) {
100
+ params.after = options.after;
101
+ }
102
+
103
+ if (options?.before !== undefined) {
104
+ params.before = options.before;
105
+ }
106
+
107
+ return params;
108
+ }
109
+
110
+ /**
111
+ * Get component field information
112
+ */
113
+ private async getComponentFields(componentType: ComponentType): Promise<string[]> {
114
+ if (this.componentDiscoverer) {
115
+ try {
116
+ const metadata = this.componentDiscoverer.getComponentMetadata(componentType);
117
+ if (metadata) {
118
+ return metadata.fields.map((field) => field.name);
119
+ }
120
+ } catch (_error) {
121
+ // Ignore error for now
122
+ }
123
+ }
124
+
125
+ // Throw error when unable to auto-parse, requiring user to explicitly specify
126
+ throw new Error(
127
+ `Unable to get field information for component ${componentType}. Please explicitly specify fields in QueryOptions or ensure component discoverer is properly configured.`
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Get component's primary key fields
133
+ */
134
+ private async getComponentPrimaryKeys(componentType: ComponentType): Promise<string[]> {
135
+ if (this.componentDiscoverer) {
136
+ try {
137
+ const metadata = this.componentDiscoverer.getComponentMetadata(componentType);
138
+ if (metadata && metadata.primaryKeys.length > 0) {
139
+ return metadata.primaryKeys;
140
+ }
141
+ } catch (_error) {
142
+ // Ignore error for now
143
+ }
144
+ }
145
+
146
+ // Throw error when unable to auto-parse, requiring user to explicitly specify
147
+ throw new Error(
148
+ `Unable to get primary key information for component ${componentType}. Please explicitly specify idFields in QueryOptions or ensure component discoverer is properly configured.`
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Get fields to use for queries (priority: user specified > dubhe config auto-parsed)
154
+ */
155
+ private async getQueryFields(
156
+ componentType: ComponentType,
157
+ userFields?: string[]
158
+ ): Promise<string[]> {
159
+ if (userFields && userFields.length > 0) {
160
+ return userFields;
161
+ }
162
+
163
+ // Use dubhe config auto-parsed fields, will throw error if failed requiring explicit specification
164
+ return this.getComponentFields(componentType);
165
+ }
166
+
167
+ /**
168
+ * Check if entity exists
169
+ */
170
+ async hasEntity(entityId: EntityId): Promise<boolean> {
171
+ if (!isValidEntityId(entityId)) return false;
172
+
173
+ try {
174
+ // Check entity existence by querying any possible component tables
175
+ // This can be optimized to query a dedicated entity table
176
+ const tables = await this.getAvailableComponents();
177
+ for (const table of tables) {
178
+ // for (const table of tables.slice(0, 3)) {
179
+ // Only check first 3 tables to avoid too many queries
180
+ try {
181
+ const condition = this.buildEntityCondition(table, entityId);
182
+ const component = await this.graphqlClient.getTableByCondition(table, condition);
183
+ if (component) return true;
184
+ } catch (_error) {
185
+ // If query fails for a table, continue checking next table
186
+ }
187
+ }
188
+
189
+ return false;
190
+ } catch (_error) {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Get all entity IDs (collected from all component tables)
197
+ */
198
+ async getAllEntities(): Promise<EntityId[]> {
199
+ try {
200
+ const tables = await this.getAvailableComponents();
201
+
202
+ // Query all tables in parallel using cached field information
203
+ const queries = await Promise.all(
204
+ tables.map(async (table) => {
205
+ const fields = await this.getQueryFields(table);
206
+ const primaryKey = this.componentPrimaryKeys.get(table) || 'entityId';
207
+
208
+ return {
209
+ key: table,
210
+ tableName: table,
211
+ params: {
212
+ fields: fields,
213
+ filter: {}
214
+ },
215
+ primaryKey // Use cached primary key information
216
+ };
217
+ })
218
+ );
219
+
220
+ const batchResult = await this.graphqlClient.batchQuery(
221
+ queries.map((q) => ({
222
+ key: q.key,
223
+ tableName: q.tableName,
224
+ params: q.params
225
+ }))
226
+ );
227
+
228
+ // Extract entity IDs using cached primary key fields
229
+ return extractUnionFromBatchResult(batchResult, tables, {
230
+ idFields: undefined, // Let extractEntityIds auto-infer
231
+ composite: false
232
+ });
233
+ } catch (_error) {
234
+ return [];
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Get entity count
240
+ */
241
+ async getEntityCount(): Promise<number> {
242
+ const entities = await this.getAllEntities();
243
+ return entities.length;
244
+ }
245
+
246
+ /**
247
+ * Check if entity has specific component
248
+ */
249
+ async hasComponent(entityId: EntityId, componentType: ComponentType): Promise<boolean> {
250
+ if (!isValidEntityId(entityId) || !isValidComponentType(componentType)) {
251
+ return false;
252
+ }
253
+
254
+ // Validate if it's an ECS-compliant component
255
+ if (!this.isECSComponent(componentType)) {
256
+ return false;
257
+ }
258
+
259
+ try {
260
+ const condition = this.buildEntityCondition(componentType, entityId);
261
+ const component = await this.graphqlClient.getTableByCondition(componentType, condition);
262
+ return component !== null;
263
+ } catch (_error) {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Get specific component data of entity
270
+ */
271
+ async getComponent<T>(entityId: EntityId, componentType: ComponentType): Promise<T | null> {
272
+ if (!isValidEntityId(entityId) || !isValidComponentType(componentType)) {
273
+ return null;
274
+ }
275
+
276
+ // Validate if it's an ECS-compliant component
277
+ if (!this.isECSComponent(componentType)) {
278
+ return null;
279
+ }
280
+
281
+ try {
282
+ const condition = this.buildEntityCondition(componentType, entityId);
283
+ const component = await this.graphqlClient.getTableByCondition(componentType, condition);
284
+ return component as T;
285
+ } catch (_error) {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Get all component types that entity has
292
+ */
293
+ async getComponents(entityId: EntityId): Promise<ComponentType[]> {
294
+ if (!isValidEntityId(entityId)) return [];
295
+
296
+ try {
297
+ const tables = await this.getAvailableComponents();
298
+ const componentTypes: ComponentType[] = [];
299
+
300
+ // Check if entity exists in each table
301
+ await Promise.all(
302
+ tables.map(async (table) => {
303
+ const hasComp = await this.hasComponent(entityId, table);
304
+ if (hasComp) {
305
+ componentTypes.push(table);
306
+ }
307
+ })
308
+ );
309
+
310
+ return componentTypes;
311
+ } catch (_error) {
312
+ return [];
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Validate if component type is ECS-compliant
318
+ */
319
+ private isECSComponent(componentType: ComponentType): boolean {
320
+ return this.availableComponents.includes(componentType);
321
+ }
322
+
323
+ /**
324
+ * Build entity query condition (using cached primary key field name)
325
+ */
326
+ private buildEntityCondition(
327
+ componentType: ComponentType,
328
+ entityId: EntityId
329
+ ): Record<string, any> {
330
+ // Get primary key field name from cache
331
+ const primaryKeyField = this.componentPrimaryKeys.get(componentType);
332
+ if (primaryKeyField) {
333
+ return { [primaryKeyField]: entityId };
334
+ } else {
335
+ // If not in cache, fallback to default 'entityId' field
336
+ return { entityId: entityId };
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Filter and validate component type list, keeping only ECS-compliant components
342
+ */
343
+ private filterValidECSComponents(componentTypes: ComponentType[]): ComponentType[] {
344
+ const validComponents = componentTypes.filter((componentType) => {
345
+ if (!isValidComponentType(componentType)) {
346
+ return false;
347
+ }
348
+
349
+ if (!this.isECSComponent(componentType)) {
350
+ return false;
351
+ }
352
+
353
+ return true;
354
+ });
355
+
356
+ return validComponents;
357
+ }
358
+
359
+ /**
360
+ * Query all entities that have a specific component with full pagination info
361
+ */
362
+ async queryWithFullPagination<T = any>(
363
+ componentType: ComponentType,
364
+ options?: QueryOptions
365
+ ): Promise<PagedQueryResult<T>> {
366
+ const emptyResult: PagedQueryResult<T> = {
367
+ entityIds: [],
368
+ items: [],
369
+ pageInfo: {
370
+ hasNextPage: false,
371
+ hasPreviousPage: false
372
+ },
373
+ totalCount: 0
374
+ };
375
+
376
+ if (!isValidComponentType(componentType)) return emptyResult;
377
+
378
+ // Validate if it's an ECS-compliant component
379
+ if (!this.isECSComponent(componentType)) {
380
+ return emptyResult;
381
+ }
382
+
383
+ try {
384
+ // Intelligently get query fields and primary key information
385
+ const queryFields = await this.getQueryFields(componentType, options?.fields);
386
+ const primaryKeys = await this.getComponentPrimaryKeys(componentType);
387
+
388
+ const paginationParams = this.buildPaginationParams(options);
389
+ const connection = await this.graphqlClient.getAllTables(componentType, {
390
+ ...paginationParams,
391
+ fields: queryFields,
392
+ orderBy: options?.orderBy
393
+ });
394
+
395
+ return extractPagedQueryResult(connection, {
396
+ idFields: options?.idFields || primaryKeys,
397
+ composite: options?.compositeId
398
+ }) as PagedQueryResult<T>;
399
+ } catch (_error) {
400
+ return emptyResult;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Query all entities that have a specific component
406
+ * Returns complete pagination information including entity IDs, actual data, and page info
407
+ */
408
+ async queryWith<T = any>(
409
+ componentType: ComponentType,
410
+ options?: QueryOptions
411
+ ): Promise<PagedQueryResult<T>> {
412
+ return this.queryWithFullPagination(componentType, options);
413
+ }
414
+
415
+ /**
416
+ * Query entities that have all specified components (intersection)
417
+ */
418
+ async queryWithAll(componentTypes: ComponentType[], options?: QueryOptions): Promise<EntityId[]> {
419
+ if (componentTypes.length === 0) return [];
420
+ if (componentTypes.length === 1) {
421
+ const result = await this.queryWith(componentTypes[0], options);
422
+ return result.entityIds;
423
+ }
424
+
425
+ const validTypes = this.filterValidECSComponents(componentTypes);
426
+ if (validTypes.length === 0) return [];
427
+
428
+ const cacheKey = createCacheKey('queryWithAll', validTypes, options);
429
+ const cached = this.getCachedResult(cacheKey);
430
+ if (cached && options?.cache !== false) return cached;
431
+
432
+ try {
433
+ // Batch query all component tables using intelligent field resolution
434
+ const paginationParams = this.buildPaginationParams(options);
435
+ const queries = await Promise.all(
436
+ validTypes.map(async (type) => {
437
+ const queryFields = await this.getQueryFields(type, options?.fields);
438
+ return {
439
+ key: type,
440
+ tableName: type,
441
+ params: {
442
+ fields: queryFields,
443
+ ...paginationParams,
444
+ orderBy: options?.orderBy
445
+ }
446
+ };
447
+ })
448
+ );
449
+
450
+ const batchResult = await this.graphqlClient.batchQuery(queries);
451
+
452
+ // If user didn't specify idFields, try using first component's primary key
453
+ let idFields = options?.idFields;
454
+ if (!idFields && validTypes.length > 0) {
455
+ try {
456
+ idFields = await this.getComponentPrimaryKeys(validTypes[0]);
457
+ } catch (_error) {
458
+ // If unable to get primary key, keep idFields undefined, let extractEntityIds auto-infer
459
+ }
460
+ }
461
+
462
+ const result = extractIntersectionFromBatchResult(batchResult, validTypes, {
463
+ idFields,
464
+ composite: options?.compositeId
465
+ });
466
+
467
+ this.setCachedResult(cacheKey, result);
468
+ return result;
469
+ } catch (_error) {
470
+ return [];
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Query entities that have any of the specified components (union)
476
+ */
477
+ async queryWithAny(componentTypes: ComponentType[], options?: QueryOptions): Promise<EntityId[]> {
478
+ if (componentTypes.length === 0) return [];
479
+ if (componentTypes.length === 1) {
480
+ const result = await this.queryWith(componentTypes[0], options);
481
+ return result.entityIds;
482
+ }
483
+
484
+ const validTypes = this.filterValidECSComponents(componentTypes);
485
+ if (validTypes.length === 0) return [];
486
+
487
+ const cacheKey = createCacheKey('queryWithAny', validTypes, options);
488
+ const cached = this.getCachedResult(cacheKey);
489
+ if (cached && options?.cache !== false) return cached;
490
+
491
+ try {
492
+ // Batch query all component tables using intelligent field resolution
493
+ const paginationParams = this.buildPaginationParams(options);
494
+ const queries = await Promise.all(
495
+ validTypes.map(async (type) => {
496
+ const queryFields = await this.getQueryFields(type, options?.fields);
497
+ return {
498
+ key: type,
499
+ tableName: type,
500
+ params: {
501
+ fields: queryFields,
502
+ ...paginationParams,
503
+ orderBy: options?.orderBy
504
+ }
505
+ };
506
+ })
507
+ );
508
+
509
+ const batchResult = await this.graphqlClient.batchQuery(queries);
510
+
511
+ // If user didn't specify idFields, try using first component's primary key
512
+ let idFields = options?.idFields;
513
+ if (!idFields && validTypes.length > 0) {
514
+ try {
515
+ idFields = await this.getComponentPrimaryKeys(validTypes[0]);
516
+ } catch (_error) {
517
+ // If unable to get primary key, keep idFields undefined, let extractEntityIds auto-infer
518
+ }
519
+ }
520
+
521
+ const result = extractUnionFromBatchResult(batchResult, validTypes, {
522
+ idFields,
523
+ composite: options?.compositeId
524
+ });
525
+
526
+ this.setCachedResult(cacheKey, result);
527
+ return result;
528
+ } catch (_error) {
529
+ return [];
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Query entities that have include components but not exclude components
535
+ */
536
+ async queryWithout(
537
+ includeTypes: ComponentType[],
538
+ excludeTypes: ComponentType[],
539
+ options?: QueryOptions
540
+ ): Promise<EntityId[]> {
541
+ if (includeTypes.length === 0) return [];
542
+
543
+ // Validate include types are all ECS-compliant components
544
+ const validIncludeTypes = this.filterValidECSComponents(includeTypes);
545
+ if (validIncludeTypes.length === 0) return [];
546
+
547
+ // Validate exclude types are all ECS-compliant components
548
+ const validExcludeTypes = this.filterValidECSComponents(excludeTypes);
549
+
550
+ try {
551
+ // First get entities that have all include components
552
+ const includedEntities = await this.queryWithAll(validIncludeTypes, options);
553
+
554
+ if (validExcludeTypes.length === 0) return includedEntities;
555
+
556
+ // Get entities that have any exclude components
557
+ const excludedEntities = await this.queryWithAny(validExcludeTypes);
558
+ const excludedSet = new Set(excludedEntities);
559
+
560
+ // Remove excluded entities from included entities
561
+ return includedEntities.filter((entityId) => !excludedSet.has(entityId));
562
+ } catch (_error) {
563
+ return [];
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Query components based on conditions with full pagination info
569
+ */
570
+ async queryWhereFullPagination<T = any>(
571
+ componentType: ComponentType,
572
+ predicate: Record<string, any>,
573
+ options?: QueryOptions
574
+ ): Promise<PagedQueryResult<T>> {
575
+ const emptyResult: PagedQueryResult<T> = {
576
+ entityIds: [],
577
+ items: [],
578
+ pageInfo: {
579
+ hasNextPage: false,
580
+ hasPreviousPage: false
581
+ },
582
+ totalCount: 0
583
+ };
584
+
585
+ if (!isValidComponentType(componentType)) return emptyResult;
586
+
587
+ // Validate if it's an ECS-compliant component
588
+ if (!this.isECSComponent(componentType)) {
589
+ return emptyResult;
590
+ }
591
+
592
+ try {
593
+ // Intelligently get query fields and primary key information
594
+ const queryFields = await this.getQueryFields(componentType, options?.fields);
595
+ const primaryKeys = await this.getComponentPrimaryKeys(componentType);
596
+
597
+ const paginationParams = this.buildPaginationParams(options);
598
+ const connection = await this.graphqlClient.getAllTables(componentType, {
599
+ filter: predicate,
600
+ ...paginationParams,
601
+ fields: queryFields,
602
+ orderBy: options?.orderBy
603
+ });
604
+
605
+ return extractPagedQueryResult(connection, {
606
+ idFields: options?.idFields || primaryKeys,
607
+ composite: options?.compositeId
608
+ }) as PagedQueryResult<T>;
609
+ } catch (_error) {
610
+ return emptyResult;
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Query components based on conditions
616
+ */
617
+ async queryWhere<_T = any>(
618
+ componentType: ComponentType,
619
+ predicate: Record<string, any>,
620
+ options?: QueryOptions
621
+ ): Promise<EntityId[]> {
622
+ const result = await this.queryWhereFullPagination(componentType, predicate, options);
623
+ return result.entityIds;
624
+ }
625
+
626
+ /**
627
+ * Range query
628
+ */
629
+ async queryRange(
630
+ componentType: ComponentType,
631
+ field: string,
632
+ min: any,
633
+ max: any,
634
+ options?: QueryOptions
635
+ ): Promise<EntityId[]> {
636
+ if (!isValidComponentType(componentType)) return [];
637
+
638
+ // Validate if it's an ECS-compliant component
639
+ if (!this.isECSComponent(componentType)) {
640
+ return [];
641
+ }
642
+
643
+ const predicate = {
644
+ [field]: {
645
+ greaterThanOrEqualTo: min,
646
+ lessThanOrEqualTo: max
647
+ }
648
+ };
649
+
650
+ return this.queryWhere(componentType, predicate, options);
651
+ }
652
+
653
+ /**
654
+ * Paginated query
655
+ */
656
+ async queryPaged(
657
+ componentTypes: ComponentType[],
658
+ page: number,
659
+ pageSize: number
660
+ ): Promise<PagedResult<EntityId>> {
661
+ try {
662
+ const allResults =
663
+ componentTypes.length === 1
664
+ ? (await this.queryWith(componentTypes[0])).entityIds
665
+ : await this.queryWithAll(componentTypes);
666
+
667
+ return paginateArray(allResults, page, pageSize);
668
+ } catch (_error) {
669
+ return {
670
+ items: [],
671
+ totalCount: 0,
672
+ hasMore: false,
673
+ page,
674
+ pageSize
675
+ };
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Create query builder
681
+ */
682
+ query(): ECSQueryBuilder {
683
+ return new ECSQueryBuilder(this);
684
+ }
685
+
686
+ /**
687
+ * Get cached result
688
+ */
689
+ private getCachedResult(cacheKey: string): EntityId[] | null {
690
+ const cached = this.queryCache.get(cacheKey);
691
+ if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
692
+ return cached.result;
693
+ }
694
+ return null;
695
+ }
696
+
697
+ /**
698
+ * Set cached result
699
+ */
700
+ private setCachedResult(cacheKey: string, result: EntityId[]): void {
701
+ this.queryCache.set(cacheKey, {
702
+ result,
703
+ timestamp: Date.now()
704
+ });
705
+ }
706
+
707
+ /**
708
+ * Clean expired cache
709
+ */
710
+ private cleanExpiredCache(): void {
711
+ const now = Date.now();
712
+ for (const [key, cached] of this.queryCache.entries()) {
713
+ if (now - cached.timestamp >= this.cacheTimeout) {
714
+ this.queryCache.delete(key);
715
+ }
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Get available component list
721
+ */
722
+ private async getAvailableComponents(): Promise<string[]> {
723
+ if (this.availableComponents.length > 0) {
724
+ return this.availableComponents;
725
+ }
726
+
727
+ // Return empty array by default, set by component discovery system
728
+ return [];
729
+ }
730
+
731
+ /**
732
+ * Dispose resources
733
+ */
734
+ dispose(): void {
735
+ this.queryCache.clear();
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Query builder implementation
741
+ */
742
+ export class ECSQueryBuilder {
743
+ private ecsQuery: ECSQuery;
744
+ private includeTypes: ComponentType[] = [];
745
+ private excludeTypes: ComponentType[] = [];
746
+ private whereConditions: Array<{
747
+ componentType: ComponentType;
748
+ predicate: Record<string, any>;
749
+ }> = [];
750
+ private orderByOptions: Array<{
751
+ componentType: ComponentType;
752
+ field: string;
753
+ direction: 'ASC' | 'DESC';
754
+ }> = [];
755
+ private limitValue?: number;
756
+ private offsetValue?: number;
757
+
758
+ constructor(ecsQuery: ECSQuery) {
759
+ this.ecsQuery = ecsQuery;
760
+ }
761
+
762
+ with(...componentTypes: ComponentType[]): ECSQueryBuilder {
763
+ this.includeTypes.push(...componentTypes);
764
+ return this;
765
+ }
766
+
767
+ without(...componentTypes: ComponentType[]): ECSQueryBuilder {
768
+ this.excludeTypes.push(...componentTypes);
769
+ return this;
770
+ }
771
+
772
+ where<_T = any>(componentType: ComponentType, predicate: Record<string, any>): ECSQueryBuilder {
773
+ this.whereConditions.push({ componentType, predicate });
774
+ return this;
775
+ }
776
+
777
+ orderBy(
778
+ componentType: ComponentType,
779
+ field: string,
780
+ direction: 'ASC' | 'DESC' = 'ASC'
781
+ ): ECSQueryBuilder {
782
+ this.orderByOptions.push({ componentType, field, direction });
783
+ return this;
784
+ }
785
+
786
+ limit(count: number): ECSQueryBuilder {
787
+ this.limitValue = count;
788
+ return this;
789
+ }
790
+
791
+ offset(count: number): ECSQueryBuilder {
792
+ this.offsetValue = count;
793
+ return this;
794
+ }
795
+
796
+ async execute(): Promise<EntityId[]> {
797
+ try {
798
+ const options: QueryOptions = {
799
+ limit: this.limitValue,
800
+ offset: this.offsetValue,
801
+ orderBy: this.orderByOptions.map((order) => ({
802
+ field: order.field,
803
+ direction: order.direction
804
+ }))
805
+ };
806
+
807
+ // If there are where conditions, handle filtering first
808
+ if (this.whereConditions.length > 0) {
809
+ const filteredResults: EntityId[][] = [];
810
+
811
+ for (const condition of this.whereConditions) {
812
+ const result = await this.ecsQuery.queryWhere(
813
+ condition.componentType,
814
+ condition.predicate,
815
+ options
816
+ );
817
+ filteredResults.push(result);
818
+ }
819
+
820
+ // Find intersection
821
+ const intersection = filteredResults.reduce((acc, current) => {
822
+ const currentSet = new Set(current);
823
+ return acc.filter((id) => currentSet.has(id));
824
+ });
825
+
826
+ return intersection;
827
+ }
828
+
829
+ // Handle basic include/exclude queries
830
+ if (this.excludeTypes.length > 0) {
831
+ return this.ecsQuery.queryWithout(this.includeTypes, this.excludeTypes, options);
832
+ } else {
833
+ return this.ecsQuery.queryWithAll(this.includeTypes, options);
834
+ }
835
+ } catch (_error) {
836
+ return [];
837
+ }
838
+ }
839
+ }