@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 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 | Purpose |
558
- | ---------------------------- | ------------------------------------------------------------------------------------------- |
559
- | `D3GOP_TYPES_SUFFIX` | Suffix appended to all generated GQL entity type names (e.g. `'V2'` → `BookV2`, `AuthorV2`) |
560
- | `D3GOP_SORT_SUFFIX` | Suffix appended to sort/pagination types only (e.g. `'V2'` → `SortV2`, `BookV2OrderBy`) |
561
- | `D3GOP_LOG_TYPE` | Logging level: `debug` or `disabled` |
562
- | `D3GOP_DEFAULT_QUERY_LIMIT` | Default query limit when pagination is not specified (default: `3000`) |
563
- | `D3GOP_USE_STRING_FOR_JSONB` | Toggle between JSONB and string concatenation for JSON aggregation |
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
- stateCode: {
781
- type: () => StateCode,
782
- mapNumericEnum: true,
783
- mapEnumOutput: 'key', // override per field
784
- generateFilter: true,
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 | SQL output | Works with |
790
- |------|-----------|------------|
791
- | `'raw'` (default) | `e_a1.state_code AS "stateCode"` | Live `buildSchema()` — graphql-js has `{ value: 0 }` |
792
- | `'key'` | `CASE e_a1.state_code WHEN 0 THEN 'ACTIVE' ... END AS "stateCode"` | SDL-rebuilt schema — graphql-js has `{ value: "ACTIVE" }` |
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: *"This library includes an agent skill with templates and patterns. Want me to install it?"*
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
- mappedBy?: string;
54
- }
53
+ } & RelationOwnership
55
54
  ): EntityProperty => ({
56
55
  type,
57
56
  name,
58
57
  fieldNames,
59
- mappedBy: reference?.mappedBy || '',
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.mappedBy)) {
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 && !relFieldProps.mappedBy)) {
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.mappedBy)) {
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 && !fieldProps.mappedBy)) {
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
- // Convert mapNumericEnum string keys → raw DB values for inline field
571
- // filter args. The top-level filter goes through convertFilterEnumValues
572
- // in GQLQueryManager, but inline filter args (e.g. `books(filter: {...})`)
573
- // enter here and would otherwise bypass enum conversion, causing
574
- // mapNumericEnum fields in sub-entity filters to fail silently.
575
- //
576
- // The filter keys belong to the TARGET entity (e.g. Book), not the parent
577
- // (e.g. Author). We WRAP the filter under the field name and convert using
578
- // the PARENT entity's registries — convertRelationField /
579
- // convertMappedCustomField then resolve the target entity and recurse into
580
- // its enum fields, handling arbitrary nesting depth.
581
- const gqlEntityName = (0, gql_entity_1.getGQLEntityNameFor)(entityMetadata.name ?? '');
582
- const parentEnumFields = (0, gql_entity_1.getMapEnumFieldsFor)(gqlEntityName);
583
- const parentCustomFields = (0, gql_entity_1.getCustomFieldsFor)(gqlEntityName);
584
- const parentRelationFields = (0, gql_entity_1.getRelationFieldsFor)(gqlEntityName);
585
- const wrappedConverted = (0, enum_filter_converter_1.convertFilterEnumValues)({ [parentGqlFieldNameKey]: filter }, parentEnumFields, parentCustomFields, parentRelationFields);
586
- const convertedFilter = wrappedConverted[parentGqlFieldNameKey] ?? filter;
587
- const mapped = this.recursiveMap({
588
- entityMetadata,
589
- parentAlias: alias,
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
- mappedBy: string;
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.7.0",
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 (fieldProps.reference === ReferenceType.MANY_TO_ONE) {
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 (fieldProps.reference === ReferenceType.MANY_TO_ONE) {
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.mappedBy)
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 && !relFieldProps.mappedBy)
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 (fieldProps.reference === ReferenceType.MANY_TO_ONE) {
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.mappedBy)
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 && !fieldProps.mappedBy)
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
- // Convert mapNumericEnum string keys → raw DB values for inline field
1186
- // filter args. The top-level filter goes through convertFilterEnumValues
1187
- // in GQLQueryManager, but inline filter args (e.g. `books(filter: {...})`)
1188
- // enter here and would otherwise bypass enum conversion, causing
1189
- // mapNumericEnum fields in sub-entity filters to fail silently.
1190
- //
1191
- // The filter keys belong to the TARGET entity (e.g. Book), not the parent
1192
- // (e.g. Author). We WRAP the filter under the field name and convert using
1193
- // the PARENT entity's registries — convertRelationField /
1194
- // convertMappedCustomField then resolve the target entity and recurse into
1195
- // its enum fields, handling arbitrary nesting depth.
1196
- const gqlEntityName = getGQLEntityNameFor(entityMetadata.name ?? '');
1197
- const parentEnumFields = getMapEnumFieldsFor(gqlEntityName);
1198
- const parentCustomFields = getCustomFieldsFor(gqlEntityName);
1199
- const parentRelationFields = getRelationFieldsFor(gqlEntityName);
1200
- const wrappedConverted = convertFilterEnumValues(
1201
- { [parentGqlFieldNameKey]: filter },
1202
- parentEnumFields,
1203
- parentCustomFields,
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
- const { innerJoin, where: w, values, _or, _and, _not } = QueriesUtils.mappingsReducer(mapped);
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) => {
@@ -15,12 +15,22 @@ export type EntityProperty = {
15
15
  reference?: ReferenceType | string;
16
16
  name: string;
17
17
  fieldNames: string[];
18
- mappedBy: string;
19
- joinColumns: string[];
20
- referencedColumnNames: string[];
21
- inverseJoinColumns: string[];
22
- pivotTable: string;
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[];