@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 +263 -134
- package/dist/entities/gql-entity.d.ts +29 -3
- package/dist/entities/gql-entity.js +407 -24
- package/dist/operations.d.ts +5 -2
- package/dist/queries/filter-processor.d.ts +82 -4
- package/dist/queries/filter-processor.js +282 -12
- package/dist/queries/gql-to-sql-mapper.d.ts +16 -3
- package/dist/queries/gql-to-sql-mapper.js +232 -49
- package/dist/queries/relationship-handler.js +6 -3
- package/dist/queries/sql-builder.d.ts +1 -1
- package/dist/queries/sql-builder.js +4 -1
- package/dist/query-manager.d.ts +5 -7
- package/dist/query-manager.js +122 -4
- package/dist/types/gql-types.d.ts +11 -0
- package/dist/types/sql-types.d.ts +143 -1
- package/dist/variables.js +10 -6
- package/package.json +7 -3
- package/src/entities/gql-entity.ts +468 -32
- package/src/entities/index.ts +1 -1
- package/src/operations.ts +11 -2
- package/src/queries/alias.ts +5 -1
- package/src/queries/filter-processor.ts +587 -33
- package/src/queries/gql-to-sql-mapper.ts +422 -71
- package/src/queries/relationship-handler.ts +13 -4
- package/src/queries/sql-builder.ts +11 -2
- package/src/query-manager.ts +173 -12
- package/src/types/gql-types.ts +40 -26
- package/src/types/sql-types.ts +166 -25
- package/src/types/utils.ts +2 -3
- package/src/variables.ts +12 -8
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
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
| `BookGQL.FilterInput`
|
|
87
|
-
| `BookGQL.PaginationInput`
|
|
88
|
-
| `BookGQL.OrderBy`
|
|
89
|
-
| `BookGQL.FieldsResolver`
|
|
90
|
-
| `BookGQL.gqlEntityName`
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
| `type`
|
|
155
|
-
| `generateFilter`
|
|
156
|
-
| `options`
|
|
157
|
-
| `alias`
|
|
158
|
-
| `array`
|
|
159
|
-
| `relatedEntityName` | `() => string`
|
|
160
|
-
| `
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
| `type`
|
|
218
|
-
| `options`
|
|
219
|
-
| `requires`
|
|
220
|
-
| `resolve`
|
|
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 {
|
|
231
|
-
|
|
232
|
-
@GQLEntityClass(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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:
|
|
253
|
-
refFields: ['
|
|
254
|
-
fields: ['
|
|
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
|
|
261
|
-
|
|
262
|
-
| `type`
|
|
263
|
-
| `options`
|
|
264
|
-
| `mapping.refEntity` | `new () => TRef`
|
|
265
|
-
| `mapping.refFields` | `keyof TRef \| Array<keyof TRef>` | Column(s) on the ref entity to match against
|
|
266
|
-
| `mapping.fields`
|
|
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 `
|
|
273
|
+
For `kingdom: { mapping: { refEntity: Kingdom, refFields: 'id', fields: 'kingdomId' } }`:
|
|
273
274
|
|
|
274
275
|
```sql
|
|
275
|
-
select e_a1.id, e_a1.
|
|
276
|
+
select e_a1.id, e_a1.kingdom_id, f_j1.value as "kingdom"
|
|
276
277
|
from (
|
|
277
|
-
select e_a1.id, e_a1.
|
|
278
|
-
from
|
|
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.
|
|
285
|
-
from
|
|
286
|
-
where e_a1.
|
|
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
|
|
321
|
-
|
|
322
|
-
| `_eq`
|
|
323
|
-
| `_ne`
|
|
324
|
-
| `_in`
|
|
325
|
-
| `_nin`
|
|
326
|
-
| `_like`
|
|
327
|
-
| `_gt` / `_gte` | Greater than / greater than or equal
|
|
328
|
-
| `_lt` / `_lte` | Less than / less than or equal
|
|
329
|
-
| `_and`
|
|
330
|
-
| `_or`
|
|
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
|
|
349
|
-
|
|
350
|
-
| **m:1** (many-to-one)
|
|
351
|
-
| **1:1** (one-to-one)
|
|
352
|
-
| **1:m** (one-to-many)
|
|
353
|
-
| **m:m** (many-to-many) | Pivot table subquery + `json_agg`
|
|
354
|
-
| **custom `mapping`**
|
|
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
|
|
363
|
-
|
|
364
|
-
| `
|
|
365
|
-
| `
|
|
366
|
-
| `D3GOP_LOG_TYPE`
|
|
367
|
-
| `D3GOP_DEFAULT_QUERY_LIMIT`
|
|
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 `
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
| `GQLtoSQLMapper`
|
|
418
|
-
| `GQLQueryManager`
|
|
419
|
-
| `FilterProcessor`
|
|
420
|
-
| `RelationshipHandler` | Generates JOIN SQL for ORM-declared relations
|
|
421
|
-
| `SQLBuilder`
|
|
422
|
-
| `AliasManager`
|
|
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
|
|
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
|
*/
|