@dav3/gql-of-power 1.6.0 → 1.7.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
@@ -561,6 +561,7 @@ pagination: {
561
561
  | `D3GOP_LOG_TYPE` | Logging level: `debug` or `disabled` |
562
562
  | `D3GOP_DEFAULT_QUERY_LIMIT` | Default query limit when pagination is not specified (default: `3000`) |
563
563
  | `D3GOP_USE_STRING_FOR_JSONB` | Toggle between JSONB and string concatenation for JSON aggregation |
564
+ | `GQL_OF_POWER_MAP_ENUM_OUTPUT` | Enum output mode: `raw` (default) or `key`. See [mapEnumOutput](#mapenumoutput-sdl-schema-support) below. |
564
565
 
565
566
  > **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.
566
567
 
@@ -574,6 +575,9 @@ setGlobalConfig({ gqlTypesSuffix: 'V2' });
574
575
 
575
576
  // Optionally set a separate suffix for sort/pagination types
576
577
  setGlobalConfig({ gqlTypesSuffix: 'V2', gqlSortSuffix: 'V2' });
578
+
579
+ // Enum output mode for SDL-rebuilt schemas (Apollo Server + .graphql file)
580
+ setGlobalConfig({ mapEnumOutput: 'key' });
577
581
  ```
578
582
 
579
583
  ---
@@ -739,6 +743,58 @@ rings { bearer(filter: { questState: InProgress }) { name } }
739
743
 
740
744
  ---
741
745
 
746
+ ### `mapEnumOutput` — SDL schema support
747
+
748
+ #### The problem
749
+
750
+ If your app builds the GraphQL schema from a **pre-generated SDL file** (e.g. Apollo Server + `buildASTSchema(parse(schemaSDL))`) instead of calling type-graphql's `buildSchema()` live, `mapNumericEnum` fields will throw:
751
+
752
+ ```
753
+ GraphQLError: Enum "ScheduleOverrideLineStateCode" cannot represent value: 0
754
+ ```
755
+
756
+ #### Why
757
+
758
+ SDL has no syntax for numeric enum values. `enum StateCode { ACTIVE, INACTIVE }` is all SDL can express — there's no `= 0`. When graphql-js rebuilds the schema from SDL text, it defaults each enum value to its name string (`{ ACTIVE: { value: "ACTIVE" } }`) instead of the original number (`{ ACTIVE: { value: 0 } }`). So `serialize(0)` fails because `0` isn't in the lookup map — only `"ACTIVE"` is.
759
+
760
+ Native `buildSchema()` preserves the in-memory TypeScript enum objects, so this only affects the SDL-rebuilt path.
761
+
762
+ #### Fix
763
+
764
+ Enable `mapEnumOutput: 'key'`. This wraps enum columns in a SQL `CASE WHEN 0 THEN 'ACTIVE' END` expression so the query returns the string key directly — which the SDL-rebuilt schema can serialize.
765
+
766
+ ```bash
767
+ # Env var (recommended for Lambda/Apollo setups)
768
+ GQL_OF_POWER_MAP_ENUM_OUTPUT=key
769
+ ```
770
+
771
+ ```typescript
772
+ // Or programmatically
773
+ import { setGlobalConfig } from '@dav3/gql-of-power';
774
+ setGlobalConfig({ mapEnumOutput: 'key' });
775
+ ```
776
+
777
+ ```typescript
778
+ // Or per-field
779
+ const fields = defineFields(MyEntity, {
780
+ stateCode: {
781
+ type: () => StateCode,
782
+ mapNumericEnum: true,
783
+ mapEnumOutput: 'key', // override per field
784
+ generateFilter: true,
785
+ },
786
+ });
787
+ ```
788
+
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" }` |
793
+
794
+ Both modes produce identical JSON output: enum fields come back as string keys (`"ACTIVE"`).
795
+
796
+ ---
797
+
742
798
  ## Known Limitations
743
799
 
744
800
  - ⚠️ Order by columns on related/joined tables not supported
@@ -748,9 +804,19 @@ rings { bearer(filter: { questState: InProgress }) { name } }
748
804
 
749
805
  ## Agent Integration
750
806
 
751
- This library ships an **agent skill** (`agents/`) that teaches AI coding assistants (Claude Code, Cursor, Hermes, or any agent reading `AGENTS.md`) how to scaffold gql-of-power entities, relationships, and advanced features correctly — using copy-paste templates, annotated relationship examples, and a decision tree for picking the right relationship type.
807
+ > **Using an AI coding assistant (Claude Code, Cursor, Hermes, etc.)?**
808
+ > This library ships a built-in **agent skill** that teaches your assistant how to scaffold gql-of-power entities, relationships, and advanced features correctly. Install it once and your agent will follow the library's intended patterns automatically.
809
+
810
+ ### What the skill provides
752
811
 
753
- To install it in a consumer project (one-time setup):
812
+ - Copy-paste entity templates (1:1, 1:m, m:1, m:n relationships)
813
+ - Decision tree for picking the right relationship type
814
+ - `mapNumericEnum`, `mapEnumOutput`, `parseJson`, count fields, `mapping` custom fields, `excludeFromInput` guides
815
+ - Inline filter operators (`_and`, `_or`, `_not`, `_startsWith`, `_is_null`, `_nbetween`, etc.)
816
+ - Nested `orderBy`, existence filters, ACL patterns
817
+ - Test patterns (unit + integration + dual-path enum testing)
818
+
819
+ ### Install
754
820
 
755
821
  ```bash
756
822
  # Claude Code
@@ -763,7 +829,16 @@ mkdir -p .cursor/rules && cp node_modules/@dav3/gql-of-power/agents/SKILL.md .cu
763
829
  ln -s node_modules/@dav3/gql-of-power/agents ~/.hermes/skills/gql-of-power
764
830
  ```
765
831
 
766
- See [`agents/install.md`](./agents/install.md) for full instructions (including a symlink option that tracks gql-of-power updates, slash-command setup, and a generic `AGENTS.md` snippet). Once installed, ask your agent to "add a gql-of-power entity" and it will follow the library's intended patterns.
832
+ See [`agents/install.md`](./agents/install.md) for full instructions (including a symlink option that tracks gql-of-power updates, slash-command setup, and a generic `AGENTS.md` snippet).
833
+
834
+ ### For agent authors / automated discovery
835
+
836
+ If you are an AI agent that has been asked to set up or use this library:
837
+
838
+ 1. **Read `AGENTS.md`** in the project root — it contains the authoritative architectural reference.
839
+ 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?"*
841
+ 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.
767
842
 
768
843
  ---
769
844
 
@@ -5,7 +5,9 @@ import { AccessControlEntry } from '../types/access-control';
5
5
  export declare const setGlobalConfig: (config: {
6
6
  gqlTypesSuffix?: string;
7
7
  gqlSortSuffix?: string;
8
+ mapEnumOutput?: "raw" | "key";
8
9
  }) => void;
10
+ export declare const getMapEnumOutputGlobal: () => "raw" | "key";
9
11
  export declare const getFieldsOptionsFor: (name: string) => Record<string, string>;
10
12
  export declare const getFieldByAlias: (entityName: string | undefined, alias: string) => string;
11
13
  export declare const getCustomFieldsFor: (name: string) => CustomFieldsSettings<any>;
@@ -25,6 +27,11 @@ export declare const registerCountField: (gqlEntityName: string, countFieldName:
25
27
  */
26
28
  export declare const clearCountFields: () => void;
27
29
  export declare const getMapEnumFieldsFor: (name: string) => Record<string, any>;
30
+ /**
31
+ * Returns the per-field enum output mode overrides for an entity.
32
+ * Fields not in this map use the global `getMapEnumOutputGlobal()` setting.
33
+ */
34
+ export declare const getMapEnumOutputFieldsFor: (name: string) => Record<string, "raw" | "key">;
28
35
  export declare const clearMapEnumFields: () => void;
29
36
  export declare const getParseJsonFieldsFor: (name: string) => Set<string>;
30
37
  export declare const registerParseJsonField: (gqlEntityName: string, fieldName: string) => void;
@@ -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.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;
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.getMapEnumOutputFieldsFor = exports.getMapEnumFieldsFor = exports.clearCountFields = exports.registerCountField = exports.getCountFieldsFor = exports.getCustomFieldsFor = exports.getFieldByAlias = exports.getFieldsOptionsFor = exports.getMapEnumOutputGlobal = exports.setGlobalConfig = void 0;
10
10
  exports.getAutoResolvers = getAutoResolvers;
11
11
  exports.defineFields = defineFields;
12
12
  exports.GQLEntityClass = GQLEntityClass;
@@ -23,6 +23,12 @@ const FieldsOptionsMap = {};
23
23
  const CustomFieldsMap = {};
24
24
  const CountFieldsMap = {};
25
25
  const MapEnumFieldsMap = {};
26
+ /**
27
+ * Per-field enum output mode override.
28
+ * Keyed by gqlEntityName → fieldName → 'raw' | 'key'.
29
+ * When a field is not in this map, the global `mapEnumOutputGlobal` is used.
30
+ */
31
+ const MapEnumOutputFieldsMap = {};
26
32
  const ParseJsonFieldsMap = {};
27
33
  /**
28
34
  * Relation fields declared via the plain `defineFields` pattern (not via
@@ -54,6 +60,11 @@ function registerFieldMetadata(fieldName, fieldOptions, gqlEntityName, target, m
54
60
  const enumObj = fieldOptions.type();
55
61
  MapEnumFieldsMap[gqlEntityName] = MapEnumFieldsMap[gqlEntityName] || {};
56
62
  MapEnumFieldsMap[gqlEntityName][fieldNameToUse] = enumObj;
63
+ // Store per-field output mode override (if provided)
64
+ if (fieldOptions.mapEnumOutput) {
65
+ MapEnumOutputFieldsMap[gqlEntityName] = MapEnumOutputFieldsMap[gqlEntityName] || {};
66
+ MapEnumOutputFieldsMap[gqlEntityName][fieldNameToUse] = fieldOptions.mapEnumOutput;
67
+ }
57
68
  }
58
69
  catch {
59
70
  // type thunk may throw for forward refs — safe to skip
@@ -111,13 +122,27 @@ function buildFilterFieldParameter(target, name, option, fieldOptions, getType,
111
122
  let gqlTypesSuffix = '';
112
123
  let gqlSortSuffix = '';
113
124
  let sortEnumRegistered = false;
125
+ /**
126
+ * Global enum output mode.
127
+ *
128
+ * Resolved at module load from the `GQL_OF_POWER_MAP_ENUM_OUTPUT` env var.
129
+ * Per-field `mapEnumOutput` takes precedence, then this global, then `'raw'`.
130
+ *
131
+ * - `'raw'` — raw DB values pass through (native `buildSchema()` path).
132
+ * - `'key'` — SQL `CASE WHEN` returns enum string keys (SDL-rebuilt schema path).
133
+ */
134
+ let mapEnumOutputGlobal = process.env.GQL_OF_POWER_MAP_ENUM_OUTPUT || 'raw';
114
135
  const setGlobalConfig = (config) => {
115
136
  if (config.gqlTypesSuffix !== undefined)
116
137
  gqlTypesSuffix = config.gqlTypesSuffix;
117
138
  if (config.gqlSortSuffix !== undefined)
118
139
  gqlSortSuffix = config.gqlSortSuffix;
140
+ if (config.mapEnumOutput !== undefined)
141
+ mapEnumOutputGlobal = config.mapEnumOutput;
119
142
  };
120
143
  exports.setGlobalConfig = setGlobalConfig;
144
+ const getMapEnumOutputGlobal = () => mapEnumOutputGlobal;
145
+ exports.getMapEnumOutputGlobal = getMapEnumOutputGlobal;
121
146
  // ─── Public accessors ────────────────────────────────────────────────────────
122
147
  const getFieldsOptionsFor = (name) => FieldsOptionsMap[name] ?? {};
123
148
  exports.getFieldsOptionsFor = getFieldsOptionsFor;
@@ -156,10 +181,19 @@ const clearCountFields = () => {
156
181
  exports.clearCountFields = clearCountFields;
157
182
  const getMapEnumFieldsFor = (name) => MapEnumFieldsMap[name] ?? {};
158
183
  exports.getMapEnumFieldsFor = getMapEnumFieldsFor;
184
+ /**
185
+ * Returns the per-field enum output mode overrides for an entity.
186
+ * Fields not in this map use the global `getMapEnumOutputGlobal()` setting.
187
+ */
188
+ const getMapEnumOutputFieldsFor = (name) => MapEnumOutputFieldsMap[name] ?? {};
189
+ exports.getMapEnumOutputFieldsFor = getMapEnumOutputFieldsFor;
159
190
  const clearMapEnumFields = () => {
160
191
  for (const key of Object.keys(MapEnumFieldsMap)) {
161
192
  delete MapEnumFieldsMap[key];
162
193
  }
194
+ for (const key of Object.keys(MapEnumOutputFieldsMap)) {
195
+ delete MapEnumOutputFieldsMap[key];
196
+ }
163
197
  };
164
198
  exports.clearMapEnumFields = clearMapEnumFields;
165
199
  const getParseJsonFieldsFor = (name) => ParseJsonFieldsMap[name] ?? new Set();
@@ -58,7 +58,7 @@ export declare class GQLtoSQLMapper {
58
58
  protected mapCountField<T>(countFieldMeta: CountFieldMeta, mapping: MappingsType, parentAlias: Alias, entityMetadata: EntityMetadata<T>, args?: any): void;
59
59
  protected mapField<T>(parentGqlFieldNameKey: string, fieldProps: {
60
60
  [key in string & keyof T]: EntityProperty;
61
- }[string & keyof T], mapping: MappingsType, alias: Alias, fields: any, gqlFieldName: string, primaryKeys: string[], parseJsonFields?: Set<string>): void;
61
+ }[string & keyof T], mapping: MappingsType, alias: Alias, fields: any, gqlFieldName: string, primaryKeys: string[], parseJsonFields?: Set<string>, enumFields?: Record<string, any>, enumKeyFields?: Set<string>): void;
62
62
  protected handleFieldArguments<T>(parentGqlFieldNameKey: string, args: any, alias: Alias, entityMetadata: EntityMetadata<T>, mapping: MappingsType): void;
63
- protected processFieldNames(alias: Alias, fieldNames: string[], mapping: MappingsType, gqlFieldName: string, parseJson?: boolean): void;
63
+ protected processFieldNames(alias: Alias, fieldNames: string[], mapping: MappingsType, gqlFieldName: string, parseJson?: boolean, enumFields?: Record<string, any>, enumKeyFields?: Set<string>): void;
64
64
  }
@@ -117,6 +117,18 @@ class GQLtoSQLMapper {
117
117
  },
118
118
  }, definedFields) ?? definedFields;
119
119
  const parseJsonFields = (0, gql_entity_1.getParseJsonFieldsFor)((0, gql_entity_1.getGQLEntityNameFor)(entityMetadata.name ?? ''));
120
+ const enumFields = (0, gql_entity_1.getMapEnumFieldsFor)((0, gql_entity_1.getGQLEntityNameFor)(entityMetadata.name ?? ''));
121
+ const enumOutputOverrides = (0, gql_entity_1.getMapEnumOutputFieldsFor)((0, gql_entity_1.getGQLEntityNameFor)(entityMetadata.name ?? ''));
122
+ // Build a set of field names that should use CASE WHEN (key mode).
123
+ // A field uses 'key' mode if: per-field override is 'key', OR no override
124
+ // and the global default is 'key'.
125
+ const globalEnumOutput = (0, gql_entity_1.getMapEnumOutputGlobal)();
126
+ const enumKeyFields = new Set();
127
+ for (const fieldName of Object.keys(enumFields)) {
128
+ const mode = enumOutputOverrides[fieldName] ?? globalEnumOutput;
129
+ if (mode === 'key')
130
+ enumKeyFields.add(fieldName);
131
+ }
120
132
  let res = (0, object_1.keys)(allFields).reduce(({ mappings }, gqlFieldNameKey) => {
121
133
  variables_1.logger.log('========================== FIELD ==========================', {
122
134
  gqlFieldNameKey,
@@ -165,7 +177,7 @@ class GQLtoSQLMapper {
165
177
  return this.mapCustomField(customFieldProps, mapping, alias, gqlFieldName, mappings, fieldsByTypeName, entityMetadata, args);
166
178
  }
167
179
  else {
168
- this.mapField(fieldName, fieldProps, mapping, alias, fieldsByTypeName, gqlFieldName, primaryKeys, parseJsonFields);
180
+ this.mapField(fieldName, fieldProps, mapping, alias, fieldsByTypeName, gqlFieldName, primaryKeys, parseJsonFields, enumFields, enumKeyFields);
169
181
  // gqlFieldName === 'battles' &&
170
182
  variables_1.logger.log('=======', { tableName, gqlFieldName, fieldName, name }, (0, gql_to_sql_types_1.mappingsTypeToString)(mapping, true));
171
183
  variables_1.logger.log('');
@@ -474,7 +486,7 @@ class GQLtoSQLMapper {
474
486
  mapping.values = { ...mapping.values, ...filterValues };
475
487
  variables_1.logger.log('mapCountField', countFieldName, 'subquery', subquery);
476
488
  }
477
- mapField(parentGqlFieldNameKey, fieldProps, mapping, alias, fields, gqlFieldName, primaryKeys, parseJsonFields = new Set()) {
489
+ mapField(parentGqlFieldNameKey, fieldProps, mapping, alias, fields, gqlFieldName, primaryKeys, parseJsonFields = new Set(), enumFields = {}, enumKeyFields = new Set()) {
478
490
  const referenceField = this.exists(fieldProps.type) && this.getMetadata(fieldProps.type);
479
491
  variables_1.logger.warn('[DIAG mapField]', gqlFieldName, 'fieldProps.type:', fieldProps.type, 'exists:', this.exists(fieldProps.type), 'reference found:', !!referenceField, 'reference:', fieldProps.reference, 'fieldNames:', fieldProps.fieldNames);
480
492
  if (referenceField) {
@@ -542,7 +554,7 @@ class GQLtoSQLMapper {
542
554
  }
543
555
  else if (fieldProps.fieldNames.length > 0) {
544
556
  variables_1.logger.log('MAPPING for FIELDS:', { parentGqlFieldNameKey, gqlFieldName }, (0, gql_to_sql_types_1.mappingsTypeToString)(mapping, true));
545
- this.processFieldNames(alias, fieldProps.fieldNames, mapping, gqlFieldName, parseJsonFields.has(gqlFieldName));
557
+ this.processFieldNames(alias, fieldProps.fieldNames, mapping, gqlFieldName, parseJsonFields.has(gqlFieldName), enumFields, enumKeyFields);
546
558
  }
547
559
  else {
548
560
  variables_1.logger.log('reference type', fieldProps.reference, 'not handled for field', gqlFieldName);
@@ -598,7 +610,7 @@ class GQLtoSQLMapper {
598
610
  }
599
611
  // mappings.set(parentGqlFieldNameKey, m);
600
612
  }
601
- processFieldNames(alias, fieldNames, mapping, gqlFieldName, parseJson = false) {
613
+ processFieldNames(alias, fieldNames, mapping, gqlFieldName, parseJson = false, enumFields = {}, enumKeyFields = new Set()) {
602
614
  variables_1.logger.info('GQLtoSQLMapper - processFieldNames', fieldNames, gqlFieldName);
603
615
  if (fieldNames.length <= 0) {
604
616
  variables_1.logger.warn('GQLtoSQLMapper - processFieldNames: fieldNames is empty', gqlFieldName, 'skipping');
@@ -612,6 +624,18 @@ class GQLtoSQLMapper {
612
624
  const jsonExpr = `REPLACE(TRIM(BOTH '"' FROM ${fieldNameWithAlias}::text), '${'\\"'}','"')::jsonb`;
613
625
  aliasedField = `${jsonExpr} AS "${gqlFieldName}"`;
614
626
  }
627
+ else if (gqlFieldName in enumFields && enumKeyFields.has(gqlFieldName)) {
628
+ // CASE WHEN mode: SQL returns the enum string key directly.
629
+ // Used when the schema is rebuilt from SDL (Apollo Server with
630
+ // pre-generated schema file), where graphql-js enum values default
631
+ // to the name string instead of the numeric value.
632
+ const caseExpr = sql_builder_1.SQLBuilder.buildEnumCaseSQL(fieldNameWithAlias, enumFields[gqlFieldName]);
633
+ aliasedField = caseExpr
634
+ ? `${caseExpr} AS "${gqlFieldName}"`
635
+ : gqlFieldName !== fieldNames[0]
636
+ ? `${fieldNameWithAlias} AS "${gqlFieldName}"`
637
+ : fieldNameWithAlias;
638
+ }
615
639
  else {
616
640
  aliasedField =
617
641
  gqlFieldName !== fieldNames[0]
@@ -46,6 +46,26 @@ export declare class SQLBuilder {
46
46
  where: string;
47
47
  }) => string): string[];
48
48
  static buildInnerBranch(rawSelect: string[], tableName: string, alias: Alias, innerJoin: string[], whereConditions: string[]): string;
49
+ /**
50
+ * Builds a SQL CASE expression that maps a raw DB enum column value to its
51
+ * enum string key (name). Used for `mapNumericEnum` fields so the query
52
+ * returns the GraphQL-ready string directly — no post-query conversion.
53
+ *
54
+ * Iterates the enum object's keys. Numeric-valued keys produce a numeric
55
+ * WHEN branch; string-valued keys produce a quoted-string WHEN branch.
56
+ *
57
+ * Example output:
58
+ * CASE "a1"."state"
59
+ * WHEN 0 THEN 'NotStarted'
60
+ * WHEN 1 THEN 'InProgress'
61
+ * ELSE NULL
62
+ * END
63
+ *
64
+ * @param columnExpr Raw SQL column expression (e.g. `"a1"."state"`)
65
+ * @param enumObj The TypeScript enum object (e.g. `QuestState`)
66
+ * @returns CASE WHEN ... THEN ... END expression, or null if no values
67
+ */
68
+ static buildEnumCaseSQL(columnExpr: string, enumObj: Record<string, any>): string | null;
49
69
  static getFieldMapper: <T>(metadata: EntityMetadata<T>, alias: Alias) => (ob: string) => string[];
50
70
  /**
51
71
  * Builds ORDER BY SQL clause from pagination input
@@ -73,6 +73,39 @@ class SQLBuilder {
73
73
  where true
74
74
  ${whereConditions.length > 0 ? ` and ( ${whereConditions.join(' and ')} )` : ''}`.replaceAll(/[ \n\t]+/gi, ' ');
75
75
  }
76
+ /**
77
+ * Builds a SQL CASE expression that maps a raw DB enum column value to its
78
+ * enum string key (name). Used for `mapNumericEnum` fields so the query
79
+ * returns the GraphQL-ready string directly — no post-query conversion.
80
+ *
81
+ * Iterates the enum object's keys. Numeric-valued keys produce a numeric
82
+ * WHEN branch; string-valued keys produce a quoted-string WHEN branch.
83
+ *
84
+ * Example output:
85
+ * CASE "a1"."state"
86
+ * WHEN 0 THEN 'NotStarted'
87
+ * WHEN 1 THEN 'InProgress'
88
+ * ELSE NULL
89
+ * END
90
+ *
91
+ * @param columnExpr Raw SQL column expression (e.g. `"a1"."state"`)
92
+ * @param enumObj The TypeScript enum object (e.g. `QuestState`)
93
+ * @returns CASE WHEN ... THEN ... END expression, or null if no values
94
+ */
95
+ static buildEnumCaseSQL(columnExpr, enumObj) {
96
+ const branches = [];
97
+ for (const key of Object.keys(enumObj)) {
98
+ // Skip TypeScript's numeric reverse-mapping entries
99
+ if (/^\d+$/.test(key))
100
+ continue;
101
+ const val = enumObj[key];
102
+ const sqlVal = typeof val === 'number' ? val : `'${String(val).replace(/'/g, "''")}'`;
103
+ branches.push(`WHEN ${sqlVal} THEN '${key}'`);
104
+ }
105
+ if (branches.length === 0)
106
+ return null;
107
+ return `CASE ${columnExpr} ${branches.join(' ')} ELSE NULL END`;
108
+ }
76
109
  static getFieldMapper = (metadata, alias) => (ob) => {
77
110
  const fieldMeta = metadata.properties[ob];
78
111
  if (!fieldMeta) {
@@ -68,6 +68,33 @@ export type FieldBaseSettings = {
68
68
  * status: { type: () => Status, mapNumericEnum: true, generateFilter: true }
69
69
  */
70
70
  mapNumericEnum?: boolean;
71
+ /**
72
+ * Controls how enum field values are emitted in the SQL query for GraphQL
73
+ * output serialization. Only takes effect when `mapNumericEnum` is also
74
+ * `true`.
75
+ *
76
+ * - `'raw'` (default) — the raw DB value (e.g. `0`, `100`) flows through
77
+ * untouched. Use this when the schema is built **live** via type-graphql's
78
+ * `buildSchema()`, which preserves numeric enum values so graphql-js's
79
+ * `serialize(0)` resolves to `"ACTIVE"` natively.
80
+ * - `'key'` — wraps the column in a `CASE WHEN ... THEN 'Active' END` SQL
81
+ * expression so the query returns the enum string key directly. Use this
82
+ * when the schema is **rebuilt from SDL** (e.g. `printSchema()` →
83
+ * `buildASTSchema()` / Apollo Server with a pre-generated `.graphql` file),
84
+ * because SDL strips numeric values and `serialize(0)` fails.
85
+ *
86
+ * Can also be set globally via `setGlobalConfig({ mapEnumOutput: 'key' })`
87
+ * or the `GQL_OF_POWER_MAP_ENUM_OUTPUT=key` environment variable.
88
+ *
89
+ * @example
90
+ * stateCode: {
91
+ * type: () => StateCode,
92
+ * mapNumericEnum: true,
93
+ * mapEnumOutput: 'key',
94
+ * generateFilter: true,
95
+ * }
96
+ */
97
+ mapEnumOutput?: 'raw' | 'key';
71
98
  /**
72
99
  * When true, the SQL query wraps this field's column with a JSON parsing
73
100
  * expression so the value is returned as a JSON object instead of a raw
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dav3/gql-of-power",
3
- "version": "1.6.0",
4
- "description": "Generate a single optimized SQL query from GraphQL operations — eliminate N+1 with recursive field-to-SQL mapping.",
3
+ "version": "1.7.0",
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",
7
7
  "module": "esnext",
@@ -30,10 +30,22 @@
30
30
  "audit:health": "fallow health",
31
31
  "audit:fix": "fallow fix"
32
32
  },
33
- "keywords": [],
33
+ "keywords": [
34
+ "graphql",
35
+ "sql",
36
+ "type-graphql",
37
+ "n-plus-1",
38
+ "agent-skill",
39
+ "claude-code",
40
+ "cursor",
41
+ "orm"
42
+ ],
34
43
  "author": "",
35
44
  "license": "MIT NON-AI License",
36
45
  "devDependencies": {
46
+ "@apollo/server": "^5.5.1",
47
+ "@as-integrations/aws-lambda": "^4.1.0",
48
+ "@graphql-tools/schema": "^10.0.38",
37
49
  "@types/bun": "latest",
38
50
  "@types/graphql-fields": "^1.3.9",
39
51
  "fallow": "^3.6.0",
@@ -33,6 +33,13 @@ const FieldsOptionsMap: Record<string, Record<string, string>> = {};
33
33
  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
+
37
+ /**
38
+ * Per-field enum output mode override.
39
+ * Keyed by gqlEntityName → fieldName → 'raw' | 'key'.
40
+ * When a field is not in this map, the global `mapEnumOutputGlobal` is used.
41
+ */
42
+ const MapEnumOutputFieldsMap: Record<string, Record<string, 'raw' | 'key'>> = {};
36
43
  const ParseJsonFieldsMap: Record<string, Set<string>> = {};
37
44
  /**
38
45
  * Relation fields declared via the plain `defineFields` pattern (not via
@@ -73,6 +80,11 @@ function registerFieldMetadata(
73
80
  const enumObj = fieldOptions.type();
74
81
  MapEnumFieldsMap[gqlEntityName] = MapEnumFieldsMap[gqlEntityName] || {};
75
82
  MapEnumFieldsMap[gqlEntityName][fieldNameToUse] = enumObj;
83
+ // Store per-field output mode override (if provided)
84
+ if (fieldOptions.mapEnumOutput) {
85
+ MapEnumOutputFieldsMap[gqlEntityName] = MapEnumOutputFieldsMap[gqlEntityName] || {};
86
+ MapEnumOutputFieldsMap[gqlEntityName][fieldNameToUse] = fieldOptions.mapEnumOutput;
87
+ }
76
88
  } catch {
77
89
  // type thunk may throw for forward refs — safe to skip
78
90
  }
@@ -143,11 +155,30 @@ let gqlTypesSuffix = '';
143
155
  let gqlSortSuffix = '';
144
156
  let sortEnumRegistered = false;
145
157
 
146
- export const setGlobalConfig = (config: { gqlTypesSuffix?: string; gqlSortSuffix?: string }) => {
158
+ /**
159
+ * Global enum output mode.
160
+ *
161
+ * Resolved at module load from the `GQL_OF_POWER_MAP_ENUM_OUTPUT` env var.
162
+ * Per-field `mapEnumOutput` takes precedence, then this global, then `'raw'`.
163
+ *
164
+ * - `'raw'` — raw DB values pass through (native `buildSchema()` path).
165
+ * - `'key'` — SQL `CASE WHEN` returns enum string keys (SDL-rebuilt schema path).
166
+ */
167
+ let mapEnumOutputGlobal: 'raw' | 'key' =
168
+ (process.env.GQL_OF_POWER_MAP_ENUM_OUTPUT as 'raw' | 'key') || 'raw';
169
+
170
+ export const setGlobalConfig = (config: {
171
+ gqlTypesSuffix?: string;
172
+ gqlSortSuffix?: string;
173
+ mapEnumOutput?: 'raw' | 'key';
174
+ }) => {
147
175
  if (config.gqlTypesSuffix !== undefined) gqlTypesSuffix = config.gqlTypesSuffix;
148
176
  if (config.gqlSortSuffix !== undefined) gqlSortSuffix = config.gqlSortSuffix;
177
+ if (config.mapEnumOutput !== undefined) mapEnumOutputGlobal = config.mapEnumOutput;
149
178
  };
150
179
 
180
+ export const getMapEnumOutputGlobal = () => mapEnumOutputGlobal;
181
+
151
182
  // ─── Public accessors ────────────────────────────────────────────────────────
152
183
 
153
184
  export const getFieldsOptionsFor = (name: string): Record<string, string> =>
@@ -193,10 +224,20 @@ export const clearCountFields = (): void => {
193
224
  export const getMapEnumFieldsFor = (name: string): Record<string, any> =>
194
225
  MapEnumFieldsMap[name] ?? {};
195
226
 
227
+ /**
228
+ * Returns the per-field enum output mode overrides for an entity.
229
+ * Fields not in this map use the global `getMapEnumOutputGlobal()` setting.
230
+ */
231
+ export const getMapEnumOutputFieldsFor = (name: string): Record<string, 'raw' | 'key'> =>
232
+ MapEnumOutputFieldsMap[name] ?? {};
233
+
196
234
  export const clearMapEnumFields = (): void => {
197
235
  for (const key of Object.keys(MapEnumFieldsMap)) {
198
236
  delete MapEnumFieldsMap[key];
199
237
  }
238
+ for (const key of Object.keys(MapEnumOutputFieldsMap)) {
239
+ delete MapEnumOutputFieldsMap[key];
240
+ }
200
241
  };
201
242
 
202
243
  export const getParseJsonFieldsFor = (name: string): Set<string> =>
@@ -1,10 +1,13 @@
1
1
  import {
2
- getCountFieldsFor,
3
2
  getCustomFieldsFor,
4
- getFieldByAlias,
5
- getGQLEntityNameFor,
3
+ getCountFieldsFor,
6
4
  getMapEnumFieldsFor,
5
+ getMapEnumOutputFieldsFor,
6
+ getMapEnumOutputGlobal,
7
7
  getParseJsonFieldsFor,
8
+ getFieldsOptionsFor,
9
+ getFieldByAlias,
10
+ getGQLEntityNameFor,
8
11
  getRelationFieldsFor,
9
12
  } from '../entities/gql-entity';
10
13
  import {
@@ -252,6 +255,19 @@ export class GQLtoSQLMapper {
252
255
  ) ?? definedFields;
253
256
 
254
257
  const parseJsonFields = getParseJsonFieldsFor(getGQLEntityNameFor(entityMetadata.name ?? ''));
258
+ const enumFields = getMapEnumFieldsFor(getGQLEntityNameFor(entityMetadata.name ?? ''));
259
+ const enumOutputOverrides = getMapEnumOutputFieldsFor(
260
+ getGQLEntityNameFor(entityMetadata.name ?? '')
261
+ );
262
+ // Build a set of field names that should use CASE WHEN (key mode).
263
+ // A field uses 'key' mode if: per-field override is 'key', OR no override
264
+ // and the global default is 'key'.
265
+ const globalEnumOutput = getMapEnumOutputGlobal();
266
+ const enumKeyFields = new Set<string>();
267
+ for (const fieldName of Object.keys(enumFields)) {
268
+ const mode = enumOutputOverrides[fieldName] ?? globalEnumOutput;
269
+ if (mode === 'key') enumKeyFields.add(fieldName);
270
+ }
255
271
 
256
272
  let res = keys(allFields).reduce(
257
273
  ({ mappings }, gqlFieldNameKey) => {
@@ -343,7 +359,9 @@ export class GQLtoSQLMapper {
343
359
  fieldsByTypeName,
344
360
  gqlFieldName,
345
361
  primaryKeys,
346
- parseJsonFields
362
+ parseJsonFields,
363
+ enumFields,
364
+ enumKeyFields
347
365
  );
348
366
  // gqlFieldName === 'battles' &&
349
367
  logger.log(
@@ -927,7 +945,9 @@ export class GQLtoSQLMapper {
927
945
  fields: any,
928
946
  gqlFieldName: string,
929
947
  primaryKeys: string[],
930
- parseJsonFields: Set<string> = new Set()
948
+ parseJsonFields: Set<string> = new Set(),
949
+ enumFields: Record<string, any> = {},
950
+ enumKeyFields: Set<string> = new Set()
931
951
  ) {
932
952
  const referenceField =
933
953
  this.exists(fieldProps.type) && this.getMetadata<any, EntityMetadata<any>>(fieldProps.type);
@@ -1137,7 +1157,9 @@ export class GQLtoSQLMapper {
1137
1157
  fieldProps.fieldNames,
1138
1158
  mapping,
1139
1159
  gqlFieldName,
1140
- parseJsonFields.has(gqlFieldName)
1160
+ parseJsonFields.has(gqlFieldName),
1161
+ enumFields,
1162
+ enumKeyFields
1141
1163
  );
1142
1164
  } else {
1143
1165
  logger.log('reference type', fieldProps.reference, 'not handled for field', gqlFieldName);
@@ -1221,7 +1243,9 @@ export class GQLtoSQLMapper {
1221
1243
  fieldNames: string[],
1222
1244
  mapping: MappingsType,
1223
1245
  gqlFieldName: string,
1224
- parseJson: boolean = false
1246
+ parseJson: boolean = false,
1247
+ enumFields: Record<string, any> = {},
1248
+ enumKeyFields: Set<string> = new Set()
1225
1249
  ) {
1226
1250
  logger.info('GQLtoSQLMapper - processFieldNames', fieldNames, gqlFieldName);
1227
1251
  if (fieldNames.length <= 0) {
@@ -1242,6 +1266,17 @@ export class GQLtoSQLMapper {
1242
1266
  if (parseJson) {
1243
1267
  const jsonExpr = `REPLACE(TRIM(BOTH '"' FROM ${fieldNameWithAlias}::text), '${'\\"'}','"')::jsonb`;
1244
1268
  aliasedField = `${jsonExpr} AS "${gqlFieldName}"`;
1269
+ } else if (gqlFieldName in enumFields && enumKeyFields.has(gqlFieldName)) {
1270
+ // CASE WHEN mode: SQL returns the enum string key directly.
1271
+ // Used when the schema is rebuilt from SDL (Apollo Server with
1272
+ // pre-generated schema file), where graphql-js enum values default
1273
+ // to the name string instead of the numeric value.
1274
+ const caseExpr = SQLBuilder.buildEnumCaseSQL(fieldNameWithAlias, enumFields[gqlFieldName]);
1275
+ aliasedField = caseExpr
1276
+ ? `${caseExpr} AS "${gqlFieldName}"`
1277
+ : gqlFieldName !== fieldNames[0]
1278
+ ? `${fieldNameWithAlias} AS "${gqlFieldName}"`
1279
+ : fieldNameWithAlias;
1245
1280
  } else {
1246
1281
  aliasedField =
1247
1282
  gqlFieldName !== fieldNames[0]
@@ -128,6 +128,39 @@ export class SQLBuilder {
128
128
  );
129
129
  }
130
130
 
131
+ /**
132
+ * Builds a SQL CASE expression that maps a raw DB enum column value to its
133
+ * enum string key (name). Used for `mapNumericEnum` fields so the query
134
+ * returns the GraphQL-ready string directly — no post-query conversion.
135
+ *
136
+ * Iterates the enum object's keys. Numeric-valued keys produce a numeric
137
+ * WHEN branch; string-valued keys produce a quoted-string WHEN branch.
138
+ *
139
+ * Example output:
140
+ * CASE "a1"."state"
141
+ * WHEN 0 THEN 'NotStarted'
142
+ * WHEN 1 THEN 'InProgress'
143
+ * ELSE NULL
144
+ * END
145
+ *
146
+ * @param columnExpr Raw SQL column expression (e.g. `"a1"."state"`)
147
+ * @param enumObj The TypeScript enum object (e.g. `QuestState`)
148
+ * @returns CASE WHEN ... THEN ... END expression, or null if no values
149
+ */
150
+ public static buildEnumCaseSQL(columnExpr: string, enumObj: Record<string, any>): string | null {
151
+ const branches: string[] = [];
152
+ for (const key of Object.keys(enumObj)) {
153
+ // Skip TypeScript's numeric reverse-mapping entries
154
+ if (/^\d+$/.test(key)) continue;
155
+
156
+ const val = enumObj[key];
157
+ const sqlVal = typeof val === 'number' ? val : `'${String(val).replace(/'/g, "''")}'`;
158
+ branches.push(`WHEN ${sqlVal} THEN '${key}'`);
159
+ }
160
+ if (branches.length === 0) return null;
161
+ return `CASE ${columnExpr} ${branches.join(' ')} ELSE NULL END`;
162
+ }
163
+
131
164
  public static getFieldMapper =
132
165
  <T>(metadata: EntityMetadata<T>, alias: Alias) =>
133
166
  (ob: string) => {
@@ -81,6 +81,33 @@ export type FieldBaseSettings = {
81
81
  * status: { type: () => Status, mapNumericEnum: true, generateFilter: true }
82
82
  */
83
83
  mapNumericEnum?: boolean;
84
+ /**
85
+ * Controls how enum field values are emitted in the SQL query for GraphQL
86
+ * output serialization. Only takes effect when `mapNumericEnum` is also
87
+ * `true`.
88
+ *
89
+ * - `'raw'` (default) — the raw DB value (e.g. `0`, `100`) flows through
90
+ * untouched. Use this when the schema is built **live** via type-graphql's
91
+ * `buildSchema()`, which preserves numeric enum values so graphql-js's
92
+ * `serialize(0)` resolves to `"ACTIVE"` natively.
93
+ * - `'key'` — wraps the column in a `CASE WHEN ... THEN 'Active' END` SQL
94
+ * expression so the query returns the enum string key directly. Use this
95
+ * when the schema is **rebuilt from SDL** (e.g. `printSchema()` →
96
+ * `buildASTSchema()` / Apollo Server with a pre-generated `.graphql` file),
97
+ * because SDL strips numeric values and `serialize(0)` fails.
98
+ *
99
+ * Can also be set globally via `setGlobalConfig({ mapEnumOutput: 'key' })`
100
+ * or the `GQL_OF_POWER_MAP_ENUM_OUTPUT=key` environment variable.
101
+ *
102
+ * @example
103
+ * stateCode: {
104
+ * type: () => StateCode,
105
+ * mapNumericEnum: true,
106
+ * mapEnumOutput: 'key',
107
+ * generateFilter: true,
108
+ * }
109
+ */
110
+ mapEnumOutput?: 'raw' | 'key';
84
111
  /**
85
112
  * When true, the SQL query wraps this field's column with a JSON parsing
86
113
  * expression so the value is returned as a JSON object instead of a raw