@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/LICENSE +92 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2413 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2353 -0
- package/dist/index.mjs.map +1 -0
- package/dist/query.d.ts +164 -0
- package/dist/subscription.d.ts +123 -0
- package/dist/types.d.ts +142 -0
- package/dist/utils.d.ts +103 -0
- package/dist/world.d.ts +306 -0
- package/package.json +99 -0
- package/src/index.ts +60 -0
- package/src/query.ts +839 -0
- package/src/subscription.ts +848 -0
- package/src/types.ts +209 -0
- package/src/utils.ts +309 -0
- package/src/world.ts +1282 -0
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
|
+
}
|