@dav3/gql-of-power 1.1.0 → 1.3.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
@@ -1,6 +1,6 @@
1
1
  # ⚔️ GQL of Power
2
2
 
3
- > **"One Query to Rule Them All"** — Master your data with the power of a single GraphQL query
3
+ > **"One Query to Rule Them All"** — Master your data with the power of a single optimized SQL query
4
4
 
5
5
  ---
6
6
 
@@ -8,9 +8,9 @@
8
8
 
9
9
  In the age of microservices and data loaders, a shadow falls upon performance: _the N+1 query problem_. Weary developers traverse the lands of Middleware-earth, orchestrating countless queries to fetch interconnected data.
10
10
 
11
- But what if there was another way? What if, with the power of a single query, you could bind all your data together?
11
+ But what if there was another way? What if, with the power of a single SQL query, you could bind all your data together?
12
12
 
13
- **GQL of Power** is a TypeScript library that harnesses ancient sorcery to generate perfectly optimized SQL queries from your GraphQL operations. It eliminates the need for data loaders entirely — one query to fetch your complete data structure, no matter how deeply nested your relationships run.
13
+ **GQL of Power** is a TypeScript library that harnesses ancient sorcery to generate perfectly optimized SQL queries from your GraphQL operations. It eliminates the need for data loaders entirely — one SQL query to fetch your complete data structure, no matter how deeply nested your relationships run.
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
 
@@ -60,8 +60,8 @@ import { ID, Int } from 'type-graphql';
60
60
 
61
61
  // --- Author ---
62
62
  const authorFields = defineFields(Author, {
63
- id: { type: () => ID, generateFilter: true },
64
- name: { type: () => String, generateFilter: true },
63
+ id: { type: () => ID, generateFilter: true },
64
+ name: { type: () => String, generateFilter: true },
65
65
  });
66
66
 
67
67
  @GQLEntityClass(Author, authorFields)
@@ -69,10 +69,10 @@ export class AuthorGQL extends GQLEntityBase {}
69
69
 
70
70
  // --- Book ---
71
71
  const bookFields = defineFields(Book, {
72
- id: { type: () => ID, generateFilter: true },
73
- title: { type: () => String, generateFilter: true },
74
- publishedYear: { type: () => Int, generateFilter: true },
75
- author: { type: () => AuthorGQL, options: { nullable: true } },
72
+ id: { type: () => ID, generateFilter: true },
73
+ title: { type: () => String, generateFilter: true },
74
+ publishedYear: { type: () => Int, generateFilter: true },
75
+ author: { type: () => AuthorGQL, options: { nullable: true } },
76
76
  });
77
77
 
78
78
  @GQLEntityClass(Book, bookFields)
@@ -81,14 +81,14 @@ export class BookGQL extends GQLEntityBase {}
81
81
 
82
82
  The decorator attaches generated statics to the class:
83
83
 
84
- | Static | Purpose |
85
- |--------|---------|
86
- | `BookGQL.FilterInput` | Generated filter input type |
87
- | `BookGQL.PaginationInput` | Generated pagination input type |
88
- | `BookGQL.OrderBy` | Generated order-by input type |
89
- | `BookGQL.FieldsResolver` | Auto-generated field resolver class |
90
- | `BookGQL.gqlEntityName` | GQL type name (with suffix if configured) |
91
- | `BookGQL.relatedEntityName` | ORM entity class name (`'Book'`) |
84
+ | Static | Purpose |
85
+ | --------------------------- | ----------------------------------------- |
86
+ | `BookGQL.FilterInput` | Generated filter input type |
87
+ | `BookGQL.PaginationInput` | Generated pagination input type |
88
+ | `BookGQL.OrderBy` | Generated order-by input type |
89
+ | `BookGQL.FieldsResolver` | Auto-generated field resolver class |
90
+ | `BookGQL.gqlEntityName` | GQL type name (with suffix if configured) |
91
+ | `BookGQL.relatedEntityName` | ORM entity class name (`'Book'`) |
92
92
 
93
93
  ### 2. Write Resolvers
94
94
 
@@ -99,14 +99,14 @@ import { GraphQLResolveInfo } from 'graphql';
99
99
 
100
100
  @GQLResolver(BookGQL)
101
101
  export class BookResolver {
102
- @Query(() => [BookGQL])
103
- async books(
104
- @Arg('filter', () => BookGQL.FilterInput, { nullable: true }) filter: any,
105
- @Arg('pagination', () => BookGQL.PaginationInput, { nullable: true }) pagination: any,
106
- @Info() info: GraphQLResolveInfo,
107
- ) {
108
- return queryManager.getQueryResultsForInfo(metadataProvider, BookGQL, info, filter, pagination);
109
- }
102
+ @Query(() => [BookGQL])
103
+ async books(
104
+ @Arg('filter', () => BookGQL.FilterInput, { nullable: true }) filter: any,
105
+ @Arg('pagination', () => BookGQL.PaginationInput, { nullable: true }) pagination: any,
106
+ @Info() info: GraphQLResolveInfo
107
+ ) {
108
+ return queryManager.getQueryResultsForInfo(metadataProvider, BookGQL, info, filter, pagination);
109
+ }
110
110
  }
111
111
  ```
112
112
 
@@ -118,10 +118,10 @@ import { getAutoResolvers } from '@dav3/gql-of-power';
118
118
  import '../entities'; // trigger @GQLEntityClass decoration
119
119
 
120
120
  const schema = buildSchemaSync({
121
- resolvers: [
122
- BookResolver,
123
- ...getAutoResolvers(), // FieldsResolver for every @GQLEntityClass entity
124
- ],
121
+ resolvers: [
122
+ BookResolver,
123
+ ...getAutoResolvers(), // FieldsResolver for every @GQLEntityClass entity
124
+ ],
125
125
  });
126
126
  ```
127
127
 
@@ -141,23 +141,24 @@ Typed wrapper that constrains config keys to `keyof T`. Identity function at run
141
141
 
142
142
  ```typescript
143
143
  const fields = defineFields(Book, {
144
- id: { type: () => ID, generateFilter: true },
145
- title: { type: () => String, generateFilter: true },
146
- typo: { type: () => String }, // TS error — 'typo' is not keyof Book ✗
144
+ id: { type: () => ID, generateFilter: true },
145
+ title: { type: () => String, generateFilter: true },
146
+ typo: { type: () => String }, // TS error — 'typo' is not keyof Book ✗
147
147
  });
148
148
  ```
149
149
 
150
150
  #### Field options
151
151
 
152
- | Option | Type | Purpose |
153
- |--------|------|---------|
154
- | `type` | `() => GraphQLType` | GraphQL return type (required) |
155
- | `generateFilter` | `boolean` | Generate filter input fields for this property |
156
- | `options` | `FieldOptions` | type-graphql field options (nullable, description, etc.) |
157
- | `alias` | `string` | Override the GQL field name |
158
- | `array` | `true` | Mark as array return type |
159
- | `relatedEntityName` | `() => string` | ORM entity name for array relation fields (auto-derived when using `@GQLEntityClass`) |
160
- | `enum` | `EnumData` | Register an enum type |
152
+ | Option | Type | Purpose |
153
+ | ------------------- | ------------------- | ------------------------------------------------------------------------------------- |
154
+ | `type` | `() => GraphQLType` | GraphQL return type (required) |
155
+ | `generateFilter` | `boolean` | Generate filter input fields for this property |
156
+ | `options` | `FieldOptions` | type-graphql field options (nullable, description, etc.) |
157
+ | `alias` | `string` | Override the GQL field name |
158
+ | `array` | `true` | Mark as array return type |
159
+ | `relatedEntityName` | `() => string` | ORM entity name for array relation fields (auto-derived when using `@GQLEntityClass`) |
160
+ | `countFieldName` | `string` | Generate a count field for this relationship (see [Count Fields](#count-fields)) |
161
+ | `enum` | `EnumData` | Register an enum type |
161
162
 
162
163
  ### `@GQLEntityClass(OrmClass, fields, extra?)`
163
164
 
@@ -195,29 +196,29 @@ Provide a GraphQL `@FieldResolver` function. The library fetches field(s) listed
195
196
 
196
197
  ```typescript
197
198
  @GQLEntityClass(Fellowship, fields, {
198
- customFields: {
199
- firstMember: {
200
- type: () => GraphQLJSON,
201
- options: { nullable: true },
202
- requires: 'id', // ensure 'id' is fetched even if client didn't request it
203
- resolveDecorators: [Root(), Ctx()],
204
- resolve: (root: Fellowship, ctx: any) => {
205
- return memberDataLoader.load(root.id);
206
- },
207
- },
208
- },
199
+ customFields: {
200
+ firstMember: {
201
+ type: () => GraphQLJSON,
202
+ options: { nullable: true },
203
+ requires: 'id', // ensure 'id' is fetched even if client didn't request it
204
+ resolveDecorators: [Root(), Ctx()],
205
+ resolve: (root: Fellowship, ctx: any) => {
206
+ return memberDataLoader.load(root.id);
207
+ },
208
+ },
209
+ },
209
210
  })
210
211
  export class FellowshipGQL extends GQLEntityBase {}
211
212
  ```
212
213
 
213
214
  #### `resolve` field options
214
215
 
215
- | Option | Purpose |
216
- |--------|---------|
217
- | `type` | GraphQL return type |
218
- | `options` | type-graphql field options |
219
- | `requires` | Field name(s) to ensure are fetched from DB |
220
- | `resolve` | The resolver function (required) |
216
+ | Option | Purpose |
217
+ | ------------------- | ---------------------------------------------------------------------- |
218
+ | `type` | GraphQL return type |
219
+ | `options` | type-graphql field options |
220
+ | `requires` | Field name(s) to ensure are fetched from DB |
221
+ | `resolve` | The resolver function (required) |
221
222
  | `resolveDecorators` | type-graphql parameter decorators in order (`[Root(), Ctx(), Info()]`) |
222
223
 
223
224
  ### Strategy 2: `mapping` — automatic SQL JOIN
@@ -227,63 +228,63 @@ Provide a `FieldMappingConfig`. The library generates a SQL `LEFT JOIN LATERAL`
227
228
  Use this when the foreign key exists as a plain column on the entity (not declared as an ORM relation).
228
229
 
229
230
  ```typescript
230
- import { CrmAccount } from './orm-entities';
231
-
232
- @GQLEntityClass(Job, fields, {
233
- customFields: {
234
- account: {
235
- type: () => CrmAccountGQL,
236
- options: { nullable: true },
237
- mapping: {
238
- refEntity: CrmAccount, // ORM entity class to JOIN to
239
- refFields: 'id', // column(s) on CrmAccount — keyof CrmAccount ✓
240
- fields: 'crmAccountId', // column(s) on Job — keyof Job ✓
241
- },
242
- },
243
- },
231
+ import { Kingdom } from './orm-entities';
232
+
233
+ @GQLEntityClass(Hobbit, fields, {
234
+ customFields: {
235
+ kingdom: {
236
+ type: () => KingdomGQL,
237
+ options: { nullable: true },
238
+ mapping: {
239
+ refEntity: Kingdom, // ORM entity class to JOIN to
240
+ refFields: 'id', // column(s) on Kingdom — keyof Kingdom ✓
241
+ fields: 'kingdomId', // column(s) on Hobbit — keyof Hobbit ✓
242
+ },
243
+ },
244
+ },
244
245
  })
245
- export class JobGQL extends GQLEntityBase {}
246
+ export class HobbitGQL extends GQLEntityBase {}
246
247
  ```
247
248
 
248
249
  Composite FK — use arrays (must have the same length):
249
250
 
250
251
  ```typescript
251
252
  mapping: {
252
- refEntity: OrderLine,
253
- refFields: ['tenantId', 'externalId'],
254
- fields: ['tenantId', 'lineExternalId'],
253
+ refEntity: Weapon,
254
+ refFields: ['realmId', 'weaponCode'],
255
+ fields: ['realmId', 'weaponExternalId'],
255
256
  }
256
257
  ```
257
258
 
258
259
  #### `mapping` field options
259
260
 
260
- | Option | Type | Purpose |
261
- |--------|------|---------|
262
- | `type` | `() => GraphQLType` | GraphQL return type |
263
- | `options` | `FieldOptions` | type-graphql field options (nullable, etc.) |
264
- | `mapping.refEntity` | `new () => TRef` | ORM entity class to JOIN to (must be in the metadata provider) |
265
- | `mapping.refFields` | `keyof TRef \| Array<keyof TRef>` | Column(s) on the ref entity to match against |
266
- | `mapping.fields` | `keyof T \| Array<keyof T>` | Column(s) on the owner entity to match from |
261
+ | Option | Type | Purpose |
262
+ | ------------------- | --------------------------------- | -------------------------------------------------------------- |
263
+ | `type` | `() => GraphQLType` | GraphQL return type |
264
+ | `options` | `FieldOptions` | type-graphql field options (nullable, etc.) |
265
+ | `mapping.refEntity` | `new () => TRef` | ORM entity class to JOIN to (must be in the metadata provider) |
266
+ | `mapping.refFields` | `keyof TRef \| Array<keyof TRef>` | Column(s) on the ref entity to match against |
267
+ | `mapping.fields` | `keyof T \| Array<keyof T>` | Column(s) on the owner entity to match from |
267
268
 
268
269
  > **Note**: `resolve` and `mapping` are mutually exclusive — TypeScript enforces this via a discriminated union. `resolveDecorators` and `requires` are only valid on the `resolve` branch.
269
270
 
270
271
  #### Generated SQL
271
272
 
272
- For `account: { mapping: { refEntity: CrmAccount, refFields: 'id', fields: 'crmAccountId' } }`:
273
+ For `kingdom: { mapping: { refEntity: Kingdom, refFields: 'id', fields: 'kingdomId' } }`:
273
274
 
274
275
  ```sql
275
- select e_a1.id, e_a1.crm_account_id, f_j1.value as "account"
276
+ select e_a1.id, e_a1.kingdom_id, f_j1.value as "kingdom"
276
277
  from (
277
- select e_a1.id, e_a1.crm_account_id
278
- from job as e_a1
278
+ select e_a1.id, e_a1.kingdom_id
279
+ from hobbit as e_a1
279
280
  where true
280
281
  ) as e_a1
281
282
  left outer join lateral (
282
283
  select row_to_json(f_j1)::jsonb as value
283
284
  from (
284
- select f_j1.id, f_j1.account_name
285
- from "crm_account" as f_j1
286
- where e_a1.crm_account_id = f_j1.id
285
+ select f_j1.id, f_j1.name
286
+ from kingdom as f_j1
287
+ where e_a1.kingdom_id = f_j1.id
287
288
  ) as f_j1
288
289
  ) as f_j1 on true
289
290
  ```
@@ -317,17 +318,114 @@ filter: {
317
318
 
318
319
  ### Filter operations
319
320
 
320
- | Operation | Meaning |
321
- |-----------|---------|
322
- | `_eq` | Equal |
323
- | `_ne` | Not equal |
324
- | `_in` | In array |
325
- | `_nin` | Not in array |
326
- | `_like` | ILIKE (case-insensitive contains) |
327
- | `_gt` / `_gte` | Greater than / greater than or equal |
328
- | `_lt` / `_lte` | Less than / less than or equal |
329
- | `_and` | Logical AND |
330
- | `_or` | Logical OR (generates UNION ALL) |
321
+ | Operation | Meaning |
322
+ | -------------- | ------------------------------------------------------ |
323
+ | `_eq` | Equal |
324
+ | `_ne` | Not equal |
325
+ | `_in` | In array |
326
+ | `_nin` | Not in array |
327
+ | `_like` | ILIKE (case-insensitive contains) |
328
+ | `_gt` / `_gte` | Greater than / greater than or equal |
329
+ | `_lt` / `_lte` | Less than / less than or equal |
330
+ | `_and` | Logical AND |
331
+ | `_or` | Logical OR (generates UNION ALL) |
332
+ | `_exists` | Check related entities exist (AND-combined per key) |
333
+ | `_not_exists` | Check no related entities exist (AND-combined per key) |
334
+
335
+ ### Existence Filters (`_exists` / `_not_exists`)
336
+
337
+ Check whether related entities match a set of conditions. Each key is a relationship field name; the value is a filter applied to that related entity. Multiple keys are AND-combined.
338
+
339
+ ```graphql
340
+ # Authors that have at least one book titled "The Hobbit"
341
+ filter: {
342
+ _exists: {
343
+ books: { title: "The Hobbit" }
344
+ }
345
+ }
346
+
347
+ # Authors that have a book titled "The Hobbit" AND no books in the "Horror" genre
348
+ filter: {
349
+ _and: [
350
+ { _exists: { books: { title: "The Hobbit" } } }
351
+ { _not_exists: { books: { genre: "Horror" } } }
352
+ ]
353
+ }
354
+
355
+ # Persons who fought in a victory battle AND wrote a book about it
356
+ filter: {
357
+ _exists: {
358
+ battles: { outcome: "Victory" }
359
+ books: { title: { _like: "%War%" } }
360
+ }
361
+ }
362
+ ```
363
+
364
+ `_exists` generates `EXISTS (SELECT 1 FROM ... WHERE ...)` subqueries. `_not_exists` generates `NOT EXISTS (...)`. Multiple keys within one `_exists`/`_not_exists` each produce a separate `EXISTS`/`NOT EXISTS` clause, AND-combined in the WHERE. OR across exists conditions is achieved via `_or`.
365
+
366
+ ---
367
+
368
+ ## Count Fields
369
+
370
+ Add `countFieldName` to any relationship field with `array: true` to auto-generate an Int count field. The count is computed via a correlated `COUNT(*)` subquery — no JOINs in the outer query.
371
+
372
+ ### Definition
373
+
374
+ ```typescript
375
+ const authorFields = defineFields(Author, {
376
+ id: { type: () => ID, generateFilter: true },
377
+ name: { type: () => String, generateFilter: true },
378
+ books: {
379
+ type: () => BookGQL,
380
+ array: true,
381
+ relatedEntityName: () => 'Book',
382
+ countFieldName: 'bookCount', // generates an `bookCount: Int` field
383
+ },
384
+ });
385
+ ```
386
+
387
+ ### Querying
388
+
389
+ ```graphql
390
+ query {
391
+ authors {
392
+ name
393
+ bookCount # total books
394
+ bookCount(filter: { publishedYear: { _gt: 1950 } }) # filtered count
395
+ }
396
+ }
397
+ ```
398
+
399
+ ### Filtering by Count
400
+
401
+ The count field is also available as a filter key with numeric operators:
402
+
403
+ ```graphql
404
+ # Authors with exactly 4 books
405
+ filter: { bookCount: 4 }
406
+
407
+ # Authors with more than 3 books
408
+ filter: { bookCount_gt: 3 }
409
+
410
+ # Nested object form
411
+ filter: { BookCount: { _gte: 2, _lte: 10 } }
412
+ ```
413
+
414
+ Supported operators: `_eq`, `_ne`, `_gt`, `_gte`, `_lt`, `_lte`.
415
+
416
+ ### Generated SQL
417
+
418
+ ```sql
419
+ -- bookCount (select)
420
+ SELECT ...,
421
+ (SELECT COUNT(*) FROM "books" AS e_w1 WHERE e_w1.author_id = a_1.id) AS "bookCount"
422
+ FROM authors AS a_1 ...
423
+
424
+ -- bookCount_gt (filter)
425
+ WHERE (SELECT COUNT(*) FROM "books" AS e_w1 WHERE e_w1.author_id = a_1.id) > :v_bookCount_gt__1
426
+ ```
427
+
428
+ Works for all relationship types (1:m, m:1, m:n). For m:n, the pivot table is included in the subquery.
331
429
 
332
430
  ---
333
431
 
@@ -345,13 +443,13 @@ pagination: {
345
443
 
346
444
  ## Relationship Handling
347
445
 
348
- | Relationship | SQL Strategy |
349
- |--------------|-------------|
350
- | **m:1** (many-to-one) | `LEFT JOIN LATERAL` + `row_to_json` |
351
- | **1:1** (one-to-one) | `LEFT JOIN LATERAL` + `row_to_json` |
352
- | **1:m** (one-to-many) | `LEFT JOIN LATERAL` + `json_agg` |
353
- | **m:m** (many-to-many) | Pivot table subquery + `json_agg` |
354
- | **custom `mapping`** | `LEFT JOIN LATERAL` + `row_to_json` (same as m:1) |
446
+ | Relationship | SQL Strategy |
447
+ | ---------------------- | ------------------------------------------------- |
448
+ | **m:1** (many-to-one) | `LEFT JOIN LATERAL` + `row_to_json` |
449
+ | **1:1** (one-to-one) | `LEFT JOIN LATERAL` + `row_to_json` |
450
+ | **1:m** (one-to-many) | `LEFT JOIN LATERAL` + `json_agg` |
451
+ | **m:m** (many-to-many) | Pivot table subquery + `json_agg` |
452
+ | **custom `mapping`** | `LEFT JOIN LATERAL` + `row_to_json` (same as m:1) |
355
453
 
356
454
  ---
357
455
 
@@ -359,15 +457,15 @@ pagination: {
359
457
 
360
458
  ### Environment Variables
361
459
 
362
- | Variable | Purpose |
363
- |----------|---------|
364
- | `D3GOP_SORT_SUFFIX` | Suffix appended to all generated GQL type names and the Sort enum (e.g. `'V2'` → `BookV2`, `SortV2`) |
365
- | `D3GOP_TYPES_SUFFIX` | Fallback suffix if `D3GOP_SORT_SUFFIX` is not set |
366
- | `D3GOP_LOG_TYPE` | Logging level: `debug` or `disabled` |
367
- | `D3GOP_DEFAULT_QUERY_LIMIT` | Default query limit when pagination is not specified (default: `3000`) |
368
- | `D3GOP_USE_STRING_FOR_JSONB` | Toggle between JSONB and string concatenation for JSON aggregation |
460
+ | Variable | Purpose |
461
+ | ---------------------------- | ------------------------------------------------------------------------------------------- |
462
+ | `D3GOP_TYPES_SUFFIX` | Suffix appended to all generated GQL entity type names (e.g. `'V2'` → `BookV2`, `AuthorV2`) |
463
+ | `D3GOP_SORT_SUFFIX` | Suffix appended to sort/pagination types only (e.g. `'V2'` → `SortV2`, `BookV2OrderBy`) |
464
+ | `D3GOP_LOG_TYPE` | Logging level: `debug` or `disabled` |
465
+ | `D3GOP_DEFAULT_QUERY_LIMIT` | Default query limit when pagination is not specified (default: `3000`) |
466
+ | `D3GOP_USE_STRING_FOR_JSONB` | Toggle between JSONB and string concatenation for JSON aggregation |
369
467
 
370
- > **Type name collision**: If you have both v1 (`createGQLTypes`) and v2 (`@GQLEntityClass`) entities in the same schema, set `D3GOP_SORT_SUFFIX` / `D3GOP_TYPES_SUFFIX` so v2 entity names are distinct (e.g. `Job` → `JobV2`). No `setGlobalConfig()` call is required — the env var is read automatically.
468
+ > **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.
371
469
 
372
470
  ### Programmatic config
373
471
 
@@ -376,6 +474,9 @@ import { setGlobalConfig } from '@dav3/gql-of-power';
376
474
 
377
475
  // Call before any @GQLEntityClass decorators run (i.e. before importing entity files)
378
476
  setGlobalConfig({ gqlTypesSuffix: 'V2' });
477
+
478
+ // Optionally set a separate suffix for sort/pagination types
479
+ setGlobalConfig({ gqlTypesSuffix: 'V2', gqlSortSuffix: 'V2' });
379
480
  ```
380
481
 
381
482
  ---
@@ -387,39 +488,67 @@ const queryManager = new GQLQueryManager();
387
488
 
388
489
  // From a GraphQL resolver — fields are parsed from resolve info automatically
389
490
  const results = await queryManager.getQueryResultsForInfo(
390
- metadataProvider,
391
- BookGQL, // @GQLEntityClass-decorated class or plain ORM class
392
- info, // GraphQLResolveInfo
393
- filter,
394
- pagination
491
+ metadataProvider,
492
+ BookGQL, // @GQLEntityClass-decorated class or plain ORM class
493
+ info, // GraphQLResolveInfo
494
+ filter,
495
+ pagination
395
496
  );
396
497
 
397
498
  // With explicit field selection (useful for testing or non-resolver contexts)
398
499
  const results = await queryManager.getQueryResultsForFields(
399
- metadataProvider,
400
- BookGQL,
401
- { id: {}, title: {} },
402
- filter,
403
- pagination
500
+ metadataProvider,
501
+ BookGQL,
502
+ { id: {}, title: {} },
503
+ filter,
504
+ pagination
404
505
  );
405
506
  ```
406
507
 
407
508
  `BookGQL.relatedEntityName` (`'Book'`) is used automatically to look up ORM metadata — no need to pass the ORM class separately.
408
509
 
510
+ ### Singular Queries
511
+
512
+ For fetching a single record, use the singular variants. These enforce `LIMIT 1` internally and return `K | null` instead of `K[]`:
513
+
514
+ ```typescript
515
+ // From a GraphQL resolver
516
+ const ring = await queryManager.getQueryResultForInfo(
517
+ metadataProvider,
518
+ RingGQL,
519
+ info,
520
+ { forgedBy: 'Sauron' },
521
+ [{ forgedYear: 'desc' }] // optional orderBy — controls *which* record is returned
522
+ );
523
+ // ring: Ring | null
524
+
525
+ // With explicit field selection
526
+ const ring = await queryManager.getQueryResultForFields(
527
+ metadataProvider,
528
+ RingGQL,
529
+ { id: {}, name: {} },
530
+ { forgedBy: 'Sauron' },
531
+ [{ forgedYear: 'desc' }]
532
+ );
533
+ // ring: Ring | null
534
+ ```
535
+
536
+ No `limit` or `offset` parameters are accepted — `LIMIT 1` is always applied.
537
+
409
538
  ---
410
539
 
411
540
  ## Architecture
412
541
 
413
542
  ### Core Components
414
543
 
415
- | Component | Purpose |
416
- |-----------|---------|
417
- | `GQLtoSQLMapper` | Transforms GraphQL field selections into SQL with proper joins |
418
- | `GQLQueryManager` | Orchestrates query building and execution |
419
- | `FilterProcessor` | Translates GQL filter inputs to SQL WHERE clauses |
420
- | `RelationshipHandler` | Generates JOIN SQL for ORM-declared relations |
421
- | `SQLBuilder` | Assembles final SQL strings and JSON aggregations |
422
- | `AliasManager` | Manages incremental SQL aliases to prevent naming conflicts |
544
+ | Component | Purpose |
545
+ | --------------------- | -------------------------------------------------------------- |
546
+ | `GQLtoSQLMapper` | Transforms GraphQL field selections into SQL with proper joins |
547
+ | `GQLQueryManager` | Orchestrates query building and execution |
548
+ | `FilterProcessor` | Translates GQL filter inputs to SQL WHERE clauses |
549
+ | `RelationshipHandler` | Generates JOIN SQL for ORM-declared relations |
550
+ | `SQLBuilder` | Assembles final SQL strings and JSON aggregations |
551
+ | `AliasManager` | Manages incremental SQL aliases to prevent naming conflicts |
423
552
 
424
553
  ### Query Flow
425
554
 
@@ -1,12 +1,33 @@
1
1
  import { getMetadataStorage } from 'type-graphql';
2
- import { CustomFieldsSettings, FieldSettings, FieldsSettings, GQLEntityFilterInputFieldType, GQLEntityPaginationInputType, RelatedFieldSettings } from '../types';
2
+ import { CountFieldMeta, CustomFieldsSettings, FieldSettings, FieldsSettings, GQLEntityFilterInputFieldType, GQLEntityPaginationInputType, RelatedFieldSettings } from '../types';
3
3
  import { AccessControlEntry } from '../types/access-control';
4
4
  export declare const setGlobalConfig: (config: {
5
- gqlTypesSuffix: string;
5
+ gqlTypesSuffix?: string;
6
+ gqlSortSuffix?: string;
6
7
  }) => void;
7
8
  export declare const getFieldsOptionsFor: (name: string) => Record<string, string>;
8
9
  export declare const getFieldByAlias: (entityName: string | undefined, alias: string) => string;
9
10
  export declare const getCustomFieldsFor: (name: string) => CustomFieldsSettings<any>;
11
+ /**
12
+ * Returns the count fields registered for the given GQL entity name.
13
+ * Keyed by the count field name (e.g. 'bookCount'), value is the count field metadata.
14
+ */
15
+ export declare const getCountFieldsFor: (name: string) => Record<string, CountFieldMeta>;
16
+ /**
17
+ * Manually registers a count field for an entity.
18
+ * Useful for testing without the @GQLEntityClass decorator.
19
+ * In production, count fields are auto-registered by the decorator when `countFieldName` is set.
20
+ */
21
+ export declare const registerCountField: (gqlEntityName: string, countFieldName: string, relationshipFieldName: string, relatedEntityName: () => string) => void;
22
+ /**
23
+ * Clears all registered count fields. Intended for test teardown.
24
+ */
25
+ export declare const clearCountFields: () => void;
26
+ export declare const getMapEnumFieldsFor: (name: string) => Record<string, any>;
27
+ export declare const clearMapEnumFields: () => void;
28
+ export declare const getParseJsonFieldsFor: (name: string) => Set<string>;
29
+ export declare const registerParseJsonField: (gqlEntityName: string, fieldName: string) => void;
30
+ export declare const clearParseJsonFields: () => void;
10
31
  export declare const getACLFor: (name: string) => AccessControlEntry<any, any>;
11
32
  export declare const getGQLEntityNameFor: (name: string) => string;
12
33
  export declare const getGQLEntityNameForClass: <T>(classType: new () => T) => string;
@@ -33,6 +54,7 @@ export type GQLEntityStaticMembers = {
33
54
  readonly FilterInput: new () => any;
34
55
  readonly PaginationInput: new () => any;
35
56
  readonly OrderBy: new () => any;
57
+ readonly Input: new () => any;
36
58
  readonly FieldsResolver: new () => any;
37
59
  readonly gqlEntityName: string;
38
60
  readonly relatedEntityName: string;
@@ -53,6 +75,7 @@ export declare abstract class GQLEntityBase {
53
75
  static FilterInput: new () => any;
54
76
  static PaginationInput: new () => any;
55
77
  static OrderBy: new () => any;
78
+ static Input: new () => any;
56
79
  static FieldsResolver: new () => any;
57
80
  static gqlEntityName: string;
58
81
  static relatedEntityName: string;
@@ -139,12 +162,14 @@ export declare function createGQLEntity<T extends Object, K>(classType: new () =
139
162
  GQLEntityOrderBy: {
140
163
  new (): {};
141
164
  };
165
+ GQLEntityInput: new () => any;
142
166
  FieldsResolver: {
143
167
  new (): {};
144
168
  };
145
169
  bindFieldResolvers: (_c: any) => void;
146
170
  };
147
171
  };
172
+ type TypeGQLMetadataStorage = ReturnType<typeof getMetadataStorage>;
148
173
  /**
149
174
  * Convenience wrapper — creates entity, builds resolvers, and returns everything merged.
150
175
  * Equivalent to calling `createGQLEntity(...).buildResolvers()` and merging results.
@@ -159,6 +184,7 @@ export declare function createGQLTypes<T extends Object, K>(classType: new () =>
159
184
  GQLEntityOrderBy: {
160
185
  new (): {};
161
186
  };
187
+ GQLEntityInput: new () => any;
162
188
  FieldsResolver: {
163
189
  new (): {};
164
190
  };
@@ -176,13 +202,13 @@ export declare function createGQLTypes<T extends Object, K>(classType: new () =>
176
202
  GQLEntityOrderBy: {
177
203
  new (): {};
178
204
  };
205
+ GQLEntityInput: new () => any;
179
206
  FieldsResolver: {
180
207
  new (): {};
181
208
  };
182
209
  bindFieldResolvers: (_c: any) => void;
183
210
  };
184
211
  };
185
- type TypeGQLMetadataStorage = ReturnType<typeof getMetadataStorage>;
186
212
  /**
187
213
  * Creates filter and sorting metadata for a field. Called during buildResolvers().
188
214
  */