@dav3/gql-of-power 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -0
- package/dist/entities/gql-entity.d.ts +2 -0
- package/dist/entities/gql-entity.js +55 -18
- package/dist/queries/enum-filter-converter.d.ts +29 -0
- package/dist/queries/enum-filter-converter.js +155 -0
- package/dist/queries/gql-to-sql-mapper.js +22 -2
- package/dist/query-manager.d.ts +2 -5
- package/dist/query-manager.js +5 -105
- package/package.json +1 -1
- package/src/entities/gql-entity.ts +60 -14
- package/src/queries/enum-filter-converter.ts +179 -0
- package/src/queries/gql-to-sql-mapper.ts +36 -2
- package/src/query-manager.ts +13 -121
package/README.md
CHANGED
|
@@ -293,6 +293,68 @@ Returns `null` when the FK column is null (LEFT JOIN).
|
|
|
293
293
|
|
|
294
294
|
---
|
|
295
295
|
|
|
296
|
+
## Three Ways to Define an Entity
|
|
297
|
+
|
|
298
|
+
There are three APIs for registering a GQL entity. They differ in **when** the generated types (FilterInput, PaginationInput, FieldsResolver, etc.) are registered — that difference matters for filter-type resolution (see [Troubleshooting](#troubleshooting)).
|
|
299
|
+
|
|
300
|
+
### Quick comparison
|
|
301
|
+
|
|
302
|
+
| API | When to use | Calls `buildResolvers()`? | FilterInput registered |
|
|
303
|
+
| ------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------ |
|
|
304
|
+
| `@GQLEntityClass` | **Default.** No circular imports between entity files. | ✅ Automatically (at decoration time) | ✅ Yes |
|
|
305
|
+
| `createGQLTypes()` | Same as `@GQLEntityClass` but as a function call (can't decorate a class, or prefer the explicit style). | ✅ Automatically (internally) | ✅ Yes |
|
|
306
|
+
| `createGQLEntity()` | **Circular imports** between entity definitions — you need to defer resolver registration. | ❌ **Deferred** — you must call `.buildResolvers()` yourself | ❌ No, until you call it |
|
|
307
|
+
|
|
308
|
+
### `@GQLEntityClass` — the default (recommended)
|
|
309
|
+
|
|
310
|
+
The decorator registers the entity **and** its FilterInput/FieldsResolver immediately at decoration time. All generated statics (`FilterInput`, `PaginationInput`, `FieldsResolver`) are available on the class.
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
@GQLEntityClass(Book, bookFields)
|
|
314
|
+
export class BookGQL extends GQLEntityBase {}
|
|
315
|
+
// BookGQL.FilterInput is ready. Done.
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### `createGQLTypes()` — function-call equivalent
|
|
319
|
+
|
|
320
|
+
Same behaviour as the decorator — it calls `createGQLEntity().buildResolvers()` internally (see `gql-entity.ts:1221-1222`). Use it when you can't or don't want to decorate a class.
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
export const BookGQL = createGQLTypes(Book, bookFields);
|
|
324
|
+
// BookGQL.FilterInput is ready. Done.
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### `createGQLEntity()` — for circular imports (advanced)
|
|
328
|
+
|
|
329
|
+
Returns the entity definition **without** registering the FilterInput or FieldsResolver. You must call `.buildResolvers()` yourself before `buildSchema()` runs. This split exists so two entity files can reference each other's `GQLEntity` type thunks without a circular-import deadlock at module-load time.
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
// book.entity.ts
|
|
333
|
+
export const BookEntity = createGQLEntity(Book, bookFields);
|
|
334
|
+
// ↑ FilterInput NOT registered yet
|
|
335
|
+
|
|
336
|
+
// author.entity.ts — can import BookEntity for the thunk even if Book imports Author
|
|
337
|
+
export const AuthorEntity = createGQLEntity(Author, authorFields, {
|
|
338
|
+
customFields: {/* ... uses () => BookEntity.GQLEntity ... */},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// schema/index.ts — YOU must call buildResolvers() on EVERY entity
|
|
342
|
+
export const BookGQL = BookEntity.buildResolvers(); // ← registers BookFilterInput
|
|
343
|
+
export const AuthorGQL = AuthorEntity.buildResolvers(); // ← registers AuthorFilterInput
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
> ⚠️ **If you forget a `.buildResolvers()` call**, any filter that references that entity (relationship fields, mapping custom-field filters, `_exists` filters) will throw at schema-build time:
|
|
347
|
+
>
|
|
348
|
+
> ```
|
|
349
|
+
> gql-of-power: FilterInput for referenced entity "Book" (BookFilterInput) is not registered.
|
|
350
|
+
> Make sure Book's buildResolvers() is called (or use createGQLTypes / @GQLEntityClass)
|
|
351
|
+
> before building the schema.
|
|
352
|
+
> ```
|
|
353
|
+
>
|
|
354
|
+
> The error names the missing entity so you know exactly which file to fix.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
296
358
|
## Filtering
|
|
297
359
|
|
|
298
360
|
Filter at any nesting level:
|
|
@@ -560,6 +622,88 @@ No `limit` or `offset` parameters are accepted — `LIMIT 1` is always applied.
|
|
|
560
622
|
|
|
561
623
|
---
|
|
562
624
|
|
|
625
|
+
## Troubleshooting
|
|
626
|
+
|
|
627
|
+
### `FilterInput for referenced entity "X" is not registered`
|
|
628
|
+
|
|
629
|
+
This error fires at `buildSchema()` time when a relationship field, mapping custom-field filter, or `_exists` filter references an entity whose FilterInput was never registered.
|
|
630
|
+
|
|
631
|
+
**Cause:** You used `createGQLEntity()` (the deferred API) for entity `X` but forgot to call `X.buildResolvers()` before building the schema. The entity's ObjectType is registered, but its FilterInput is not.
|
|
632
|
+
|
|
633
|
+
**Fix:** Either switch the entity to `@GQLEntityClass` / `createGQLTypes()` (which call `buildResolvers()` automatically), or add the missing call:
|
|
634
|
+
|
|
635
|
+
```typescript
|
|
636
|
+
// schema/index.ts — collect ALL entities and call buildResolvers()
|
|
637
|
+
const BookGQL = BookEntity.buildResolvers();
|
|
638
|
+
const AuthorGQL = AuthorEntity.buildResolvers();
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Before August 2026 this was a **silent wrong-type bug**: the missing FilterInput fell back to the parent entity's FilterInput, so `Author.books` would generate `AuthorFilterInput` instead of `BookFilterInput`. The library now throws with the message above.
|
|
642
|
+
|
|
643
|
+
### Mapping custom-field filter is missing from the schema
|
|
644
|
+
|
|
645
|
+
If a `customFields` entry with `mapping` doesn't produce its nested filter (e.g. `HomeRegion: { name_eq: '...' }` is absent), check whether `resolve` was **also** set on the same field:
|
|
646
|
+
|
|
647
|
+
```typescript
|
|
648
|
+
customFields: {
|
|
649
|
+
homeRegion: {
|
|
650
|
+
type: () => RegionGQL.GQLEntity,
|
|
651
|
+
mapping: { refEntity: Region, refFields: 'id', fields: 'homeRegionId' },
|
|
652
|
+
generateFilter: true,
|
|
653
|
+
resolve: (root) => ..., // ← THIS makes the filter silently disappear
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
`resolve` and `mapping` are **mutually exclusive** on the same custom field. When both are set, only the resolver registers — the mapping filter is skipped. TypeScript's `CustomFieldSettings<T>` union enforces this at the type level, but `as any` bypasses it. Remove `resolve` (and `resolveDecorators`/`requires`) from a mapping-strategy field.
|
|
659
|
+
|
|
660
|
+
### Filter key is PascalCase, not camelCase
|
|
661
|
+
|
|
662
|
+
Mapping custom-field filters appear in the FilterInput under a **capitalized** key:
|
|
663
|
+
|
|
664
|
+
```graphql
|
|
665
|
+
# ✅ Correct — PascalCase filter key
|
|
666
|
+
persons(filter: { HomeRegion: { name_eq: 'Gondor' } }) { ... }
|
|
667
|
+
|
|
668
|
+
# ❌ Wrong — camelCase doesn't exist on the FilterInput
|
|
669
|
+
persons(filter: { homeRegion: { name_eq: 'Gondor' } })
|
|
670
|
+
# Field "homeRegion" is not defined by type "PersonFilterInput". Did you mean "HomeRegion"?
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
The field **selection** in the query stays camelCase (`homeRegion { ... }`); only the filter key is capitalized. Count-field nested filters follow the same rule (`BookCount: { _gte: 2 }`).
|
|
674
|
+
|
|
675
|
+
### Relationship-field filters aren't statically typed
|
|
676
|
+
|
|
677
|
+
`GQLEntityFilterInputFieldType<T>` surfaces filter operators for scalar fields but does **not** statically include nested relationship sub-filters (they're generated at runtime). Cast the filter object `as any` when using relationship filters:
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
queryManager.getQueryResultsForInfo(provider, PersonGQL, info, {
|
|
681
|
+
fellowship: { id_in: [1, 2] }, // works at runtime, not statically typed
|
|
682
|
+
} as any);
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
This is the library's own test-suite pattern.
|
|
686
|
+
|
|
687
|
+
### `mapNumericEnum` filter values silently fail inside nested/inline filters
|
|
688
|
+
|
|
689
|
+
If a `mapNumericEnum` field filters correctly at the **top level** but silently returns no results (or the wrong rows) when placed inside an inline field-argument filter on a nested relation — e.g.:
|
|
690
|
+
|
|
691
|
+
```graphql
|
|
692
|
+
# Works: top-level filter → 'InProgress' converted to 1
|
|
693
|
+
bearers(filter: { questState: InProgress }) { name }
|
|
694
|
+
|
|
695
|
+
# Silently failed before August 2026: inline filter → 'InProgress' passed as a string
|
|
696
|
+
rings { bearer(filter: { questState: InProgress }) { name } }
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
**Cause:** enum conversion (`convertFilterEnumValues`) was historically applied only to top-level filters in `GQLQueryManager`. Inline field-argument filters (`books(filter: {...})`), count-field filters (`bookCount(filter: {...})`), and multi-level nested filters entered the SQL mapper through a different code path that bypassed conversion entirely. The `mapNumericEnum` string key went straight to SQL as a string instead of being converted to the numeric DB value, so the `WHERE` clause silently matched nothing.
|
|
700
|
+
|
|
701
|
+
**Fix (August 2026):** `handleFieldArguments` and `mapCountField` in the SQL mapper now call `convertFilterEnumValues` on inline filter args before SQL generation. The conversion resolves the target entity's `mapNumericEnum` fields through the parent entity's relation-field registry and recurses to arbitrary depth. This means filters now behave identically at the top level, inside `relation(filter:)`, inside nested `relation(filter: { nested: {...} })`, inside count subqueries, and inside `_or`/`_and` arrays at any level.
|
|
702
|
+
|
|
703
|
+
**If you still see this pattern:** ensure the target entity is decorator-registered (`@GQLEntityClass` / `createGQLTypes`) so its `mapNumericEnum` fields appear in the `MapEnumFieldsMap` registry — conversion silently passes values through for unregistered entities.
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
563
707
|
## Known Limitations
|
|
564
708
|
|
|
565
709
|
- ⚠️ Class-level `_not` conditions not yet supported
|
|
@@ -29,6 +29,8 @@ export declare const clearMapEnumFields: () => void;
|
|
|
29
29
|
export declare const getParseJsonFieldsFor: (name: string) => Set<string>;
|
|
30
30
|
export declare const registerParseJsonField: (gqlEntityName: string, fieldName: string) => void;
|
|
31
31
|
export declare const clearParseJsonFields: () => void;
|
|
32
|
+
export declare const getRelationFieldsFor: (name: string) => Record<string, () => string>;
|
|
33
|
+
export declare const clearRelationFields: () => void;
|
|
32
34
|
export declare const getACLFor: (name: string) => AccessControlEntry<any, any>;
|
|
33
35
|
export declare const getGQLEntityNameFor: (name: string) => string;
|
|
34
36
|
export declare const getGQLEntityNameForClass: <T>(classType: new () => T) => string;
|
|
@@ -6,7 +6,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
6
6
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
7
|
};
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.createGQLEntityFields = exports.GQLEntityBase = exports.getGQLEntityTypeFor = exports.getGQLEntityFieldResolverNameFor = exports.getGQLEntityFieldResolverName = exports.getGQLEntityNameForClass = exports.getGQLEntityNameFor = exports.getACLFor = exports.clearParseJsonFields = exports.registerParseJsonField = exports.getParseJsonFieldsFor = exports.clearMapEnumFields = exports.getMapEnumFieldsFor = exports.clearCountFields = exports.registerCountField = exports.getCountFieldsFor = exports.getCustomFieldsFor = exports.getFieldByAlias = exports.getFieldsOptionsFor = exports.setGlobalConfig = void 0;
|
|
9
|
+
exports.createGQLEntityFields = exports.GQLEntityBase = exports.getGQLEntityTypeFor = exports.getGQLEntityFieldResolverNameFor = exports.getGQLEntityFieldResolverName = exports.getGQLEntityNameForClass = exports.getGQLEntityNameFor = exports.getACLFor = exports.clearRelationFields = exports.getRelationFieldsFor = exports.clearParseJsonFields = exports.registerParseJsonField = exports.getParseJsonFieldsFor = exports.clearMapEnumFields = exports.getMapEnumFieldsFor = exports.clearCountFields = exports.registerCountField = exports.getCountFieldsFor = exports.getCustomFieldsFor = exports.getFieldByAlias = exports.getFieldsOptionsFor = exports.setGlobalConfig = void 0;
|
|
10
10
|
exports.getAutoResolvers = getAutoResolvers;
|
|
11
11
|
exports.defineFields = defineFields;
|
|
12
12
|
exports.GQLEntityClass = GQLEntityClass;
|
|
@@ -24,6 +24,14 @@ const CustomFieldsMap = {};
|
|
|
24
24
|
const CountFieldsMap = {};
|
|
25
25
|
const MapEnumFieldsMap = {};
|
|
26
26
|
const ParseJsonFieldsMap = {};
|
|
27
|
+
/**
|
|
28
|
+
* Relation fields declared via the plain `defineFields` pattern (not via
|
|
29
|
+
* `customFields`/`mapping`). Keyed by gqlEntityName → field name → target
|
|
30
|
+
* entity's ORM name (the raw string returned by `relatedEntityName()`). Used by
|
|
31
|
+
* `convertFilterEnumValues` to recurse into nested relation filters and apply
|
|
32
|
+
* the target entity's mapNumericEnum conversions.
|
|
33
|
+
*/
|
|
34
|
+
const RelationFieldsMap = {};
|
|
27
35
|
const aclMap = {};
|
|
28
36
|
/**
|
|
29
37
|
* Shared field-metadata registration used by both the @GQLEntityClass decorator
|
|
@@ -167,6 +175,14 @@ const clearParseJsonFields = () => {
|
|
|
167
175
|
}
|
|
168
176
|
};
|
|
169
177
|
exports.clearParseJsonFields = clearParseJsonFields;
|
|
178
|
+
const getRelationFieldsFor = (name) => RelationFieldsMap[name] ?? {};
|
|
179
|
+
exports.getRelationFieldsFor = getRelationFieldsFor;
|
|
180
|
+
const clearRelationFields = () => {
|
|
181
|
+
for (const key of Object.keys(RelationFieldsMap)) {
|
|
182
|
+
delete RelationFieldsMap[key];
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
exports.clearRelationFields = clearRelationFields;
|
|
170
186
|
const getACLFor = (name) => aclMap[name] ?? {};
|
|
171
187
|
exports.getACLFor = getACLFor;
|
|
172
188
|
const getGQLEntityNameFor = (name) => `${name}${gqlTypesSuffix || process.env['D3GOP_TYPES_SUFFIX'] || ''}`;
|
|
@@ -436,7 +452,15 @@ function _buildInputType(gqlEntityName, fields, opts, metadata, customFields) {
|
|
|
436
452
|
return GQLEntityInput;
|
|
437
453
|
}
|
|
438
454
|
// ─── Shared resolver builder ─────────────────────────────────────────────────
|
|
439
|
-
/**
|
|
455
|
+
/**
|
|
456
|
+
* Phase 2: attach @FieldResolver methods for fields with `mapNumericEnum`.
|
|
457
|
+
*
|
|
458
|
+
* The resolver returns the raw DB value unchanged. graphql-js's
|
|
459
|
+
* `GraphQLEnumType.serialize()` performs the value→name conversion itself
|
|
460
|
+
* (e.g. 100 → "Forged") using the internal values registered by
|
|
461
|
+
* `registerEnumType`. Returning the string key here would cause
|
|
462
|
+
* "Enum cannot represent value" errors at serialization time.
|
|
463
|
+
*/
|
|
440
464
|
function _registerMapNumericEnumResolvers(rawFields, FieldsResolver) {
|
|
441
465
|
for (const fieldName of Object.keys(rawFields)) {
|
|
442
466
|
const fieldOpts = rawFields[fieldName];
|
|
@@ -448,20 +472,7 @@ function _registerMapNumericEnumResolvers(rawFields, FieldsResolver) {
|
|
|
448
472
|
const value = root[fieldNameToUse];
|
|
449
473
|
if (value === null || value === undefined)
|
|
450
474
|
return null;
|
|
451
|
-
|
|
452
|
-
const enumObj = enumTypeThunk();
|
|
453
|
-
const key = enumObj[value];
|
|
454
|
-
if (typeof key === 'string')
|
|
455
|
-
return key;
|
|
456
|
-
for (const enumKey of Object.keys(enumObj)) {
|
|
457
|
-
if (enumObj[enumKey] === value)
|
|
458
|
-
return enumKey;
|
|
459
|
-
}
|
|
460
|
-
return value;
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return value;
|
|
464
|
-
}
|
|
475
|
+
return value;
|
|
465
476
|
};
|
|
466
477
|
Object.defineProperty(FieldsResolver.prototype, fieldNameToUse, {
|
|
467
478
|
value: resolveFn,
|
|
@@ -597,7 +608,15 @@ function _registerMappingFieldFilter(fieldNameToUse, fieldOptions, GQLEntityFilt
|
|
|
597
608
|
target: GQLEntityFilterInput,
|
|
598
609
|
name: UppercasedFieldName,
|
|
599
610
|
schemaName: UppercasedFieldName,
|
|
600
|
-
getType: () =>
|
|
611
|
+
getType: () => {
|
|
612
|
+
const type = TypeMap[refFilterTypeName];
|
|
613
|
+
if (!type) {
|
|
614
|
+
throw new Error(`gql-of-power: FilterInput for referenced entity "${refGqlEntityName}" (${refFilterTypeName}) ` +
|
|
615
|
+
`is not registered. Make sure ${refGqlEntityName}'s buildResolvers() is called ` +
|
|
616
|
+
`(or use createGQLTypes / @GQLEntityClass) before building the schema.`);
|
|
617
|
+
}
|
|
618
|
+
return type;
|
|
619
|
+
},
|
|
601
620
|
typeOptions: { nullable: true },
|
|
602
621
|
complexity: undefined,
|
|
603
622
|
description: `Filter by ${fieldNameToUse} fields`,
|
|
@@ -788,7 +807,15 @@ function _registerExistsFilters(gqlEntityName, fields, opts, metadata, GQLEntity
|
|
|
788
807
|
target: GQLEntityExistsFilterInput,
|
|
789
808
|
name: relFieldName,
|
|
790
809
|
schemaName: relFieldName,
|
|
791
|
-
getType: () =>
|
|
810
|
+
getType: () => {
|
|
811
|
+
const type = TypeMap[relFilterTypeName];
|
|
812
|
+
if (!type) {
|
|
813
|
+
throw new Error(`gql-of-power: FilterInput for related entity "${relGQLEntityName}" (${relFilterTypeName}) ` +
|
|
814
|
+
`is not registered. Make sure ${relGQLEntityName}'s buildResolvers() is called ` +
|
|
815
|
+
`(or use createGQLTypes / @GQLEntityClass) before building the schema.`);
|
|
816
|
+
}
|
|
817
|
+
return type;
|
|
818
|
+
},
|
|
792
819
|
typeOptions: { nullable: true },
|
|
793
820
|
complexity: undefined,
|
|
794
821
|
description: `Filter ${relFieldName} by their fields for existence check`,
|
|
@@ -1003,6 +1030,16 @@ function createGQLEntityFilters(fieldOptions, fieldName, GQLEntity, metadata, GQ
|
|
|
1003
1030
|
deprecationReason: undefined,
|
|
1004
1031
|
};
|
|
1005
1032
|
metadata.collectClassFieldMetadata(fieldFilter);
|
|
1033
|
+
// Register relation fields for nested-filter enum conversion. The nested
|
|
1034
|
+
// FilterInput is exposed under both the camelCased fieldName (above) and the
|
|
1035
|
+
// UppercasedFieldName — record both so convertFilterEnumValues can resolve
|
|
1036
|
+
// whichever key the caller uses.
|
|
1037
|
+
if ('relatedEntityName' in fieldOptions && fieldOptions.relatedEntityName) {
|
|
1038
|
+
RelationFieldsMap[gqlEntityName] = RelationFieldsMap[gqlEntityName] || {};
|
|
1039
|
+
const relThunk = fieldOptions.relatedEntityName;
|
|
1040
|
+
RelationFieldsMap[gqlEntityName][fieldName] = relThunk;
|
|
1041
|
+
RelationFieldsMap[gqlEntityName][UppercasedFieldName] = relThunk;
|
|
1042
|
+
}
|
|
1006
1043
|
if ('array' in fieldOptions && 'relatedEntityName' in fieldOptions) {
|
|
1007
1044
|
const relatedEntityName = (0, exports.getGQLEntityNameFor)(fieldOptions.relatedEntityName());
|
|
1008
1045
|
metadata.collectHandlerParamMetadata({
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare function toDbValue(value: any, enumObj: any, isArray: boolean): any;
|
|
2
|
+
export declare function findEnumFieldName(key: string, enumFields: Record<string, any>): string | null;
|
|
3
|
+
/** Convert an operator-keyed object (e.g. { _eq: 'X', _in: [...] }) whose target is an enum field. */
|
|
4
|
+
export declare function convertOperatorObject(operatorObject: Record<string, any>, enumObj: any): Record<string, any>;
|
|
5
|
+
/** Recurse into a mapped custom-field nested filter using the referenced entity's enums. */
|
|
6
|
+
export declare function convertMappedCustomField(key: string, value: any, mappedCustomFields: Record<string, {
|
|
7
|
+
mapping: {
|
|
8
|
+
refEntity: new () => any;
|
|
9
|
+
};
|
|
10
|
+
}>): any;
|
|
11
|
+
/**
|
|
12
|
+
* Recurse into a plain-`defineFields` relation nested filter using the target
|
|
13
|
+
* entity's enums. The relation field is looked up in `RelationFieldsMap`
|
|
14
|
+
* (registered by `createGQLEntityFilters`), which stores the target entity's
|
|
15
|
+
* ORM name thunk. Falls back to returning the value untouched if the key isn't
|
|
16
|
+
* a known relation field.
|
|
17
|
+
*/
|
|
18
|
+
export declare function convertRelationField(key: string, value: any, relationFields: Record<string, () => string>): any;
|
|
19
|
+
export declare function convertFilterEnumValues(filter: any, enumFields: Record<string, any>, mappedCustomFields?: Record<string, {
|
|
20
|
+
mapping: {
|
|
21
|
+
refEntity: new () => any;
|
|
22
|
+
};
|
|
23
|
+
}>, relationFields?: Record<string, () => string>): any;
|
|
24
|
+
/** Resolve a single filter key/value to its enum-converted form. */
|
|
25
|
+
export declare function convertFilterEntry(key: string, value: any, enumFields: Record<string, any>, mappedCustomFields?: Record<string, {
|
|
26
|
+
mapping: {
|
|
27
|
+
refEntity: new () => any;
|
|
28
|
+
};
|
|
29
|
+
}>, relationFields?: Record<string, () => string>): any;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toDbValue = toDbValue;
|
|
4
|
+
exports.findEnumFieldName = findEnumFieldName;
|
|
5
|
+
exports.convertOperatorObject = convertOperatorObject;
|
|
6
|
+
exports.convertMappedCustomField = convertMappedCustomField;
|
|
7
|
+
exports.convertRelationField = convertRelationField;
|
|
8
|
+
exports.convertFilterEnumValues = convertFilterEnumValues;
|
|
9
|
+
exports.convertFilterEntry = convertFilterEntry;
|
|
10
|
+
/**
|
|
11
|
+
* Enum filter value conversion — shared module.
|
|
12
|
+
*
|
|
13
|
+
* Converts GraphQL enum string keys (e.g. `'Active'`) into their raw DB values
|
|
14
|
+
* (e.g. `0`) inside filter objects before they reach SQL generation.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `query-manager.ts` so that both `GQLQueryManager` (top-level
|
|
17
|
+
* filter path) and `GQLtoSQLMapper` (inline field-argument filter path) can
|
|
18
|
+
* import it without a circular dependency:
|
|
19
|
+
*
|
|
20
|
+
* gql-to-sql-mapper ← (would cycle) ← query-manager
|
|
21
|
+
* gql-to-sql-mapper ← enum-filter-converter → gql-entity
|
|
22
|
+
* query-manager ← enum-filter-converter → gql-entity
|
|
23
|
+
*/
|
|
24
|
+
const gql_entity_1 = require("../entities/gql-entity");
|
|
25
|
+
const ENUM_OPERATORS = [
|
|
26
|
+
'_eq',
|
|
27
|
+
'_ne',
|
|
28
|
+
'_gt',
|
|
29
|
+
'_gte',
|
|
30
|
+
'_lt',
|
|
31
|
+
'_lte',
|
|
32
|
+
'_like',
|
|
33
|
+
'_re',
|
|
34
|
+
'_ilike',
|
|
35
|
+
'_fulltext',
|
|
36
|
+
'_in',
|
|
37
|
+
'_nin',
|
|
38
|
+
'_between',
|
|
39
|
+
];
|
|
40
|
+
/** Keys whose (array) value should be recursively enum-converted element-by-element. */
|
|
41
|
+
const LOGICAL_KEYS = new Set(['_and', '_or', '_not']);
|
|
42
|
+
/** Keys that are always passed through verbatim (existence subquery shapes). */
|
|
43
|
+
const PASSTHROUGH_KEYS = new Set(['_exists', '_not_exists']);
|
|
44
|
+
function toDbValue(value, enumObj, isArray) {
|
|
45
|
+
if (value === null || value === undefined)
|
|
46
|
+
return value;
|
|
47
|
+
if (isArray && Array.isArray(value)) {
|
|
48
|
+
return value.map((v) => {
|
|
49
|
+
if (typeof v === 'string' && v in enumObj)
|
|
50
|
+
return enumObj[v];
|
|
51
|
+
return v;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === 'string' && value in enumObj)
|
|
55
|
+
return enumObj[value];
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function findEnumFieldName(key, enumFields) {
|
|
59
|
+
if (key in enumFields)
|
|
60
|
+
return key;
|
|
61
|
+
for (const op of ENUM_OPERATORS) {
|
|
62
|
+
if (key.endsWith(op)) {
|
|
63
|
+
const fieldName = key.slice(0, -op.length);
|
|
64
|
+
if (fieldName in enumFields)
|
|
65
|
+
return fieldName;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
69
|
+
if (lowercased in enumFields)
|
|
70
|
+
return lowercased;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/** Convert an operator-keyed object (e.g. { _eq: 'X', _in: [...] }) whose target is an enum field. */
|
|
74
|
+
function convertOperatorObject(operatorObject, enumObj) {
|
|
75
|
+
const converted = {};
|
|
76
|
+
for (const [op, opVal] of Object.entries(operatorObject)) {
|
|
77
|
+
const isArr = op === '_in' || op === '_nin';
|
|
78
|
+
converted[op] = toDbValue(opVal, enumObj, isArr);
|
|
79
|
+
}
|
|
80
|
+
return converted;
|
|
81
|
+
}
|
|
82
|
+
/** Recurse into a mapped custom-field nested filter using the referenced entity's enums. */
|
|
83
|
+
function convertMappedCustomField(key, value, mappedCustomFields) {
|
|
84
|
+
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
85
|
+
const mappedField = mappedCustomFields[key] ??
|
|
86
|
+
mappedCustomFields[lowercased];
|
|
87
|
+
if (!mappedField)
|
|
88
|
+
return value;
|
|
89
|
+
const refGqlEntityName = (0, gql_entity_1.getGQLEntityNameFor)(mappedField.mapping.refEntity.name);
|
|
90
|
+
const refEnumFields = (0, gql_entity_1.getMapEnumFieldsFor)(refGqlEntityName);
|
|
91
|
+
const refCustomFields = (0, gql_entity_1.getCustomFieldsFor)(refGqlEntityName);
|
|
92
|
+
const refRelationFields = (0, gql_entity_1.getRelationFieldsFor)(refGqlEntityName);
|
|
93
|
+
return convertFilterEnumValues(value, refEnumFields, refCustomFields, refRelationFields);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Recurse into a plain-`defineFields` relation nested filter using the target
|
|
97
|
+
* entity's enums. The relation field is looked up in `RelationFieldsMap`
|
|
98
|
+
* (registered by `createGQLEntityFilters`), which stores the target entity's
|
|
99
|
+
* ORM name thunk. Falls back to returning the value untouched if the key isn't
|
|
100
|
+
* a known relation field.
|
|
101
|
+
*/
|
|
102
|
+
function convertRelationField(key, value, relationFields) {
|
|
103
|
+
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
104
|
+
const relThunk = relationFields[key] ?? relationFields[lowercased];
|
|
105
|
+
if (!relThunk)
|
|
106
|
+
return value;
|
|
107
|
+
const targetGqlEntityName = (0, gql_entity_1.getGQLEntityNameFor)(relThunk());
|
|
108
|
+
const targetEnumFields = (0, gql_entity_1.getMapEnumFieldsFor)(targetGqlEntityName);
|
|
109
|
+
const targetCustomFields = (0, gql_entity_1.getCustomFieldsFor)(targetGqlEntityName);
|
|
110
|
+
const targetRelationFields = (0, gql_entity_1.getRelationFieldsFor)(targetGqlEntityName);
|
|
111
|
+
return convertFilterEnumValues(value, targetEnumFields, targetCustomFields, targetRelationFields);
|
|
112
|
+
}
|
|
113
|
+
function convertFilterEnumValues(filter, enumFields, mappedCustomFields, relationFields) {
|
|
114
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter))
|
|
115
|
+
return filter;
|
|
116
|
+
if (Object.keys(enumFields).length === 0 &&
|
|
117
|
+
!mappedCustomFields &&
|
|
118
|
+
(!relationFields || Object.keys(relationFields).length === 0))
|
|
119
|
+
return filter;
|
|
120
|
+
const result = {};
|
|
121
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
122
|
+
result[key] = convertFilterEntry(key, value, enumFields, mappedCustomFields, relationFields);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
/** Resolve a single filter key/value to its enum-converted form. */
|
|
127
|
+
function convertFilterEntry(key, value, enumFields, mappedCustomFields, relationFields) {
|
|
128
|
+
if (LOGICAL_KEYS.has(key)) {
|
|
129
|
+
return Array.isArray(value)
|
|
130
|
+
? value.map((v) => convertFilterEnumValues(v, enumFields, mappedCustomFields, relationFields))
|
|
131
|
+
: value;
|
|
132
|
+
}
|
|
133
|
+
if (PASSTHROUGH_KEYS.has(key))
|
|
134
|
+
return value;
|
|
135
|
+
const enumFieldName = findEnumFieldName(key, enumFields);
|
|
136
|
+
if (enumFieldName) {
|
|
137
|
+
const enumObj = enumFields[enumFieldName];
|
|
138
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
139
|
+
return convertOperatorObject(value, enumObj);
|
|
140
|
+
}
|
|
141
|
+
const isArr = key.endsWith('_in') || key.endsWith('_nin');
|
|
142
|
+
return toDbValue(value, enumObj, isArr);
|
|
143
|
+
}
|
|
144
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
145
|
+
if (mappedCustomFields) {
|
|
146
|
+
const converted = convertMappedCustomField(key, value, mappedCustomFields);
|
|
147
|
+
if (converted !== value)
|
|
148
|
+
return converted;
|
|
149
|
+
}
|
|
150
|
+
if (relationFields) {
|
|
151
|
+
return convertRelationField(key, value, relationFields);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
@@ -7,6 +7,7 @@ const gql_to_sql_types_1 = require("../types/gql-to-sql-types");
|
|
|
7
7
|
const object_1 = require("../utils/object");
|
|
8
8
|
const variables_1 = require("../variables");
|
|
9
9
|
const alias_1 = require("./alias");
|
|
10
|
+
const enum_filter_converter_1 = require("./enum-filter-converter");
|
|
10
11
|
const filter_processor_1 = require("./filter-processor");
|
|
11
12
|
const relationship_handler_1 = require("./relationship-handler");
|
|
12
13
|
const sql_builder_1 = require("./sql-builder");
|
|
@@ -411,11 +412,13 @@ class GQLtoSQLMapper {
|
|
|
411
412
|
let filterOuterJoin = [];
|
|
412
413
|
let filterOr = [];
|
|
413
414
|
if (args?.filter) {
|
|
415
|
+
const countGqlEntityName = (0, gql_entity_1.getGQLEntityNameFor)(relatedMetadata.name ?? '');
|
|
416
|
+
const countConvertedFilter = (0, enum_filter_converter_1.convertFilterEnumValues)(args.filter, (0, gql_entity_1.getMapEnumFieldsFor)(countGqlEntityName), (0, gql_entity_1.getCustomFieldsFor)(countGqlEntityName), (0, gql_entity_1.getRelationFieldsFor)(countGqlEntityName));
|
|
414
417
|
const filterMapped = this.recursiveMap({
|
|
415
418
|
entityMetadata: relatedMetadata,
|
|
416
419
|
parentAlias: countAlias,
|
|
417
420
|
alias: countAlias,
|
|
418
|
-
gqlFilters: [
|
|
421
|
+
gqlFilters: [countConvertedFilter],
|
|
419
422
|
isFieldFilter: true,
|
|
420
423
|
});
|
|
421
424
|
const reduced = utils_1.QueriesUtils.mappingsReducer(filterMapped);
|
|
@@ -526,12 +529,29 @@ class GQLtoSQLMapper {
|
|
|
526
529
|
const { filter, pagination } = args ?? {};
|
|
527
530
|
variables_1.logger.log(prefix, 'args', parentGqlFieldNameKey, { ...filter }, JSON.stringify(pagination));
|
|
528
531
|
if (filter || pagination) {
|
|
532
|
+
// Convert mapNumericEnum string keys → raw DB values for inline field
|
|
533
|
+
// filter args. The top-level filter goes through convertFilterEnumValues
|
|
534
|
+
// in GQLQueryManager, but inline filter args (e.g. `books(filter: {...})`)
|
|
535
|
+
// enter here and would otherwise bypass enum conversion, causing
|
|
536
|
+
// mapNumericEnum fields in sub-entity filters to fail silently.
|
|
537
|
+
//
|
|
538
|
+
// The filter keys belong to the TARGET entity (e.g. Book), not the parent
|
|
539
|
+
// (e.g. Author). We WRAP the filter under the field name and convert using
|
|
540
|
+
// the PARENT entity's registries — convertRelationField /
|
|
541
|
+
// convertMappedCustomField then resolve the target entity and recurse into
|
|
542
|
+
// its enum fields, handling arbitrary nesting depth.
|
|
543
|
+
const gqlEntityName = (0, gql_entity_1.getGQLEntityNameFor)(entityMetadata.name ?? '');
|
|
544
|
+
const parentEnumFields = (0, gql_entity_1.getMapEnumFieldsFor)(gqlEntityName);
|
|
545
|
+
const parentCustomFields = (0, gql_entity_1.getCustomFieldsFor)(gqlEntityName);
|
|
546
|
+
const parentRelationFields = (0, gql_entity_1.getRelationFieldsFor)(gqlEntityName);
|
|
547
|
+
const wrappedConverted = (0, enum_filter_converter_1.convertFilterEnumValues)({ [parentGqlFieldNameKey]: filter }, parentEnumFields, parentCustomFields, parentRelationFields);
|
|
548
|
+
const convertedFilter = wrappedConverted[parentGqlFieldNameKey] ?? filter;
|
|
529
549
|
const mapped = this.recursiveMap({
|
|
530
550
|
entityMetadata,
|
|
531
551
|
parentAlias: alias,
|
|
532
552
|
alias,
|
|
533
553
|
gqlFilters: [
|
|
534
|
-
{ [parentGqlFieldNameKey]: { ...
|
|
554
|
+
{ [parentGqlFieldNameKey]: { ...convertedFilter } },
|
|
535
555
|
],
|
|
536
556
|
isFieldFilter: true,
|
|
537
557
|
});
|
package/dist/query-manager.d.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { GraphQLResolveInfo } from 'graphql';
|
|
2
|
+
import { convertFilterEnumValues } from './queries/enum-filter-converter';
|
|
2
3
|
import { DatabaseDriver, FieldSelection, MetadataProviderType } from './types/sql-types';
|
|
3
4
|
import { GQLEntityFilterInputFieldType, GQLEntityOrderByInputType, GQLEntityPaginationInputType } from './types/gql-types';
|
|
5
|
+
export { convertFilterEnumValues };
|
|
4
6
|
export declare const getGQLFields: (info: GraphQLResolveInfo) => Partial<{
|
|
5
7
|
[x: string]: {} | import("./types/sql-types").FieldDetails<any, string> | undefined;
|
|
6
8
|
}>;
|
|
7
|
-
export declare function convertFilterEnumValues(filter: any, enumFields: Record<string, any>, mappedCustomFields?: Record<string, {
|
|
8
|
-
mapping: {
|
|
9
|
-
refEntity: new () => any;
|
|
10
|
-
};
|
|
11
|
-
}>): any;
|
|
12
9
|
export declare class GQLQueryManager {
|
|
13
10
|
private opts?;
|
|
14
11
|
constructor(opts?: {
|
package/dist/query-manager.js
CHANGED
|
@@ -3,13 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.GQLQueryManager = exports.getGQLFields = void 0;
|
|
7
|
-
exports.convertFilterEnumValues = convertFilterEnumValues;
|
|
6
|
+
exports.GQLQueryManager = exports.getGQLFields = exports.convertFilterEnumValues = void 0;
|
|
8
7
|
const graphql_fields_1 = __importDefault(require("graphql-fields"));
|
|
9
8
|
const graphql_parse_resolve_info_1 = require("graphql-parse-resolve-info");
|
|
10
9
|
const knex_1 = __importDefault(require("knex"));
|
|
11
10
|
const gql_entity_1 = require("./entities/gql-entity");
|
|
12
11
|
const gql_to_sql_mapper_1 = require("./queries/gql-to-sql-mapper");
|
|
12
|
+
const enum_filter_converter_1 = require("./queries/enum-filter-converter");
|
|
13
|
+
Object.defineProperty(exports, "convertFilterEnumValues", { enumerable: true, get: function () { return enum_filter_converter_1.convertFilterEnumValues; } });
|
|
13
14
|
const variables_1 = require("./variables");
|
|
14
15
|
const getGQLFields = (info) => {
|
|
15
16
|
graphql_fields_1.default;
|
|
@@ -34,108 +35,6 @@ const getGQLFields = (info) => {
|
|
|
34
35
|
}
|
|
35
36
|
};
|
|
36
37
|
exports.getGQLFields = getGQLFields;
|
|
37
|
-
const ENUM_OPERATORS = [
|
|
38
|
-
'_eq',
|
|
39
|
-
'_ne',
|
|
40
|
-
'_gt',
|
|
41
|
-
'_gte',
|
|
42
|
-
'_lt',
|
|
43
|
-
'_lte',
|
|
44
|
-
'_like',
|
|
45
|
-
'_re',
|
|
46
|
-
'_ilike',
|
|
47
|
-
'_fulltext',
|
|
48
|
-
'_in',
|
|
49
|
-
'_nin',
|
|
50
|
-
'_between',
|
|
51
|
-
];
|
|
52
|
-
/** Keys whose (array) value should be recursively enum-converted element-by-element. */
|
|
53
|
-
const LOGICAL_KEYS = new Set(['_and', '_or', '_not']);
|
|
54
|
-
/** Keys that are always passed through verbatim (existence subquery shapes). */
|
|
55
|
-
const PASSTHROUGH_KEYS = new Set(['_exists', '_not_exists']);
|
|
56
|
-
function toDbValue(value, enumObj, isArray) {
|
|
57
|
-
if (value === null || value === undefined)
|
|
58
|
-
return value;
|
|
59
|
-
if (isArray && Array.isArray(value)) {
|
|
60
|
-
return value.map((v) => {
|
|
61
|
-
if (typeof v === 'string' && v in enumObj)
|
|
62
|
-
return enumObj[v];
|
|
63
|
-
return v;
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
if (typeof value === 'string' && value in enumObj)
|
|
67
|
-
return enumObj[value];
|
|
68
|
-
return value;
|
|
69
|
-
}
|
|
70
|
-
function findEnumFieldName(key, enumFields) {
|
|
71
|
-
if (key in enumFields)
|
|
72
|
-
return key;
|
|
73
|
-
for (const op of ENUM_OPERATORS) {
|
|
74
|
-
if (key.endsWith(op)) {
|
|
75
|
-
const fieldName = key.slice(0, -op.length);
|
|
76
|
-
if (fieldName in enumFields)
|
|
77
|
-
return fieldName;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
81
|
-
if (lowercased in enumFields)
|
|
82
|
-
return lowercased;
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
/** Convert an operator-keyed object (e.g. { _eq: 'X', _in: [...] }) whose target is an enum field. */
|
|
86
|
-
function convertOperatorObject(operatorObject, enumObj) {
|
|
87
|
-
const converted = {};
|
|
88
|
-
for (const [op, opVal] of Object.entries(operatorObject)) {
|
|
89
|
-
const isArr = op === '_in' || op === '_nin';
|
|
90
|
-
converted[op] = toDbValue(opVal, enumObj, isArr);
|
|
91
|
-
}
|
|
92
|
-
return converted;
|
|
93
|
-
}
|
|
94
|
-
/** Recurse into a mapped custom-field nested filter using the referenced entity's enums. */
|
|
95
|
-
function convertMappedCustomField(key, value, mappedCustomFields) {
|
|
96
|
-
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
97
|
-
const mappedField = mappedCustomFields[key] ??
|
|
98
|
-
mappedCustomFields[lowercased];
|
|
99
|
-
if (!mappedField)
|
|
100
|
-
return value;
|
|
101
|
-
const refGqlEntityName = (0, gql_entity_1.getGQLEntityNameFor)(mappedField.mapping.refEntity.name);
|
|
102
|
-
const refEnumFields = (0, gql_entity_1.getMapEnumFieldsFor)(refGqlEntityName);
|
|
103
|
-
return convertFilterEnumValues(value, refEnumFields);
|
|
104
|
-
}
|
|
105
|
-
function convertFilterEnumValues(filter, enumFields, mappedCustomFields) {
|
|
106
|
-
if (!filter || typeof filter !== 'object' || Array.isArray(filter))
|
|
107
|
-
return filter;
|
|
108
|
-
if (Object.keys(enumFields).length === 0 && !mappedCustomFields)
|
|
109
|
-
return filter;
|
|
110
|
-
const result = {};
|
|
111
|
-
for (const [key, value] of Object.entries(filter)) {
|
|
112
|
-
result[key] = convertFilterEntry(key, value, enumFields, mappedCustomFields);
|
|
113
|
-
}
|
|
114
|
-
return result;
|
|
115
|
-
}
|
|
116
|
-
/** Resolve a single filter key/value to its enum-converted form. */
|
|
117
|
-
function convertFilterEntry(key, value, enumFields, mappedCustomFields) {
|
|
118
|
-
if (LOGICAL_KEYS.has(key)) {
|
|
119
|
-
return Array.isArray(value)
|
|
120
|
-
? value.map((v) => convertFilterEnumValues(v, enumFields, mappedCustomFields))
|
|
121
|
-
: value;
|
|
122
|
-
}
|
|
123
|
-
if (PASSTHROUGH_KEYS.has(key))
|
|
124
|
-
return value;
|
|
125
|
-
const enumFieldName = findEnumFieldName(key, enumFields);
|
|
126
|
-
if (enumFieldName) {
|
|
127
|
-
const enumObj = enumFields[enumFieldName];
|
|
128
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
129
|
-
return convertOperatorObject(value, enumObj);
|
|
130
|
-
}
|
|
131
|
-
const isArr = key.endsWith('_in') || key.endsWith('_nin');
|
|
132
|
-
return toDbValue(value, enumObj, isArr);
|
|
133
|
-
}
|
|
134
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value) && mappedCustomFields) {
|
|
135
|
-
return convertMappedCustomField(key, value, mappedCustomFields);
|
|
136
|
-
}
|
|
137
|
-
return value;
|
|
138
|
-
}
|
|
139
38
|
class GQLQueryManager {
|
|
140
39
|
opts;
|
|
141
40
|
constructor(opts) {
|
|
@@ -178,6 +77,7 @@ class GQLQueryManager {
|
|
|
178
77
|
}
|
|
179
78
|
const customFields = (0, gql_entity_1.getCustomFieldsFor)((0, gql_entity_1.getGQLEntityNameFor)(entityName));
|
|
180
79
|
const enumFields = (0, gql_entity_1.getMapEnumFieldsFor)((0, gql_entity_1.getGQLEntityNameFor)(entityName));
|
|
80
|
+
const relationFields = (0, gql_entity_1.getRelationFieldsFor)((0, gql_entity_1.getGQLEntityNameFor)(entityName));
|
|
181
81
|
const mapper = new gql_to_sql_mapper_1.GQLtoSQLMapper(provider, this.opts);
|
|
182
82
|
let entityForMapper = entity;
|
|
183
83
|
if (entityName !== entity.name) {
|
|
@@ -186,7 +86,7 @@ class GQLQueryManager {
|
|
|
186
86
|
Object.defineProperty(entityForMapper, 'name', { value: entityName });
|
|
187
87
|
Object.setPrototypeOf(entityForMapper, entity);
|
|
188
88
|
}
|
|
189
|
-
const convertedFilter = convertFilterEnumValues(filter, enumFields, customFields);
|
|
89
|
+
const convertedFilter = (0, enum_filter_converter_1.convertFilterEnumValues)(filter, enumFields, customFields, relationFields);
|
|
190
90
|
const { bindings, querySQL } = mapper.buildQueryAndBindingsFor({
|
|
191
91
|
fields,
|
|
192
92
|
customFields,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dav3/gql-of-power",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Generate a single optimized SQL query from GraphQL operations — eliminate N+1 with recursive field-to-SQL mapping.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -34,6 +34,14 @@ const CustomFieldsMap: Record<string, CustomFieldsSettings<any>> = {};
|
|
|
34
34
|
const CountFieldsMap: Record<string, Record<string, CountFieldMeta>> = {};
|
|
35
35
|
const MapEnumFieldsMap: Record<string, Record<string, any>> = {};
|
|
36
36
|
const ParseJsonFieldsMap: Record<string, Set<string>> = {};
|
|
37
|
+
/**
|
|
38
|
+
* Relation fields declared via the plain `defineFields` pattern (not via
|
|
39
|
+
* `customFields`/`mapping`). Keyed by gqlEntityName → field name → target
|
|
40
|
+
* entity's ORM name (the raw string returned by `relatedEntityName()`). Used by
|
|
41
|
+
* `convertFilterEnumValues` to recurse into nested relation filters and apply
|
|
42
|
+
* the target entity's mapNumericEnum conversions.
|
|
43
|
+
*/
|
|
44
|
+
const RelationFieldsMap: Record<string, Record<string, () => string>> = {};
|
|
37
45
|
|
|
38
46
|
const aclMap: AccessControlList<any, any> = {};
|
|
39
47
|
|
|
@@ -205,6 +213,15 @@ export const clearParseJsonFields = (): void => {
|
|
|
205
213
|
}
|
|
206
214
|
};
|
|
207
215
|
|
|
216
|
+
export const getRelationFieldsFor = (name: string): Record<string, () => string> =>
|
|
217
|
+
RelationFieldsMap[name] ?? {};
|
|
218
|
+
|
|
219
|
+
export const clearRelationFields = (): void => {
|
|
220
|
+
for (const key of Object.keys(RelationFieldsMap)) {
|
|
221
|
+
delete RelationFieldsMap[key];
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
208
225
|
export const getACLFor = (name: string) => aclMap[name] ?? {};
|
|
209
226
|
|
|
210
227
|
export const getGQLEntityNameFor = (name: string) =>
|
|
@@ -574,7 +591,15 @@ function _buildInputType<T>(
|
|
|
574
591
|
|
|
575
592
|
// ─── Shared resolver builder ─────────────────────────────────────────────────
|
|
576
593
|
|
|
577
|
-
/**
|
|
594
|
+
/**
|
|
595
|
+
* Phase 2: attach @FieldResolver methods for fields with `mapNumericEnum`.
|
|
596
|
+
*
|
|
597
|
+
* The resolver returns the raw DB value unchanged. graphql-js's
|
|
598
|
+
* `GraphQLEnumType.serialize()` performs the value→name conversion itself
|
|
599
|
+
* (e.g. 100 → "Forged") using the internal values registered by
|
|
600
|
+
* `registerEnumType`. Returning the string key here would cause
|
|
601
|
+
* "Enum cannot represent value" errors at serialization time.
|
|
602
|
+
*/
|
|
578
603
|
function _registerMapNumericEnumResolvers<T>(
|
|
579
604
|
rawFields: Partial<FieldsSettings<T>>,
|
|
580
605
|
FieldsResolver: any
|
|
@@ -589,17 +614,7 @@ function _registerMapNumericEnumResolvers<T>(
|
|
|
589
614
|
const resolveFn = (root: any) => {
|
|
590
615
|
const value = root[fieldNameToUse];
|
|
591
616
|
if (value === null || value === undefined) return null;
|
|
592
|
-
|
|
593
|
-
const enumObj = enumTypeThunk();
|
|
594
|
-
const key = enumObj[value];
|
|
595
|
-
if (typeof key === 'string') return key;
|
|
596
|
-
for (const enumKey of Object.keys(enumObj)) {
|
|
597
|
-
if (enumObj[enumKey] === value) return enumKey;
|
|
598
|
-
}
|
|
599
|
-
return value;
|
|
600
|
-
} catch {
|
|
601
|
-
return value;
|
|
602
|
-
}
|
|
617
|
+
return value;
|
|
603
618
|
};
|
|
604
619
|
|
|
605
620
|
Object.defineProperty(FieldsResolver.prototype, fieldNameToUse, {
|
|
@@ -819,7 +834,17 @@ function _registerMappingFieldFilter<T>(
|
|
|
819
834
|
target: GQLEntityFilterInput,
|
|
820
835
|
name: UppercasedFieldName,
|
|
821
836
|
schemaName: UppercasedFieldName,
|
|
822
|
-
getType: () =>
|
|
837
|
+
getType: () => {
|
|
838
|
+
const type = TypeMap[refFilterTypeName];
|
|
839
|
+
if (!type) {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`gql-of-power: FilterInput for referenced entity "${refGqlEntityName}" (${refFilterTypeName}) ` +
|
|
842
|
+
`is not registered. Make sure ${refGqlEntityName}'s buildResolvers() is called ` +
|
|
843
|
+
`(or use createGQLTypes / @GQLEntityClass) before building the schema.`
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
return type;
|
|
847
|
+
},
|
|
823
848
|
typeOptions: { nullable: true },
|
|
824
849
|
complexity: undefined,
|
|
825
850
|
description: `Filter by ${fieldNameToUse} fields`,
|
|
@@ -1051,7 +1076,17 @@ function _registerExistsFilters<T>(
|
|
|
1051
1076
|
target: GQLEntityExistsFilterInput,
|
|
1052
1077
|
name: relFieldName,
|
|
1053
1078
|
schemaName: relFieldName,
|
|
1054
|
-
getType: () =>
|
|
1079
|
+
getType: () => {
|
|
1080
|
+
const type = TypeMap[relFilterTypeName];
|
|
1081
|
+
if (!type) {
|
|
1082
|
+
throw new Error(
|
|
1083
|
+
`gql-of-power: FilterInput for related entity "${relGQLEntityName}" (${relFilterTypeName}) ` +
|
|
1084
|
+
`is not registered. Make sure ${relGQLEntityName}'s buildResolvers() is called ` +
|
|
1085
|
+
`(or use createGQLTypes / @GQLEntityClass) before building the schema.`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
return type;
|
|
1089
|
+
},
|
|
1055
1090
|
typeOptions: { nullable: true },
|
|
1056
1091
|
complexity: undefined,
|
|
1057
1092
|
description: `Filter ${relFieldName} by their fields for existence check`,
|
|
@@ -1348,6 +1383,17 @@ export function createGQLEntityFilters<T, K>(
|
|
|
1348
1383
|
} as FieldParameter;
|
|
1349
1384
|
metadata.collectClassFieldMetadata(fieldFilter);
|
|
1350
1385
|
|
|
1386
|
+
// Register relation fields for nested-filter enum conversion. The nested
|
|
1387
|
+
// FilterInput is exposed under both the camelCased fieldName (above) and the
|
|
1388
|
+
// UppercasedFieldName — record both so convertFilterEnumValues can resolve
|
|
1389
|
+
// whichever key the caller uses.
|
|
1390
|
+
if ('relatedEntityName' in fieldOptions && fieldOptions.relatedEntityName) {
|
|
1391
|
+
RelationFieldsMap[gqlEntityName] = RelationFieldsMap[gqlEntityName] || {};
|
|
1392
|
+
const relThunk = fieldOptions.relatedEntityName as () => string;
|
|
1393
|
+
RelationFieldsMap[gqlEntityName][fieldName] = relThunk;
|
|
1394
|
+
RelationFieldsMap[gqlEntityName][UppercasedFieldName] = relThunk;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1351
1397
|
if ('array' in fieldOptions && 'relatedEntityName' in fieldOptions) {
|
|
1352
1398
|
const relatedEntityName = getGQLEntityNameFor(fieldOptions.relatedEntityName());
|
|
1353
1399
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enum filter value conversion — shared module.
|
|
3
|
+
*
|
|
4
|
+
* Converts GraphQL enum string keys (e.g. `'Active'`) into their raw DB values
|
|
5
|
+
* (e.g. `0`) inside filter objects before they reach SQL generation.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `query-manager.ts` so that both `GQLQueryManager` (top-level
|
|
8
|
+
* filter path) and `GQLtoSQLMapper` (inline field-argument filter path) can
|
|
9
|
+
* import it without a circular dependency:
|
|
10
|
+
*
|
|
11
|
+
* gql-to-sql-mapper ← (would cycle) ← query-manager
|
|
12
|
+
* gql-to-sql-mapper ← enum-filter-converter → gql-entity
|
|
13
|
+
* query-manager ← enum-filter-converter → gql-entity
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
getCustomFieldsFor,
|
|
17
|
+
getGQLEntityNameFor,
|
|
18
|
+
getMapEnumFieldsFor,
|
|
19
|
+
getRelationFieldsFor,
|
|
20
|
+
} from '../entities/gql-entity';
|
|
21
|
+
|
|
22
|
+
const ENUM_OPERATORS = [
|
|
23
|
+
'_eq',
|
|
24
|
+
'_ne',
|
|
25
|
+
'_gt',
|
|
26
|
+
'_gte',
|
|
27
|
+
'_lt',
|
|
28
|
+
'_lte',
|
|
29
|
+
'_like',
|
|
30
|
+
'_re',
|
|
31
|
+
'_ilike',
|
|
32
|
+
'_fulltext',
|
|
33
|
+
'_in',
|
|
34
|
+
'_nin',
|
|
35
|
+
'_between',
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/** Keys whose (array) value should be recursively enum-converted element-by-element. */
|
|
39
|
+
const LOGICAL_KEYS = new Set(['_and', '_or', '_not']);
|
|
40
|
+
/** Keys that are always passed through verbatim (existence subquery shapes). */
|
|
41
|
+
const PASSTHROUGH_KEYS = new Set(['_exists', '_not_exists']);
|
|
42
|
+
|
|
43
|
+
export function toDbValue(value: any, enumObj: any, isArray: boolean): any {
|
|
44
|
+
if (value === null || value === undefined) return value;
|
|
45
|
+
if (isArray && Array.isArray(value)) {
|
|
46
|
+
return value.map((v) => {
|
|
47
|
+
if (typeof v === 'string' && v in enumObj) return enumObj[v];
|
|
48
|
+
return v;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (typeof value === 'string' && value in enumObj) return enumObj[value];
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findEnumFieldName(key: string, enumFields: Record<string, any>): string | null {
|
|
56
|
+
if (key in enumFields) return key;
|
|
57
|
+
for (const op of ENUM_OPERATORS) {
|
|
58
|
+
if (key.endsWith(op)) {
|
|
59
|
+
const fieldName = key.slice(0, -op.length);
|
|
60
|
+
if (fieldName in enumFields) return fieldName;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
64
|
+
if (lowercased in enumFields) return lowercased;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Convert an operator-keyed object (e.g. { _eq: 'X', _in: [...] }) whose target is an enum field. */
|
|
69
|
+
export function convertOperatorObject(
|
|
70
|
+
operatorObject: Record<string, any>,
|
|
71
|
+
enumObj: any
|
|
72
|
+
): Record<string, any> {
|
|
73
|
+
const converted: Record<string, any> = {};
|
|
74
|
+
for (const [op, opVal] of Object.entries(operatorObject)) {
|
|
75
|
+
const isArr = op === '_in' || op === '_nin';
|
|
76
|
+
converted[op] = toDbValue(opVal, enumObj, isArr);
|
|
77
|
+
}
|
|
78
|
+
return converted;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Recurse into a mapped custom-field nested filter using the referenced entity's enums. */
|
|
82
|
+
export function convertMappedCustomField(
|
|
83
|
+
key: string,
|
|
84
|
+
value: any,
|
|
85
|
+
mappedCustomFields: Record<string, { mapping: { refEntity: new () => any } }>
|
|
86
|
+
): any {
|
|
87
|
+
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
88
|
+
const mappedField =
|
|
89
|
+
mappedCustomFields[key as keyof typeof mappedCustomFields] ??
|
|
90
|
+
mappedCustomFields[lowercased as keyof typeof mappedCustomFields];
|
|
91
|
+
if (!mappedField) return value;
|
|
92
|
+
const refGqlEntityName = getGQLEntityNameFor(mappedField.mapping.refEntity.name);
|
|
93
|
+
const refEnumFields = getMapEnumFieldsFor(refGqlEntityName);
|
|
94
|
+
const refCustomFields = getCustomFieldsFor(refGqlEntityName);
|
|
95
|
+
const refRelationFields = getRelationFieldsFor(refGqlEntityName);
|
|
96
|
+
return convertFilterEnumValues(value, refEnumFields, refCustomFields, refRelationFields);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Recurse into a plain-`defineFields` relation nested filter using the target
|
|
101
|
+
* entity's enums. The relation field is looked up in `RelationFieldsMap`
|
|
102
|
+
* (registered by `createGQLEntityFilters`), which stores the target entity's
|
|
103
|
+
* ORM name thunk. Falls back to returning the value untouched if the key isn't
|
|
104
|
+
* a known relation field.
|
|
105
|
+
*/
|
|
106
|
+
export function convertRelationField(
|
|
107
|
+
key: string,
|
|
108
|
+
value: any,
|
|
109
|
+
relationFields: Record<string, () => string>
|
|
110
|
+
): any {
|
|
111
|
+
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
112
|
+
const relThunk = relationFields[key] ?? relationFields[lowercased];
|
|
113
|
+
if (!relThunk) return value;
|
|
114
|
+
const targetGqlEntityName = getGQLEntityNameFor(relThunk());
|
|
115
|
+
const targetEnumFields = getMapEnumFieldsFor(targetGqlEntityName);
|
|
116
|
+
const targetCustomFields = getCustomFieldsFor(targetGqlEntityName);
|
|
117
|
+
const targetRelationFields = getRelationFieldsFor(targetGqlEntityName);
|
|
118
|
+
return convertFilterEnumValues(value, targetEnumFields, targetCustomFields, targetRelationFields);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function convertFilterEnumValues(
|
|
122
|
+
filter: any,
|
|
123
|
+
enumFields: Record<string, any>,
|
|
124
|
+
mappedCustomFields?: Record<string, { mapping: { refEntity: new () => any } }>,
|
|
125
|
+
relationFields?: Record<string, () => string>
|
|
126
|
+
): any {
|
|
127
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) return filter;
|
|
128
|
+
if (
|
|
129
|
+
Object.keys(enumFields).length === 0 &&
|
|
130
|
+
!mappedCustomFields &&
|
|
131
|
+
(!relationFields || Object.keys(relationFields).length === 0)
|
|
132
|
+
)
|
|
133
|
+
return filter;
|
|
134
|
+
|
|
135
|
+
const result: any = {};
|
|
136
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
137
|
+
result[key] = convertFilterEntry(key, value, enumFields, mappedCustomFields, relationFields);
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Resolve a single filter key/value to its enum-converted form. */
|
|
143
|
+
export function convertFilterEntry(
|
|
144
|
+
key: string,
|
|
145
|
+
value: any,
|
|
146
|
+
enumFields: Record<string, any>,
|
|
147
|
+
mappedCustomFields?: Record<string, { mapping: { refEntity: new () => any } }>,
|
|
148
|
+
relationFields?: Record<string, () => string>
|
|
149
|
+
): any {
|
|
150
|
+
if (LOGICAL_KEYS.has(key)) {
|
|
151
|
+
return Array.isArray(value)
|
|
152
|
+
? value.map((v: any) =>
|
|
153
|
+
convertFilterEnumValues(v, enumFields, mappedCustomFields, relationFields)
|
|
154
|
+
)
|
|
155
|
+
: value;
|
|
156
|
+
}
|
|
157
|
+
if (PASSTHROUGH_KEYS.has(key)) return value;
|
|
158
|
+
|
|
159
|
+
const enumFieldName = findEnumFieldName(key, enumFields);
|
|
160
|
+
if (enumFieldName) {
|
|
161
|
+
const enumObj = enumFields[enumFieldName];
|
|
162
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
163
|
+
return convertOperatorObject(value as Record<string, any>, enumObj);
|
|
164
|
+
}
|
|
165
|
+
const isArr = key.endsWith('_in') || key.endsWith('_nin');
|
|
166
|
+
return toDbValue(value, enumObj, isArr);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
170
|
+
if (mappedCustomFields) {
|
|
171
|
+
const converted = convertMappedCustomField(key, value, mappedCustomFields);
|
|
172
|
+
if (converted !== value) return converted;
|
|
173
|
+
}
|
|
174
|
+
if (relationFields) {
|
|
175
|
+
return convertRelationField(key, value, relationFields);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getCountFieldsFor,
|
|
3
|
+
getCustomFieldsFor,
|
|
3
4
|
getFieldByAlias,
|
|
4
5
|
getGQLEntityNameFor,
|
|
6
|
+
getMapEnumFieldsFor,
|
|
5
7
|
getParseJsonFieldsFor,
|
|
8
|
+
getRelationFieldsFor,
|
|
6
9
|
} from '../entities/gql-entity';
|
|
7
10
|
import {
|
|
8
11
|
CountFieldMeta,
|
|
@@ -22,6 +25,7 @@ import { MappingsType, mappingsTypeToString } from '../types/gql-to-sql-types';
|
|
|
22
25
|
import { keys } from '../utils/object';
|
|
23
26
|
import { logger } from '../variables';
|
|
24
27
|
import { Alias, AliasManager, AliasType } from './alias';
|
|
28
|
+
import { convertFilterEnumValues } from './enum-filter-converter';
|
|
25
29
|
import { FilterProcessor } from './filter-processor';
|
|
26
30
|
import { RelationshipHandler } from './relationship-handler';
|
|
27
31
|
import { SQLBuilder } from './sql-builder';
|
|
@@ -840,11 +844,18 @@ export class GQLtoSQLMapper {
|
|
|
840
844
|
let filterOr: MappingsType[] = [];
|
|
841
845
|
|
|
842
846
|
if (args?.filter) {
|
|
847
|
+
const countGqlEntityName = getGQLEntityNameFor(relatedMetadata.name ?? '');
|
|
848
|
+
const countConvertedFilter = convertFilterEnumValues(
|
|
849
|
+
args.filter,
|
|
850
|
+
getMapEnumFieldsFor(countGqlEntityName),
|
|
851
|
+
getCustomFieldsFor(countGqlEntityName),
|
|
852
|
+
getRelationFieldsFor(countGqlEntityName)
|
|
853
|
+
);
|
|
843
854
|
const filterMapped = this.recursiveMap({
|
|
844
855
|
entityMetadata: relatedMetadata,
|
|
845
856
|
parentAlias: countAlias,
|
|
846
857
|
alias: countAlias,
|
|
847
|
-
gqlFilters: [
|
|
858
|
+
gqlFilters: [countConvertedFilter],
|
|
848
859
|
isFieldFilter: true,
|
|
849
860
|
});
|
|
850
861
|
|
|
@@ -1120,12 +1131,35 @@ export class GQLtoSQLMapper {
|
|
|
1120
1131
|
|
|
1121
1132
|
logger.log(prefix, 'args', parentGqlFieldNameKey, { ...filter }, JSON.stringify(pagination));
|
|
1122
1133
|
if (filter || pagination) {
|
|
1134
|
+
// Convert mapNumericEnum string keys → raw DB values for inline field
|
|
1135
|
+
// filter args. The top-level filter goes through convertFilterEnumValues
|
|
1136
|
+
// in GQLQueryManager, but inline filter args (e.g. `books(filter: {...})`)
|
|
1137
|
+
// enter here and would otherwise bypass enum conversion, causing
|
|
1138
|
+
// mapNumericEnum fields in sub-entity filters to fail silently.
|
|
1139
|
+
//
|
|
1140
|
+
// The filter keys belong to the TARGET entity (e.g. Book), not the parent
|
|
1141
|
+
// (e.g. Author). We WRAP the filter under the field name and convert using
|
|
1142
|
+
// the PARENT entity's registries — convertRelationField /
|
|
1143
|
+
// convertMappedCustomField then resolve the target entity and recurse into
|
|
1144
|
+
// its enum fields, handling arbitrary nesting depth.
|
|
1145
|
+
const gqlEntityName = getGQLEntityNameFor(entityMetadata.name ?? '');
|
|
1146
|
+
const parentEnumFields = getMapEnumFieldsFor(gqlEntityName);
|
|
1147
|
+
const parentCustomFields = getCustomFieldsFor(gqlEntityName);
|
|
1148
|
+
const parentRelationFields = getRelationFieldsFor(gqlEntityName);
|
|
1149
|
+
const wrappedConverted = convertFilterEnumValues(
|
|
1150
|
+
{ [parentGqlFieldNameKey]: filter },
|
|
1151
|
+
parentEnumFields,
|
|
1152
|
+
parentCustomFields,
|
|
1153
|
+
parentRelationFields
|
|
1154
|
+
);
|
|
1155
|
+
const convertedFilter = wrappedConverted[parentGqlFieldNameKey] ?? filter;
|
|
1156
|
+
|
|
1123
1157
|
const mapped = this.recursiveMap({
|
|
1124
1158
|
entityMetadata,
|
|
1125
1159
|
parentAlias: alias,
|
|
1126
1160
|
alias,
|
|
1127
1161
|
gqlFilters: [
|
|
1128
|
-
{ [parentGqlFieldNameKey]: { ...
|
|
1162
|
+
{ [parentGqlFieldNameKey]: { ...convertedFilter } } as GQLEntityFilterInputFieldType<T>,
|
|
1129
1163
|
],
|
|
1130
1164
|
isFieldFilter: true,
|
|
1131
1165
|
});
|
package/src/query-manager.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
getCustomFieldsFor,
|
|
10
10
|
getGQLEntityNameFor,
|
|
11
11
|
getMapEnumFieldsFor,
|
|
12
|
+
getRelationFieldsFor,
|
|
12
13
|
} from './entities/gql-entity';
|
|
13
14
|
import { GQLtoSQLMapper } from './queries/gql-to-sql-mapper';
|
|
15
|
+
import { convertFilterEnumValues } from './queries/enum-filter-converter';
|
|
14
16
|
import { DatabaseDriver, FieldSelection, MetadataProviderType } from './types/sql-types';
|
|
15
17
|
import {
|
|
16
18
|
GQLEntityFilterInputFieldType,
|
|
@@ -19,6 +21,10 @@ import {
|
|
|
19
21
|
} from './types/gql-types';
|
|
20
22
|
import { logger } from './variables';
|
|
21
23
|
|
|
24
|
+
// Re-export for backwards compatibility — consumers and tests import
|
|
25
|
+
// convertFilterEnumValues from here.
|
|
26
|
+
export { convertFilterEnumValues };
|
|
27
|
+
|
|
22
28
|
export const getGQLFields = (info: GraphQLResolveInfo) => {
|
|
23
29
|
graphqlFields;
|
|
24
30
|
try {
|
|
@@ -44,126 +50,6 @@ export const getGQLFields = (info: GraphQLResolveInfo) => {
|
|
|
44
50
|
}
|
|
45
51
|
};
|
|
46
52
|
|
|
47
|
-
const ENUM_OPERATORS = [
|
|
48
|
-
'_eq',
|
|
49
|
-
'_ne',
|
|
50
|
-
'_gt',
|
|
51
|
-
'_gte',
|
|
52
|
-
'_lt',
|
|
53
|
-
'_lte',
|
|
54
|
-
'_like',
|
|
55
|
-
'_re',
|
|
56
|
-
'_ilike',
|
|
57
|
-
'_fulltext',
|
|
58
|
-
'_in',
|
|
59
|
-
'_nin',
|
|
60
|
-
'_between',
|
|
61
|
-
] as const;
|
|
62
|
-
|
|
63
|
-
/** Keys whose (array) value should be recursively enum-converted element-by-element. */
|
|
64
|
-
const LOGICAL_KEYS = new Set(['_and', '_or', '_not']);
|
|
65
|
-
/** Keys that are always passed through verbatim (existence subquery shapes). */
|
|
66
|
-
const PASSTHROUGH_KEYS = new Set(['_exists', '_not_exists']);
|
|
67
|
-
|
|
68
|
-
function toDbValue(value: any, enumObj: any, isArray: boolean): any {
|
|
69
|
-
if (value === null || value === undefined) return value;
|
|
70
|
-
if (isArray && Array.isArray(value)) {
|
|
71
|
-
return value.map((v) => {
|
|
72
|
-
if (typeof v === 'string' && v in enumObj) return enumObj[v];
|
|
73
|
-
return v;
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
if (typeof value === 'string' && value in enumObj) return enumObj[value];
|
|
77
|
-
return value;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function findEnumFieldName(key: string, enumFields: Record<string, any>): string | null {
|
|
81
|
-
if (key in enumFields) return key;
|
|
82
|
-
for (const op of ENUM_OPERATORS) {
|
|
83
|
-
if (key.endsWith(op)) {
|
|
84
|
-
const fieldName = key.slice(0, -op.length);
|
|
85
|
-
if (fieldName in enumFields) return fieldName;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
89
|
-
if (lowercased in enumFields) return lowercased;
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Convert an operator-keyed object (e.g. { _eq: 'X', _in: [...] }) whose target is an enum field. */
|
|
94
|
-
function convertOperatorObject(
|
|
95
|
-
operatorObject: Record<string, any>,
|
|
96
|
-
enumObj: any
|
|
97
|
-
): Record<string, any> {
|
|
98
|
-
const converted: Record<string, any> = {};
|
|
99
|
-
for (const [op, opVal] of Object.entries(operatorObject)) {
|
|
100
|
-
const isArr = op === '_in' || op === '_nin';
|
|
101
|
-
converted[op] = toDbValue(opVal, enumObj, isArr);
|
|
102
|
-
}
|
|
103
|
-
return converted;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Recurse into a mapped custom-field nested filter using the referenced entity's enums. */
|
|
107
|
-
function convertMappedCustomField(
|
|
108
|
-
key: string,
|
|
109
|
-
value: any,
|
|
110
|
-
mappedCustomFields: Record<string, { mapping: { refEntity: new () => any } }>
|
|
111
|
-
): any {
|
|
112
|
-
const lowercased = key.charAt(0).toLowerCase() + key.slice(1);
|
|
113
|
-
const mappedField =
|
|
114
|
-
mappedCustomFields[key as keyof typeof mappedCustomFields] ??
|
|
115
|
-
mappedCustomFields[lowercased as keyof typeof mappedCustomFields];
|
|
116
|
-
if (!mappedField) return value;
|
|
117
|
-
const refGqlEntityName = getGQLEntityNameFor(mappedField.mapping.refEntity.name);
|
|
118
|
-
const refEnumFields = getMapEnumFieldsFor(refGqlEntityName);
|
|
119
|
-
return convertFilterEnumValues(value, refEnumFields);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function convertFilterEnumValues(
|
|
123
|
-
filter: any,
|
|
124
|
-
enumFields: Record<string, any>,
|
|
125
|
-
mappedCustomFields?: Record<string, { mapping: { refEntity: new () => any } }>
|
|
126
|
-
): any {
|
|
127
|
-
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) return filter;
|
|
128
|
-
if (Object.keys(enumFields).length === 0 && !mappedCustomFields) return filter;
|
|
129
|
-
|
|
130
|
-
const result: any = {};
|
|
131
|
-
for (const [key, value] of Object.entries(filter)) {
|
|
132
|
-
result[key] = convertFilterEntry(key, value, enumFields, mappedCustomFields);
|
|
133
|
-
}
|
|
134
|
-
return result;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Resolve a single filter key/value to its enum-converted form. */
|
|
138
|
-
function convertFilterEntry(
|
|
139
|
-
key: string,
|
|
140
|
-
value: any,
|
|
141
|
-
enumFields: Record<string, any>,
|
|
142
|
-
mappedCustomFields?: Record<string, { mapping: { refEntity: new () => any } }>
|
|
143
|
-
): any {
|
|
144
|
-
if (LOGICAL_KEYS.has(key)) {
|
|
145
|
-
return Array.isArray(value)
|
|
146
|
-
? value.map((v: any) => convertFilterEnumValues(v, enumFields, mappedCustomFields))
|
|
147
|
-
: value;
|
|
148
|
-
}
|
|
149
|
-
if (PASSTHROUGH_KEYS.has(key)) return value;
|
|
150
|
-
|
|
151
|
-
const enumFieldName = findEnumFieldName(key, enumFields);
|
|
152
|
-
if (enumFieldName) {
|
|
153
|
-
const enumObj = enumFields[enumFieldName];
|
|
154
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
155
|
-
return convertOperatorObject(value as Record<string, any>, enumObj);
|
|
156
|
-
}
|
|
157
|
-
const isArr = key.endsWith('_in') || key.endsWith('_nin');
|
|
158
|
-
return toDbValue(value, enumObj, isArr);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value) && mappedCustomFields) {
|
|
162
|
-
return convertMappedCustomField(key, value, mappedCustomFields);
|
|
163
|
-
}
|
|
164
|
-
return value;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
53
|
export class GQLQueryManager {
|
|
168
54
|
constructor(private opts?: { namedParameterPrefix?: string }) {}
|
|
169
55
|
async getQueryResultsForInfo<T, FilterT = T, K = any>(
|
|
@@ -229,6 +115,7 @@ export class GQLQueryManager {
|
|
|
229
115
|
}
|
|
230
116
|
const customFields = getCustomFieldsFor(getGQLEntityNameFor(entityName));
|
|
231
117
|
const enumFields = getMapEnumFieldsFor(getGQLEntityNameFor(entityName));
|
|
118
|
+
const relationFields = getRelationFieldsFor(getGQLEntityNameFor(entityName));
|
|
232
119
|
const mapper = new GQLtoSQLMapper(provider, this.opts);
|
|
233
120
|
|
|
234
121
|
let entityForMapper: new () => T = entity;
|
|
@@ -238,7 +125,12 @@ export class GQLQueryManager {
|
|
|
238
125
|
Object.setPrototypeOf(entityForMapper, entity);
|
|
239
126
|
}
|
|
240
127
|
|
|
241
|
-
const convertedFilter = convertFilterEnumValues(
|
|
128
|
+
const convertedFilter = convertFilterEnumValues(
|
|
129
|
+
filter,
|
|
130
|
+
enumFields,
|
|
131
|
+
customFields,
|
|
132
|
+
relationFields
|
|
133
|
+
);
|
|
242
134
|
|
|
243
135
|
const { bindings, querySQL } = mapper.buildQueryAndBindingsFor({
|
|
244
136
|
fields,
|