@dav3/gql-of-power 1.7.0 → 1.9.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 +20 -18
- package/agents/templates/entity-skeleton.ts.txt +3 -4
- package/dist/queries/filter-processor.js +10 -5
- package/dist/queries/gql-to-sql-mapper.js +31 -39
- package/dist/types/gql-to-sql-types.d.ts +9 -0
- package/dist/types/sql-types.d.ts +15 -1
- package/package.json +4 -2
- package/src/queries/filter-processor.ts +15 -6
- package/src/queries/gql-to-sql-mapper.ts +38 -45
- package/src/types/gql-to-sql-types.ts +9 -0
- package/src/types/sql-types.ts +16 -6
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ But what if there was another way? What if, with the power of a single SQL query
|
|
|
14
14
|
|
|
15
15
|
Like the One Ring wielding dominion over all other Rings, GQL of Power commands your database with singular authority.
|
|
16
16
|
|
|
17
|
+
> 🎮 **[Try the Live Demo](https://dave-palt.github.io/gql-of-power/)** — Write GraphQL queries in the browser and watch the SQL generate in real time. 100% client-side, no database needed.
|
|
18
|
+
|
|
17
19
|
---
|
|
18
20
|
|
|
19
21
|
## Features
|
|
@@ -554,13 +556,13 @@ pagination: {
|
|
|
554
556
|
|
|
555
557
|
### Environment Variables
|
|
556
558
|
|
|
557
|
-
| Variable
|
|
558
|
-
|
|
|
559
|
-
| `D3GOP_TYPES_SUFFIX`
|
|
560
|
-
| `D3GOP_SORT_SUFFIX`
|
|
561
|
-
| `D3GOP_LOG_TYPE`
|
|
562
|
-
| `D3GOP_DEFAULT_QUERY_LIMIT`
|
|
563
|
-
| `D3GOP_USE_STRING_FOR_JSONB`
|
|
559
|
+
| Variable | Purpose |
|
|
560
|
+
| ------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
|
561
|
+
| `D3GOP_TYPES_SUFFIX` | Suffix appended to all generated GQL entity type names (e.g. `'V2'` → `BookV2`, `AuthorV2`) |
|
|
562
|
+
| `D3GOP_SORT_SUFFIX` | Suffix appended to sort/pagination types only (e.g. `'V2'` → `SortV2`, `BookV2OrderBy`) |
|
|
563
|
+
| `D3GOP_LOG_TYPE` | Logging level: `debug` or `disabled` |
|
|
564
|
+
| `D3GOP_DEFAULT_QUERY_LIMIT` | Default query limit when pagination is not specified (default: `3000`) |
|
|
565
|
+
| `D3GOP_USE_STRING_FOR_JSONB` | Toggle between JSONB and string concatenation for JSON aggregation |
|
|
564
566
|
| `GQL_OF_POWER_MAP_ENUM_OUTPUT` | Enum output mode: `raw` (default) or `key`. See [mapEnumOutput](#mapenumoutput-sdl-schema-support) below. |
|
|
565
567
|
|
|
566
568
|
> **Type name collision**: If you have both v1 (`createGQLTypes`) and v2 (`@GQLEntityClass`) entities in the same schema, set `D3GOP_TYPES_SUFFIX` so v2 entity names are distinct (e.g. `Hobbit` → `HobbitV2`). Use `D3GOP_SORT_SUFFIX` separately if sort/pagination types also need a suffix. No `setGlobalConfig()` call is required — the env vars are read automatically.
|
|
@@ -777,19 +779,19 @@ setGlobalConfig({ mapEnumOutput: 'key' });
|
|
|
777
779
|
```typescript
|
|
778
780
|
// Or per-field
|
|
779
781
|
const fields = defineFields(MyEntity, {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
782
|
+
stateCode: {
|
|
783
|
+
type: () => StateCode,
|
|
784
|
+
mapNumericEnum: true,
|
|
785
|
+
mapEnumOutput: 'key', // override per field
|
|
786
|
+
generateFilter: true,
|
|
787
|
+
},
|
|
786
788
|
});
|
|
787
789
|
```
|
|
788
790
|
|
|
789
|
-
| Mode
|
|
790
|
-
|
|
791
|
-
| `'raw'` (default) | `e_a1.state_code AS "stateCode"`
|
|
792
|
-
| `'key'`
|
|
791
|
+
| Mode | SQL output | Works with |
|
|
792
|
+
| ----------------- | ------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
793
|
+
| `'raw'` (default) | `e_a1.state_code AS "stateCode"` | Live `buildSchema()` — graphql-js has `{ value: 0 }` |
|
|
794
|
+
| `'key'` | `CASE e_a1.state_code WHEN 0 THEN 'ACTIVE' ... END AS "stateCode"` | SDL-rebuilt schema — graphql-js has `{ value: "ACTIVE" }` |
|
|
793
795
|
|
|
794
796
|
Both modes produce identical JSON output: enum fields come back as string keys (`"ACTIVE"`).
|
|
795
797
|
|
|
@@ -837,7 +839,7 @@ If you are an AI agent that has been asked to set up or use this library:
|
|
|
837
839
|
|
|
838
840
|
1. **Read `AGENTS.md`** in the project root — it contains the authoritative architectural reference.
|
|
839
841
|
2. **Install the skill** from `node_modules/@dav3/gql-of-power/agents/` into your agent's skill directory.
|
|
840
|
-
3. **Suggest the user install it** if they haven't — say:
|
|
842
|
+
3. **Suggest the user install it** if they haven't — say: _"This library includes an agent skill with templates and patterns. Want me to install it?"_
|
|
841
843
|
4. Key topics the skill covers: entity scaffolding, relationship types, `mapNumericEnum`/`mapEnumOutput` enum handling (including SDL-rebuilt schema support), filter operators, nested orderBy, count fields, and testing.
|
|
842
844
|
|
|
843
845
|
---
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
GQLEntityFilterInputFieldType,
|
|
15
15
|
FieldsSettings,
|
|
16
16
|
} from '@dav3/gql-of-power';
|
|
17
|
-
import { EntityMetadata, EntityProperty, ReferenceType } from '@dav3/gql-of-power';
|
|
17
|
+
import { EntityMetadata, EntityProperty, ReferenceType, RelationOwnership } from '@dav3/gql-of-power';
|
|
18
18
|
import { Arg, Info, Int, Query, Resolver } from 'type-graphql';
|
|
19
19
|
import { queryManager, provider } from './your-app-setup'; // <-- your wiring
|
|
20
20
|
|
|
@@ -50,13 +50,12 @@ const prop = (
|
|
|
50
50
|
referenceType: ReferenceType;
|
|
51
51
|
joinColumns?: string[];
|
|
52
52
|
referencedColumnNames?: string[];
|
|
53
|
-
|
|
54
|
-
}
|
|
53
|
+
} & RelationOwnership
|
|
55
54
|
): EntityProperty => ({
|
|
56
55
|
type,
|
|
57
56
|
name,
|
|
58
57
|
fieldNames,
|
|
59
|
-
|
|
58
|
+
...reference,
|
|
60
59
|
joinColumns: reference?.joinColumns || [],
|
|
61
60
|
referencedColumnNames: reference?.referencedColumnNames || [],
|
|
62
61
|
inverseJoinColumns: [],
|
|
@@ -488,7 +488,7 @@ class FilterProcessor extends operations_1.ClassOperations {
|
|
|
488
488
|
const countAlias = this.aliasManager.next(alias_1.AliasType.entity, 'w');
|
|
489
489
|
let joinCondition = '';
|
|
490
490
|
if (fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_MANY ||
|
|
491
|
-
fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE) {
|
|
491
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)) {
|
|
492
492
|
const refFieldProps = relatedMetadata.properties[fieldProps.mappedBy];
|
|
493
493
|
const ons = refFieldProps.joinColumns;
|
|
494
494
|
const entityOns = refFieldProps.referencedColumnNames;
|
|
@@ -496,7 +496,8 @@ class FilterProcessor extends operations_1.ClassOperations {
|
|
|
496
496
|
.map((o, i) => `${parentAlias.toColumnName(o)} = ${countAlias.toColumnName(ons[i])}`)
|
|
497
497
|
.join(' and ');
|
|
498
498
|
}
|
|
499
|
-
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE
|
|
499
|
+
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE ||
|
|
500
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)) {
|
|
500
501
|
const ons = fieldProps.referencedColumnNames.length > 0
|
|
501
502
|
? fieldProps.referencedColumnNames
|
|
502
503
|
: relatedMetadata.primaryKeys;
|
|
@@ -574,10 +575,11 @@ class FilterProcessor extends operations_1.ClassOperations {
|
|
|
574
575
|
variables_1.logger.log('FilterProcessor - handleReferenceFieldFilter: referenceField', referenceField.name, fieldProps.reference, 'recursiveMapResults', recursiveMapResults, '_or', _or, alias.toString(), childAlias.toString());
|
|
575
576
|
// Handle different relationship types
|
|
576
577
|
if (fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_MANY ||
|
|
577
|
-
fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE) {
|
|
578
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)) {
|
|
578
579
|
this.mapFilterOneToX(referenceField, fieldProps, alias, childAlias, gqlFieldName, whereWithValues, outerJoin, innerJoin, values, mapping, _or);
|
|
579
580
|
}
|
|
580
|
-
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE
|
|
581
|
+
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE ||
|
|
582
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)) {
|
|
581
583
|
this.mapFilterManyToOne(fieldProps, referenceField, alias, childAlias, whereWithValues, outerJoin, innerJoin, mapping, values, _or);
|
|
582
584
|
}
|
|
583
585
|
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_MANY) {
|
|
@@ -791,13 +793,16 @@ class FilterProcessor extends operations_1.ClassOperations {
|
|
|
791
793
|
*/
|
|
792
794
|
mapFilterManyToOne(fieldProps, referenceField, parentAlias, alias, whereWithValues, outerJoin, innerJoin, mapping, values, _or) {
|
|
793
795
|
if (fieldProps.fieldNames.length && referenceField.tableName) {
|
|
796
|
+
// On the owning side (m:1 or owning 1:1), mappedBy is empty and
|
|
797
|
+
// referenceField.properties[''] is undefined — only used for the
|
|
798
|
+
// error message below. Resolve defensively.
|
|
794
799
|
const referenceFieldProps = referenceField.properties[fieldProps.mappedBy];
|
|
795
800
|
const ons = fieldProps.referencedColumnNames.length > 0
|
|
796
801
|
? fieldProps.referencedColumnNames
|
|
797
802
|
: referenceField.primaryKeys;
|
|
798
803
|
const entityOns = fieldProps.fieldNames;
|
|
799
804
|
if (ons.length !== entityOns.length) {
|
|
800
|
-
throw new Error(`m:1 join with different number of columns ${ons.length} !== ${entityOns.length} on ${referenceFieldProps.name}`);
|
|
805
|
+
throw new Error(`m:1 join with different number of columns ${ons.length} !== ${entityOns.length} on ${referenceFieldProps?.name ?? fieldProps.name}`);
|
|
801
806
|
}
|
|
802
807
|
const whereSQL = entityOns
|
|
803
808
|
.map((o, i) => {
|
|
@@ -366,11 +366,11 @@ class GQLtoSQLMapper {
|
|
|
366
366
|
_dispatchRequiredRelationMapping(relFieldProps, refMetadata, mapping, latestAlias, childAlias, whereWithValues, refValues, refInnerJoin, refOuterJoin, refSelect, refJson, limit, offset, orderBy, config, ownerMetadata) {
|
|
367
367
|
const primaryKeys = ownerMetadata?.primaryKeys ?? [];
|
|
368
368
|
if (relFieldProps.reference === sql_types_1.ReferenceType.ONE_TO_MANY ||
|
|
369
|
-
(relFieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && relFieldProps.
|
|
369
|
+
(relFieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && !relFieldProps.inversedBy)) {
|
|
370
370
|
this.relationshipHandler.mapOneToX(refMetadata, relFieldProps, mapping, latestAlias, childAlias, whereWithValues, refValues, limit, offset, orderBy, config.as, refJson, refSelect, refInnerJoin, refOuterJoin);
|
|
371
371
|
}
|
|
372
372
|
else if (relFieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE ||
|
|
373
|
-
(relFieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE &&
|
|
373
|
+
(relFieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && relFieldProps.inversedBy)) {
|
|
374
374
|
this.relationshipHandler.mapManyToOne(relFieldProps, refMetadata, latestAlias, childAlias, mapping, whereWithValues, refValues, refInnerJoin, limit, offset, config.as, refSelect, refJson, refOuterJoin);
|
|
375
375
|
}
|
|
376
376
|
else if (relFieldProps.reference === sql_types_1.ReferenceType.MANY_TO_MANY) {
|
|
@@ -419,7 +419,7 @@ class GQLtoSQLMapper {
|
|
|
419
419
|
// Build the join condition between parent and child, same logic as FilterProcessor
|
|
420
420
|
let joinCondition = '';
|
|
421
421
|
if (fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_MANY ||
|
|
422
|
-
fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE) {
|
|
422
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)) {
|
|
423
423
|
const refFieldProps = relatedMetadata.properties[fieldProps.mappedBy];
|
|
424
424
|
const ons = refFieldProps.joinColumns;
|
|
425
425
|
const entityOns = refFieldProps.referencedColumnNames;
|
|
@@ -427,7 +427,8 @@ class GQLtoSQLMapper {
|
|
|
427
427
|
.map((o, i) => `${parentAlias.toColumnName(o)} = ${countAlias.toColumnName(ons[i])}`)
|
|
428
428
|
.join(' and ');
|
|
429
429
|
}
|
|
430
|
-
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE
|
|
430
|
+
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE ||
|
|
431
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)) {
|
|
431
432
|
const ons = fieldProps.referencedColumnNames.length > 0
|
|
432
433
|
? fieldProps.referencedColumnNames
|
|
433
434
|
: relatedMetadata.primaryKeys;
|
|
@@ -507,6 +508,10 @@ class GQLtoSQLMapper {
|
|
|
507
508
|
parentAlias: alias,
|
|
508
509
|
alias: childAlias,
|
|
509
510
|
parentGqlFieldNameKey: parentGqlFieldNameKey,
|
|
511
|
+
// Pass the inline filter (from `books(filter: {...})`) to the
|
|
512
|
+
// CHILD's recursiveMap so the WHERE lands inside the child's
|
|
513
|
+
// lateral join subquery — child-row filtering, NOT EXISTS on parent.
|
|
514
|
+
gqlFilters: mapping.inlineFilter ? [mapping.inlineFilter] : undefined,
|
|
510
515
|
});
|
|
511
516
|
variables_1.logger.log('NEW MAPPING:', { parentGqlFieldNameKey, gqlFieldName }, [...newMappings.keys()], (0, gql_to_sql_types_1.mappingsTypeToString)(mapping, true));
|
|
512
517
|
const newMapping = utils_1.QueriesUtils.newMappings();
|
|
@@ -535,12 +540,12 @@ class GQLtoSQLMapper {
|
|
|
535
540
|
}, true));
|
|
536
541
|
variables_1.logger.log('GQLtoSQLMapper - recursiveMap - referenceField', referenceField.name, 'innerJoin', innerJoin, 'limit', limit, 'offset', offset, 'orderBy', mapping.orderBy, 'reference', fieldProps.reference, 'fields');
|
|
537
542
|
if (fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_MANY ||
|
|
538
|
-
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && fieldProps.
|
|
543
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)) {
|
|
539
544
|
variables_1.logger.warn('[DIAG mapField dispatch]', gqlFieldName, '→ mapOneToX');
|
|
540
545
|
this.relationshipHandler.mapOneToX(referenceField, fieldProps, mapping, alias, childAlias, whereWithValues, values, limit, offset, orderBy, gqlFieldName, json, select, innerJoin, outerJoin);
|
|
541
546
|
}
|
|
542
547
|
else if (fieldProps.reference === sql_types_1.ReferenceType.MANY_TO_ONE ||
|
|
543
|
-
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE &&
|
|
548
|
+
(fieldProps.reference === sql_types_1.ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)) {
|
|
544
549
|
variables_1.logger.warn('[DIAG mapField dispatch]', gqlFieldName, '→ mapManyToOne', 'fieldNames:', fieldProps.fieldNames, 'refTableName:', referenceField.tableName);
|
|
545
550
|
this.relationshipHandler.mapManyToOne(fieldProps, referenceField, alias, childAlias, mapping, whereWithValues, values, innerJoin, limit, offset, gqlFieldName, select, json, outerJoin);
|
|
546
551
|
}
|
|
@@ -567,41 +572,28 @@ class GQLtoSQLMapper {
|
|
|
567
572
|
const { filter, pagination } = args ?? {};
|
|
568
573
|
variables_1.logger.log(prefix, 'args', parentGqlFieldNameKey, { ...filter }, JSON.stringify(pagination));
|
|
569
574
|
if (filter || pagination) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
alias,
|
|
591
|
-
gqlFilters: [
|
|
592
|
-
{ [parentGqlFieldNameKey]: { ...convertedFilter } },
|
|
593
|
-
],
|
|
594
|
-
isFieldFilter: true,
|
|
595
|
-
});
|
|
596
|
-
const { innerJoin, where: w, values, _or, _and, _not } = utils_1.QueriesUtils.mappingsReducer(mapped);
|
|
597
|
-
mapping.innerJoin.push(...innerJoin);
|
|
598
|
-
mapping.where.push(...w);
|
|
599
|
-
mapping.values = { ...mapping.values, ...values };
|
|
575
|
+
if (filter) {
|
|
576
|
+
// Convert mapNumericEnum string keys → raw DB values for inline
|
|
577
|
+
// field filter args. The filter keys belong to the TARGET entity
|
|
578
|
+
// (e.g. Book), not the parent (e.g. Author). We WRAP the filter
|
|
579
|
+
// under the field name and convert using the PARENT entity's
|
|
580
|
+
// registries — convertRelationField / convertMappedCustomField
|
|
581
|
+
// then resolve the target entity and recurse into its enum
|
|
582
|
+
// fields, handling arbitrary nesting depth.
|
|
583
|
+
const gqlEntityName = (0, gql_entity_1.getGQLEntityNameFor)(entityMetadata.name ?? '');
|
|
584
|
+
const parentEnumFields = (0, gql_entity_1.getMapEnumFieldsFor)(gqlEntityName);
|
|
585
|
+
const parentCustomFields = (0, gql_entity_1.getCustomFieldsFor)(gqlEntityName);
|
|
586
|
+
const parentRelationFields = (0, gql_entity_1.getRelationFieldsFor)(gqlEntityName);
|
|
587
|
+
const wrappedConverted = (0, enum_filter_converter_1.convertFilterEnumValues)({ [parentGqlFieldNameKey]: filter }, parentEnumFields, parentCustomFields, parentRelationFields);
|
|
588
|
+
const convertedFilter = wrappedConverted[parentGqlFieldNameKey] ?? filter;
|
|
589
|
+
// Store the converted filter for mapField to pass to the child's
|
|
590
|
+
// recursiveMap. This ensures the WHERE clause lands inside the
|
|
591
|
+
// child's lateral join subquery (child-row filtering), NOT as an
|
|
592
|
+
// EXISTS on the parent.
|
|
593
|
+
mapping.inlineFilter = convertedFilter;
|
|
594
|
+
}
|
|
600
595
|
mapping.limit = pagination?.limit;
|
|
601
596
|
mapping.offset = pagination?.offset;
|
|
602
|
-
mapping._or.push(..._or);
|
|
603
|
-
mapping._and.push(..._and);
|
|
604
|
-
mapping._not.push(..._not);
|
|
605
597
|
mapping.orderBy.push(...(pagination?.orderBy ?? []));
|
|
606
598
|
variables_1.logger.log('GQLtoSQLMapper - handleFieldArguments - processed', filter, 'mapping', (0, gql_to_sql_types_1.mappingsTypeToString)(mapping, true));
|
|
607
599
|
}
|
|
@@ -15,6 +15,15 @@ export type MappingsType = {
|
|
|
15
15
|
_or: MappingsType[];
|
|
16
16
|
_and: MappingsType[];
|
|
17
17
|
_not: MappingsType[];
|
|
18
|
+
/**
|
|
19
|
+
* Converted inline filter args from a relation field's `filter` argument
|
|
20
|
+
* (e.g. `books(filter: { title_eq: "X" })`). Stored by
|
|
21
|
+
* `handleFieldArguments` and consumed by `mapField`, which passes it to the
|
|
22
|
+
* child entity's `recursiveMap` as `gqlFilters` so the WHERE lands inside
|
|
23
|
+
* the child's lateral join subquery (child-row filtering), NOT as an EXISTS
|
|
24
|
+
* on the parent.
|
|
25
|
+
*/
|
|
26
|
+
inlineFilter?: any;
|
|
18
27
|
};
|
|
19
28
|
export declare const mappingsTypeToString: (m: MappingsType, full?: boolean) => string;
|
|
20
29
|
export type FilterMappingType = {
|
|
@@ -14,12 +14,26 @@ export type EntityProperty = {
|
|
|
14
14
|
reference?: ReferenceType | string;
|
|
15
15
|
name: string;
|
|
16
16
|
fieldNames: string[];
|
|
17
|
-
|
|
17
|
+
} & RelationOwnership & {
|
|
18
18
|
joinColumns: string[];
|
|
19
19
|
referencedColumnNames: string[];
|
|
20
20
|
inverseJoinColumns: string[];
|
|
21
21
|
pivotTable: string;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Enforces mutual exclusivity between `mappedBy` and `inversedBy`.
|
|
25
|
+
* A relation is either:
|
|
26
|
+
* - inverse side: `mappedBy` set, `inversedBy` absent
|
|
27
|
+
* - owning side: `inversedBy` set, `mappedBy` absent
|
|
28
|
+
* Scalar fields have neither (satisfies either branch since both are optional).
|
|
29
|
+
*/
|
|
30
|
+
export type RelationOwnership = {
|
|
31
|
+
mappedBy?: string;
|
|
32
|
+
inversedBy?: never;
|
|
33
|
+
} | {
|
|
34
|
+
inversedBy?: string;
|
|
35
|
+
mappedBy?: never;
|
|
36
|
+
};
|
|
23
37
|
export type EntityMetadata<T> = {
|
|
24
38
|
primaryKeys: string[];
|
|
25
39
|
name?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dav3/gql-of-power",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Generate a single optimized SQL query from GraphQL operations — eliminate N+1 with recursive field-to-SQL mapping. Ships with agent skill for AI coding assistants.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"audit:dead-code": "fallow dead-code",
|
|
29
29
|
"audit:dupes": "fallow dupes",
|
|
30
30
|
"audit:health": "fallow health",
|
|
31
|
-
"audit:fix": "fallow fix"
|
|
31
|
+
"audit:fix": "fallow fix",
|
|
32
|
+
"build:demo": "bun build examples/demo/src/main.ts --target=browser --format=iife --outfile=examples/demo/dist/main.js --define process.env.D3GOP_LOG_TYPE='\"disabled\"'"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
34
35
|
"graphql",
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"@graphql-tools/schema": "^10.0.38",
|
|
49
50
|
"@types/bun": "latest",
|
|
50
51
|
"@types/graphql-fields": "^1.3.9",
|
|
52
|
+
"class-validator": "^0.15.1",
|
|
51
53
|
"fallow": "^3.6.0",
|
|
52
54
|
"graphql-yoga": "^5.21.0",
|
|
53
55
|
"reflect-metadata": "^0.2.2",
|
|
@@ -844,7 +844,7 @@ export class FilterProcessor extends ClassOperations {
|
|
|
844
844
|
let joinCondition = '';
|
|
845
845
|
if (
|
|
846
846
|
fieldProps.reference === ReferenceType.ONE_TO_MANY ||
|
|
847
|
-
fieldProps.reference === ReferenceType.ONE_TO_ONE
|
|
847
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)
|
|
848
848
|
) {
|
|
849
849
|
const refFieldProps = relatedMetadata.properties[
|
|
850
850
|
fieldProps.mappedBy as keyof typeof relatedMetadata.properties
|
|
@@ -854,7 +854,10 @@ export class FilterProcessor extends ClassOperations {
|
|
|
854
854
|
joinCondition = entityOns
|
|
855
855
|
.map((o, i) => `${parentAlias.toColumnName(o)} = ${countAlias.toColumnName(ons[i])}`)
|
|
856
856
|
.join(' and ');
|
|
857
|
-
} else if (
|
|
857
|
+
} else if (
|
|
858
|
+
fieldProps.reference === ReferenceType.MANY_TO_ONE ||
|
|
859
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)
|
|
860
|
+
) {
|
|
858
861
|
const ons =
|
|
859
862
|
fieldProps.referencedColumnNames.length > 0
|
|
860
863
|
? fieldProps.referencedColumnNames
|
|
@@ -1039,7 +1042,7 @@ export class FilterProcessor extends ClassOperations {
|
|
|
1039
1042
|
// Handle different relationship types
|
|
1040
1043
|
if (
|
|
1041
1044
|
fieldProps.reference === ReferenceType.ONE_TO_MANY ||
|
|
1042
|
-
fieldProps.reference === ReferenceType.ONE_TO_ONE
|
|
1045
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)
|
|
1043
1046
|
) {
|
|
1044
1047
|
this.mapFilterOneToX<T>(
|
|
1045
1048
|
referenceField,
|
|
@@ -1054,7 +1057,10 @@ export class FilterProcessor extends ClassOperations {
|
|
|
1054
1057
|
mapping,
|
|
1055
1058
|
_or
|
|
1056
1059
|
);
|
|
1057
|
-
} else if (
|
|
1060
|
+
} else if (
|
|
1061
|
+
fieldProps.reference === ReferenceType.MANY_TO_ONE ||
|
|
1062
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)
|
|
1063
|
+
) {
|
|
1058
1064
|
this.mapFilterManyToOne<T>(
|
|
1059
1065
|
fieldProps,
|
|
1060
1066
|
referenceField,
|
|
@@ -1548,9 +1554,12 @@ export class FilterProcessor extends ClassOperations {
|
|
|
1548
1554
|
_or: MappingsType[]
|
|
1549
1555
|
): void {
|
|
1550
1556
|
if (fieldProps.fieldNames.length && referenceField.tableName) {
|
|
1557
|
+
// On the owning side (m:1 or owning 1:1), mappedBy is empty and
|
|
1558
|
+
// referenceField.properties[''] is undefined — only used for the
|
|
1559
|
+
// error message below. Resolve defensively.
|
|
1551
1560
|
const referenceFieldProps = referenceField.properties[
|
|
1552
1561
|
fieldProps.mappedBy as keyof typeof referenceField.properties
|
|
1553
|
-
] as EntityProperty;
|
|
1562
|
+
] as EntityProperty | undefined;
|
|
1554
1563
|
|
|
1555
1564
|
const ons =
|
|
1556
1565
|
fieldProps.referencedColumnNames.length > 0
|
|
@@ -1560,7 +1569,7 @@ export class FilterProcessor extends ClassOperations {
|
|
|
1560
1569
|
|
|
1561
1570
|
if (ons.length !== entityOns.length) {
|
|
1562
1571
|
throw new Error(
|
|
1563
|
-
`m:1 join with different number of columns ${ons.length} !== ${entityOns.length} on ${referenceFieldProps.name}`
|
|
1572
|
+
`m:1 join with different number of columns ${ons.length} !== ${entityOns.length} on ${referenceFieldProps?.name ?? fieldProps.name}`
|
|
1564
1573
|
);
|
|
1565
1574
|
}
|
|
1566
1575
|
|
|
@@ -735,7 +735,7 @@ export class GQLtoSQLMapper {
|
|
|
735
735
|
const primaryKeys = ownerMetadata?.primaryKeys ?? [];
|
|
736
736
|
if (
|
|
737
737
|
relFieldProps.reference === ReferenceType.ONE_TO_MANY ||
|
|
738
|
-
(relFieldProps.reference === ReferenceType.ONE_TO_ONE && relFieldProps.
|
|
738
|
+
(relFieldProps.reference === ReferenceType.ONE_TO_ONE && !relFieldProps.inversedBy)
|
|
739
739
|
) {
|
|
740
740
|
this.relationshipHandler.mapOneToX(
|
|
741
741
|
refMetadata,
|
|
@@ -756,7 +756,7 @@ export class GQLtoSQLMapper {
|
|
|
756
756
|
);
|
|
757
757
|
} else if (
|
|
758
758
|
relFieldProps.reference === ReferenceType.MANY_TO_ONE ||
|
|
759
|
-
(relFieldProps.reference === ReferenceType.ONE_TO_ONE &&
|
|
759
|
+
(relFieldProps.reference === ReferenceType.ONE_TO_ONE && relFieldProps.inversedBy)
|
|
760
760
|
) {
|
|
761
761
|
this.relationshipHandler.mapManyToOne(
|
|
762
762
|
relFieldProps,
|
|
@@ -856,7 +856,7 @@ export class GQLtoSQLMapper {
|
|
|
856
856
|
let joinCondition = '';
|
|
857
857
|
if (
|
|
858
858
|
fieldProps.reference === ReferenceType.ONE_TO_MANY ||
|
|
859
|
-
fieldProps.reference === ReferenceType.ONE_TO_ONE
|
|
859
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)
|
|
860
860
|
) {
|
|
861
861
|
const refFieldProps = relatedMetadata.properties[
|
|
862
862
|
fieldProps.mappedBy as keyof typeof relatedMetadata.properties
|
|
@@ -866,7 +866,10 @@ export class GQLtoSQLMapper {
|
|
|
866
866
|
joinCondition = entityOns
|
|
867
867
|
.map((o, i) => `${parentAlias.toColumnName(o)} = ${countAlias.toColumnName(ons[i])}`)
|
|
868
868
|
.join(' and ');
|
|
869
|
-
} else if (
|
|
869
|
+
} else if (
|
|
870
|
+
fieldProps.reference === ReferenceType.MANY_TO_ONE ||
|
|
871
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)
|
|
872
|
+
) {
|
|
870
873
|
const ons =
|
|
871
874
|
fieldProps.referencedColumnNames.length > 0
|
|
872
875
|
? fieldProps.referencedColumnNames
|
|
@@ -998,6 +1001,10 @@ export class GQLtoSQLMapper {
|
|
|
998
1001
|
parentAlias: alias,
|
|
999
1002
|
alias: childAlias,
|
|
1000
1003
|
parentGqlFieldNameKey: parentGqlFieldNameKey,
|
|
1004
|
+
// Pass the inline filter (from `books(filter: {...})`) to the
|
|
1005
|
+
// CHILD's recursiveMap so the WHERE lands inside the child's
|
|
1006
|
+
// lateral join subquery — child-row filtering, NOT EXISTS on parent.
|
|
1007
|
+
gqlFilters: mapping.inlineFilter ? [mapping.inlineFilter] : undefined,
|
|
1001
1008
|
});
|
|
1002
1009
|
|
|
1003
1010
|
logger.log(
|
|
@@ -1068,7 +1075,7 @@ export class GQLtoSQLMapper {
|
|
|
1068
1075
|
);
|
|
1069
1076
|
if (
|
|
1070
1077
|
fieldProps.reference === ReferenceType.ONE_TO_MANY ||
|
|
1071
|
-
(fieldProps.reference === ReferenceType.ONE_TO_ONE && fieldProps.
|
|
1078
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && !fieldProps.inversedBy)
|
|
1072
1079
|
) {
|
|
1073
1080
|
logger.warn('[DIAG mapField dispatch]', gqlFieldName, '→ mapOneToX');
|
|
1074
1081
|
this.relationshipHandler.mapOneToX(
|
|
@@ -1090,7 +1097,7 @@ export class GQLtoSQLMapper {
|
|
|
1090
1097
|
);
|
|
1091
1098
|
} else if (
|
|
1092
1099
|
fieldProps.reference === ReferenceType.MANY_TO_ONE ||
|
|
1093
|
-
(fieldProps.reference === ReferenceType.ONE_TO_ONE &&
|
|
1100
|
+
(fieldProps.reference === ReferenceType.ONE_TO_ONE && fieldProps.inversedBy)
|
|
1094
1101
|
) {
|
|
1095
1102
|
logger.warn(
|
|
1096
1103
|
'[DIAG mapField dispatch]',
|
|
@@ -1182,49 +1189,35 @@ export class GQLtoSQLMapper {
|
|
|
1182
1189
|
|
|
1183
1190
|
logger.log(prefix, 'args', parentGqlFieldNameKey, { ...filter }, JSON.stringify(pagination));
|
|
1184
1191
|
if (filter || pagination) {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
parentRelationFields
|
|
1205
|
-
);
|
|
1206
|
-
const convertedFilter = wrappedConverted[parentGqlFieldNameKey] ?? filter;
|
|
1207
|
-
|
|
1208
|
-
const mapped = this.recursiveMap({
|
|
1209
|
-
entityMetadata,
|
|
1210
|
-
parentAlias: alias,
|
|
1211
|
-
alias,
|
|
1212
|
-
gqlFilters: [
|
|
1213
|
-
{ [parentGqlFieldNameKey]: { ...convertedFilter } } as GQLEntityFilterInputFieldType<T>,
|
|
1214
|
-
],
|
|
1215
|
-
isFieldFilter: true,
|
|
1216
|
-
});
|
|
1192
|
+
if (filter) {
|
|
1193
|
+
// Convert mapNumericEnum string keys → raw DB values for inline
|
|
1194
|
+
// field filter args. The filter keys belong to the TARGET entity
|
|
1195
|
+
// (e.g. Book), not the parent (e.g. Author). We WRAP the filter
|
|
1196
|
+
// under the field name and convert using the PARENT entity's
|
|
1197
|
+
// registries — convertRelationField / convertMappedCustomField
|
|
1198
|
+
// then resolve the target entity and recurse into its enum
|
|
1199
|
+
// fields, handling arbitrary nesting depth.
|
|
1200
|
+
const gqlEntityName = getGQLEntityNameFor(entityMetadata.name ?? '');
|
|
1201
|
+
const parentEnumFields = getMapEnumFieldsFor(gqlEntityName);
|
|
1202
|
+
const parentCustomFields = getCustomFieldsFor(gqlEntityName);
|
|
1203
|
+
const parentRelationFields = getRelationFieldsFor(gqlEntityName);
|
|
1204
|
+
const wrappedConverted = convertFilterEnumValues(
|
|
1205
|
+
{ [parentGqlFieldNameKey]: filter },
|
|
1206
|
+
parentEnumFields,
|
|
1207
|
+
parentCustomFields,
|
|
1208
|
+
parentRelationFields
|
|
1209
|
+
);
|
|
1210
|
+
const convertedFilter = wrappedConverted[parentGqlFieldNameKey] ?? filter;
|
|
1217
1211
|
|
|
1218
|
-
|
|
1212
|
+
// Store the converted filter for mapField to pass to the child's
|
|
1213
|
+
// recursiveMap. This ensures the WHERE clause lands inside the
|
|
1214
|
+
// child's lateral join subquery (child-row filtering), NOT as an
|
|
1215
|
+
// EXISTS on the parent.
|
|
1216
|
+
mapping.inlineFilter = convertedFilter;
|
|
1217
|
+
}
|
|
1219
1218
|
|
|
1220
|
-
mapping.innerJoin.push(...innerJoin);
|
|
1221
|
-
mapping.where.push(...w);
|
|
1222
|
-
mapping.values = { ...mapping.values, ...values };
|
|
1223
1219
|
mapping.limit = pagination?.limit;
|
|
1224
1220
|
mapping.offset = pagination?.offset;
|
|
1225
|
-
mapping._or.push(..._or);
|
|
1226
|
-
mapping._and.push(..._and);
|
|
1227
|
-
mapping._not.push(..._not);
|
|
1228
1221
|
mapping.orderBy.push(...(pagination?.orderBy ?? []));
|
|
1229
1222
|
logger.log(
|
|
1230
1223
|
'GQLtoSQLMapper - handleFieldArguments - processed',
|
|
@@ -19,6 +19,15 @@ export type MappingsType = {
|
|
|
19
19
|
_or: MappingsType[];
|
|
20
20
|
_and: MappingsType[];
|
|
21
21
|
_not: MappingsType[];
|
|
22
|
+
/**
|
|
23
|
+
* Converted inline filter args from a relation field's `filter` argument
|
|
24
|
+
* (e.g. `books(filter: { title_eq: "X" })`). Stored by
|
|
25
|
+
* `handleFieldArguments` and consumed by `mapField`, which passes it to the
|
|
26
|
+
* child entity's `recursiveMap` as `gqlFilters` so the WHERE lands inside
|
|
27
|
+
* the child's lateral join subquery (child-row filtering), NOT as an EXISTS
|
|
28
|
+
* on the parent.
|
|
29
|
+
*/
|
|
30
|
+
inlineFilter?: any;
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
export const mappingsTypeToString = (m: MappingsType, full = false) => {
|
package/src/types/sql-types.ts
CHANGED
|
@@ -15,12 +15,22 @@ export type EntityProperty = {
|
|
|
15
15
|
reference?: ReferenceType | string;
|
|
16
16
|
name: string;
|
|
17
17
|
fieldNames: string[];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
};
|
|
18
|
+
} & RelationOwnership & {
|
|
19
|
+
joinColumns: string[];
|
|
20
|
+
referencedColumnNames: string[];
|
|
21
|
+
inverseJoinColumns: string[];
|
|
22
|
+
pivotTable: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Enforces mutual exclusivity between `mappedBy` and `inversedBy`.
|
|
27
|
+
* A relation is either:
|
|
28
|
+
* - inverse side: `mappedBy` set, `inversedBy` absent
|
|
29
|
+
* - owning side: `inversedBy` set, `mappedBy` absent
|
|
30
|
+
* Scalar fields have neither (satisfies either branch since both are optional).
|
|
31
|
+
*/
|
|
32
|
+
export type RelationOwnership =
|
|
33
|
+
{ mappedBy?: string; inversedBy?: never } | { inversedBy?: string; mappedBy?: never };
|
|
24
34
|
|
|
25
35
|
export type EntityMetadata<T> = {
|
|
26
36
|
primaryKeys: string[];
|