@dav3/gql-of-power 1.0.1 → 1.2.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 +304 -266
- package/dist/entities/gql-entity.d.ts +165 -9
- package/dist/entities/gql-entity.js +343 -88
- package/dist/entities/index.js +17 -1
- package/dist/index.js +21 -5
- package/dist/operations.js +10 -4
- package/dist/queries/alias.js +11 -6
- package/dist/queries/filter-processor.d.ts +7 -4
- package/dist/queries/filter-processor.js +140 -130
- package/dist/queries/gql-to-sql-mapper.js +173 -108
- package/dist/queries/index.js +18 -2
- package/dist/queries/relationship-handler.js +33 -38
- package/dist/queries/sql-builder.d.ts +2 -1
- package/dist/queries/sql-builder.js +31 -16
- package/dist/queries/utils.js +7 -2
- package/dist/query-manager.d.ts +1 -1
- package/dist/query-manager.js +66 -55
- package/dist/types/access-control.js +5 -2
- package/dist/types/gql-to-sql-types.js +7 -3
- package/dist/types/gql-types.js +2 -1
- package/dist/types/index.js +20 -4
- package/dist/types/sql-types.d.ts +114 -7
- package/dist/types/sql-types.js +7 -4
- package/dist/types/utils.js +2 -1
- package/dist/utils/index.js +17 -1
- package/dist/utils/object.js +5 -1
- package/dist/variables.js +8 -4
- package/package.json +2 -3
- package/src/entities/gql-entity.ts +375 -61
- package/src/entities/index.ts +1 -1
- package/src/queries/alias.ts +5 -1
- package/src/queries/filter-processor.ts +118 -108
- package/src/queries/gql-to-sql-mapper.ts +167 -61
- package/src/queries/relationship-handler.ts +14 -16
- package/src/queries/sql-builder.ts +46 -33
- package/src/query-manager.ts +54 -42
- package/src/types/gql-types.ts +28 -26
- package/src/types/sql-types.ts +141 -31
- package/src/types/utils.ts +2 -3
- package/jest.config.js +0 -11
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ In the age of microservices and data loaders, a shadow falls upon performance: _
|
|
|
10
10
|
|
|
11
11
|
But what if there was another way? What if, with the power of a single 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 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
|
|
|
@@ -20,7 +20,7 @@ Like the One Ring wielding dominion over all other Rings, GQL of Power commands
|
|
|
20
20
|
|
|
21
21
|
### 🗡️ Query Unification
|
|
22
22
|
|
|
23
|
-
Generate a **single, optimized SQL query** from any GraphQL query structure—no matter how complex your relationships are.
|
|
23
|
+
Generate a **single, optimized SQL query** from any GraphQL query structure — no matter how complex your relationships are.
|
|
24
24
|
|
|
25
25
|
- ✨ **Eliminate Data Loaders** – Replace the tedious choreography of multiple queries with one powerful statement
|
|
26
26
|
- 🏰 **Intelligent Relationship Handling** – Navigate 1:1, 1:m, m:1, and m:m relationships seamlessly
|
|
@@ -33,391 +33,429 @@ Generate a **single, optimized SQL query** from any GraphQL query structure—no
|
|
|
33
33
|
- **Recursive Query Mapping** – Transforms GraphQL field selections into SQL recursively
|
|
34
34
|
- **Automatic Join Generation** – Intelligently creates JOINs based on ORM entity relationships
|
|
35
35
|
- **Dynamic Filtering** – Support for field-level and class-level filter operations (`_eq`, `_in`, `_like`, `_gt`, `_and`, `_or`, etc.)
|
|
36
|
-
- **Pagination
|
|
37
|
-
- **Custom Fields** – Extend entities with computed properties, DataLoaders,
|
|
38
|
-
- **Framework Agnostic** – Works with any ORM through a simple MetadataProvider interface
|
|
36
|
+
- **Pagination & Sorting** – Native limit, offset, and order-by handling
|
|
37
|
+
- **Custom Fields** – Extend entities with computed properties, DataLoaders, or automatic SQL JOINs for unmapped foreign keys
|
|
38
|
+
- **Framework Agnostic** – Works with any ORM through a simple `MetadataProvider` interface
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
42
|
## Installation
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
# Using pnpm (recommended)
|
|
46
45
|
pnpm add @dav3/gql-of-power
|
|
47
|
-
|
|
48
|
-
# Using npm
|
|
49
|
-
npm install @dav3/gql-of-power
|
|
50
|
-
|
|
51
|
-
# Using yarn
|
|
52
|
-
yarn add @dav3/gql-of-power
|
|
53
46
|
```
|
|
54
47
|
|
|
55
48
|
---
|
|
56
49
|
|
|
57
50
|
## Quick Start
|
|
58
51
|
|
|
59
|
-
### 1. Define Your Entities
|
|
52
|
+
### 1. Define Your GQL Entities
|
|
53
|
+
|
|
54
|
+
Use `defineFields` + `@GQLEntityClass` to declare GraphQL entities. `defineFields` is typed against the ORM class — invalid field names are caught at compile time.
|
|
60
55
|
|
|
61
56
|
```typescript
|
|
62
|
-
import {
|
|
63
|
-
import {
|
|
57
|
+
import { defineFields, GQLEntityBase, GQLEntityClass } from '@dav3/gql-of-power';
|
|
58
|
+
import { Author, Book } from './orm-entities';
|
|
59
|
+
import { ID, Int } from 'type-graphql';
|
|
60
|
+
|
|
61
|
+
// --- Author ---
|
|
62
|
+
const authorFields = defineFields(Author, {
|
|
63
|
+
id: { type: () => ID, generateFilter: true },
|
|
64
|
+
name: { type: () => String, generateFilter: true },
|
|
65
|
+
});
|
|
64
66
|
|
|
65
|
-
@
|
|
66
|
-
export class
|
|
67
|
-
@Field(() => Int)
|
|
68
|
-
id: number;
|
|
67
|
+
@GQLEntityClass(Author, authorFields)
|
|
68
|
+
export class AuthorGQL extends GQLEntityBase {}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// --- Book ---
|
|
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 } },
|
|
76
|
+
});
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
@GQLEntityClass(Book, bookFields)
|
|
79
|
+
export class BookGQL extends GQLEntityBase {}
|
|
80
|
+
```
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
export class Book extends GQLEntity {
|
|
79
|
-
@Field(() => Int)
|
|
80
|
-
id: number;
|
|
82
|
+
The decorator attaches generated statics to the class:
|
|
81
83
|
|
|
82
|
-
|
|
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
92
|
|
|
85
|
-
|
|
86
|
-
publicationYear: number;
|
|
93
|
+
### 2. Write Resolvers
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
```typescript
|
|
96
|
+
import { Resolver, Query, Arg, Info } from 'type-graphql';
|
|
97
|
+
import { GQLResolver, getAutoResolvers } from '@dav3/gql-of-power';
|
|
98
|
+
import { GraphQLResolveInfo } from 'graphql';
|
|
99
|
+
|
|
100
|
+
@GQLResolver(BookGQL)
|
|
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
|
+
}
|
|
90
110
|
}
|
|
91
111
|
```
|
|
92
112
|
|
|
93
|
-
###
|
|
94
|
-
|
|
95
|
-
Provide ORM metadata that tells GQL of Power about your tables, columns, and relationships:
|
|
113
|
+
### 3. Build the Schema
|
|
96
114
|
|
|
97
115
|
```typescript
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
};
|
|
116
|
+
import { buildSchemaSync } from 'type-graphql';
|
|
117
|
+
import { getAutoResolvers } from '@dav3/gql-of-power';
|
|
118
|
+
import '../entities'; // trigger @GQLEntityClass decoration
|
|
119
|
+
|
|
120
|
+
const schema = buildSchemaSync({
|
|
121
|
+
resolvers: [
|
|
122
|
+
BookResolver,
|
|
123
|
+
...getAutoResolvers(), // FieldsResolver for every @GQLEntityClass entity
|
|
124
|
+
],
|
|
125
|
+
});
|
|
109
126
|
```
|
|
110
127
|
|
|
111
|
-
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Examples
|
|
131
|
+
|
|
132
|
+
For complete, LotR-themed walkthroughs of both mapping strategies (direct ORM relation and custom field mapping), see **[EXAMPLES.md](./EXAMPLES.md)**.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Entity API
|
|
137
|
+
|
|
138
|
+
### `defineFields<T>(OrmClass, config)`
|
|
139
|
+
|
|
140
|
+
Typed wrapper that constrains config keys to `keyof T`. Identity function at runtime — it exists purely for TypeScript inference.
|
|
112
141
|
|
|
113
142
|
```typescript
|
|
114
|
-
|
|
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 ✗
|
|
147
|
+
});
|
|
148
|
+
```
|
|
115
149
|
|
|
116
|
-
|
|
150
|
+
#### Field options
|
|
117
151
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 |
|
|
127
161
|
|
|
128
|
-
|
|
162
|
+
### `@GQLEntityClass(OrmClass, fields, extra?)`
|
|
163
|
+
|
|
164
|
+
Class decorator that registers the entity with type-graphql and attaches generated statics.
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
@GQLEntityClass(Book, fields, {
|
|
168
|
+
customFields: { ... }, // optional — see Custom Fields
|
|
169
|
+
acl: { ... }, // optional — access control
|
|
170
|
+
})
|
|
171
|
+
export class BookGQL extends GQLEntityBase {}
|
|
129
172
|
```
|
|
130
173
|
|
|
131
|
-
|
|
174
|
+
### `extends GQLEntityBase`
|
|
132
175
|
|
|
133
|
-
|
|
176
|
+
Required base class. Provides TypeScript visibility of the decorator-attached statics (`FilterInput`, `PaginationInput`, etc.) without `declare static` boilerplate on each entity.
|
|
134
177
|
|
|
135
|
-
###
|
|
178
|
+
### `@GQLResolver(EntityClass)`
|
|
136
179
|
|
|
137
|
-
|
|
138
|
-
| ------------------ | ----------------------------------------------------------------- |
|
|
139
|
-
| **GQLtoSQLMapper** | The main artifact—transforms GraphQL into SQL with proper joins |
|
|
140
|
-
| **QueryManager** | The orchestrator—manages the entire query execution flow |
|
|
141
|
-
| **EntitySystem** | The registry—defines GraphQL entities with auto-generated filters |
|
|
142
|
-
| **Operations** | The spells—defines all available filtering operations |
|
|
180
|
+
Marks a class as a custom resolver for the given GQL entity. Applies `@Resolver(() => EntityClass)` and integrates with type-graphql's resolver merging (the auto `FieldsResolver` and your custom query resolver coexist seamlessly).
|
|
143
181
|
|
|
144
|
-
###
|
|
182
|
+
### `getAutoResolvers()`
|
|
145
183
|
|
|
146
|
-
|
|
147
|
-
2. **Mapping** – `GQLtoSQLMapper.recursiveMap()` builds SQL recursively
|
|
148
|
-
3. **Aggregation** – JSON aggregation combines nested results efficiently
|
|
149
|
-
4. **Execution** – Final SQL is executed against your database
|
|
184
|
+
Returns all `FieldsResolver` classes registered by `@GQLEntityClass` decorators. Pass the result into `buildSchemaSync({ resolvers: [...getAutoResolvers()] })`.
|
|
150
185
|
|
|
151
186
|
---
|
|
152
187
|
|
|
153
|
-
##
|
|
188
|
+
## Custom Fields
|
|
189
|
+
|
|
190
|
+
Custom fields extend a GQL entity with fields that don't exist as direct ORM properties. There are two mutually exclusive strategies.
|
|
154
191
|
|
|
155
|
-
###
|
|
192
|
+
### Strategy 1: `resolve` — DataLoader / computed
|
|
156
193
|
|
|
157
|
-
|
|
194
|
+
Provide a GraphQL `@FieldResolver` function. The library fetches field(s) listed in `requires` from the main query, then your `resolve` function runs at GraphQL resolution time.
|
|
158
195
|
|
|
159
196
|
```typescript
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
197
|
+
@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
|
+
},
|
|
165
207
|
},
|
|
166
208
|
},
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
// Complex nested AND/OR conditions
|
|
170
|
-
await queryManager.execute(Author, gqlInfo, {
|
|
171
|
-
filter: {
|
|
172
|
-
_and: [{ name: { _like: '%Tolkien%' } }, { books: { publicationYear: { _gte: 1950 } } }],
|
|
173
|
-
},
|
|
174
|
-
});
|
|
209
|
+
})
|
|
210
|
+
export class FellowshipGQL extends GQLEntityBase {}
|
|
175
211
|
```
|
|
176
212
|
|
|
177
|
-
|
|
213
|
+
#### `resolve` field options
|
|
178
214
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
### Filter Operations
|
|
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) |
|
|
221
|
+
| `resolveDecorators` | type-graphql parameter decorators in order (`[Root(), Ctx(), Info()]`) |
|
|
191
222
|
|
|
192
|
-
|
|
193
|
-
| -------------- | --------------------------------- | ---------------------------------- |
|
|
194
|
-
| `_eq` | `{ id: { _eq: 5 } }` | Equal to |
|
|
195
|
-
| `_ne` | `{ status: { _ne: 'inactive' } }` | Not equal to |
|
|
196
|
-
| `_in` | `{ id: { _in: [1, 2, 3] } }` | In array |
|
|
197
|
-
| `_like` | `{ name: { _like: '%Frodo%' } }` | String contains (case-insensitive) |
|
|
198
|
-
| `_gt` / `_gte` | `{ year: { _gte: 1900 } }` | Greater than (or equal) |
|
|
199
|
-
| `_lt` / `_lte` | `{ year: { _lte: 2000 } }` | Less than (or equal) |
|
|
223
|
+
### Strategy 2: `mapping` — automatic SQL JOIN
|
|
200
224
|
|
|
201
|
-
|
|
225
|
+
Provide a `FieldMappingConfig`. The library generates a SQL `LEFT JOIN LATERAL` automatically and returns the related object directly from the SQL result. No resolver function needed.
|
|
202
226
|
|
|
203
|
-
|
|
227
|
+
Use this when the foreign key exists as a plain column on the entity (not declared as an ORM relation).
|
|
204
228
|
|
|
205
229
|
```typescript
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Export FieldsResolver to use in your resolver classes
|
|
224
|
-
export const FellowshipFieldsResolver = FellowshipGQL.FieldsResolver;
|
|
230
|
+
import { Kingdom } from './orm-entities';
|
|
231
|
+
|
|
232
|
+
@GQLEntityClass(Hobbit, fields, {
|
|
233
|
+
customFields: {
|
|
234
|
+
kingdom: {
|
|
235
|
+
type: () => KingdomGQL,
|
|
236
|
+
options: { nullable: true },
|
|
237
|
+
mapping: {
|
|
238
|
+
refEntity: Kingdom, // ORM entity class to JOIN to
|
|
239
|
+
refFields: 'id', // column(s) on Kingdom — keyof Kingdom ✓
|
|
240
|
+
fields: 'kingdomId', // column(s) on Hobbit — keyof Hobbit ✓
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
export class HobbitGQL extends GQLEntityBase {}
|
|
225
246
|
```
|
|
226
247
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
| Property | Purpose | Connection |
|
|
230
|
-
|----------|---------|------------|
|
|
231
|
-
| `type` | Declares the GraphQL return type that appears in the schema | The type clients see when querying this field |
|
|
232
|
-
| `options` | TypeGraphQL field options like nullable, deprecation, descriptions | Controls field behavior in the GraphQL schema |
|
|
233
|
-
| `requires` | Specifies which fields from the main query must be present in the result | GQL of Power ensures these database columns are fetched alongside the optimized query |
|
|
234
|
-
| `resolve` | Function that receives the root entity object and returns the custom field value | Executed after the main query completes, receiving the fetched entity data |
|
|
235
|
-
| `resolveDecorators` | TypeGraphQL decorators that map function parameters | Determines which context parameters (`Root`, `Ctx`, `Info`) are injected into the resolve function |
|
|
236
|
-
|
|
237
|
-
#### Use Cases
|
|
248
|
+
Composite FK — use arrays (must have the same length):
|
|
238
249
|
|
|
239
|
-
🧙 **DataLoader Integration** – Batch-load related data without N+1
|
|
240
250
|
```typescript
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
251
|
+
mapping: {
|
|
252
|
+
refEntity: Weapon,
|
|
253
|
+
refFields: ['realmId', 'weaponCode'],
|
|
254
|
+
fields: ['realmId', 'weaponExternalId'],
|
|
244
255
|
}
|
|
245
256
|
```
|
|
246
257
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
258
|
+
#### `mapping` field options
|
|
259
|
+
|
|
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 |
|
|
267
|
+
|
|
268
|
+
> **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
|
+
#### Generated SQL
|
|
271
|
+
|
|
272
|
+
For `kingdom: { mapping: { refEntity: Kingdom, refFields: 'id', fields: 'kingdomId' } }`:
|
|
273
|
+
|
|
274
|
+
```sql
|
|
275
|
+
select e_a1.id, e_a1.kingdom_id, f_j1.value as "kingdom"
|
|
276
|
+
from (
|
|
277
|
+
select e_a1.id, e_a1.kingdom_id
|
|
278
|
+
from hobbit as e_a1
|
|
279
|
+
where true
|
|
280
|
+
) as e_a1
|
|
281
|
+
left outer join lateral (
|
|
282
|
+
select row_to_json(f_j1)::jsonb as value
|
|
283
|
+
from (
|
|
284
|
+
select f_j1.id, f_j1.name
|
|
285
|
+
from kingdom as f_j1
|
|
286
|
+
where e_a1.kingdom_id = f_j1.id
|
|
287
|
+
) as f_j1
|
|
288
|
+
) as f_j1 on true
|
|
253
289
|
```
|
|
254
290
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
type: () => String,
|
|
259
|
-
resolve: (root: Fellowship) => formatTitle(root.name),
|
|
260
|
-
}
|
|
261
|
-
```
|
|
291
|
+
Returns `null` when the FK column is null (LEFT JOIN).
|
|
292
|
+
|
|
293
|
+
---
|
|
262
294
|
|
|
263
|
-
|
|
295
|
+
## Filtering
|
|
264
296
|
|
|
265
|
-
|
|
297
|
+
Filter at any nesting level:
|
|
266
298
|
|
|
267
299
|
```typescript
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
filter
|
|
284
|
-
);
|
|
285
|
-
}
|
|
300
|
+
// Simple equality
|
|
301
|
+
filter: { title: 'The Fellowship of the Ring' }
|
|
302
|
+
|
|
303
|
+
// Operators
|
|
304
|
+
filter: { publishedYear: { _gte: 1950 } }
|
|
305
|
+
|
|
306
|
+
// Nested relation filter
|
|
307
|
+
filter: { author: { name: { _like: '%Tolkien%' } } }
|
|
308
|
+
|
|
309
|
+
// AND / OR
|
|
310
|
+
filter: {
|
|
311
|
+
_or: [
|
|
312
|
+
{ title: { _like: '%Ring%' } },
|
|
313
|
+
{ publishedYear: { _lt: 1960 } },
|
|
314
|
+
],
|
|
286
315
|
}
|
|
287
316
|
```
|
|
288
317
|
|
|
289
|
-
|
|
318
|
+
### Filter operations
|
|
290
319
|
|
|
291
|
-
|
|
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) |
|
|
292
331
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
### Environment Variables
|
|
332
|
+
---
|
|
296
333
|
|
|
297
|
-
|
|
298
|
-
# Toggle between JSONB and string concatenation for JSON aggregation
|
|
299
|
-
D3GOP_USE_STRING_FOR_JSONB=true
|
|
334
|
+
## Pagination
|
|
300
335
|
|
|
301
|
-
|
|
302
|
-
|
|
336
|
+
```typescript
|
|
337
|
+
pagination: {
|
|
338
|
+
limit: 20,
|
|
339
|
+
offset: 40,
|
|
340
|
+
orderBy: [{ publishedYear: 'desc' }],
|
|
341
|
+
}
|
|
303
342
|
```
|
|
304
343
|
|
|
305
344
|
---
|
|
306
345
|
|
|
307
|
-
##
|
|
346
|
+
## Relationship Handling
|
|
308
347
|
|
|
309
|
-
|
|
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) |
|
|
310
355
|
|
|
311
|
-
|
|
312
|
-
# Install dependencies
|
|
313
|
-
pnpm install
|
|
356
|
+
---
|
|
314
357
|
|
|
315
|
-
|
|
316
|
-
pnpm run build
|
|
358
|
+
## Configuration
|
|
317
359
|
|
|
318
|
-
|
|
319
|
-
pnpm run watch
|
|
360
|
+
### Environment Variables
|
|
320
361
|
|
|
321
|
-
|
|
322
|
-
|
|
362
|
+
| Variable | Purpose |
|
|
363
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------- |
|
|
364
|
+
| `D3GOP_TYPES_SUFFIX` | Suffix appended to all generated GQL entity type names (e.g. `'V2'` → `BookV2`, `AuthorV2`) |
|
|
365
|
+
| `D3GOP_SORT_SUFFIX` | Suffix appended to sort/pagination types only (e.g. `'V2'` → `SortV2`, `BookV2OrderBy`) |
|
|
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 |
|
|
323
369
|
|
|
324
|
-
|
|
325
|
-
pnpm test:unit
|
|
370
|
+
> **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.
|
|
326
371
|
|
|
327
|
-
|
|
328
|
-
pnpm test:integration
|
|
372
|
+
### Programmatic config
|
|
329
373
|
|
|
330
|
-
|
|
331
|
-
|
|
374
|
+
```typescript
|
|
375
|
+
import { setGlobalConfig } from '@dav3/gql-of-power';
|
|
332
376
|
|
|
333
|
-
|
|
334
|
-
|
|
377
|
+
// Call before any @GQLEntityClass decorators run (i.e. before importing entity files)
|
|
378
|
+
setGlobalConfig({ gqlTypesSuffix: 'V2' });
|
|
335
379
|
|
|
336
|
-
|
|
337
|
-
|
|
380
|
+
// Optionally set a separate suffix for sort/pagination types
|
|
381
|
+
setGlobalConfig({ gqlTypesSuffix: 'V2', gqlSortSuffix: 'V2' });
|
|
338
382
|
```
|
|
339
383
|
|
|
340
384
|
---
|
|
341
385
|
|
|
342
|
-
##
|
|
343
|
-
|
|
344
|
-
### The Problem It Solves
|
|
345
|
-
|
|
346
|
-
Traditional GraphQL + ORM combinations suffer from the **N+1 query problem**:
|
|
386
|
+
## `GQLQueryManager`
|
|
347
387
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
388
|
+
```typescript
|
|
389
|
+
const queryManager = new GQLQueryManager();
|
|
390
|
+
|
|
391
|
+
// From a GraphQL resolver — fields are parsed from resolve info automatically
|
|
392
|
+
const results = await queryManager.getQueryResultsForInfo(
|
|
393
|
+
metadataProvider,
|
|
394
|
+
BookGQL, // @GQLEntityClass-decorated class or plain ORM class
|
|
395
|
+
info, // GraphQLResolveInfo
|
|
396
|
+
filter,
|
|
397
|
+
pagination
|
|
398
|
+
);
|
|
354
399
|
|
|
355
|
-
|
|
356
|
-
|
|
400
|
+
// With explicit field selection (useful for testing or non-resolver contexts)
|
|
401
|
+
const results = await queryManager.getQueryResultsForFields(
|
|
402
|
+
metadataProvider,
|
|
403
|
+
BookGQL,
|
|
404
|
+
{ id: {}, title: {} },
|
|
405
|
+
filter,
|
|
406
|
+
pagination
|
|
407
|
+
);
|
|
357
408
|
```
|
|
358
409
|
|
|
359
|
-
|
|
410
|
+
`BookGQL.relatedEntityName` (`'Book'`) is used automatically to look up ORM metadata — no need to pass the ORM class separately.
|
|
360
411
|
|
|
361
412
|
---
|
|
362
413
|
|
|
363
|
-
## Architecture
|
|
414
|
+
## Architecture
|
|
364
415
|
|
|
365
|
-
###
|
|
416
|
+
### Core Components
|
|
366
417
|
|
|
367
|
-
|
|
|
368
|
-
|
|
|
369
|
-
|
|
|
370
|
-
|
|
|
371
|
-
|
|
|
372
|
-
|
|
|
418
|
+
| Component | Purpose |
|
|
419
|
+
| --------------------- | -------------------------------------------------------------- |
|
|
420
|
+
| `GQLtoSQLMapper` | Transforms GraphQL field selections into SQL with proper joins |
|
|
421
|
+
| `GQLQueryManager` | Orchestrates query building and execution |
|
|
422
|
+
| `FilterProcessor` | Translates GQL filter inputs to SQL WHERE clauses |
|
|
423
|
+
| `RelationshipHandler` | Generates JOIN SQL for ORM-declared relations |
|
|
424
|
+
| `SQLBuilder` | Assembles final SQL strings and JSON aggregations |
|
|
425
|
+
| `AliasManager` | Manages incremental SQL aliases to prevent naming conflicts |
|
|
373
426
|
|
|
374
|
-
###
|
|
427
|
+
### Query Flow
|
|
375
428
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
429
|
+
1. **Parse** – `graphql-parse-resolve-info` extracts the requested fields from `GraphQLResolveInfo`
|
|
430
|
+
2. **Map** – `GQLtoSQLMapper.recursiveMap()` walks the field tree, building `select`, `join`, `where` sets
|
|
431
|
+
3. **Aggregate** – JSON aggregation (`row_to_json`, `json_agg`) combines nested results
|
|
432
|
+
4. **Bind** – Named parameters are bound via knex raw
|
|
433
|
+
5. **Execute** – Single SQL sent to the database
|
|
380
434
|
|
|
381
435
|
---
|
|
382
436
|
|
|
383
437
|
## Known Limitations
|
|
384
438
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
- ⚠️
|
|
388
|
-
- ⚠️ Order by reference table columns (e.g., "order authors by their latest book publication")
|
|
389
|
-
- ⚠️ Access Control Lists (ACL) pending async refactoring
|
|
390
|
-
|
|
391
|
-
---
|
|
392
|
-
|
|
393
|
-
## Examples
|
|
394
|
-
|
|
395
|
-
See the `/examples` directory for complete, runnable examples:
|
|
396
|
-
|
|
397
|
-
- **Middle-earth Schema** – Full Author/Book relationship example inspired by Tolkien's lore
|
|
398
|
-
- **Database Integration** – Real PostgreSQL examples with TypeTypeORM metadata
|
|
439
|
+
- ⚠️ Class-level `_not` conditions not yet supported
|
|
440
|
+
- ⚠️ Order by columns on related/joined tables not supported
|
|
441
|
+
- ⚠️ ACL pending async refactoring
|
|
399
442
|
|
|
400
443
|
---
|
|
401
444
|
|
|
402
|
-
##
|
|
445
|
+
## Development
|
|
403
446
|
|
|
404
|
-
|
|
447
|
+
```bash
|
|
448
|
+
bun install # Install dependencies
|
|
449
|
+
bun run build # Compile TypeScript → dist/
|
|
450
|
+
bun run test # Run all tests
|
|
451
|
+
bun run test:watch # Watch mode
|
|
452
|
+
```
|
|
405
453
|
|
|
406
454
|
---
|
|
407
455
|
|
|
408
456
|
## License
|
|
409
457
|
|
|
410
|
-
MIT NON-AI License
|
|
411
|
-
|
|
412
|
-
Forged by [Dav3](https://github.com/dave-palt) with the wisdom of Middle-earth.
|
|
413
|
-
|
|
414
|
-
---
|
|
415
|
-
|
|
416
|
-
## Support & Community
|
|
417
|
-
|
|
418
|
-
- 📖 Full documentation coming soon
|
|
419
|
-
- 🐛 Found a bug? [Open an issue](https://github.com/dave-palt/gql-of-power/issues)
|
|
420
|
-
- 💬 Have a question? Reach out to the fellowship
|
|
458
|
+
MIT NON-AI License — Forged by [Dav3](https://github.com/dave-palt) with the wisdom of Middle-earth.
|
|
421
459
|
|
|
422
460
|
---
|
|
423
461
|
|