@simtlix/simfinity-js 2.4.4 โ 2.5.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/.claude/worktrees/agitated-kepler/.claude/settings.local.json +23 -0
- package/.claude/worktrees/agitated-kepler/AGGREGATION_CHANGES_SUMMARY.md +235 -0
- package/.claude/worktrees/agitated-kepler/AGGREGATION_EXAMPLE.md +567 -0
- package/.claude/worktrees/agitated-kepler/LICENSE +201 -0
- package/.claude/worktrees/agitated-kepler/README.md +3941 -0
- package/.claude/worktrees/agitated-kepler/eslint.config.mjs +71 -0
- package/.claude/worktrees/agitated-kepler/package-lock.json +4740 -0
- package/.claude/worktrees/agitated-kepler/package.json +41 -0
- package/.claude/worktrees/agitated-kepler/src/auth/errors.js +44 -0
- package/.claude/worktrees/agitated-kepler/src/auth/expressions.js +273 -0
- package/.claude/worktrees/agitated-kepler/src/auth/index.js +391 -0
- package/.claude/worktrees/agitated-kepler/src/auth/rules.js +274 -0
- package/.claude/worktrees/agitated-kepler/src/const/QLOperator.js +39 -0
- package/.claude/worktrees/agitated-kepler/src/const/QLSort.js +28 -0
- package/.claude/worktrees/agitated-kepler/src/const/QLValue.js +39 -0
- package/.claude/worktrees/agitated-kepler/src/errors/internal-server.error.js +11 -0
- package/.claude/worktrees/agitated-kepler/src/errors/simfinity.error.js +15 -0
- package/.claude/worktrees/agitated-kepler/src/index.js +2412 -0
- package/.claude/worktrees/agitated-kepler/src/plugins.js +53 -0
- package/.claude/worktrees/agitated-kepler/src/scalars.js +188 -0
- package/.claude/worktrees/agitated-kepler/src/validators.js +250 -0
- package/.claude/worktrees/agitated-kepler/yarn.lock +1154 -0
- package/.cursor/rules/simfinity-core-functions.mdc +3 -1
- package/README.md +202 -0
- package/git-report.js +224 -0
- package/package.json +1 -1
- package/src/index.js +237 -23
|
@@ -0,0 +1,3941 @@
|
|
|
1
|
+
# Simfinity.js
|
|
2
|
+
|
|
3
|
+
A powerful Node.js framework that automatically generates GraphQL schemas from your data models, bringing all the power and flexibility of MongoDB query language to GraphQL interfaces.
|
|
4
|
+
|
|
5
|
+
## ๐ Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Features](#-features)
|
|
8
|
+
- [Installation](#-installation)
|
|
9
|
+
- [Quick Start](#-quick-start)
|
|
10
|
+
- [Core Concepts](#-core-concepts)
|
|
11
|
+
- [Connecting Models](#connecting-models)
|
|
12
|
+
- [Creating Schemas](#creating-schemas)
|
|
13
|
+
- [Global Configuration](#global-configuration)
|
|
14
|
+
- [Basic Usage](#-basic-usage)
|
|
15
|
+
- [Automatic Query Generation](#automatic-query-generation)
|
|
16
|
+
- [Automatic Mutation Generation](#automatic-mutation-generation)
|
|
17
|
+
- [Filtering and Querying](#filtering-and-querying)
|
|
18
|
+
- [Collection Field Filtering](#collection-field-filtering)
|
|
19
|
+
- [Relationships](#-relationships)
|
|
20
|
+
- [Defining Relationships](#defining-relationships)
|
|
21
|
+
- [Auto-Generated Resolve Methods](#auto-generated-resolve-methods)
|
|
22
|
+
- [Adding Types Without Endpoints](#adding-types-without-endpoints)
|
|
23
|
+
- [Embedded vs Referenced Relationships](#embedded-vs-referenced-relationships)
|
|
24
|
+
- [Querying Relationships](#querying-relationships)
|
|
25
|
+
- [Validations](#-validations)
|
|
26
|
+
- [Field-Level Validations](#field-level-validations)
|
|
27
|
+
- [Type-Level Validations](#type-level-validations)
|
|
28
|
+
- [Custom Validated Scalar Types](#custom-validated-scalar-types)
|
|
29
|
+
- [Custom Error Classes](#custom-error-classes)
|
|
30
|
+
- [State Machines](#-state-machines)
|
|
31
|
+
- [Controllers & Lifecycle Hooks](#๏ธ-controllers--lifecycle-hooks)
|
|
32
|
+
- [Hook Parameters](#hook-parameters)
|
|
33
|
+
- [Query Scope](#-query-scope)
|
|
34
|
+
- [Overview](#overview)
|
|
35
|
+
- [Defining Scope](#defining-scope)
|
|
36
|
+
- [Scope for Find Operations](#scope-for-find-operations)
|
|
37
|
+
- [Scope for Aggregate Operations](#scope-for-aggregate-operations)
|
|
38
|
+
- [Scope for Get By ID Operations](#scope-for-get-by-id-operations)
|
|
39
|
+
- [Scope Function Parameters](#scope-function-parameters)
|
|
40
|
+
- [Authorization](#-authorization)
|
|
41
|
+
- [Quick Start](#quick-start-1)
|
|
42
|
+
- [Permission Schema](#permission-schema)
|
|
43
|
+
- [Rule Helpers](#rule-helpers)
|
|
44
|
+
- [Policy Expressions (JSON AST)](#policy-expressions-json-ast)
|
|
45
|
+
- [Integration with GraphQL Yoga / Envelop](#integration-with-graphql-yoga--envelop)
|
|
46
|
+
- [Legacy: Integration with graphql-middleware](#legacy-integration-with-graphql-middleware)
|
|
47
|
+
- [Middlewares](#-middlewares)
|
|
48
|
+
- [Adding Middlewares](#adding-middlewares)
|
|
49
|
+
- [Middleware Parameters](#middleware-parameters)
|
|
50
|
+
- [Common Use Cases](#common-use-cases)
|
|
51
|
+
- [Advanced Features](#-advanced-features)
|
|
52
|
+
- [Field Extensions](#field-extensions)
|
|
53
|
+
- [Custom Mutations](#custom-mutations)
|
|
54
|
+
- [Working with Existing Mongoose Models](#working-with-existing-mongoose-models)
|
|
55
|
+
- [Programmatic Data Access](#programmatic-data-access)
|
|
56
|
+
- [Aggregation Queries](#-aggregation-queries)
|
|
57
|
+
- [Complete Example](#-complete-example)
|
|
58
|
+
- [Resources](#-resources)
|
|
59
|
+
- [License](#-license)
|
|
60
|
+
- [Contributing](#-contributing)
|
|
61
|
+
|
|
62
|
+
## โจ Features
|
|
63
|
+
|
|
64
|
+
- **Automatic Schema Generation**: Define your object model, and Simfinity.js generates all queries and mutations
|
|
65
|
+
- **MongoDB Integration**: Seamless translation between GraphQL and MongoDB
|
|
66
|
+
- **Powerful Querying**: Any query that can be executed in MongoDB can be executed in GraphQL
|
|
67
|
+
- **Aggregation Queries**: Built-in support for GROUP BY queries with aggregation operations (SUM, COUNT, AVG, MIN, MAX)
|
|
68
|
+
- **Auto-Generated Resolvers**: Automatically generates resolve methods for relationship fields
|
|
69
|
+
- **Automatic Index Creation**: Automatically creates MongoDB indexes for all ObjectId fields, including nested embedded objects and relationship fields
|
|
70
|
+
- **Business Logic**: Implement business logic and domain validations declaratively
|
|
71
|
+
- **State Machines**: Built-in support for declarative state machine workflows
|
|
72
|
+
- **Lifecycle Hooks**: Controller methods for granular control over operations
|
|
73
|
+
- **Custom Validation**: Field-level and type-level custom validations
|
|
74
|
+
- **Relationship Management**: Support for embedded and referenced relationships
|
|
75
|
+
- **Authorization**: Production-grade GraphQL authorization with RBAC/ABAC, function-based rules, declarative policy expressions, and native Envelop/Yoga plugin support
|
|
76
|
+
|
|
77
|
+
## ๐ฆ Installation
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install mongoose graphql @simtlix/simfinity-js
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Prerequisites**: Simfinity.js requires `mongoose` and `graphql` as peer dependencies.
|
|
84
|
+
|
|
85
|
+
## ๐ Quick Start
|
|
86
|
+
|
|
87
|
+
### 1. Basic Setup
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
const express = require('express');
|
|
91
|
+
const { graphqlHTTP } = require('express-graphql');
|
|
92
|
+
const mongoose = require('mongoose');
|
|
93
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
94
|
+
|
|
95
|
+
// Connect to MongoDB
|
|
96
|
+
mongoose.connect('mongodb://localhost:27017/bookstore', {
|
|
97
|
+
useNewUrlParser: true,
|
|
98
|
+
useUnifiedTopology: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const app = express();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 2. Define Your GraphQL Type
|
|
105
|
+
|
|
106
|
+
```javascript
|
|
107
|
+
const { GraphQLObjectType, GraphQLString, GraphQLNonNull, GraphQLID } = require('graphql');
|
|
108
|
+
|
|
109
|
+
const BookType = new GraphQLObjectType({
|
|
110
|
+
name: 'Book',
|
|
111
|
+
fields: () => ({
|
|
112
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
113
|
+
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
114
|
+
author: { type: GraphQLString },
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. Connect to Simfinity
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
// Connect the type to Simfinity
|
|
123
|
+
simfinity.connect(null, BookType, 'book', 'books');
|
|
124
|
+
|
|
125
|
+
// Create the GraphQL schema
|
|
126
|
+
const schema = simfinity.createSchema();
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 4. Setup GraphQL Endpoint
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
app.use('/graphql', graphqlHTTP({
|
|
133
|
+
schema,
|
|
134
|
+
graphiql: true,
|
|
135
|
+
formatError: simfinity.buildErrorFormatter((err) => {
|
|
136
|
+
console.log(err);
|
|
137
|
+
})
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
app.listen(4000, () => {
|
|
141
|
+
console.log('Server is running on port 4000');
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 5. Try It Out
|
|
146
|
+
|
|
147
|
+
Open [http://localhost:4000/graphql](http://localhost:4000/graphql) and try these queries:
|
|
148
|
+
|
|
149
|
+
**Create a book:**
|
|
150
|
+
```graphql
|
|
151
|
+
mutation {
|
|
152
|
+
addBook(input: {
|
|
153
|
+
title: "The Hitchhiker's Guide to the Galaxy"
|
|
154
|
+
author: "Douglas Adams"
|
|
155
|
+
}) {
|
|
156
|
+
id
|
|
157
|
+
title
|
|
158
|
+
author
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**List all books:**
|
|
164
|
+
```graphql
|
|
165
|
+
query {
|
|
166
|
+
books {
|
|
167
|
+
id
|
|
168
|
+
title
|
|
169
|
+
author
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
> For a full working application, see the [Series Sample Project](https://github.com/simtlix/series-sample) -- a complete TV series microservice with types, relationships, state machines, controllers, and authorization.
|
|
175
|
+
|
|
176
|
+
## ๐ง Core Concepts
|
|
177
|
+
|
|
178
|
+
### Connecting Models
|
|
179
|
+
|
|
180
|
+
The `simfinity.connect()` method links your GraphQL types to Simfinity's automatic schema generation:
|
|
181
|
+
|
|
182
|
+
```javascript
|
|
183
|
+
simfinity.connect(
|
|
184
|
+
mongooseModel, // Optional: Custom Mongoose model (null for auto-generation)
|
|
185
|
+
graphQLType, // Required: Your GraphQLObjectType
|
|
186
|
+
singularEndpointName, // Required: Singular name for mutations (e.g., 'book')
|
|
187
|
+
pluralEndpointName, // Required: Plural name for queries (e.g., 'books')
|
|
188
|
+
controller, // Optional: Controller with lifecycle hooks
|
|
189
|
+
onModelCreated, // Optional: Callback when Mongoose model is created
|
|
190
|
+
stateMachine // Optional: State machine configuration
|
|
191
|
+
);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Creating Schemas
|
|
195
|
+
|
|
196
|
+
Generate your complete GraphQL schema with optional type filtering:
|
|
197
|
+
|
|
198
|
+
```javascript
|
|
199
|
+
const schema = simfinity.createSchema(
|
|
200
|
+
includedQueryTypes, // Optional: Array of types to include in queries
|
|
201
|
+
includedMutationTypes, // Optional: Array of types to include in mutations
|
|
202
|
+
includedCustomMutations // Optional: Array of custom mutations to include
|
|
203
|
+
);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Global Configuration
|
|
207
|
+
|
|
208
|
+
```javascript
|
|
209
|
+
// Prevent automatic MongoDB collection creation (useful for testing)
|
|
210
|
+
simfinity.preventCreatingCollection(true);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## ๐ Basic Usage
|
|
214
|
+
|
|
215
|
+
### Automatic Query Generation
|
|
216
|
+
|
|
217
|
+
Simfinity automatically generates queries for each connected type:
|
|
218
|
+
|
|
219
|
+
```javascript
|
|
220
|
+
// For a BookType, you get:
|
|
221
|
+
// - book(id: ID): Book - Get single book by ID
|
|
222
|
+
// - books(...filters): [Book] - Get filtered list of books
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Automatic Mutation Generation
|
|
226
|
+
|
|
227
|
+
Simfinity automatically generates mutations for each connected type:
|
|
228
|
+
|
|
229
|
+
```javascript
|
|
230
|
+
// For a BookType, you get:
|
|
231
|
+
// - addBook(input: BookInput): Book
|
|
232
|
+
// - updateBook(input: BookInputForUpdate): Book
|
|
233
|
+
// - deleteBook(id: ID): Book
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Filtering and Querying
|
|
237
|
+
|
|
238
|
+
Query with powerful filtering options:
|
|
239
|
+
|
|
240
|
+
```graphql
|
|
241
|
+
query {
|
|
242
|
+
books(
|
|
243
|
+
title: { operator: LIKE, value: "Galaxy" }
|
|
244
|
+
author: { operator: EQ, value: "Douglas Adams" }
|
|
245
|
+
pagination: { page: 1, size: 10, count: true }
|
|
246
|
+
sort: { terms: [{ field: "title", order: ASC }] }
|
|
247
|
+
) {
|
|
248
|
+
id
|
|
249
|
+
title
|
|
250
|
+
author
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
#### Available Operators
|
|
256
|
+
|
|
257
|
+
- `EQ` - Equal
|
|
258
|
+
- `NE` - Not equal
|
|
259
|
+
- `GT` - Greater than
|
|
260
|
+
- `LT` - Less than
|
|
261
|
+
- `GTE` - Greater than or equal
|
|
262
|
+
- `LTE` - Less than or equal
|
|
263
|
+
- `LIKE` - Pattern matching
|
|
264
|
+
- `IN` - In array
|
|
265
|
+
- `NIN` - Not in array
|
|
266
|
+
- `BTW` - Between two values
|
|
267
|
+
|
|
268
|
+
### Logical Filters (AND / OR)
|
|
269
|
+
|
|
270
|
+
By default, all field-level filters are combined with implicit AND logic. For complex conditions requiring OR logic or nested combinations, use the `AND` and `OR` query arguments.
|
|
271
|
+
|
|
272
|
+
#### Simple OR
|
|
273
|
+
|
|
274
|
+
Return books in either the Sci-Fi or Fantasy category:
|
|
275
|
+
|
|
276
|
+
```graphql
|
|
277
|
+
query {
|
|
278
|
+
books(
|
|
279
|
+
OR: [
|
|
280
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
|
|
281
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
|
|
282
|
+
]
|
|
283
|
+
) {
|
|
284
|
+
id
|
|
285
|
+
title
|
|
286
|
+
category
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
#### Flat Filters Combined with OR
|
|
292
|
+
|
|
293
|
+
Flat field filters are always ANDed at the top level, making them ideal for scope/security conditions that cannot be bypassed by user OR logic:
|
|
294
|
+
|
|
295
|
+
```graphql
|
|
296
|
+
query {
|
|
297
|
+
books(
|
|
298
|
+
rating: { operator: GTE, value: 7.0 }
|
|
299
|
+
OR: [
|
|
300
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
|
|
301
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
|
|
302
|
+
]
|
|
303
|
+
) {
|
|
304
|
+
id
|
|
305
|
+
title
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
This translates to: `rating >= 7.0 AND (category = "Sci-Fi" OR category = "Fantasy")`.
|
|
311
|
+
|
|
312
|
+
#### Nested AND inside OR
|
|
313
|
+
|
|
314
|
+
```graphql
|
|
315
|
+
query {
|
|
316
|
+
books(
|
|
317
|
+
OR: [
|
|
318
|
+
{ AND: [
|
|
319
|
+
{ conditions: [{ field: "rating", operator: GTE, value: 9.0 }] }
|
|
320
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
|
|
321
|
+
]}
|
|
322
|
+
{ AND: [
|
|
323
|
+
{ conditions: [{ field: "rating", operator: GTE, value: 8.0 }] }
|
|
324
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
|
|
325
|
+
]}
|
|
326
|
+
]
|
|
327
|
+
) {
|
|
328
|
+
id
|
|
329
|
+
title
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
This translates to: `(rating >= 9.0 AND category = "Sci-Fi") OR (rating >= 8.0 AND category = "Fantasy")`.
|
|
335
|
+
|
|
336
|
+
#### Filtering on Relationships within AND/OR
|
|
337
|
+
|
|
338
|
+
Use the `path` parameter to filter on related entity fields:
|
|
339
|
+
|
|
340
|
+
```graphql
|
|
341
|
+
query {
|
|
342
|
+
books(
|
|
343
|
+
OR: [
|
|
344
|
+
{ conditions: [{ field: "author", path: "name", operator: LIKE, value: "Adams" }] }
|
|
345
|
+
{ conditions: [{ field: "author", path: "name", operator: LIKE, value: "Pratchett" }] }
|
|
346
|
+
]
|
|
347
|
+
) {
|
|
348
|
+
id
|
|
349
|
+
title
|
|
350
|
+
author { name }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
#### Mixing Flat Filters with AND/OR
|
|
356
|
+
|
|
357
|
+
You can freely combine the existing flat filter syntax with AND/OR groups. Flat filters and AND groups are all ANDed together at the top level:
|
|
358
|
+
|
|
359
|
+
```graphql
|
|
360
|
+
query {
|
|
361
|
+
books(
|
|
362
|
+
rating: { operator: GTE, value: 7.0 }
|
|
363
|
+
author: { terms: [{ path: "country", operator: EQ, value: "UK" }] }
|
|
364
|
+
OR: [
|
|
365
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
|
|
366
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
|
|
367
|
+
]
|
|
368
|
+
) {
|
|
369
|
+
id
|
|
370
|
+
title
|
|
371
|
+
rating
|
|
372
|
+
category
|
|
373
|
+
author { name country }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
This translates to: `rating >= 7.0 AND author.country = "UK" AND (category = "Sci-Fi" OR category = "Fantasy")`. The flat field filters (`rating`, `author`) use the existing syntax while the OR group uses the new `QLFilterGroup` syntax.
|
|
379
|
+
|
|
380
|
+
You can also combine flat filters with explicit AND groups for more complex logic:
|
|
381
|
+
|
|
382
|
+
```graphql
|
|
383
|
+
query {
|
|
384
|
+
books(
|
|
385
|
+
rating: { operator: GTE, value: 5.0 }
|
|
386
|
+
AND: [
|
|
387
|
+
{
|
|
388
|
+
OR: [
|
|
389
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
|
|
390
|
+
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
|
|
391
|
+
]
|
|
392
|
+
}
|
|
393
|
+
{
|
|
394
|
+
OR: [
|
|
395
|
+
{ conditions: [{ field: "author", path: "country", operator: EQ, value: "UK" }] }
|
|
396
|
+
{ conditions: [{ field: "author", path: "country", operator: EQ, value: "US" }] }
|
|
397
|
+
]
|
|
398
|
+
}
|
|
399
|
+
]
|
|
400
|
+
) {
|
|
401
|
+
id
|
|
402
|
+
title
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
This translates to: `rating >= 5.0 AND (category = "Sci-Fi" OR category = "Fantasy") AND (author.country = "UK" OR author.country = "US")`.
|
|
408
|
+
|
|
409
|
+
#### Collection Filtering with AND/OR
|
|
410
|
+
|
|
411
|
+
AND/OR filters are also available on collection fields (one-to-many relationships). The auto-generated resolvers for collection fields support the same `AND` and `OR` arguments:
|
|
412
|
+
|
|
413
|
+
```graphql
|
|
414
|
+
query {
|
|
415
|
+
series {
|
|
416
|
+
seasons(
|
|
417
|
+
OR: [
|
|
418
|
+
{ conditions: [{ field: "year", operator: EQ, value: 2020 }] }
|
|
419
|
+
{ conditions: [{ field: "year", operator: EQ, value: 2021 }] }
|
|
420
|
+
]
|
|
421
|
+
) {
|
|
422
|
+
number
|
|
423
|
+
year
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
You can mix flat collection filters with AND/OR:
|
|
430
|
+
|
|
431
|
+
```graphql
|
|
432
|
+
query {
|
|
433
|
+
series {
|
|
434
|
+
seasons(
|
|
435
|
+
number: { operator: GT, value: 1 }
|
|
436
|
+
OR: [
|
|
437
|
+
{ conditions: [{ field: "year", operator: EQ, value: 2020 }] }
|
|
438
|
+
{
|
|
439
|
+
AND: [
|
|
440
|
+
{ conditions: [{ field: "year", operator: GTE, value: 2022 }] }
|
|
441
|
+
{ conditions: [
|
|
442
|
+
{ field: "episodes", path: "name", operator: LIKE, value: "Final" }
|
|
443
|
+
] }
|
|
444
|
+
]
|
|
445
|
+
}
|
|
446
|
+
]
|
|
447
|
+
) {
|
|
448
|
+
number
|
|
449
|
+
year
|
|
450
|
+
episodes { name }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
This translates to: `number > 1 AND (year = 2020 OR (year >= 2022 AND episodes.name LIKE "Final"))`.
|
|
457
|
+
|
|
458
|
+
#### Filter Types Reference
|
|
459
|
+
|
|
460
|
+
| Type | Fields | Description |
|
|
461
|
+
|------|--------|-------------|
|
|
462
|
+
| `QLFilterGroup` | `AND: [QLFilterGroup]`, `OR: [QLFilterGroup]`, `conditions: [QLFilterCondition]` | Recursive logical group |
|
|
463
|
+
| `QLFilterCondition` | `field: String!`, `operator: QLOperator`, `value: QLValue`, `path: String` | Individual filter condition |
|
|
464
|
+
|
|
465
|
+
- `field` identifies the entity field by name (e.g., `"title"`, `"author"`)
|
|
466
|
+
- `path` is required for object/relationship fields (e.g., `"name"`, `"country.name"`)
|
|
467
|
+
- Multiple `conditions` in the same group are combined with AND
|
|
468
|
+
- Maximum nesting depth: 5 levels
|
|
469
|
+
|
|
470
|
+
### Collection Field Filtering
|
|
471
|
+
|
|
472
|
+
Simfinity.js now supports filtering collection fields (one-to-many relationships) using the same powerful query format. This allows you to filter related objects directly within your GraphQL queries.
|
|
473
|
+
|
|
474
|
+
#### Basic Collection Filtering
|
|
475
|
+
|
|
476
|
+
Filter collection fields using the same operators and format as main queries:
|
|
477
|
+
|
|
478
|
+
```graphql
|
|
479
|
+
query {
|
|
480
|
+
series {
|
|
481
|
+
seasons(number: { operator: EQ, value: 1 }) {
|
|
482
|
+
number
|
|
483
|
+
id
|
|
484
|
+
year
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
#### Advanced Collection Filtering
|
|
491
|
+
|
|
492
|
+
You can use complex filtering with nested object properties:
|
|
493
|
+
|
|
494
|
+
```graphql
|
|
495
|
+
query {
|
|
496
|
+
series {
|
|
497
|
+
seasons(
|
|
498
|
+
year: { operator: GTE, value: 2020 }
|
|
499
|
+
episodes: {
|
|
500
|
+
terms: [
|
|
501
|
+
{
|
|
502
|
+
path: "name",
|
|
503
|
+
operator: LIKE,
|
|
504
|
+
value: "Pilot"
|
|
505
|
+
}
|
|
506
|
+
]
|
|
507
|
+
}
|
|
508
|
+
) {
|
|
509
|
+
number
|
|
510
|
+
year
|
|
511
|
+
episodes {
|
|
512
|
+
name
|
|
513
|
+
date
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
#### Collection Filtering with Multiple Conditions
|
|
521
|
+
|
|
522
|
+
Combine multiple filter conditions for collection fields:
|
|
523
|
+
|
|
524
|
+
```graphql
|
|
525
|
+
query {
|
|
526
|
+
series {
|
|
527
|
+
seasons(
|
|
528
|
+
number: { operator: GT, value: 1 }
|
|
529
|
+
year: { operator: BTW, value: [2015, 2023] }
|
|
530
|
+
) {
|
|
531
|
+
number
|
|
532
|
+
year
|
|
533
|
+
state
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
#### Nested Collection Filtering
|
|
540
|
+
|
|
541
|
+
Filter deeply nested collections using dot notation:
|
|
542
|
+
|
|
543
|
+
```graphql
|
|
544
|
+
query {
|
|
545
|
+
series {
|
|
546
|
+
seasons(
|
|
547
|
+
episodes: {
|
|
548
|
+
terms: [
|
|
549
|
+
{
|
|
550
|
+
path: "name",
|
|
551
|
+
operator: LIKE,
|
|
552
|
+
value: "Final"
|
|
553
|
+
}
|
|
554
|
+
]
|
|
555
|
+
}
|
|
556
|
+
) {
|
|
557
|
+
number
|
|
558
|
+
episodes {
|
|
559
|
+
name
|
|
560
|
+
date
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
#### Collection Filtering with Array Operations
|
|
568
|
+
|
|
569
|
+
Use array operations for collection fields:
|
|
570
|
+
|
|
571
|
+
```graphql
|
|
572
|
+
query {
|
|
573
|
+
series {
|
|
574
|
+
seasons(
|
|
575
|
+
categories: { operator: IN, value: ["Drama", "Crime"] }
|
|
576
|
+
) {
|
|
577
|
+
number
|
|
578
|
+
categories
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
**Note**: Collection field filtering uses the exact same format as main query filtering, ensuring consistency across your GraphQL API. All available operators (`EQ`, `NE`, `GT`, `LT`, `GTE`, `LTE`, `LIKE`, `IN`, `NIN`, `BTW`) work with collection fields.
|
|
585
|
+
|
|
586
|
+
## ๐ Relationships
|
|
587
|
+
|
|
588
|
+
### Defining Relationships
|
|
589
|
+
|
|
590
|
+
Use the `extensions.relation` field to define relationships between types:
|
|
591
|
+
|
|
592
|
+
```javascript
|
|
593
|
+
const AuthorType = new GraphQLObjectType({
|
|
594
|
+
name: 'Author',
|
|
595
|
+
fields: () => ({
|
|
596
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
597
|
+
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
598
|
+
books: {
|
|
599
|
+
type: new GraphQLList(BookType),
|
|
600
|
+
extensions: {
|
|
601
|
+
relation: {
|
|
602
|
+
connectionField: 'author',
|
|
603
|
+
displayField: 'title'
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
// resolve method automatically generated! ๐
|
|
607
|
+
},
|
|
608
|
+
}),
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const BookType = new GraphQLObjectType({
|
|
612
|
+
name: 'Book',
|
|
613
|
+
fields: () => ({
|
|
614
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
615
|
+
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
616
|
+
author: {
|
|
617
|
+
type: AuthorType,
|
|
618
|
+
extensions: {
|
|
619
|
+
relation: {
|
|
620
|
+
displayField: 'name'
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
// resolve method automatically generated! ๐
|
|
624
|
+
},
|
|
625
|
+
}),
|
|
626
|
+
});
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Relationship Configuration
|
|
630
|
+
|
|
631
|
+
- `connectionField`: **(Required for collections)** The field storing the related object's ID - only needed for one-to-many relationships (GraphQLList). For single object relationships, the field name is automatically inferred from the GraphQL field name.
|
|
632
|
+
- `displayField`: **(Optional)** Field to use for display in UI components
|
|
633
|
+
- `embedded`: **(Optional)** Whether the relation is embedded (default: false)
|
|
634
|
+
|
|
635
|
+
### Auto-Generated Resolve Methods
|
|
636
|
+
|
|
637
|
+
๐ **NEW**: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate.
|
|
638
|
+
|
|
639
|
+
#### Before (Manual Resolvers)
|
|
640
|
+
|
|
641
|
+
```javascript
|
|
642
|
+
const BookType = new GraphQLObjectType({
|
|
643
|
+
name: 'Book',
|
|
644
|
+
fields: () => ({
|
|
645
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
646
|
+
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
647
|
+
author: {
|
|
648
|
+
type: AuthorType,
|
|
649
|
+
extensions: {
|
|
650
|
+
relation: {
|
|
651
|
+
displayField: 'name'
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
// You had to manually write this
|
|
655
|
+
resolve(parent) {
|
|
656
|
+
return simfinity.getModel(AuthorType).findById(parent.author);
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
comments: {
|
|
660
|
+
type: new GraphQLList(CommentType),
|
|
661
|
+
extensions: {
|
|
662
|
+
relation: {
|
|
663
|
+
connectionField: 'bookId',
|
|
664
|
+
displayField: 'text'
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
// You had to manually write this too
|
|
668
|
+
resolve(parent) {
|
|
669
|
+
return simfinity.getModel(CommentType).find({ bookId: parent.id });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}),
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
#### After (Auto-Generated Resolvers)
|
|
677
|
+
|
|
678
|
+
```javascript
|
|
679
|
+
const BookType = new GraphQLObjectType({
|
|
680
|
+
name: 'Book',
|
|
681
|
+
fields: () => ({
|
|
682
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
683
|
+
title: { type: new GraphQLNonNull(GraphQLString) },
|
|
684
|
+
author: {
|
|
685
|
+
type: AuthorType,
|
|
686
|
+
extensions: {
|
|
687
|
+
relation: {
|
|
688
|
+
displayField: 'name'
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
// resolve method automatically generated! ๐
|
|
692
|
+
},
|
|
693
|
+
comments: {
|
|
694
|
+
type: new GraphQLList(CommentType),
|
|
695
|
+
extensions: {
|
|
696
|
+
relation: {
|
|
697
|
+
connectionField: 'bookId',
|
|
698
|
+
displayField: 'text'
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
// resolve method automatically generated! ๐
|
|
702
|
+
}
|
|
703
|
+
}),
|
|
704
|
+
});
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
#### How It Works
|
|
708
|
+
|
|
709
|
+
- **Single Object Relationships**: Automatically generates `findById()` resolvers using the field name or `connectionField`
|
|
710
|
+
- **Collection Relationships**: Automatically generates `find()` resolvers using the `connectionField` to query related objects
|
|
711
|
+
- **Lazy Loading**: Models are looked up at runtime, so types can be connected in any order
|
|
712
|
+
- **Backwards Compatible**: Existing manual resolve methods are preserved and not overwritten
|
|
713
|
+
- **Type Safety**: Clear error messages if related types aren't properly connected
|
|
714
|
+
|
|
715
|
+
#### Connect Your Types
|
|
716
|
+
|
|
717
|
+
```javascript
|
|
718
|
+
// Connect all your types to Simfinity
|
|
719
|
+
simfinity.connect(null, AuthorType, 'author', 'authors');
|
|
720
|
+
simfinity.connect(null, BookType, 'book', 'books');
|
|
721
|
+
simfinity.connect(null, CommentType, 'comment', 'comments');
|
|
722
|
+
|
|
723
|
+
// Or use addNoEndpointType for types that don't need direct queries/mutations
|
|
724
|
+
simfinity.addNoEndpointType(AuthorType);
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
That's it! All relationship resolvers are automatically generated when you connect your types.
|
|
728
|
+
|
|
729
|
+
### Adding Types Without Endpoints
|
|
730
|
+
|
|
731
|
+
Use `addNoEndpointType()` for types that should be included in the GraphQL schema but don't need their own CRUD operations:
|
|
732
|
+
|
|
733
|
+
```javascript
|
|
734
|
+
simfinity.addNoEndpointType(TypeName);
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
**When to use `addNoEndpointType()` vs `connect()`:**
|
|
738
|
+
|
|
739
|
+
| Method | Use Case | Creates Endpoints | Use Example |
|
|
740
|
+
|--------|----------|-------------------|-------------|
|
|
741
|
+
| `connect()` | Types that need CRUD operations | โ
Yes | User, Product, Order |
|
|
742
|
+
| `addNoEndpointType()` | Types only used in relationships | โ No | Address, Settings, Director |
|
|
743
|
+
|
|
744
|
+
#### Perfect Example: TV Series with Embedded Director
|
|
745
|
+
|
|
746
|
+
From the [series-sample](https://github.com/simtlix/series-sample) project:
|
|
747
|
+
|
|
748
|
+
```javascript
|
|
749
|
+
// Director type - Used only as embedded data, no direct API access needed
|
|
750
|
+
const directorType = new GraphQLObjectType({
|
|
751
|
+
name: 'director',
|
|
752
|
+
fields: () => ({
|
|
753
|
+
id: { type: GraphQLID },
|
|
754
|
+
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
755
|
+
country: { type: GraphQLString }
|
|
756
|
+
})
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// Add to schema WITHOUT creating endpoints
|
|
760
|
+
simfinity.addNoEndpointType(directorType);
|
|
761
|
+
|
|
762
|
+
// Serie type - Has its own endpoints and embeds director data
|
|
763
|
+
const serieType = new GraphQLObjectType({
|
|
764
|
+
name: 'serie',
|
|
765
|
+
fields: () => ({
|
|
766
|
+
id: { type: GraphQLID },
|
|
767
|
+
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
768
|
+
categories: { type: new GraphQLList(GraphQLString) },
|
|
769
|
+
director: {
|
|
770
|
+
type: new GraphQLNonNull(directorType),
|
|
771
|
+
extensions: {
|
|
772
|
+
relation: {
|
|
773
|
+
embedded: true, // Director data stored within serie document
|
|
774
|
+
displayField: 'name'
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
})
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Create full CRUD endpoints for series
|
|
782
|
+
simfinity.connect(null, serieType, 'serie', 'series');
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
**Result:**
|
|
786
|
+
- โ
`addserie`, `updateserie`, `deleteserie` mutations available
|
|
787
|
+
- โ
`serie`, `series` queries available
|
|
788
|
+
- โ No `adddirector`, `director`, `directors` endpoints (director is embedded)
|
|
789
|
+
|
|
790
|
+
**Usage:**
|
|
791
|
+
```graphql
|
|
792
|
+
mutation {
|
|
793
|
+
addserie(input: {
|
|
794
|
+
name: "Breaking Bad"
|
|
795
|
+
categories: ["crime", "drama", "thriller"]
|
|
796
|
+
director: {
|
|
797
|
+
name: "Vince Gilligan"
|
|
798
|
+
country: "United States"
|
|
799
|
+
}
|
|
800
|
+
}) {
|
|
801
|
+
id
|
|
802
|
+
name
|
|
803
|
+
director {
|
|
804
|
+
name
|
|
805
|
+
country
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
#### When to Use Each Approach
|
|
812
|
+
|
|
813
|
+
**Use `addNoEndpointType()` for:**
|
|
814
|
+
- Simple data objects with few fields
|
|
815
|
+
- Data that doesn't need CRUD operations
|
|
816
|
+
- Objects that belong to a single parent (1:1 relationships)
|
|
817
|
+
- Configuration or settings objects
|
|
818
|
+
- **Examples**: Address, Director info, Product specifications
|
|
819
|
+
|
|
820
|
+
**Use `connect()` for:**
|
|
821
|
+
- Complex entities that need their own endpoints
|
|
822
|
+
- Data that needs CRUD operations
|
|
823
|
+
- Objects shared between multiple parents (many:many relationships)
|
|
824
|
+
- Objects with business logic (controllers, state machines)
|
|
825
|
+
- **Examples**: User, Product, Order, Season, Episode
|
|
826
|
+
|
|
827
|
+
### Embedded vs Referenced Relationships
|
|
828
|
+
|
|
829
|
+
**Referenced Relationships** (default):
|
|
830
|
+
```javascript
|
|
831
|
+
// Stores author ID in the book document
|
|
832
|
+
author: {
|
|
833
|
+
type: AuthorType,
|
|
834
|
+
extensions: {
|
|
835
|
+
relation: {
|
|
836
|
+
// connectionField not needed for single object relationships
|
|
837
|
+
embedded: false // This is the default
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
**Embedded Relationships**:
|
|
844
|
+
```javascript
|
|
845
|
+
// Stores the full publisher object in the book document
|
|
846
|
+
publisher: {
|
|
847
|
+
type: PublisherType,
|
|
848
|
+
extensions: {
|
|
849
|
+
relation: {
|
|
850
|
+
embedded: true
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### Querying Relationships
|
|
857
|
+
|
|
858
|
+
Query nested relationships with dot notation:
|
|
859
|
+
|
|
860
|
+
```graphql
|
|
861
|
+
query {
|
|
862
|
+
books(author: {
|
|
863
|
+
terms: [
|
|
864
|
+
{
|
|
865
|
+
path: "country.name",
|
|
866
|
+
operator: EQ,
|
|
867
|
+
value: "England"
|
|
868
|
+
}
|
|
869
|
+
]
|
|
870
|
+
}) {
|
|
871
|
+
id
|
|
872
|
+
title
|
|
873
|
+
author {
|
|
874
|
+
name
|
|
875
|
+
country {
|
|
876
|
+
name
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
### Creating Objects with Relationships
|
|
884
|
+
|
|
885
|
+
**Link to existing objects:**
|
|
886
|
+
```graphql
|
|
887
|
+
mutation {
|
|
888
|
+
addBook(input: {
|
|
889
|
+
title: "New Book"
|
|
890
|
+
author: {
|
|
891
|
+
id: "existing_author_id"
|
|
892
|
+
}
|
|
893
|
+
}) {
|
|
894
|
+
id
|
|
895
|
+
title
|
|
896
|
+
author {
|
|
897
|
+
name
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
**Create embedded objects:**
|
|
904
|
+
```graphql
|
|
905
|
+
mutation {
|
|
906
|
+
addBook(input: {
|
|
907
|
+
title: "New Book"
|
|
908
|
+
publisher: {
|
|
909
|
+
name: "Penguin Books"
|
|
910
|
+
location: "London"
|
|
911
|
+
}
|
|
912
|
+
}) {
|
|
913
|
+
id
|
|
914
|
+
title
|
|
915
|
+
publisher {
|
|
916
|
+
name
|
|
917
|
+
location
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
### Collection Fields
|
|
924
|
+
|
|
925
|
+
Work with arrays of related objects:
|
|
926
|
+
|
|
927
|
+
```graphql
|
|
928
|
+
mutation {
|
|
929
|
+
updateBook(input: {
|
|
930
|
+
id: "book_id"
|
|
931
|
+
reviews: {
|
|
932
|
+
added: [
|
|
933
|
+
{ rating: 5, comment: "Amazing!" }
|
|
934
|
+
{ rating: 4, comment: "Good read" }
|
|
935
|
+
]
|
|
936
|
+
updated: [
|
|
937
|
+
{ id: "review_id", rating: 3 }
|
|
938
|
+
]
|
|
939
|
+
deleted: ["review_id_to_delete"]
|
|
940
|
+
}
|
|
941
|
+
}) {
|
|
942
|
+
id
|
|
943
|
+
title
|
|
944
|
+
reviews {
|
|
945
|
+
rating
|
|
946
|
+
comment
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
## โ
Validations
|
|
953
|
+
|
|
954
|
+
### Declarative Validation Helpers
|
|
955
|
+
|
|
956
|
+
Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
|
|
957
|
+
|
|
958
|
+
#### Using Validators
|
|
959
|
+
|
|
960
|
+
```javascript
|
|
961
|
+
const { validators } = require('@simtlix/simfinity-js');
|
|
962
|
+
|
|
963
|
+
const PersonType = new GraphQLObjectType({
|
|
964
|
+
name: 'Person',
|
|
965
|
+
fields: () => ({
|
|
966
|
+
id: { type: GraphQLID },
|
|
967
|
+
name: {
|
|
968
|
+
type: GraphQLString,
|
|
969
|
+
extensions: {
|
|
970
|
+
validations: validators.stringLength('Name', 2, 100)
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
email: {
|
|
974
|
+
type: GraphQLString,
|
|
975
|
+
extensions: {
|
|
976
|
+
validations: validators.email()
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
website: {
|
|
980
|
+
type: GraphQLString,
|
|
981
|
+
extensions: {
|
|
982
|
+
validations: validators.url()
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
age: {
|
|
986
|
+
type: GraphQLInt,
|
|
987
|
+
extensions: {
|
|
988
|
+
validations: validators.numberRange('Age', 0, 120)
|
|
989
|
+
}
|
|
990
|
+
},
|
|
991
|
+
price: {
|
|
992
|
+
type: GraphQLFloat,
|
|
993
|
+
extensions: {
|
|
994
|
+
validations: validators.positive('Price')
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
})
|
|
998
|
+
});
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
#### Available Validators
|
|
1002
|
+
|
|
1003
|
+
**String Validators:**
|
|
1004
|
+
- `validators.stringLength(name, min, max)` - Validates string length with min/max bounds (required for CREATE)
|
|
1005
|
+
- `validators.maxLength(name, max)` - Validates maximum string length
|
|
1006
|
+
- `validators.pattern(name, regex, message)` - Validates against a regex pattern
|
|
1007
|
+
- `validators.email()` - Validates email format
|
|
1008
|
+
- `validators.url()` - Validates URL format
|
|
1009
|
+
|
|
1010
|
+
**Number Validators:**
|
|
1011
|
+
- `validators.numberRange(name, min, max)` - Validates number range
|
|
1012
|
+
- `validators.positive(name)` - Ensures number is positive
|
|
1013
|
+
|
|
1014
|
+
**Array Validators:**
|
|
1015
|
+
- `validators.arrayLength(name, maxItems, itemValidator)` - Validates array length and optionally each item
|
|
1016
|
+
|
|
1017
|
+
**Date Validators:**
|
|
1018
|
+
- `validators.dateFormat(name, format)` - Validates date format
|
|
1019
|
+
- `validators.futureDate(name)` - Ensures date is in the future
|
|
1020
|
+
|
|
1021
|
+
#### Validator Features
|
|
1022
|
+
|
|
1023
|
+
- **Automatic Operation Handling**: Validators work for both `CREATE` (save) and `UPDATE` operations
|
|
1024
|
+
- **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated)
|
|
1025
|
+
- **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages
|
|
1026
|
+
|
|
1027
|
+
#### Example: Multiple Validators
|
|
1028
|
+
|
|
1029
|
+
```javascript
|
|
1030
|
+
const ProductType = new GraphQLObjectType({
|
|
1031
|
+
name: 'Product',
|
|
1032
|
+
fields: () => ({
|
|
1033
|
+
id: { type: GraphQLID },
|
|
1034
|
+
name: {
|
|
1035
|
+
type: GraphQLString,
|
|
1036
|
+
extensions: {
|
|
1037
|
+
validations: validators.stringLength('Product Name', 3, 200)
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
sku: {
|
|
1041
|
+
type: GraphQLString,
|
|
1042
|
+
extensions: {
|
|
1043
|
+
validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
price: {
|
|
1047
|
+
type: GraphQLFloat,
|
|
1048
|
+
extensions: {
|
|
1049
|
+
validations: validators.positive('Price')
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
tags: {
|
|
1053
|
+
type: new GraphQLList(GraphQLString),
|
|
1054
|
+
extensions: {
|
|
1055
|
+
validations: validators.arrayLength('Tags', 10)
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
})
|
|
1059
|
+
});
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
### Field-Level Validations (Manual)
|
|
1063
|
+
|
|
1064
|
+
For custom validation logic, you can still write manual validators:
|
|
1065
|
+
|
|
1066
|
+
```javascript
|
|
1067
|
+
const { SimfinityError } = require('@simtlix/simfinity-js');
|
|
1068
|
+
|
|
1069
|
+
const validateAge = {
|
|
1070
|
+
validate: async (typeName, fieldName, value, session) => {
|
|
1071
|
+
if (value < 0 || value > 120) {
|
|
1072
|
+
throw new SimfinityError(`Invalid age: ${value}`, 'VALIDATION_ERROR', 400);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const PersonType = new GraphQLObjectType({
|
|
1078
|
+
name: 'Person',
|
|
1079
|
+
fields: () => ({
|
|
1080
|
+
id: { type: GraphQLID },
|
|
1081
|
+
name: {
|
|
1082
|
+
type: GraphQLString,
|
|
1083
|
+
extensions: {
|
|
1084
|
+
validations: {
|
|
1085
|
+
save: [{
|
|
1086
|
+
validate: async (typeName, fieldName, value, session) => {
|
|
1087
|
+
if (!value || value.length < 2) {
|
|
1088
|
+
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}],
|
|
1092
|
+
update: [{
|
|
1093
|
+
validate: async (typeName, fieldName, value, session) => {
|
|
1094
|
+
if (value && value.length < 2) {
|
|
1095
|
+
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}]
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
age: {
|
|
1103
|
+
type: GraphQLInt,
|
|
1104
|
+
extensions: {
|
|
1105
|
+
validations: {
|
|
1106
|
+
save: [validateAge],
|
|
1107
|
+
update: [validateAge]
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
})
|
|
1112
|
+
});
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
### Type-Level Validations
|
|
1116
|
+
|
|
1117
|
+
Validate objects as a whole:
|
|
1118
|
+
|
|
1119
|
+
```javascript
|
|
1120
|
+
const orderValidator = {
|
|
1121
|
+
validate: async (typeName, args, modelArgs, session) => {
|
|
1122
|
+
// Cross-field validation
|
|
1123
|
+
if (modelArgs.deliveryDate < modelArgs.orderDate) {
|
|
1124
|
+
throw new SimfinityError('Delivery date cannot be before order date', 'VALIDATION_ERROR', 400);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Business rule validation
|
|
1128
|
+
if (modelArgs.items.length === 0) {
|
|
1129
|
+
throw new SimfinityError('Order must contain at least one item', 'BUSINESS_ERROR', 400);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const OrderType = new GraphQLObjectType({
|
|
1135
|
+
name: 'Order',
|
|
1136
|
+
extensions: {
|
|
1137
|
+
validations: {
|
|
1138
|
+
save: [orderValidator],
|
|
1139
|
+
update: [orderValidator]
|
|
1140
|
+
}
|
|
1141
|
+
},
|
|
1142
|
+
fields: () => ({
|
|
1143
|
+
// ... fields
|
|
1144
|
+
})
|
|
1145
|
+
});
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
### Custom Validated Scalar Types
|
|
1149
|
+
|
|
1150
|
+
Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}`.
|
|
1151
|
+
|
|
1152
|
+
#### Pre-built Scalars
|
|
1153
|
+
|
|
1154
|
+
Simfinity.js provides ready-to-use validated scalars for common patterns:
|
|
1155
|
+
|
|
1156
|
+
```javascript
|
|
1157
|
+
const { scalars } = require('@simtlix/simfinity-js');
|
|
1158
|
+
|
|
1159
|
+
const UserType = new GraphQLObjectType({
|
|
1160
|
+
name: 'User',
|
|
1161
|
+
fields: () => ({
|
|
1162
|
+
id: { type: GraphQLID },
|
|
1163
|
+
email: { type: scalars.EmailScalar }, // Type name: Email_String
|
|
1164
|
+
website: { type: scalars.URLScalar }, // Type name: URL_String
|
|
1165
|
+
age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
|
|
1166
|
+
price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
|
|
1167
|
+
}),
|
|
1168
|
+
});
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
**Available Pre-built Scalars:**
|
|
1172
|
+
- `scalars.EmailScalar` - Validates email format (`Email_String`)
|
|
1173
|
+
- `scalars.URLScalar` - Validates URL format (`URL_String`)
|
|
1174
|
+
- `scalars.PositiveIntScalar` - Validates positive integers (`PositiveInt_Int`)
|
|
1175
|
+
- `scalars.PositiveFloatScalar` - Validates positive floats (`PositiveFloat_Float`)
|
|
1176
|
+
|
|
1177
|
+
#### Factory Functions for Custom Scalars
|
|
1178
|
+
|
|
1179
|
+
Create custom validated scalars with parameters:
|
|
1180
|
+
|
|
1181
|
+
```javascript
|
|
1182
|
+
const { scalars } = require('@simtlix/simfinity-js');
|
|
1183
|
+
|
|
1184
|
+
// Create a bounded string scalar (name length between 2-100 characters)
|
|
1185
|
+
const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
|
|
1186
|
+
|
|
1187
|
+
// Create a bounded integer scalar (age between 0-120)
|
|
1188
|
+
const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
|
|
1189
|
+
|
|
1190
|
+
// Create a bounded float scalar (rating between 0-10)
|
|
1191
|
+
const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
|
|
1192
|
+
|
|
1193
|
+
// Create a pattern-based string scalar (phone number format)
|
|
1194
|
+
const PhoneScalar = scalars.createPatternStringScalar(
|
|
1195
|
+
'Phone',
|
|
1196
|
+
/^\+?[\d\s\-()]+$/,
|
|
1197
|
+
'Invalid phone number format'
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
// Use in your types
|
|
1201
|
+
const PersonType = new GraphQLObjectType({
|
|
1202
|
+
name: 'Person',
|
|
1203
|
+
fields: () => ({
|
|
1204
|
+
id: { type: GraphQLID },
|
|
1205
|
+
name: { type: NameScalar }, // Type name: Name_String
|
|
1206
|
+
age: { type: AgeScalar }, // Type name: Age_Int
|
|
1207
|
+
rating: { type: RatingScalar }, // Type name: Rating_Float
|
|
1208
|
+
phone: { type: PhoneScalar } // Type name: Phone_String
|
|
1209
|
+
}),
|
|
1210
|
+
});
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
**Available Factory Functions:**
|
|
1214
|
+
- `scalars.createBoundedStringScalar(name, min, max)` - String with length bounds
|
|
1215
|
+
- `scalars.createBoundedIntScalar(name, min, max)` - Integer with range validation
|
|
1216
|
+
- `scalars.createBoundedFloatScalar(name, min, max)` - Float with range validation
|
|
1217
|
+
- `scalars.createPatternStringScalar(name, pattern, message)` - String with regex pattern validation
|
|
1218
|
+
|
|
1219
|
+
#### Creating Custom Scalars Manually
|
|
1220
|
+
|
|
1221
|
+
You can also create custom scalars using `createValidatedScalar` directly:
|
|
1222
|
+
|
|
1223
|
+
```javascript
|
|
1224
|
+
const { GraphQLString, GraphQLInt } = require('graphql');
|
|
1225
|
+
const { createValidatedScalar } = require('@simtlix/simfinity-js');
|
|
1226
|
+
|
|
1227
|
+
// Email scalar with validation (generates type name: Email_String)
|
|
1228
|
+
const EmailScalar = createValidatedScalar(
|
|
1229
|
+
'Email',
|
|
1230
|
+
'A valid email address',
|
|
1231
|
+
GraphQLString,
|
|
1232
|
+
(value) => {
|
|
1233
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1234
|
+
if (!emailRegex.test(value)) {
|
|
1235
|
+
throw new Error('Invalid email format');
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
// Positive integer scalar (generates type name: PositiveInt_Int)
|
|
1241
|
+
const PositiveIntScalar = createValidatedScalar(
|
|
1242
|
+
'PositiveInt',
|
|
1243
|
+
'A positive integer',
|
|
1244
|
+
GraphQLInt,
|
|
1245
|
+
(value) => {
|
|
1246
|
+
if (value <= 0) {
|
|
1247
|
+
throw new Error('Value must be positive');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
);
|
|
1251
|
+
|
|
1252
|
+
// Use in your types
|
|
1253
|
+
const UserType = new GraphQLObjectType({
|
|
1254
|
+
name: 'User',
|
|
1255
|
+
fields: () => ({
|
|
1256
|
+
id: { type: GraphQLID },
|
|
1257
|
+
email: { type: EmailScalar }, // Type name: Email_String
|
|
1258
|
+
age: { type: PositiveIntScalar }, // Type name: PositiveInt_Int
|
|
1259
|
+
}),
|
|
1260
|
+
});
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
### Custom Error Classes
|
|
1264
|
+
|
|
1265
|
+
Create domain-specific error classes:
|
|
1266
|
+
|
|
1267
|
+
```javascript
|
|
1268
|
+
const { SimfinityError } = require('@simtlix/simfinity-js');
|
|
1269
|
+
|
|
1270
|
+
// Business logic error
|
|
1271
|
+
class BusinessError extends SimfinityError {
|
|
1272
|
+
constructor(message) {
|
|
1273
|
+
super(message, 'BUSINESS_ERROR', 400);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Authorization error
|
|
1278
|
+
class AuthorizationError extends SimfinityError {
|
|
1279
|
+
constructor(message) {
|
|
1280
|
+
super(message, 'UNAUTHORIZED', 401);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Not found error
|
|
1285
|
+
class NotFoundError extends SimfinityError {
|
|
1286
|
+
constructor(message) {
|
|
1287
|
+
super(message, 'NOT_FOUND', 404);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
## ๐ State Machines
|
|
1293
|
+
|
|
1294
|
+
Implement declarative state machine workflows:
|
|
1295
|
+
|
|
1296
|
+
### 1. Define States
|
|
1297
|
+
|
|
1298
|
+
```javascript
|
|
1299
|
+
const { GraphQLEnumType } = require('graphql');
|
|
1300
|
+
|
|
1301
|
+
const OrderState = new GraphQLEnumType({
|
|
1302
|
+
name: 'OrderState',
|
|
1303
|
+
values: {
|
|
1304
|
+
PENDING: { value: 'PENDING' },
|
|
1305
|
+
PROCESSING: { value: 'PROCESSING' },
|
|
1306
|
+
SHIPPED: { value: 'SHIPPED' },
|
|
1307
|
+
DELIVERED: { value: 'DELIVERED' },
|
|
1308
|
+
CANCELLED: { value: 'CANCELLED' }
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
### 2. Define Type with State Field
|
|
1314
|
+
|
|
1315
|
+
```javascript
|
|
1316
|
+
const OrderType = new GraphQLObjectType({
|
|
1317
|
+
name: 'Order',
|
|
1318
|
+
fields: () => ({
|
|
1319
|
+
id: { type: GraphQLID },
|
|
1320
|
+
customer: { type: GraphQLString },
|
|
1321
|
+
state: { type: OrderState }
|
|
1322
|
+
})
|
|
1323
|
+
});
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
### 3. Configure State Machine
|
|
1327
|
+
|
|
1328
|
+
```javascript
|
|
1329
|
+
const stateMachine = {
|
|
1330
|
+
initialState: { name: 'PENDING', value: 'PENDING' },
|
|
1331
|
+
actions: {
|
|
1332
|
+
process: {
|
|
1333
|
+
from: { name: 'PENDING', value: 'PENDING' },
|
|
1334
|
+
to: { name: 'PROCESSING', value: 'PROCESSING' },
|
|
1335
|
+
description: 'Process the order',
|
|
1336
|
+
action: async (args, session) => {
|
|
1337
|
+
// Business logic for processing
|
|
1338
|
+
console.log(`Processing order ${args.id}`);
|
|
1339
|
+
// You can perform additional operations here
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
ship: {
|
|
1343
|
+
from: { name: 'PROCESSING', value: 'PROCESSING' },
|
|
1344
|
+
to: { name: 'SHIPPED', value: 'SHIPPED' },
|
|
1345
|
+
description: 'Ship the order',
|
|
1346
|
+
action: async (args, session) => {
|
|
1347
|
+
// Business logic for shipping
|
|
1348
|
+
console.log(`Shipping order ${args.id}`);
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
deliver: {
|
|
1352
|
+
from: { name: 'SHIPPED', value: 'SHIPPED' },
|
|
1353
|
+
to: { name: 'DELIVERED', value: 'DELIVERED' },
|
|
1354
|
+
description: 'Mark as delivered'
|
|
1355
|
+
},
|
|
1356
|
+
cancel: {
|
|
1357
|
+
from: { name: 'PENDING', value: 'PENDING' },
|
|
1358
|
+
to: { name: 'CANCELLED', value: 'CANCELLED' },
|
|
1359
|
+
description: 'Cancel the order'
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
### 4. Connect with State Machine
|
|
1366
|
+
|
|
1367
|
+
```javascript
|
|
1368
|
+
simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
### 5. Use State Machine Mutations
|
|
1372
|
+
|
|
1373
|
+
The state machine automatically generates mutations for each action:
|
|
1374
|
+
|
|
1375
|
+
```graphql
|
|
1376
|
+
mutation {
|
|
1377
|
+
process_order(input: {
|
|
1378
|
+
id: "order_id"
|
|
1379
|
+
}) {
|
|
1380
|
+
id
|
|
1381
|
+
state
|
|
1382
|
+
customer
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
**Important Notes**:
|
|
1388
|
+
- The `state` field is automatically read-only and managed by the state machine
|
|
1389
|
+
- State transitions are only allowed based on the defined actions
|
|
1390
|
+
- Business logic in the `action` function is executed during transitions
|
|
1391
|
+
- Invalid transitions throw errors automatically
|
|
1392
|
+
|
|
1393
|
+
## ๐๏ธ Controllers & Lifecycle Hooks
|
|
1394
|
+
|
|
1395
|
+
Controllers provide fine-grained control over operations with lifecycle hooks:
|
|
1396
|
+
|
|
1397
|
+
```javascript
|
|
1398
|
+
const bookController = {
|
|
1399
|
+
onSaving: async (doc, args, session, context) => {
|
|
1400
|
+
// Before saving - doc is a Mongoose document
|
|
1401
|
+
if (!doc.title || doc.title.trim().length === 0) {
|
|
1402
|
+
throw new Error('Book title cannot be empty');
|
|
1403
|
+
}
|
|
1404
|
+
// Access user from context to set owner
|
|
1405
|
+
if (context && context.user) {
|
|
1406
|
+
doc.owner = context.user.id;
|
|
1407
|
+
}
|
|
1408
|
+
console.log(`Creating book: ${doc.title}`);
|
|
1409
|
+
},
|
|
1410
|
+
|
|
1411
|
+
onSaved: async (doc, args, session, context) => {
|
|
1412
|
+
// After saving - doc is a plain object
|
|
1413
|
+
console.log(`Book saved: ${doc._id}`);
|
|
1414
|
+
// Can access context.user for post-save operations like notifications
|
|
1415
|
+
},
|
|
1416
|
+
|
|
1417
|
+
onUpdating: async (id, doc, session, context) => {
|
|
1418
|
+
// Before updating - doc contains only changed fields
|
|
1419
|
+
// Validate user has permission to update
|
|
1420
|
+
if (context && context.user && context.user.role !== 'admin') {
|
|
1421
|
+
throw new simfinity.SimfinityError('Only admins can update books', 'FORBIDDEN', 403);
|
|
1422
|
+
}
|
|
1423
|
+
console.log(`Updating book ${id}`);
|
|
1424
|
+
},
|
|
1425
|
+
|
|
1426
|
+
onUpdated: async (doc, session, context) => {
|
|
1427
|
+
// After updating - doc is the updated document
|
|
1428
|
+
console.log(`Book updated: ${doc.title}`);
|
|
1429
|
+
},
|
|
1430
|
+
|
|
1431
|
+
onDelete: async (doc, session, context) => {
|
|
1432
|
+
// Before deleting - doc is the document to be deleted
|
|
1433
|
+
// Validate user has permission to delete
|
|
1434
|
+
if (context && context.user && context.user.role !== 'admin') {
|
|
1435
|
+
throw new simfinity.SimfinityError('Only admins can delete books', 'FORBIDDEN', 403);
|
|
1436
|
+
}
|
|
1437
|
+
console.log(`Deleting book: ${doc.title}`);
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
// Connect with controller
|
|
1442
|
+
simfinity.connect(null, BookType, 'book', 'books', bookController);
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
### Hook Parameters
|
|
1446
|
+
|
|
1447
|
+
**`onSaving(doc, args, session, context)`**:
|
|
1448
|
+
- `doc`: Mongoose Document instance (not yet saved)
|
|
1449
|
+
- `args`: Raw GraphQL mutation input
|
|
1450
|
+
- `session`: Mongoose session for transaction
|
|
1451
|
+
- `context`: GraphQL context object (includes request info, user data, etc.)
|
|
1452
|
+
|
|
1453
|
+
**`onSaved(doc, args, session, context)`**:
|
|
1454
|
+
- `doc`: Plain object of saved document
|
|
1455
|
+
- `args`: Raw GraphQL mutation input
|
|
1456
|
+
- `session`: Mongoose session for transaction
|
|
1457
|
+
- `context`: GraphQL context object (includes request info, user data, etc.)
|
|
1458
|
+
|
|
1459
|
+
**`onUpdating(id, doc, session, context)`**:
|
|
1460
|
+
- `id`: Document ID being updated
|
|
1461
|
+
- `doc`: Plain object with only changed fields
|
|
1462
|
+
- `session`: Mongoose session for transaction
|
|
1463
|
+
- `context`: GraphQL context object (includes request info, user data, etc.)
|
|
1464
|
+
|
|
1465
|
+
**`onUpdated(doc, session, context)`**:
|
|
1466
|
+
- `doc`: Full updated Mongoose document
|
|
1467
|
+
- `session`: Mongoose session for transaction
|
|
1468
|
+
- `context`: GraphQL context object (includes request info, user data, etc.)
|
|
1469
|
+
|
|
1470
|
+
**`onDelete(doc, session, context)`**:
|
|
1471
|
+
- `doc`: Plain object of document to be deleted
|
|
1472
|
+
- `session`: Mongoose session for transaction
|
|
1473
|
+
- `context`: GraphQL context object (includes request info, user data, etc.)
|
|
1474
|
+
|
|
1475
|
+
### Using Context in Controllers
|
|
1476
|
+
|
|
1477
|
+
The `context` parameter provides access to the GraphQL request context, which typically includes user information, request metadata, and other application-specific data. This is particularly useful for:
|
|
1478
|
+
|
|
1479
|
+
- **Setting ownership**: Automatically assign the current user as the owner of new entities
|
|
1480
|
+
- **Authorization checks**: Validate user permissions before allowing operations
|
|
1481
|
+
- **Audit logging**: Track who performed which operations
|
|
1482
|
+
- **User-specific business logic**: Apply different logic based on user roles or attributes
|
|
1483
|
+
|
|
1484
|
+
**Example: Setting Owner on Creation**
|
|
1485
|
+
|
|
1486
|
+
```javascript
|
|
1487
|
+
const documentController = {
|
|
1488
|
+
onSaving: async (doc, args, session, context) => {
|
|
1489
|
+
// Automatically set the owner to the current user
|
|
1490
|
+
if (context && context.user) {
|
|
1491
|
+
doc.owner = context.user.id;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
**Example: Role-Based Authorization**
|
|
1498
|
+
|
|
1499
|
+
```javascript
|
|
1500
|
+
const adminOnlyController = {
|
|
1501
|
+
onUpdating: async (id, doc, session, context) => {
|
|
1502
|
+
if (!context || !context.user || context.user.role !== 'admin') {
|
|
1503
|
+
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
|
|
1504
|
+
}
|
|
1505
|
+
},
|
|
1506
|
+
|
|
1507
|
+
onDelete: async (doc, session, context) => {
|
|
1508
|
+
if (!context || !context.user || context.user.role !== 'admin') {
|
|
1509
|
+
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
```
|
|
1514
|
+
|
|
1515
|
+
**Note**: When using `saveObject` programmatically (outside of GraphQL), the `context` parameter is optional and may be `undefined`. Always check for context existence before accessing its properties.
|
|
1516
|
+
|
|
1517
|
+
## ๐ Query Scope
|
|
1518
|
+
|
|
1519
|
+
### Overview
|
|
1520
|
+
|
|
1521
|
+
Query scope allows you to automatically modify query arguments based on context (e.g., user permissions). This enables automatic filtering so that users can only see documents they're authorized to access. Scope functions are executed after middleware and before query execution, allowing you to append filter conditions to queries and aggregations.
|
|
1522
|
+
|
|
1523
|
+
### Defining Scope
|
|
1524
|
+
|
|
1525
|
+
Define scope in the type extensions, similar to how validations are defined:
|
|
1526
|
+
|
|
1527
|
+
```javascript
|
|
1528
|
+
const EpisodeType = new GraphQLObjectType({
|
|
1529
|
+
name: 'episode',
|
|
1530
|
+
extensions: {
|
|
1531
|
+
validations: {
|
|
1532
|
+
create: [validateEpisodeFields],
|
|
1533
|
+
update: [validateEpisodeBusinessRules]
|
|
1534
|
+
},
|
|
1535
|
+
scope: {
|
|
1536
|
+
find: async ({ type, args, operation, context }) => {
|
|
1537
|
+
// Modify args in place to add filter conditions
|
|
1538
|
+
args.owner = {
|
|
1539
|
+
terms: [
|
|
1540
|
+
{
|
|
1541
|
+
path: 'id',
|
|
1542
|
+
operator: 'EQ',
|
|
1543
|
+
value: context.user.id
|
|
1544
|
+
}
|
|
1545
|
+
]
|
|
1546
|
+
};
|
|
1547
|
+
},
|
|
1548
|
+
aggregate: async ({ type, args, operation, context }) => {
|
|
1549
|
+
// Apply same scope to aggregate queries
|
|
1550
|
+
args.owner = {
|
|
1551
|
+
terms: [
|
|
1552
|
+
{
|
|
1553
|
+
path: 'id',
|
|
1554
|
+
operator: 'EQ',
|
|
1555
|
+
value: context.user.id
|
|
1556
|
+
}
|
|
1557
|
+
]
|
|
1558
|
+
};
|
|
1559
|
+
},
|
|
1560
|
+
get_by_id: async ({ type, args, operation, context }) => {
|
|
1561
|
+
// For get_by_id, scope is automatically merged with id filter
|
|
1562
|
+
args.owner = {
|
|
1563
|
+
terms: [
|
|
1564
|
+
{
|
|
1565
|
+
path: 'id',
|
|
1566
|
+
operator: 'EQ',
|
|
1567
|
+
value: context.user.id
|
|
1568
|
+
}
|
|
1569
|
+
]
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
},
|
|
1574
|
+
fields: () => ({
|
|
1575
|
+
id: { type: GraphQLID },
|
|
1576
|
+
name: { type: GraphQLString },
|
|
1577
|
+
owner: {
|
|
1578
|
+
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
1579
|
+
extensions: {
|
|
1580
|
+
relation: {
|
|
1581
|
+
connectionField: 'owner',
|
|
1582
|
+
displayField: 'name'
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
})
|
|
1587
|
+
});
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
### Scope for Find Operations
|
|
1591
|
+
|
|
1592
|
+
Scope functions for `find` operations modify the query arguments that are passed to `buildQuery`. The modified arguments are automatically used to filter results:
|
|
1593
|
+
|
|
1594
|
+
```javascript
|
|
1595
|
+
const DocumentType = new GraphQLObjectType({
|
|
1596
|
+
name: 'Document',
|
|
1597
|
+
extensions: {
|
|
1598
|
+
scope: {
|
|
1599
|
+
find: async ({ type, args, operation, context }) => {
|
|
1600
|
+
// Only show documents owned by the current user
|
|
1601
|
+
args.owner = {
|
|
1602
|
+
terms: [
|
|
1603
|
+
{
|
|
1604
|
+
path: 'id',
|
|
1605
|
+
operator: 'EQ',
|
|
1606
|
+
value: context.user.id
|
|
1607
|
+
}
|
|
1608
|
+
]
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
},
|
|
1613
|
+
fields: () => ({
|
|
1614
|
+
id: { type: GraphQLID },
|
|
1615
|
+
title: { type: GraphQLString },
|
|
1616
|
+
owner: {
|
|
1617
|
+
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
1618
|
+
extensions: {
|
|
1619
|
+
relation: {
|
|
1620
|
+
connectionField: 'owner',
|
|
1621
|
+
displayField: 'name'
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
})
|
|
1626
|
+
});
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
**Result**: All `documents` queries will automatically filter to only return documents where `owner.id` equals `context.user.id`.
|
|
1630
|
+
|
|
1631
|
+
### Scope for Aggregate Operations
|
|
1632
|
+
|
|
1633
|
+
Scope functions for `aggregate` operations work the same way, ensuring aggregation queries also respect the scope:
|
|
1634
|
+
|
|
1635
|
+
```javascript
|
|
1636
|
+
const OrderType = new GraphQLObjectType({
|
|
1637
|
+
name: 'Order',
|
|
1638
|
+
extensions: {
|
|
1639
|
+
scope: {
|
|
1640
|
+
aggregate: async ({ type, args, operation, context }) => {
|
|
1641
|
+
// Only aggregate orders for the current user's organization
|
|
1642
|
+
args.organization = {
|
|
1643
|
+
terms: [
|
|
1644
|
+
{
|
|
1645
|
+
path: 'id',
|
|
1646
|
+
operator: 'EQ',
|
|
1647
|
+
value: context.user.organizationId
|
|
1648
|
+
}
|
|
1649
|
+
]
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
},
|
|
1654
|
+
fields: () => ({
|
|
1655
|
+
// ... fields
|
|
1656
|
+
})
|
|
1657
|
+
});
|
|
1658
|
+
```
|
|
1659
|
+
|
|
1660
|
+
**Result**: All `orders_aggregate` queries will automatically filter to only aggregate orders from the user's organization.
|
|
1661
|
+
|
|
1662
|
+
### Scope for Get By ID Operations
|
|
1663
|
+
|
|
1664
|
+
For `get_by_id` operations, scope functions modify a temporary query arguments object that includes the id filter. The system automatically combines the id filter with scope filters:
|
|
1665
|
+
|
|
1666
|
+
```javascript
|
|
1667
|
+
const PrivateDocumentType = new GraphQLObjectType({
|
|
1668
|
+
name: 'PrivateDocument',
|
|
1669
|
+
extensions: {
|
|
1670
|
+
scope: {
|
|
1671
|
+
get_by_id: async ({ type, args, operation, context }) => {
|
|
1672
|
+
// Ensure user can only access their own documents
|
|
1673
|
+
args.owner = {
|
|
1674
|
+
terms: [
|
|
1675
|
+
{
|
|
1676
|
+
path: 'id',
|
|
1677
|
+
operator: 'EQ',
|
|
1678
|
+
value: context.user.id
|
|
1679
|
+
}
|
|
1680
|
+
]
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
},
|
|
1685
|
+
fields: () => ({
|
|
1686
|
+
// ... fields
|
|
1687
|
+
})
|
|
1688
|
+
});
|
|
1689
|
+
```
|
|
1690
|
+
|
|
1691
|
+
**Result**: When querying `privatedocument(id: "some_id")`, the system will:
|
|
1692
|
+
1. Create a query that includes both the id filter and the owner scope filter
|
|
1693
|
+
2. Only return the document if it matches both conditions
|
|
1694
|
+
3. Return `null` if the document exists but doesn't match the scope
|
|
1695
|
+
|
|
1696
|
+
### Scope Function Parameters
|
|
1697
|
+
|
|
1698
|
+
Scope functions receive the same parameters as middleware for consistency:
|
|
1699
|
+
|
|
1700
|
+
```javascript
|
|
1701
|
+
{
|
|
1702
|
+
type, // Type information (model, gqltype, controller, etc.)
|
|
1703
|
+
args, // GraphQL arguments passed to the operation (modify this object)
|
|
1704
|
+
operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
|
|
1705
|
+
context // GraphQL context object (includes request info, user data, etc.)
|
|
1706
|
+
}
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
### Filter Structure
|
|
1710
|
+
|
|
1711
|
+
When modifying `args` in scope functions, use the appropriate filter structure:
|
|
1712
|
+
|
|
1713
|
+
**For scalar fields:**
|
|
1714
|
+
```javascript
|
|
1715
|
+
args.fieldName = {
|
|
1716
|
+
operator: 'EQ',
|
|
1717
|
+
value: 'someValue'
|
|
1718
|
+
};
|
|
1719
|
+
```
|
|
1720
|
+
|
|
1721
|
+
**For object/relation fields (QLTypeFilterExpression):**
|
|
1722
|
+
```javascript
|
|
1723
|
+
args.relationField = {
|
|
1724
|
+
terms: [
|
|
1725
|
+
{
|
|
1726
|
+
path: 'fieldName',
|
|
1727
|
+
operator: 'EQ',
|
|
1728
|
+
value: 'someValue'
|
|
1729
|
+
}
|
|
1730
|
+
]
|
|
1731
|
+
};
|
|
1732
|
+
```
|
|
1733
|
+
|
|
1734
|
+
### Complete Example
|
|
1735
|
+
|
|
1736
|
+
Here's a complete example showing scope for all query operations:
|
|
1737
|
+
|
|
1738
|
+
```javascript
|
|
1739
|
+
const EpisodeType = new GraphQLObjectType({
|
|
1740
|
+
name: 'episode',
|
|
1741
|
+
extensions: {
|
|
1742
|
+
validations: {
|
|
1743
|
+
save: [validateEpisodeFields],
|
|
1744
|
+
update: [validateEpisodeBusinessRules]
|
|
1745
|
+
},
|
|
1746
|
+
scope: {
|
|
1747
|
+
find: async ({ type, args, operation, context }) => {
|
|
1748
|
+
// Only show episodes from seasons the user has access to
|
|
1749
|
+
args.season = {
|
|
1750
|
+
terms: [
|
|
1751
|
+
{
|
|
1752
|
+
path: 'owner.id',
|
|
1753
|
+
operator: 'EQ',
|
|
1754
|
+
value: context.user.id
|
|
1755
|
+
}
|
|
1756
|
+
]
|
|
1757
|
+
};
|
|
1758
|
+
},
|
|
1759
|
+
aggregate: async ({ type, args, operation, context }) => {
|
|
1760
|
+
// Apply same scope to aggregations
|
|
1761
|
+
args.season = {
|
|
1762
|
+
terms: [
|
|
1763
|
+
{
|
|
1764
|
+
path: 'owner.id',
|
|
1765
|
+
operator: 'EQ',
|
|
1766
|
+
value: context.user.id
|
|
1767
|
+
}
|
|
1768
|
+
]
|
|
1769
|
+
};
|
|
1770
|
+
},
|
|
1771
|
+
get_by_id: async ({ type, args, operation, context }) => {
|
|
1772
|
+
// Ensure user can only access their own episodes
|
|
1773
|
+
args.owner = {
|
|
1774
|
+
terms: [
|
|
1775
|
+
{
|
|
1776
|
+
path: 'id',
|
|
1777
|
+
operator: 'EQ',
|
|
1778
|
+
value: context.user.id
|
|
1779
|
+
}
|
|
1780
|
+
]
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
fields: () => ({
|
|
1786
|
+
id: { type: GraphQLID },
|
|
1787
|
+
number: { type: GraphQLInt },
|
|
1788
|
+
name: { type: GraphQLString },
|
|
1789
|
+
season: {
|
|
1790
|
+
type: new GraphQLNonNull(simfinity.getType('season')),
|
|
1791
|
+
extensions: {
|
|
1792
|
+
relation: {
|
|
1793
|
+
connectionField: 'season',
|
|
1794
|
+
displayField: 'number'
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
},
|
|
1798
|
+
owner: {
|
|
1799
|
+
type: new GraphQLNonNull(simfinity.getType('user')),
|
|
1800
|
+
extensions: {
|
|
1801
|
+
relation: {
|
|
1802
|
+
connectionField: 'owner',
|
|
1803
|
+
displayField: 'name'
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
})
|
|
1808
|
+
});
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1811
|
+
### Important Notes
|
|
1812
|
+
|
|
1813
|
+
- **Execution Order**: Scope functions are executed **after** middleware, so middleware can set up context (e.g., user info) that scope functions can use
|
|
1814
|
+
- **Modify Args In Place**: Scope functions should modify the `args` object directly
|
|
1815
|
+
- **Filter Structure**: Use the correct filter structure (`QLFilter` for scalars, `QLTypeFilterExpression` for relations)
|
|
1816
|
+
- **All Query Operations**: Scope applies to `find`, `aggregate`, and `get_by_id` operations
|
|
1817
|
+
- **Automatic Merging**: For `get_by_id`, the id filter is automatically combined with scope filters
|
|
1818
|
+
- **Context Access**: Use `context.user`, `context.ip`, or other context properties to determine scope
|
|
1819
|
+
|
|
1820
|
+
### Use Cases
|
|
1821
|
+
|
|
1822
|
+
- **Multi-tenancy**: Filter documents by organization or tenant
|
|
1823
|
+
- **User-specific data**: Only show documents owned by the current user
|
|
1824
|
+
- **Role-based access**: Filter based on user roles or permissions
|
|
1825
|
+
- **Department/Team scoping**: Show only data relevant to user's department
|
|
1826
|
+
- **Geographic scoping**: Filter by user's location or region
|
|
1827
|
+
|
|
1828
|
+
## ๐ Authorization
|
|
1829
|
+
|
|
1830
|
+
Simfinity.js provides production-grade centralized GraphQL authorization supporting RBAC/ABAC, function-based rules, declarative policy expressions (JSON AST), wildcard permissions, and configurable default policies. It ships as a native Envelop plugin for GraphQL Yoga (recommended) and also supports the legacy graphql-middleware approach.
|
|
1831
|
+
|
|
1832
|
+
### Quick Start
|
|
1833
|
+
|
|
1834
|
+
```javascript
|
|
1835
|
+
const { auth } = require('@simtlix/simfinity-js');
|
|
1836
|
+
const { createYoga } = require('graphql-yoga');
|
|
1837
|
+
|
|
1838
|
+
const { createAuthPlugin, requireAuth, requireRole } = auth;
|
|
1839
|
+
|
|
1840
|
+
// Define your permission schema
|
|
1841
|
+
// Query/Mutation names match the ones generated by simfinity.connect()
|
|
1842
|
+
const permissions = {
|
|
1843
|
+
Query: {
|
|
1844
|
+
series: requireAuth(),
|
|
1845
|
+
seasons: requireAuth(),
|
|
1846
|
+
},
|
|
1847
|
+
Mutation: {
|
|
1848
|
+
deleteserie: requireRole('admin'),
|
|
1849
|
+
deletestar: requireRole('admin'),
|
|
1850
|
+
},
|
|
1851
|
+
serie: {
|
|
1852
|
+
'*': requireAuth(), // Wildcard: all fields require auth
|
|
1853
|
+
},
|
|
1854
|
+
};
|
|
1855
|
+
|
|
1856
|
+
// Create the Envelop auth plugin and pass it to your server
|
|
1857
|
+
const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' });
|
|
1858
|
+
const yoga = createYoga({ schema, plugins: [authPlugin] });
|
|
1859
|
+
```
|
|
1860
|
+
|
|
1861
|
+
### Permission Schema
|
|
1862
|
+
|
|
1863
|
+
The permission schema defines authorization rules per type and field:
|
|
1864
|
+
|
|
1865
|
+
```javascript
|
|
1866
|
+
const permissions = {
|
|
1867
|
+
// Operation types (Query, Mutation, Subscription)
|
|
1868
|
+
Query: {
|
|
1869
|
+
fieldName: ruleOrRules,
|
|
1870
|
+
},
|
|
1871
|
+
|
|
1872
|
+
// Object types
|
|
1873
|
+
TypeName: {
|
|
1874
|
+
'*': wildcardRule, // Applies to all fields unless overridden
|
|
1875
|
+
fieldName: specificRule, // Overrides wildcard for this field
|
|
1876
|
+
},
|
|
1877
|
+
};
|
|
1878
|
+
```
|
|
1879
|
+
|
|
1880
|
+
**Resolution Order:**
|
|
1881
|
+
1. Check exact field rule: `permissions[TypeName][fieldName]`
|
|
1882
|
+
2. Fallback to wildcard: `permissions[TypeName]['*']`
|
|
1883
|
+
3. Apply default policy (ALLOW or DENY)
|
|
1884
|
+
|
|
1885
|
+
**Rule Types:**
|
|
1886
|
+
- **Function**: `(parent, args, ctx, info) => boolean | void | Promise<boolean | void>`
|
|
1887
|
+
- **Array of functions**: All rules must pass (AND logic)
|
|
1888
|
+
- **Policy expression**: JSON AST object (see below)
|
|
1889
|
+
|
|
1890
|
+
**Rule Semantics:**
|
|
1891
|
+
- `return true` or `return void` โ allow
|
|
1892
|
+
- `return false` โ deny
|
|
1893
|
+
- `throw Error` โ deny with error
|
|
1894
|
+
|
|
1895
|
+
### Rule Helpers
|
|
1896
|
+
|
|
1897
|
+
Simfinity.js provides reusable rule builders:
|
|
1898
|
+
|
|
1899
|
+
```javascript
|
|
1900
|
+
const { auth } = require('@simtlix/simfinity-js');
|
|
1901
|
+
|
|
1902
|
+
const {
|
|
1903
|
+
resolvePath, // Utility to resolve dotted paths in objects
|
|
1904
|
+
requireAuth, // Requires ctx.user to exist
|
|
1905
|
+
requireRole, // Requires specific role(s)
|
|
1906
|
+
requirePermission, // Requires specific permission(s)
|
|
1907
|
+
composeRules, // Combine rules (AND logic)
|
|
1908
|
+
anyRule, // Combine rules (OR logic)
|
|
1909
|
+
isOwner, // Check resource ownership
|
|
1910
|
+
allow, // Always allow
|
|
1911
|
+
deny, // Always deny
|
|
1912
|
+
createRule, // Create custom rule
|
|
1913
|
+
} = auth;
|
|
1914
|
+
```
|
|
1915
|
+
|
|
1916
|
+
#### requireAuth(userPath?)
|
|
1917
|
+
|
|
1918
|
+
Requires the user to be authenticated. Supports custom user paths in context:
|
|
1919
|
+
|
|
1920
|
+
```javascript
|
|
1921
|
+
const permissions = {
|
|
1922
|
+
Query: {
|
|
1923
|
+
// Default: checks ctx.user
|
|
1924
|
+
me: requireAuth(),
|
|
1925
|
+
|
|
1926
|
+
// Custom path: checks ctx.auth.currentUser
|
|
1927
|
+
profile: requireAuth('auth.currentUser'),
|
|
1928
|
+
|
|
1929
|
+
// Deep path: checks ctx.session.data.user
|
|
1930
|
+
settings: requireAuth('session.data.user'),
|
|
1931
|
+
},
|
|
1932
|
+
};
|
|
1933
|
+
```
|
|
1934
|
+
|
|
1935
|
+
#### requireRole(role, options?)
|
|
1936
|
+
|
|
1937
|
+
Requires the user to have a specific role. Supports custom paths:
|
|
1938
|
+
|
|
1939
|
+
```javascript
|
|
1940
|
+
const permissions = {
|
|
1941
|
+
Query: {
|
|
1942
|
+
// Default: checks ctx.user.role
|
|
1943
|
+
adminDashboard: requireRole('ADMIN'),
|
|
1944
|
+
modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
|
|
1945
|
+
|
|
1946
|
+
// Custom paths: checks ctx.auth.user.profile.role
|
|
1947
|
+
superAdmin: requireRole('SUPER_ADMIN', {
|
|
1948
|
+
userPath: 'auth.user',
|
|
1949
|
+
rolePath: 'profile.role',
|
|
1950
|
+
}),
|
|
1951
|
+
},
|
|
1952
|
+
};
|
|
1953
|
+
```
|
|
1954
|
+
|
|
1955
|
+
#### requirePermission(permission, options?)
|
|
1956
|
+
|
|
1957
|
+
Requires the user to have specific permission(s). Supports custom paths:
|
|
1958
|
+
|
|
1959
|
+
```javascript
|
|
1960
|
+
const permissions = {
|
|
1961
|
+
Mutation: {
|
|
1962
|
+
// Default: checks ctx.user.permissions
|
|
1963
|
+
deletePost: requirePermission('posts:delete'),
|
|
1964
|
+
manageUsers: requirePermission(['users:read', 'users:write']), // All required
|
|
1965
|
+
|
|
1966
|
+
// Custom paths: checks ctx.session.user.access.grants
|
|
1967
|
+
admin: requirePermission('admin:all', {
|
|
1968
|
+
userPath: 'session.user',
|
|
1969
|
+
permissionsPath: 'access.grants',
|
|
1970
|
+
}),
|
|
1971
|
+
},
|
|
1972
|
+
};
|
|
1973
|
+
```
|
|
1974
|
+
|
|
1975
|
+
#### composeRules(...rules)
|
|
1976
|
+
|
|
1977
|
+
Combines multiple rules with AND logic (all must pass):
|
|
1978
|
+
|
|
1979
|
+
```javascript
|
|
1980
|
+
const permissions = {
|
|
1981
|
+
Mutation: {
|
|
1982
|
+
updatePost: composeRules(
|
|
1983
|
+
requireAuth(),
|
|
1984
|
+
requireRole('EDITOR'),
|
|
1985
|
+
async (post, args, ctx) => post.authorId === ctx.user.id,
|
|
1986
|
+
),
|
|
1987
|
+
},
|
|
1988
|
+
};
|
|
1989
|
+
```
|
|
1990
|
+
|
|
1991
|
+
#### anyRule(...rules)
|
|
1992
|
+
|
|
1993
|
+
Combines multiple rules with OR logic (any must pass):
|
|
1994
|
+
|
|
1995
|
+
```javascript
|
|
1996
|
+
const permissions = {
|
|
1997
|
+
Post: {
|
|
1998
|
+
content: anyRule(
|
|
1999
|
+
requireRole('ADMIN'),
|
|
2000
|
+
async (post, args, ctx) => post.authorId === ctx.user.id,
|
|
2001
|
+
),
|
|
2002
|
+
},
|
|
2003
|
+
};
|
|
2004
|
+
```
|
|
2005
|
+
|
|
2006
|
+
#### isOwner(ownerField, userIdField)
|
|
2007
|
+
|
|
2008
|
+
Checks if the authenticated user owns the resource:
|
|
2009
|
+
|
|
2010
|
+
```javascript
|
|
2011
|
+
const permissions = {
|
|
2012
|
+
Post: {
|
|
2013
|
+
'*': composeRules(
|
|
2014
|
+
requireAuth(),
|
|
2015
|
+
isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
|
|
2016
|
+
),
|
|
2017
|
+
},
|
|
2018
|
+
};
|
|
2019
|
+
```
|
|
2020
|
+
|
|
2021
|
+
### Policy Expressions (JSON AST)
|
|
2022
|
+
|
|
2023
|
+
For declarative rules, use JSON AST policy expressions:
|
|
2024
|
+
|
|
2025
|
+
```javascript
|
|
2026
|
+
const permissions = {
|
|
2027
|
+
Post: {
|
|
2028
|
+
content: {
|
|
2029
|
+
anyOf: [
|
|
2030
|
+
{ eq: [{ ref: 'parent.published' }, true] },
|
|
2031
|
+
{ eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
|
|
2032
|
+
],
|
|
2033
|
+
},
|
|
2034
|
+
},
|
|
2035
|
+
};
|
|
2036
|
+
```
|
|
2037
|
+
|
|
2038
|
+
**Supported Operators:**
|
|
2039
|
+
|
|
2040
|
+
| Operator | Description | Example |
|
|
2041
|
+
|----------|-------------|---------|
|
|
2042
|
+
| `eq` | Equals | `{ eq: [{ ref: 'parent.status' }, 'active'] }` |
|
|
2043
|
+
| `in` | Value in array | `{ in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] }` |
|
|
2044
|
+
| `allOf` | All must be true (AND) | `{ allOf: [expr1, expr2] }` |
|
|
2045
|
+
| `anyOf` | Any must be true (OR) | `{ anyOf: [expr1, expr2] }` |
|
|
2046
|
+
| `not` | Negation | `{ not: { eq: [{ ref: 'parent.deleted' }, true] } }` |
|
|
2047
|
+
|
|
2048
|
+
**References:**
|
|
2049
|
+
|
|
2050
|
+
Use `{ ref: 'path' }` to reference values:
|
|
2051
|
+
- `parent.*` - Parent resolver result (the object being resolved)
|
|
2052
|
+
- `args.*` - GraphQL arguments
|
|
2053
|
+
- `ctx.*` - GraphQL context
|
|
2054
|
+
|
|
2055
|
+
**Security:**
|
|
2056
|
+
- Only `parent`, `args`, and `ctx` roots are allowed
|
|
2057
|
+
- Unknown operators fail closed (deny)
|
|
2058
|
+
- No `eval()` or `Function()` - pure object traversal
|
|
2059
|
+
|
|
2060
|
+
### Integration with GraphQL Yoga / Envelop
|
|
2061
|
+
|
|
2062
|
+
The recommended way to use the auth system is via the Envelop plugin, which works natively with GraphQL Yoga and any Envelop-based server. The plugin wraps resolvers in-place without rebuilding the schema, avoiding compatibility issues.
|
|
2063
|
+
|
|
2064
|
+
```javascript
|
|
2065
|
+
const { createYoga } = require('graphql-yoga');
|
|
2066
|
+
const { createServer } = require('http');
|
|
2067
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
2068
|
+
|
|
2069
|
+
const { auth } = simfinity;
|
|
2070
|
+
const { createAuthPlugin, requireAuth, requireRole, composeRules, isOwner, deny } = auth;
|
|
2071
|
+
|
|
2072
|
+
// Define your types and connect them
|
|
2073
|
+
simfinity.connect(null, SerieType, 'serie', 'series');
|
|
2074
|
+
simfinity.connect(null, SeasonType, 'season', 'seasons');
|
|
2075
|
+
simfinity.connect(null, StarType, 'star', 'stars');
|
|
2076
|
+
|
|
2077
|
+
// Create base schema
|
|
2078
|
+
const schema = simfinity.createSchema();
|
|
2079
|
+
|
|
2080
|
+
// Define permissions
|
|
2081
|
+
// Query/Mutation names match the ones generated by simfinity.connect()
|
|
2082
|
+
const permissions = {
|
|
2083
|
+
Query: {
|
|
2084
|
+
series: requireAuth(),
|
|
2085
|
+
seasons: requireAuth(),
|
|
2086
|
+
stars: requireAuth(),
|
|
2087
|
+
},
|
|
2088
|
+
Mutation: {
|
|
2089
|
+
addserie: requireAuth(),
|
|
2090
|
+
updateserie: composeRules(requireAuth(), isOwner('createdBy')),
|
|
2091
|
+
deleteserie: requireRole('admin'),
|
|
2092
|
+
deletestar: requireRole('admin'),
|
|
2093
|
+
},
|
|
2094
|
+
serie: {
|
|
2095
|
+
'*': requireAuth(),
|
|
2096
|
+
},
|
|
2097
|
+
season: {
|
|
2098
|
+
'*': requireAuth(),
|
|
2099
|
+
},
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
// Create auth plugin
|
|
2103
|
+
const authPlugin = createAuthPlugin(permissions, {
|
|
2104
|
+
defaultPolicy: 'ALLOW',
|
|
2105
|
+
debug: false,
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
// Setup Yoga with the auth plugin
|
|
2109
|
+
const yoga = createYoga({
|
|
2110
|
+
schema,
|
|
2111
|
+
plugins: [authPlugin],
|
|
2112
|
+
context: (req) => ({
|
|
2113
|
+
user: req.user, // Set by your authentication layer
|
|
2114
|
+
}),
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
const server = createServer(yoga);
|
|
2118
|
+
server.listen(4000);
|
|
2119
|
+
```
|
|
2120
|
+
|
|
2121
|
+
### Legacy: Integration with graphql-middleware
|
|
2122
|
+
|
|
2123
|
+
> **Deprecated:** `applyMiddleware` from `graphql-middleware` rebuilds the schema via `mapSchema`,
|
|
2124
|
+
> which can cause `"Schema must contain uniquely named types"` errors with Simfinity schemas.
|
|
2125
|
+
> Use `createAuthPlugin` with GraphQL Yoga / Envelop instead.
|
|
2126
|
+
|
|
2127
|
+
```javascript
|
|
2128
|
+
const { applyMiddleware } = require('graphql-middleware');
|
|
2129
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
2130
|
+
|
|
2131
|
+
const { auth } = simfinity;
|
|
2132
|
+
const { createAuthMiddleware, requireAuth, requireRole } = auth;
|
|
2133
|
+
|
|
2134
|
+
const baseSchema = simfinity.createSchema();
|
|
2135
|
+
|
|
2136
|
+
const authMiddleware = createAuthMiddleware(permissions, {
|
|
2137
|
+
defaultPolicy: 'DENY',
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
const schema = applyMiddleware(baseSchema, authMiddleware);
|
|
2141
|
+
```
|
|
2142
|
+
|
|
2143
|
+
### Plugin / Middleware Options
|
|
2144
|
+
|
|
2145
|
+
```javascript
|
|
2146
|
+
const plugin = createAuthPlugin(permissions, {
|
|
2147
|
+
defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
|
|
2148
|
+
debug: false, // Enable debug logging
|
|
2149
|
+
});
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
| Option | Type | Default | Description |
|
|
2153
|
+
|--------|------|---------|-------------|
|
|
2154
|
+
| `defaultPolicy` | `'ALLOW' \| 'DENY'` | `'DENY'` | Policy when no rule matches |
|
|
2155
|
+
| `debug` | `boolean` | `false` | Log authorization decisions |
|
|
2156
|
+
|
|
2157
|
+
### Error Handling
|
|
2158
|
+
|
|
2159
|
+
The auth middleware uses Simfinity error classes:
|
|
2160
|
+
|
|
2161
|
+
```javascript
|
|
2162
|
+
const { auth } = require('@simtlix/simfinity-js');
|
|
2163
|
+
|
|
2164
|
+
const { UnauthenticatedError, ForbiddenError } = auth;
|
|
2165
|
+
|
|
2166
|
+
// UnauthenticatedError: code 'UNAUTHENTICATED', status 401
|
|
2167
|
+
// ForbiddenError: code 'FORBIDDEN', status 403
|
|
2168
|
+
```
|
|
2169
|
+
|
|
2170
|
+
Custom error handling in rules:
|
|
2171
|
+
|
|
2172
|
+
```javascript
|
|
2173
|
+
const permissions = {
|
|
2174
|
+
Mutation: {
|
|
2175
|
+
deleteserie: async (parent, args, ctx) => {
|
|
2176
|
+
if (!ctx.user) {
|
|
2177
|
+
throw new auth.UnauthenticatedError('Please log in');
|
|
2178
|
+
}
|
|
2179
|
+
if (ctx.user.role !== 'admin') {
|
|
2180
|
+
throw new auth.ForbiddenError('Only admins can delete series');
|
|
2181
|
+
}
|
|
2182
|
+
return true;
|
|
2183
|
+
},
|
|
2184
|
+
},
|
|
2185
|
+
};
|
|
2186
|
+
```
|
|
2187
|
+
|
|
2188
|
+
### Best Practices
|
|
2189
|
+
|
|
2190
|
+
1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security
|
|
2191
|
+
2. **Use wildcards wisely**: `'*'` rules provide baseline security per type
|
|
2192
|
+
3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions
|
|
2193
|
+
4. **Fail closed**: Custom rules should deny on unexpected conditions
|
|
2194
|
+
5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules
|
|
2195
|
+
6. **Test thoroughly**: Auth rules are critical - test all scenarios
|
|
2196
|
+
|
|
2197
|
+
## ๐ง Middlewares
|
|
2198
|
+
|
|
2199
|
+
Middlewares provide a powerful way to intercept and process all GraphQL operations before they execute. Use them for cross-cutting concerns like authentication, logging, validation, and performance monitoring.
|
|
2200
|
+
|
|
2201
|
+
### Adding Middlewares
|
|
2202
|
+
|
|
2203
|
+
Register middlewares using `simfinity.use()`. Middlewares execute in the order they're registered:
|
|
2204
|
+
|
|
2205
|
+
```javascript
|
|
2206
|
+
// Basic logging middleware
|
|
2207
|
+
simfinity.use((params, next) => {
|
|
2208
|
+
console.log(`Executing ${params.operation} on ${params.type?.name || 'custom mutation'}`);
|
|
2209
|
+
next();
|
|
2210
|
+
});
|
|
2211
|
+
```
|
|
2212
|
+
|
|
2213
|
+
### Middleware Parameters
|
|
2214
|
+
|
|
2215
|
+
Each middleware receives a `params` object containing:
|
|
2216
|
+
|
|
2217
|
+
```javascript
|
|
2218
|
+
simfinity.use((params, next) => {
|
|
2219
|
+
// params object contains:
|
|
2220
|
+
const {
|
|
2221
|
+
type, // Type information (model, gqltype, controller, etc.)
|
|
2222
|
+
args, // GraphQL arguments passed to the operation
|
|
2223
|
+
operation, // Operation type: 'save', 'update', 'delete', 'get_by_id', 'find', 'state_changed', 'custom_mutation'
|
|
2224
|
+
context, // GraphQL context object (includes request info, user data, etc.)
|
|
2225
|
+
actionName, // For state machine actions (only present for state_changed operations)
|
|
2226
|
+
actionField, // State machine action details (only present for state_changed operations)
|
|
2227
|
+
entry // Custom mutation name (only present for custom_mutation operations)
|
|
2228
|
+
} = params;
|
|
2229
|
+
|
|
2230
|
+
// Always call next() to continue the middleware chain
|
|
2231
|
+
next();
|
|
2232
|
+
});
|
|
2233
|
+
```
|
|
2234
|
+
|
|
2235
|
+
### Common Use Cases
|
|
2236
|
+
|
|
2237
|
+
#### 1. Authentication & Authorization
|
|
2238
|
+
|
|
2239
|
+
```javascript
|
|
2240
|
+
simfinity.use((params, next) => {
|
|
2241
|
+
const { context, operation, type } = params;
|
|
2242
|
+
|
|
2243
|
+
// Skip authentication for read operations
|
|
2244
|
+
if (operation === 'get_by_id' || operation === 'find') {
|
|
2245
|
+
return next();
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// Check if user is authenticated
|
|
2249
|
+
if (!context.user) {
|
|
2250
|
+
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// Check permissions for specific types
|
|
2254
|
+
if (type?.name === 'User' && context.user.role !== 'admin') {
|
|
2255
|
+
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
next();
|
|
2259
|
+
});
|
|
2260
|
+
```
|
|
2261
|
+
|
|
2262
|
+
#### 2. Request Logging & Monitoring
|
|
2263
|
+
|
|
2264
|
+
```javascript
|
|
2265
|
+
simfinity.use((params, next) => {
|
|
2266
|
+
const { operation, type, args, context } = params;
|
|
2267
|
+
const startTime = Date.now();
|
|
2268
|
+
|
|
2269
|
+
console.log(`[${new Date().toISOString()}] Starting ${operation}${type ? ` on ${type.name}` : ''}`);
|
|
2270
|
+
|
|
2271
|
+
// Continue with the operation
|
|
2272
|
+
next();
|
|
2273
|
+
|
|
2274
|
+
const duration = Date.now() - startTime;
|
|
2275
|
+
console.log(`[${new Date().toISOString()}] Completed ${operation} in ${duration}ms`);
|
|
2276
|
+
});
|
|
2277
|
+
```
|
|
2278
|
+
|
|
2279
|
+
#### 3. Input Validation & Sanitization
|
|
2280
|
+
|
|
2281
|
+
```javascript
|
|
2282
|
+
simfinity.use((params, next) => {
|
|
2283
|
+
const { operation, args, type } = params;
|
|
2284
|
+
|
|
2285
|
+
// Validate input for save operations
|
|
2286
|
+
if (operation === 'save' && args.input) {
|
|
2287
|
+
// Trim string fields
|
|
2288
|
+
Object.keys(args.input).forEach(key => {
|
|
2289
|
+
if (typeof args.input[key] === 'string') {
|
|
2290
|
+
args.input[key] = args.input[key].trim();
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
// Validate required business rules
|
|
2295
|
+
if (type?.name === 'Book' && args.input.title && args.input.title.length < 3) {
|
|
2296
|
+
throw new simfinity.SimfinityError('Book title must be at least 3 characters', 'VALIDATION_ERROR', 400);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
next();
|
|
2301
|
+
});
|
|
2302
|
+
```
|
|
2303
|
+
|
|
2304
|
+
#### 4. Rate Limiting
|
|
2305
|
+
|
|
2306
|
+
```javascript
|
|
2307
|
+
const requestCounts = new Map();
|
|
2308
|
+
|
|
2309
|
+
simfinity.use((params, next) => {
|
|
2310
|
+
const { context, operation } = params;
|
|
2311
|
+
const userId = context.user?.id || context.ip;
|
|
2312
|
+
const now = Date.now();
|
|
2313
|
+
const windowMs = 60000; // 1 minute
|
|
2314
|
+
const maxRequests = 100;
|
|
2315
|
+
|
|
2316
|
+
// Only apply rate limiting to mutations
|
|
2317
|
+
if (operation === 'save' || operation === 'update' || operation === 'delete') {
|
|
2318
|
+
const userRequests = requestCounts.get(userId) || [];
|
|
2319
|
+
const recentRequests = userRequests.filter(time => now - time < windowMs);
|
|
2320
|
+
|
|
2321
|
+
if (recentRequests.length >= maxRequests) {
|
|
2322
|
+
throw new simfinity.SimfinityError('Rate limit exceeded', 'TOO_MANY_REQUESTS', 429);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
recentRequests.push(now);
|
|
2326
|
+
requestCounts.set(userId, recentRequests);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
next();
|
|
2330
|
+
});
|
|
2331
|
+
```
|
|
2332
|
+
|
|
2333
|
+
#### 5. Audit Trail
|
|
2334
|
+
|
|
2335
|
+
```javascript
|
|
2336
|
+
simfinity.use((params, next) => {
|
|
2337
|
+
const { operation, type, args, context } = params;
|
|
2338
|
+
|
|
2339
|
+
// Log all mutations for audit purposes
|
|
2340
|
+
if (operation === 'save' || operation === 'update' || operation === 'delete') {
|
|
2341
|
+
const auditEntry = {
|
|
2342
|
+
timestamp: new Date(),
|
|
2343
|
+
user: context.user?.id,
|
|
2344
|
+
operation,
|
|
2345
|
+
type: type?.name,
|
|
2346
|
+
entityId: args.id || 'new',
|
|
2347
|
+
data: operation === 'delete' ? null : args.input,
|
|
2348
|
+
ip: context.ip,
|
|
2349
|
+
userAgent: context.userAgent
|
|
2350
|
+
};
|
|
2351
|
+
|
|
2352
|
+
// Save to audit log (could be database, file, or external service)
|
|
2353
|
+
console.log('AUDIT:', JSON.stringify(auditEntry));
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
next();
|
|
2357
|
+
});
|
|
2358
|
+
```
|
|
2359
|
+
|
|
2360
|
+
### Multiple Middlewares
|
|
2361
|
+
|
|
2362
|
+
Middlewares execute in registration order. Each middleware must call `next()` to continue the chain:
|
|
2363
|
+
|
|
2364
|
+
```javascript
|
|
2365
|
+
// Middleware 1: Authentication
|
|
2366
|
+
simfinity.use((params, next) => {
|
|
2367
|
+
console.log('1. Checking authentication...');
|
|
2368
|
+
// Authentication logic here
|
|
2369
|
+
next(); // Continue to next middleware
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
// Middleware 2: Authorization
|
|
2373
|
+
simfinity.use((params, next) => {
|
|
2374
|
+
console.log('2. Checking permissions...');
|
|
2375
|
+
// Authorization logic here
|
|
2376
|
+
next(); // Continue to next middleware
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
// Middleware 3: Logging
|
|
2380
|
+
simfinity.use((params, next) => {
|
|
2381
|
+
console.log('3. Logging request...');
|
|
2382
|
+
// Logging logic here
|
|
2383
|
+
next(); // Continue to GraphQL operation
|
|
2384
|
+
});
|
|
2385
|
+
```
|
|
2386
|
+
|
|
2387
|
+
### Error Handling in Middlewares
|
|
2388
|
+
|
|
2389
|
+
Middlewares can throw errors to stop the operation:
|
|
2390
|
+
|
|
2391
|
+
```javascript
|
|
2392
|
+
simfinity.use((params, next) => {
|
|
2393
|
+
const { context, operation } = params;
|
|
2394
|
+
|
|
2395
|
+
try {
|
|
2396
|
+
// Validation logic
|
|
2397
|
+
if (!context.user && operation !== 'find') {
|
|
2398
|
+
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
next(); // Continue only if validation passes
|
|
2402
|
+
} catch (error) {
|
|
2403
|
+
// Error automatically bubbles up to GraphQL error handling
|
|
2404
|
+
throw error;
|
|
2405
|
+
}
|
|
2406
|
+
});
|
|
2407
|
+
```
|
|
2408
|
+
|
|
2409
|
+
### Conditional Middleware Execution
|
|
2410
|
+
|
|
2411
|
+
Execute middleware logic conditionally based on operation type or context:
|
|
2412
|
+
|
|
2413
|
+
```javascript
|
|
2414
|
+
simfinity.use((params, next) => {
|
|
2415
|
+
const { operation, type, context } = params;
|
|
2416
|
+
|
|
2417
|
+
// Only apply to specific types
|
|
2418
|
+
if (type?.name === 'SensitiveData') {
|
|
2419
|
+
// Special handling for sensitive data
|
|
2420
|
+
if (!context.user?.hasHighSecurity) {
|
|
2421
|
+
throw new simfinity.SimfinityError('High security clearance required', 'FORBIDDEN', 403);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// Only apply to mutation operations
|
|
2426
|
+
if (['save', 'update', 'delete', 'state_changed'].includes(operation)) {
|
|
2427
|
+
// Mutation-specific logic
|
|
2428
|
+
console.log(`Mutation ${operation} executing...`);
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
next();
|
|
2432
|
+
});
|
|
2433
|
+
```
|
|
2434
|
+
|
|
2435
|
+
### Best Practices
|
|
2436
|
+
|
|
2437
|
+
1. **Always call `next()`**: Failing to call `next()` will hang the request
|
|
2438
|
+
2. **Handle errors gracefully**: Use try-catch blocks for error-prone operations
|
|
2439
|
+
3. **Keep middlewares focused**: Each middleware should handle one concern
|
|
2440
|
+
4. **Order matters**: Register middlewares in logical order (auth โ validation โ logging)
|
|
2441
|
+
5. **Performance consideration**: Middlewares run on every operation, keep them lightweight
|
|
2442
|
+
6. **Use context wisely**: Store request-specific data in the GraphQL context object
|
|
2443
|
+
|
|
2444
|
+
## ๐ง Advanced Features
|
|
2445
|
+
|
|
2446
|
+
### Field Extensions
|
|
2447
|
+
|
|
2448
|
+
Control field behavior with extensions:
|
|
2449
|
+
|
|
2450
|
+
```javascript
|
|
2451
|
+
const BookType = new GraphQLObjectType({
|
|
2452
|
+
name: 'Book',
|
|
2453
|
+
fields: () => ({
|
|
2454
|
+
id: { type: GraphQLID },
|
|
2455
|
+
title: {
|
|
2456
|
+
type: GraphQLString,
|
|
2457
|
+
extensions: {
|
|
2458
|
+
unique: true, // Creates unique index in MongoDB
|
|
2459
|
+
readOnly: true // Excludes from input types
|
|
2460
|
+
}
|
|
2461
|
+
},
|
|
2462
|
+
isbn: {
|
|
2463
|
+
type: GraphQLString,
|
|
2464
|
+
extensions: {
|
|
2465
|
+
unique: true
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
})
|
|
2469
|
+
});
|
|
2470
|
+
```
|
|
2471
|
+
|
|
2472
|
+
### Custom Mutations
|
|
2473
|
+
|
|
2474
|
+
Register custom mutations beyond the automatic CRUD operations:
|
|
2475
|
+
|
|
2476
|
+
```javascript
|
|
2477
|
+
simfinity.registerMutation(
|
|
2478
|
+
'sendBookNotification',
|
|
2479
|
+
'Send notification about a book',
|
|
2480
|
+
BookNotificationInput, // Input type
|
|
2481
|
+
NotificationResult, // Output type
|
|
2482
|
+
async (args, session) => {
|
|
2483
|
+
// Custom business logic
|
|
2484
|
+
const book = await BookModel.findById(args.bookId);
|
|
2485
|
+
// Send notification logic here
|
|
2486
|
+
return { success: true, message: 'Notification sent' };
|
|
2487
|
+
}
|
|
2488
|
+
);
|
|
2489
|
+
```
|
|
2490
|
+
|
|
2491
|
+
### Adding Types Without Endpoints
|
|
2492
|
+
|
|
2493
|
+
Include types in the schema without generating endpoints. See the [detailed guide on addNoEndpointType()](#adding-types-without-endpoints) for when and how to use this pattern:
|
|
2494
|
+
|
|
2495
|
+
```javascript
|
|
2496
|
+
// This type can be used in relationships but won't have queries/mutations
|
|
2497
|
+
simfinity.addNoEndpointType(AddressType);
|
|
2498
|
+
```
|
|
2499
|
+
|
|
2500
|
+
### Working with Existing Mongoose Models
|
|
2501
|
+
|
|
2502
|
+
Use your existing Mongoose models:
|
|
2503
|
+
|
|
2504
|
+
```javascript
|
|
2505
|
+
const mongoose = require('mongoose');
|
|
2506
|
+
|
|
2507
|
+
const BookSchema = new mongoose.Schema({
|
|
2508
|
+
title: String,
|
|
2509
|
+
author: String,
|
|
2510
|
+
publishedDate: Date
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
const BookModel = mongoose.model('Book', BookSchema);
|
|
2514
|
+
|
|
2515
|
+
// Use existing model
|
|
2516
|
+
simfinity.connect(BookModel, BookType, 'book', 'books');
|
|
2517
|
+
```
|
|
2518
|
+
|
|
2519
|
+
### Programmatic Data Access
|
|
2520
|
+
|
|
2521
|
+
Access data programmatically outside of GraphQL:
|
|
2522
|
+
|
|
2523
|
+
```javascript
|
|
2524
|
+
// Save an object programmatically
|
|
2525
|
+
const newBook = await simfinity.saveObject('Book', {
|
|
2526
|
+
title: 'New Book',
|
|
2527
|
+
author: 'Author Name'
|
|
2528
|
+
}, session);
|
|
2529
|
+
|
|
2530
|
+
// Get the Mongoose model for a type
|
|
2531
|
+
const BookModel = simfinity.getModel(BookType);
|
|
2532
|
+
const books = await BookModel.find({ author: 'Douglas Adams' });
|
|
2533
|
+
|
|
2534
|
+
// Get the GraphQL type definition by name
|
|
2535
|
+
const UserType = simfinity.getType('User');
|
|
2536
|
+
console.log(UserType.name); // 'User'
|
|
2537
|
+
console.log(UserType.getFields()); // Access GraphQL fields
|
|
2538
|
+
|
|
2539
|
+
// Get the input type for a GraphQL type
|
|
2540
|
+
const BookInput = simfinity.getInputType(BookType);
|
|
2541
|
+
```
|
|
2542
|
+
|
|
2543
|
+
## ๐ Aggregation Queries
|
|
2544
|
+
|
|
2545
|
+
Simfinity.js now supports powerful GraphQL aggregation queries with GROUP BY functionality, allowing you to perform aggregate operations (SUM, COUNT, AVG, MIN, MAX) on your data.
|
|
2546
|
+
|
|
2547
|
+
### Overview
|
|
2548
|
+
|
|
2549
|
+
For each entity type registered with `connect()`, an additional aggregation endpoint is automatically generated with the format `{entityname}_aggregate`.
|
|
2550
|
+
|
|
2551
|
+
### Features
|
|
2552
|
+
|
|
2553
|
+
- **Group By**: Group results by any field (direct or related entity field path)
|
|
2554
|
+
- **Aggregation Operations**: SUM, COUNT, AVG, MIN, MAX
|
|
2555
|
+
- **Filtering**: Use the same filter parameters as regular queries
|
|
2556
|
+
- **Sorting**: Sort by groupId or any calculated fact (metrics), with support for multiple sort fields
|
|
2557
|
+
- **Pagination**: Use the same pagination parameters as regular queries
|
|
2558
|
+
- **Related Entity Fields**: Group by or aggregate on fields from related entities using dot notation
|
|
2559
|
+
|
|
2560
|
+
### GraphQL Types
|
|
2561
|
+
|
|
2562
|
+
#### QLAggregationOperation (Enum)
|
|
2563
|
+
- `SUM`: Sum of numeric values
|
|
2564
|
+
- `COUNT`: Count of records
|
|
2565
|
+
- `AVG`: Average of numeric values
|
|
2566
|
+
- `MIN`: Minimum value
|
|
2567
|
+
- `MAX`: Maximum value
|
|
2568
|
+
|
|
2569
|
+
#### QLTypeAggregationFact (Input)
|
|
2570
|
+
```graphql
|
|
2571
|
+
input QLTypeAggregationFact {
|
|
2572
|
+
operation: QLAggregationOperation!
|
|
2573
|
+
factName: String!
|
|
2574
|
+
path: String!
|
|
2575
|
+
}
|
|
2576
|
+
```
|
|
2577
|
+
|
|
2578
|
+
#### QLTypeAggregationExpression (Input)
|
|
2579
|
+
```graphql
|
|
2580
|
+
input QLTypeAggregationExpression {
|
|
2581
|
+
groupId: String!
|
|
2582
|
+
facts: [QLTypeAggregationFact!]!
|
|
2583
|
+
}
|
|
2584
|
+
```
|
|
2585
|
+
|
|
2586
|
+
#### QLTypeAggregationResult (Output)
|
|
2587
|
+
```graphql
|
|
2588
|
+
type QLTypeAggregationResult {
|
|
2589
|
+
groupId: JSON
|
|
2590
|
+
facts: JSON
|
|
2591
|
+
}
|
|
2592
|
+
```
|
|
2593
|
+
|
|
2594
|
+
### Quick Examples
|
|
2595
|
+
|
|
2596
|
+
#### Simple Group By
|
|
2597
|
+
```graphql
|
|
2598
|
+
query {
|
|
2599
|
+
series_aggregate(
|
|
2600
|
+
aggregation: {
|
|
2601
|
+
groupId: "category"
|
|
2602
|
+
facts: [
|
|
2603
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
2604
|
+
]
|
|
2605
|
+
}
|
|
2606
|
+
) {
|
|
2607
|
+
groupId
|
|
2608
|
+
facts
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
```
|
|
2612
|
+
|
|
2613
|
+
#### Group By Related Entity
|
|
2614
|
+
```graphql
|
|
2615
|
+
query {
|
|
2616
|
+
series_aggregate(
|
|
2617
|
+
aggregation: {
|
|
2618
|
+
groupId: "country.name"
|
|
2619
|
+
facts: [
|
|
2620
|
+
{ operation: COUNT, factName: "count", path: "id" }
|
|
2621
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
2622
|
+
]
|
|
2623
|
+
}
|
|
2624
|
+
) {
|
|
2625
|
+
groupId
|
|
2626
|
+
facts
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
```
|
|
2630
|
+
|
|
2631
|
+
#### Multiple Aggregation Facts
|
|
2632
|
+
```graphql
|
|
2633
|
+
query {
|
|
2634
|
+
series_aggregate(
|
|
2635
|
+
aggregation: {
|
|
2636
|
+
groupId: "category"
|
|
2637
|
+
facts: [
|
|
2638
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
2639
|
+
{ operation: SUM, factName: "totalEpisodes", path: "episodeCount" }
|
|
2640
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
2641
|
+
{ operation: MIN, factName: "minRating", path: "rating" }
|
|
2642
|
+
{ operation: MAX, factName: "maxRating", path: "rating" }
|
|
2643
|
+
]
|
|
2644
|
+
}
|
|
2645
|
+
) {
|
|
2646
|
+
groupId
|
|
2647
|
+
facts
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
```
|
|
2651
|
+
|
|
2652
|
+
#### With Filtering
|
|
2653
|
+
```graphql
|
|
2654
|
+
query {
|
|
2655
|
+
series_aggregate(
|
|
2656
|
+
rating: { operator: GTE, value: 8.0 }
|
|
2657
|
+
aggregation: {
|
|
2658
|
+
groupId: "category"
|
|
2659
|
+
facts: [
|
|
2660
|
+
{ operation: COUNT, factName: "highRated", path: "id" }
|
|
2661
|
+
]
|
|
2662
|
+
}
|
|
2663
|
+
) {
|
|
2664
|
+
groupId
|
|
2665
|
+
facts
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
```
|
|
2669
|
+
|
|
2670
|
+
#### Sorting by Multiple Fields
|
|
2671
|
+
```graphql
|
|
2672
|
+
query {
|
|
2673
|
+
series_aggregate(
|
|
2674
|
+
sort: {
|
|
2675
|
+
terms: [
|
|
2676
|
+
{ field: "total", order: "DESC" }, # Sort by count first
|
|
2677
|
+
{ field: "groupId", order: "ASC" } # Then by name
|
|
2678
|
+
]
|
|
2679
|
+
}
|
|
2680
|
+
aggregation: {
|
|
2681
|
+
groupId: "category"
|
|
2682
|
+
facts: [
|
|
2683
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
2684
|
+
{ operation: AVG, factName: "avgRating", path: "rating" }
|
|
2685
|
+
]
|
|
2686
|
+
}
|
|
2687
|
+
) {
|
|
2688
|
+
groupId
|
|
2689
|
+
facts
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
```
|
|
2693
|
+
|
|
2694
|
+
#### With Pagination (Top 5)
|
|
2695
|
+
```graphql
|
|
2696
|
+
query {
|
|
2697
|
+
series_aggregate(
|
|
2698
|
+
sort: {
|
|
2699
|
+
terms: [{ field: "total", order: "DESC" }]
|
|
2700
|
+
}
|
|
2701
|
+
pagination: {
|
|
2702
|
+
page: 1
|
|
2703
|
+
size: 5
|
|
2704
|
+
}
|
|
2705
|
+
aggregation: {
|
|
2706
|
+
groupId: "category"
|
|
2707
|
+
facts: [
|
|
2708
|
+
{ operation: COUNT, factName: "total", path: "id" }
|
|
2709
|
+
]
|
|
2710
|
+
}
|
|
2711
|
+
) {
|
|
2712
|
+
groupId
|
|
2713
|
+
facts
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
```
|
|
2717
|
+
|
|
2718
|
+
### Field Path Resolution
|
|
2719
|
+
|
|
2720
|
+
The `groupId` and `path` parameters support:
|
|
2721
|
+
|
|
2722
|
+
1. **Direct Fields**: Simple field names from the entity
|
|
2723
|
+
- Example: `"category"`, `"rating"`, `"id"`
|
|
2724
|
+
|
|
2725
|
+
2. **Related Entity Fields**: Dot notation for fields in related entities
|
|
2726
|
+
- Example: `"country.name"`, `"studio.foundedYear"`
|
|
2727
|
+
|
|
2728
|
+
3. **Nested Related Entities**: Multiple levels of relationships
|
|
2729
|
+
- Example: `"country.region.name"`
|
|
2730
|
+
|
|
2731
|
+
### Sorting Options
|
|
2732
|
+
|
|
2733
|
+
- Sort by **groupId** or **any fact name**
|
|
2734
|
+
- **Multiple sort fields supported** - results are sorted by the first field, then by the second field for ties, etc.
|
|
2735
|
+
- Set the `field` parameter to:
|
|
2736
|
+
- `"groupId"` to sort by the grouping field
|
|
2737
|
+
- Any fact name (e.g., `"avgRating"`, `"total"`) to sort by that calculated metric
|
|
2738
|
+
- The `order` parameter (ASC/DESC) determines the sort direction for each field
|
|
2739
|
+
- If a field doesn't match groupId or any fact name, it defaults to groupId
|
|
2740
|
+
- If no sort is specified, defaults to sorting by groupId ascending
|
|
2741
|
+
|
|
2742
|
+
### Pagination Notes
|
|
2743
|
+
|
|
2744
|
+
- The `page` and `size` parameters work as expected
|
|
2745
|
+
- The `count` parameter is **ignored** for aggregation queries
|
|
2746
|
+
- Pagination is applied **after** grouping and sorting
|
|
2747
|
+
|
|
2748
|
+
### MongoDB Translation
|
|
2749
|
+
|
|
2750
|
+
Aggregation queries are translated to efficient MongoDB aggregation pipelines:
|
|
2751
|
+
|
|
2752
|
+
1. **$lookup**: Joins with related entity collections
|
|
2753
|
+
2. **$unwind**: Flattens joined arrays
|
|
2754
|
+
3. **$match**: Applies filters (before grouping)
|
|
2755
|
+
4. **$group**: Groups by the specified field with aggregation operations
|
|
2756
|
+
5. **$project**: Formats final output with groupId and facts fields
|
|
2757
|
+
6. **$sort**: Sorts results by groupId or facts (with multiple fields support)
|
|
2758
|
+
7. **$limit** / **$skip**: Applied for pagination (after sorting)
|
|
2759
|
+
|
|
2760
|
+
### Result Structure
|
|
2761
|
+
|
|
2762
|
+
Results are returned in a consistent format:
|
|
2763
|
+
```json
|
|
2764
|
+
{
|
|
2765
|
+
"groupId": <value>,
|
|
2766
|
+
"facts": {
|
|
2767
|
+
"factName1": <calculated_value>,
|
|
2768
|
+
"factName2": <calculated_value>
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
```
|
|
2772
|
+
|
|
2773
|
+
For complete documentation with more examples, see [AGGREGATION_EXAMPLE.md](./AGGREGATION_EXAMPLE.md) and [AGGREGATION_CHANGES_SUMMARY.md](./AGGREGATION_CHANGES_SUMMARY.md).
|
|
2774
|
+
|
|
2775
|
+
## ๐ Complete Example
|
|
2776
|
+
|
|
2777
|
+
Here's a complete bookstore example with relationships, validations, and state machines:
|
|
2778
|
+
|
|
2779
|
+
```javascript
|
|
2780
|
+
const express = require('express');
|
|
2781
|
+
const { graphqlHTTP } = require('express-graphql');
|
|
2782
|
+
const mongoose = require('mongoose');
|
|
2783
|
+
const {
|
|
2784
|
+
GraphQLObjectType,
|
|
2785
|
+
GraphQLString,
|
|
2786
|
+
GraphQLNonNull,
|
|
2787
|
+
GraphQLID,
|
|
2788
|
+
GraphQLList,
|
|
2789
|
+
GraphQLInt,
|
|
2790
|
+
GraphQLEnumType
|
|
2791
|
+
} = require('graphql');
|
|
2792
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
2793
|
+
|
|
2794
|
+
// Connect to MongoDB
|
|
2795
|
+
mongoose.connect('mongodb://localhost:27017/bookstore', {
|
|
2796
|
+
useNewUrlParser: true,
|
|
2797
|
+
useUnifiedTopology: true,
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
// Define Types
|
|
2801
|
+
const AuthorType = new GraphQLObjectType({
|
|
2802
|
+
name: 'Author',
|
|
2803
|
+
fields: () => ({
|
|
2804
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
2805
|
+
name: { type: new GraphQLNonNull(GraphQLString) },
|
|
2806
|
+
email: { type: GraphQLString },
|
|
2807
|
+
books: {
|
|
2808
|
+
type: new GraphQLList(BookType),
|
|
2809
|
+
extensions: {
|
|
2810
|
+
relation: {
|
|
2811
|
+
connectionField: 'author',
|
|
2812
|
+
displayField: 'title'
|
|
2813
|
+
},
|
|
2814
|
+
},
|
|
2815
|
+
resolve(parent) {
|
|
2816
|
+
return simfinity.getModel(BookType).find({ author: parent.id });
|
|
2817
|
+
}
|
|
2818
|
+
},
|
|
2819
|
+
}),
|
|
2820
|
+
});
|
|
2821
|
+
|
|
2822
|
+
const BookType = new GraphQLObjectType({
|
|
2823
|
+
name: 'Book',
|
|
2824
|
+
fields: () => ({
|
|
2825
|
+
id: { type: new GraphQLNonNull(GraphQLID) },
|
|
2826
|
+
title: {
|
|
2827
|
+
type: new GraphQLNonNull(GraphQLString),
|
|
2828
|
+
extensions: {
|
|
2829
|
+
validations: {
|
|
2830
|
+
save: [{
|
|
2831
|
+
validate: async (typeName, fieldName, value, session) => {
|
|
2832
|
+
if (!value || value.length < 2) {
|
|
2833
|
+
throw new simfinity.SimfinityError('Title must be at least 2 characters', 'VALIDATION_ERROR', 400);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
}]
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
},
|
|
2840
|
+
pages: { type: GraphQLInt },
|
|
2841
|
+
author: {
|
|
2842
|
+
type: AuthorType,
|
|
2843
|
+
extensions: {
|
|
2844
|
+
relation: {
|
|
2845
|
+
displayField: 'name'
|
|
2846
|
+
},
|
|
2847
|
+
},
|
|
2848
|
+
resolve(parent) {
|
|
2849
|
+
return simfinity.getModel(AuthorType).findById(parent.author);
|
|
2850
|
+
}
|
|
2851
|
+
},
|
|
2852
|
+
}),
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
// Define Controllers
|
|
2856
|
+
const bookController = {
|
|
2857
|
+
onSaving: async (doc, args, session) => {
|
|
2858
|
+
console.log(`Creating book: ${doc.title}`);
|
|
2859
|
+
},
|
|
2860
|
+
|
|
2861
|
+
onSaved: async (doc, args, session) => {
|
|
2862
|
+
console.log(`Book saved: ${doc.title}`);
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
|
|
2866
|
+
// Connect Types
|
|
2867
|
+
simfinity.connect(null, AuthorType, 'author', 'authors');
|
|
2868
|
+
simfinity.connect(null, BookType, 'book', 'books', bookController);
|
|
2869
|
+
|
|
2870
|
+
// Create Schema
|
|
2871
|
+
const schema = simfinity.createSchema();
|
|
2872
|
+
|
|
2873
|
+
// Setup Express Server
|
|
2874
|
+
const app = express();
|
|
2875
|
+
|
|
2876
|
+
app.use('/graphql', graphqlHTTP({
|
|
2877
|
+
schema,
|
|
2878
|
+
graphiql: true,
|
|
2879
|
+
formatError: simfinity.buildErrorFormatter((err) => {
|
|
2880
|
+
console.log(err);
|
|
2881
|
+
})
|
|
2882
|
+
}));
|
|
2883
|
+
|
|
2884
|
+
app.listen(4000, () => {
|
|
2885
|
+
console.log('Bookstore API running on http://localhost:4000/graphql');
|
|
2886
|
+
});
|
|
2887
|
+
```
|
|
2888
|
+
|
|
2889
|
+
## ๐ Resources
|
|
2890
|
+
|
|
2891
|
+
- **[Series Sample Project](https://github.com/simtlix/series-sample)** - A complete TV series microservice built with Simfinity.js demonstrating types, relationships, state machines, controllers, and authorization
|
|
2892
|
+
- **[Samples Repository](https://github.com/simtlix/simfinity.js-samples)** - Complete examples and use cases
|
|
2893
|
+
- **[MongoDB Query Language](https://docs.mongodb.com/manual/tutorial/query-documents/)** - Learn about MongoDB querying
|
|
2894
|
+
- **[GraphQL Documentation](https://graphql.org/learn/)** - Learn about GraphQL
|
|
2895
|
+
|
|
2896
|
+
## ๐ License
|
|
2897
|
+
|
|
2898
|
+
Apache-2.0 License - see the [LICENSE](LICENSE) file for details.
|
|
2899
|
+
|
|
2900
|
+
## ๐ค Contributing
|
|
2901
|
+
|
|
2902
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
2903
|
+
|
|
2904
|
+
---
|
|
2905
|
+
|
|
2906
|
+
*Built with โค๏ธ by [Simtlix](https://github.com/simtlix)*
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
## ๐ Query Examples from Series-Sample
|
|
2910
|
+
|
|
2911
|
+
Here are some practical GraphQL query examples from the series-sample project, showcasing how to use simfinity.js effectively:
|
|
2912
|
+
|
|
2913
|
+
### 1. Series with Directors from a Specific Country
|
|
2914
|
+
|
|
2915
|
+
Find all series that have directors from the United States:
|
|
2916
|
+
|
|
2917
|
+
```graphql
|
|
2918
|
+
query {
|
|
2919
|
+
series(director: {
|
|
2920
|
+
terms: [
|
|
2921
|
+
{
|
|
2922
|
+
path: "country",
|
|
2923
|
+
operator: EQ,
|
|
2924
|
+
value: "United States"
|
|
2925
|
+
}
|
|
2926
|
+
]
|
|
2927
|
+
}) {
|
|
2928
|
+
id
|
|
2929
|
+
name
|
|
2930
|
+
categories
|
|
2931
|
+
director {
|
|
2932
|
+
name
|
|
2933
|
+
country
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
```
|
|
2938
|
+
|
|
2939
|
+
### 2. Series with a Specific Episode Name
|
|
2940
|
+
|
|
2941
|
+
Find series that contain an episode with the name "Pilot":
|
|
2942
|
+
|
|
2943
|
+
```graphql
|
|
2944
|
+
query {
|
|
2945
|
+
series(
|
|
2946
|
+
seasons: {
|
|
2947
|
+
terms: [
|
|
2948
|
+
{
|
|
2949
|
+
path: "episodes.name",
|
|
2950
|
+
operator: EQ,
|
|
2951
|
+
value: "Pilot"
|
|
2952
|
+
}
|
|
2953
|
+
]
|
|
2954
|
+
}
|
|
2955
|
+
) {
|
|
2956
|
+
id
|
|
2957
|
+
name
|
|
2958
|
+
seasons {
|
|
2959
|
+
number
|
|
2960
|
+
episodes {
|
|
2961
|
+
number
|
|
2962
|
+
name
|
|
2963
|
+
date
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
```
|
|
2969
|
+
|
|
2970
|
+
### 3. Series with a Particular Star
|
|
2971
|
+
|
|
2972
|
+
Find series that feature "Bryan Cranston":
|
|
2973
|
+
|
|
2974
|
+
```graphql
|
|
2975
|
+
query {
|
|
2976
|
+
assignedStarsAndSeries(star: {
|
|
2977
|
+
terms: [
|
|
2978
|
+
{
|
|
2979
|
+
path: "name",
|
|
2980
|
+
operator: EQ,
|
|
2981
|
+
value: "Bryan Cranston"
|
|
2982
|
+
}
|
|
2983
|
+
]
|
|
2984
|
+
}) {
|
|
2985
|
+
id
|
|
2986
|
+
star {
|
|
2987
|
+
name
|
|
2988
|
+
}
|
|
2989
|
+
serie {
|
|
2990
|
+
id
|
|
2991
|
+
name
|
|
2992
|
+
categories
|
|
2993
|
+
director {
|
|
2994
|
+
name
|
|
2995
|
+
country
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
```
|
|
3001
|
+
|
|
3002
|
+
### 4. Seasons from Series with Directors from a Given Country
|
|
3003
|
+
|
|
3004
|
+
Find all seasons that belong to series directed by someone from the United States:
|
|
3005
|
+
|
|
3006
|
+
```graphql
|
|
3007
|
+
query {
|
|
3008
|
+
seasons(serie: {
|
|
3009
|
+
terms: [
|
|
3010
|
+
{
|
|
3011
|
+
path: "director.country",
|
|
3012
|
+
operator: EQ,
|
|
3013
|
+
value: "United States"
|
|
3014
|
+
}
|
|
3015
|
+
]
|
|
3016
|
+
}) {
|
|
3017
|
+
id
|
|
3018
|
+
number
|
|
3019
|
+
year
|
|
3020
|
+
state
|
|
3021
|
+
serie {
|
|
3022
|
+
name
|
|
3023
|
+
categories
|
|
3024
|
+
director {
|
|
3025
|
+
name
|
|
3026
|
+
country
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
episodes {
|
|
3030
|
+
number
|
|
3031
|
+
name
|
|
3032
|
+
date
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
```
|
|
3037
|
+
|
|
3038
|
+
### 5. Combining Scalar and ObjectType Filters
|
|
3039
|
+
|
|
3040
|
+
Find series named "Breaking Bad" that have at least one season with number 1:
|
|
3041
|
+
|
|
3042
|
+
```graphql
|
|
3043
|
+
query {
|
|
3044
|
+
series(
|
|
3045
|
+
name: {
|
|
3046
|
+
operator: EQ,
|
|
3047
|
+
value: "Breaking Bad"
|
|
3048
|
+
}
|
|
3049
|
+
seasons: {
|
|
3050
|
+
terms: [
|
|
3051
|
+
{
|
|
3052
|
+
path: "number",
|
|
3053
|
+
operator: EQ,
|
|
3054
|
+
value: 1
|
|
3055
|
+
}
|
|
3056
|
+
]
|
|
3057
|
+
}
|
|
3058
|
+
) {
|
|
3059
|
+
id
|
|
3060
|
+
name
|
|
3061
|
+
director {
|
|
3062
|
+
name
|
|
3063
|
+
country
|
|
3064
|
+
}
|
|
3065
|
+
seasons {
|
|
3066
|
+
number
|
|
3067
|
+
episodes {
|
|
3068
|
+
name
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
```
|
|
3074
|
+
|
|
3075
|
+
### 6. Complex Nested Queries
|
|
3076
|
+
|
|
3077
|
+
Get complete information for a specific series:
|
|
3078
|
+
|
|
3079
|
+
```graphql
|
|
3080
|
+
query {
|
|
3081
|
+
series(name: {
|
|
3082
|
+
operator: EQ,
|
|
3083
|
+
value: "Breaking Bad"
|
|
3084
|
+
}) {
|
|
3085
|
+
id
|
|
3086
|
+
name
|
|
3087
|
+
categories
|
|
3088
|
+
director {
|
|
3089
|
+
name
|
|
3090
|
+
country
|
|
3091
|
+
}
|
|
3092
|
+
seasons {
|
|
3093
|
+
number
|
|
3094
|
+
year
|
|
3095
|
+
state
|
|
3096
|
+
episodes {
|
|
3097
|
+
number
|
|
3098
|
+
name
|
|
3099
|
+
date
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
```
|
|
3105
|
+
|
|
3106
|
+
### 7. Episodes from a Specific Season and Series
|
|
3107
|
+
|
|
3108
|
+
Find all episodes from Season 1 of Breaking Bad:
|
|
3109
|
+
|
|
3110
|
+
```graphql
|
|
3111
|
+
query {
|
|
3112
|
+
episodes(season: {
|
|
3113
|
+
terms: [
|
|
3114
|
+
{
|
|
3115
|
+
path: "number",
|
|
3116
|
+
operator: EQ,
|
|
3117
|
+
value: 1
|
|
3118
|
+
},
|
|
3119
|
+
{
|
|
3120
|
+
path: "serie.name",
|
|
3121
|
+
operator: EQ,
|
|
3122
|
+
value: "Breaking Bad"
|
|
3123
|
+
}
|
|
3124
|
+
]
|
|
3125
|
+
}) {
|
|
3126
|
+
id
|
|
3127
|
+
number
|
|
3128
|
+
name
|
|
3129
|
+
date
|
|
3130
|
+
season {
|
|
3131
|
+
number
|
|
3132
|
+
serie {
|
|
3133
|
+
name
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
```
|
|
3139
|
+
|
|
3140
|
+
### 8. Series by Category
|
|
3141
|
+
|
|
3142
|
+
Find all crime series:
|
|
3143
|
+
|
|
3144
|
+
```graphql
|
|
3145
|
+
query {
|
|
3146
|
+
series(categories: {
|
|
3147
|
+
operator: EQ,
|
|
3148
|
+
value: "Crime"
|
|
3149
|
+
}) {
|
|
3150
|
+
id
|
|
3151
|
+
name
|
|
3152
|
+
categories
|
|
3153
|
+
director {
|
|
3154
|
+
name
|
|
3155
|
+
country
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
```
|
|
3160
|
+
|
|
3161
|
+
### 9. Search by Partial Episode Name
|
|
3162
|
+
|
|
3163
|
+
Find episodes containing "Fire" in the name:
|
|
3164
|
+
|
|
3165
|
+
```graphql
|
|
3166
|
+
query {
|
|
3167
|
+
episodes(name: {
|
|
3168
|
+
operator: LIKE,
|
|
3169
|
+
value: "Fire"
|
|
3170
|
+
}) {
|
|
3171
|
+
id
|
|
3172
|
+
number
|
|
3173
|
+
name
|
|
3174
|
+
date
|
|
3175
|
+
season {
|
|
3176
|
+
number
|
|
3177
|
+
serie {
|
|
3178
|
+
name
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
```
|
|
3184
|
+
|
|
3185
|
+
### 10. Pagination
|
|
3186
|
+
|
|
3187
|
+
Simfinity.js supports built-in pagination with optional total count:
|
|
3188
|
+
|
|
3189
|
+
```graphql
|
|
3190
|
+
query {
|
|
3191
|
+
series(
|
|
3192
|
+
categories: {
|
|
3193
|
+
operator: EQ,
|
|
3194
|
+
value: "Crime"
|
|
3195
|
+
}
|
|
3196
|
+
pagination: {
|
|
3197
|
+
page: 1,
|
|
3198
|
+
size: 2,
|
|
3199
|
+
count: true
|
|
3200
|
+
}
|
|
3201
|
+
) {
|
|
3202
|
+
id
|
|
3203
|
+
name
|
|
3204
|
+
categories
|
|
3205
|
+
director {
|
|
3206
|
+
name
|
|
3207
|
+
country
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
```
|
|
3212
|
+
|
|
3213
|
+
#### Pagination Parameters:
|
|
3214
|
+
- **page**: Page number (starts at 1, not 0)
|
|
3215
|
+
- **size**: Number of items per page
|
|
3216
|
+
- **count**: Optional boolean - if `true`, returns total count of matching records
|
|
3217
|
+
|
|
3218
|
+
#### Getting Total Count:
|
|
3219
|
+
When `count: true` is specified, the total count is available in the response extensions. You need to configure a plugin to expose it. Simfinity.js provides utility plugins for both Apollo Server and Envelop:
|
|
3220
|
+
|
|
3221
|
+
```javascript
|
|
3222
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
3223
|
+
|
|
3224
|
+
// For Envelop
|
|
3225
|
+
const getEnveloped = envelop({
|
|
3226
|
+
plugins: [
|
|
3227
|
+
useSchema(schema),
|
|
3228
|
+
simfinity.plugins.envelopCountPlugin(),
|
|
3229
|
+
],
|
|
3230
|
+
});
|
|
3231
|
+
|
|
3232
|
+
// For Apollo Server
|
|
3233
|
+
const server = new ApolloServer({
|
|
3234
|
+
schema,
|
|
3235
|
+
plugins: [
|
|
3236
|
+
simfinity.plugins.apolloCountPlugin(),
|
|
3237
|
+
],
|
|
3238
|
+
});
|
|
3239
|
+
```
|
|
3240
|
+
|
|
3241
|
+
See the [Plugins for Count in Extensions](#-plugins-for-count-in-extensions) section for complete examples.
|
|
3242
|
+
|
|
3243
|
+
#### Example Response:
|
|
3244
|
+
```json
|
|
3245
|
+
{
|
|
3246
|
+
"data": {
|
|
3247
|
+
"series": [
|
|
3248
|
+
{
|
|
3249
|
+
"id": "1",
|
|
3250
|
+
"name": "Breaking Bad",
|
|
3251
|
+
"categories": ["Crime", "Drama"],
|
|
3252
|
+
"director": {
|
|
3253
|
+
"name": "Vince Gilligan",
|
|
3254
|
+
"country": "United States"
|
|
3255
|
+
}
|
|
3256
|
+
},
|
|
3257
|
+
{
|
|
3258
|
+
"id": "2",
|
|
3259
|
+
"name": "Better Call Saul",
|
|
3260
|
+
"categories": ["Crime", "Drama"],
|
|
3261
|
+
"director": {
|
|
3262
|
+
"name": "Vince Gilligan",
|
|
3263
|
+
"country": "United States"
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
]
|
|
3267
|
+
},
|
|
3268
|
+
"extensions": {
|
|
3269
|
+
"count": 15
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
```
|
|
3273
|
+
|
|
3274
|
+
### 11. Sorting
|
|
3275
|
+
|
|
3276
|
+
Simfinity.js supports sorting with multiple fields and sort orders:
|
|
3277
|
+
|
|
3278
|
+
```graphql
|
|
3279
|
+
query {
|
|
3280
|
+
series(
|
|
3281
|
+
categories: { operator: EQ, value: "Crime" }
|
|
3282
|
+
pagination: { page: 1, size: 5, count: true }
|
|
3283
|
+
sort: {
|
|
3284
|
+
terms: [
|
|
3285
|
+
{
|
|
3286
|
+
field: "name",
|
|
3287
|
+
order: DESC
|
|
3288
|
+
}
|
|
3289
|
+
]
|
|
3290
|
+
}
|
|
3291
|
+
) {
|
|
3292
|
+
id
|
|
3293
|
+
name
|
|
3294
|
+
categories
|
|
3295
|
+
director {
|
|
3296
|
+
name
|
|
3297
|
+
country
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
```
|
|
3302
|
+
|
|
3303
|
+
#### Sorting Parameters:
|
|
3304
|
+
- **sort**: Contains sorting configuration
|
|
3305
|
+
- **terms**: Array of sort criteria (allows multiple sort fields)
|
|
3306
|
+
- **field**: The field name to sort by
|
|
3307
|
+
- **order**: Sort order - `ASC` (ascending) or `DESC` (descending)
|
|
3308
|
+
|
|
3309
|
+
#### Sorting by Nested Fields:
|
|
3310
|
+
You can sort by fields from related/nested objects using dot notation:
|
|
3311
|
+
|
|
3312
|
+
```graphql
|
|
3313
|
+
query {
|
|
3314
|
+
series(
|
|
3315
|
+
categories: { operator: EQ, value: "Drama" }
|
|
3316
|
+
pagination: { page: 1, size: 5, count: true }
|
|
3317
|
+
sort: {
|
|
3318
|
+
terms: [
|
|
3319
|
+
{
|
|
3320
|
+
field: "director.name",
|
|
3321
|
+
order: DESC
|
|
3322
|
+
}
|
|
3323
|
+
]
|
|
3324
|
+
}
|
|
3325
|
+
) {
|
|
3326
|
+
id
|
|
3327
|
+
name
|
|
3328
|
+
categories
|
|
3329
|
+
director {
|
|
3330
|
+
name
|
|
3331
|
+
country
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
```
|
|
3336
|
+
|
|
3337
|
+
#### Multiple Sort Fields:
|
|
3338
|
+
You can sort by multiple fields with different orders:
|
|
3339
|
+
|
|
3340
|
+
```graphql
|
|
3341
|
+
query {
|
|
3342
|
+
series(
|
|
3343
|
+
sort: {
|
|
3344
|
+
terms: [
|
|
3345
|
+
{ field: "director.country", order: ASC },
|
|
3346
|
+
{ field: "name", order: DESC }
|
|
3347
|
+
]
|
|
3348
|
+
}
|
|
3349
|
+
) {
|
|
3350
|
+
id
|
|
3351
|
+
name
|
|
3352
|
+
director {
|
|
3353
|
+
name
|
|
3354
|
+
country
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
```
|
|
3359
|
+
|
|
3360
|
+
#### Combining Features:
|
|
3361
|
+
The example above demonstrates combining **filtering**, **pagination**, and **sorting** in a single query - a common pattern for data tables and lists with full functionality.
|
|
3362
|
+
|
|
3363
|
+
### 12. Series Released in a Specific Year Range
|
|
3364
|
+
|
|
3365
|
+
Find series with seasons released between 2010-2015:
|
|
3366
|
+
|
|
3367
|
+
```graphql
|
|
3368
|
+
query {
|
|
3369
|
+
seasons(year: {
|
|
3370
|
+
operator: BETWEEN,
|
|
3371
|
+
value: [2010, 2015]
|
|
3372
|
+
}) {
|
|
3373
|
+
id
|
|
3374
|
+
number
|
|
3375
|
+
year
|
|
3376
|
+
serie {
|
|
3377
|
+
name
|
|
3378
|
+
director {
|
|
3379
|
+
name
|
|
3380
|
+
country
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
```
|
|
3386
|
+
|
|
3387
|
+
|
|
3388
|
+
## ๐ State Machine Example from Series-Sample
|
|
3389
|
+
|
|
3390
|
+
Simfinity.js provides built-in state machine support for managing entity lifecycles. Here's an example of how a state machine is implemented in the Season entity from the series-sample project.
|
|
3391
|
+
|
|
3392
|
+
### State Machine Configuration
|
|
3393
|
+
|
|
3394
|
+
State machines require **GraphQL Enum Types** to define states and proper state references:
|
|
3395
|
+
|
|
3396
|
+
**Step 1: Define the GraphQL Enum Type**
|
|
3397
|
+
|
|
3398
|
+
```javascript
|
|
3399
|
+
const { GraphQLEnumType } = require('graphql');
|
|
3400
|
+
|
|
3401
|
+
const seasonState = new GraphQLEnumType({
|
|
3402
|
+
name: 'seasonState',
|
|
3403
|
+
values: {
|
|
3404
|
+
SCHEDULED: { value: 'SCHEDULED' },
|
|
3405
|
+
ACTIVE: { value: 'ACTIVE' },
|
|
3406
|
+
FINISHED: { value: 'FINISHED' }
|
|
3407
|
+
}
|
|
3408
|
+
});
|
|
3409
|
+
```
|
|
3410
|
+
|
|
3411
|
+
**Step 2: Use Enum in GraphQL Object Type**
|
|
3412
|
+
|
|
3413
|
+
```javascript
|
|
3414
|
+
const seasonType = new GraphQLObjectType({
|
|
3415
|
+
name: 'season',
|
|
3416
|
+
fields: () => ({
|
|
3417
|
+
id: { type: GraphQLID },
|
|
3418
|
+
number: { type: GraphQLInt },
|
|
3419
|
+
year: { type: GraphQLInt },
|
|
3420
|
+
state: { type: seasonState }, // โ Use the enum type
|
|
3421
|
+
// ... other fields
|
|
3422
|
+
})
|
|
3423
|
+
});
|
|
3424
|
+
```
|
|
3425
|
+
|
|
3426
|
+
**Step 3: Define State Machine with Enum Values**
|
|
3427
|
+
|
|
3428
|
+
```javascript
|
|
3429
|
+
const stateMachine = {
|
|
3430
|
+
initialState: seasonState.getValue('SCHEDULED'),
|
|
3431
|
+
actions: {
|
|
3432
|
+
activate: {
|
|
3433
|
+
from: seasonState.getValue('SCHEDULED'),
|
|
3434
|
+
to: seasonState.getValue('ACTIVE'),
|
|
3435
|
+
action: async (params) => {
|
|
3436
|
+
console.log('Season activated:', JSON.stringify(params));
|
|
3437
|
+
}
|
|
3438
|
+
},
|
|
3439
|
+
finalize: {
|
|
3440
|
+
from: seasonState.getValue('ACTIVE'),
|
|
3441
|
+
to: seasonState.getValue('FINISHED'),
|
|
3442
|
+
action: async (params) => {
|
|
3443
|
+
console.log('Season finalized:', JSON.stringify(params));
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
};
|
|
3448
|
+
|
|
3449
|
+
// Connect type with state machine
|
|
3450
|
+
simfinity.connect(null, seasonType, 'season', 'seasons', null, null, stateMachine);
|
|
3451
|
+
```
|
|
3452
|
+
|
|
3453
|
+
### Season States
|
|
3454
|
+
|
|
3455
|
+
The Season entity has three states:
|
|
3456
|
+
|
|
3457
|
+
1. **SCHEDULED** - Initial state when season is created
|
|
3458
|
+
2. **ACTIVE** - Season is currently airing
|
|
3459
|
+
3. **FINISHED** - Season has completed airing
|
|
3460
|
+
|
|
3461
|
+
### State Transitions
|
|
3462
|
+
|
|
3463
|
+
**Available transitions:**
|
|
3464
|
+
- `activate`: SCHEDULED โ ACTIVE
|
|
3465
|
+
- `finalize`: ACTIVE โ FINISHED
|
|
3466
|
+
|
|
3467
|
+
### State Machine Mutations
|
|
3468
|
+
|
|
3469
|
+
Simfinity.js automatically generates state transition mutations:
|
|
3470
|
+
|
|
3471
|
+
```graphql
|
|
3472
|
+
# Activate a scheduled season
|
|
3473
|
+
mutation {
|
|
3474
|
+
activateseason(id: "season_id_here") {
|
|
3475
|
+
id
|
|
3476
|
+
number
|
|
3477
|
+
year
|
|
3478
|
+
state
|
|
3479
|
+
serie {
|
|
3480
|
+
name
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
```
|
|
3485
|
+
|
|
3486
|
+
```graphql
|
|
3487
|
+
# Finalize an active season
|
|
3488
|
+
mutation {
|
|
3489
|
+
finalizeseason(id: "season_id_here") {
|
|
3490
|
+
id
|
|
3491
|
+
number
|
|
3492
|
+
year
|
|
3493
|
+
state
|
|
3494
|
+
serie {
|
|
3495
|
+
name
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
```
|
|
3500
|
+
|
|
3501
|
+
### State Machine Features
|
|
3502
|
+
|
|
3503
|
+
**Validation:**
|
|
3504
|
+
- Only valid transitions are allowed
|
|
3505
|
+
- Attempting invalid transitions returns an error
|
|
3506
|
+
- State field is read-only (managed by state machine)
|
|
3507
|
+
|
|
3508
|
+
**Custom Actions:**
|
|
3509
|
+
- Each transition can execute custom business logic
|
|
3510
|
+
- Actions receive parameters including entity data
|
|
3511
|
+
- Actions can perform side effects (logging, notifications, etc.)
|
|
3512
|
+
|
|
3513
|
+
**Query by State:**
|
|
3514
|
+
```graphql
|
|
3515
|
+
query {
|
|
3516
|
+
seasons(state: {
|
|
3517
|
+
operator: EQ,
|
|
3518
|
+
value: ACTIVE
|
|
3519
|
+
}) {
|
|
3520
|
+
id
|
|
3521
|
+
number
|
|
3522
|
+
year
|
|
3523
|
+
state
|
|
3524
|
+
serie {
|
|
3525
|
+
name
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
```
|
|
3530
|
+
|
|
3531
|
+
### State Machine Best Practices
|
|
3532
|
+
|
|
3533
|
+
1. **GraphQL Enum Types**: Always define states as GraphQL enums for type safety
|
|
3534
|
+
2. **getValue() Method**: Use `enumType.getValue('VALUE')` for state machine configuration
|
|
3535
|
+
3. **Initial State**: Define clear initial state using enum values
|
|
3536
|
+
4. **Linear Flows**: Design logical progression (SCHEDULED โ ACTIVE โ FINISHED)
|
|
3537
|
+
5. **Type Safety**: GraphQL enums provide validation and autocomplete
|
|
3538
|
+
6. **Actions**: Implement side effects in transition actions
|
|
3539
|
+
7. **Error Handling**: Handle transition failures gracefully
|
|
3540
|
+
|
|
3541
|
+
### Key Implementation Points
|
|
3542
|
+
|
|
3543
|
+
- **Enum Definition**: States must be defined as `GraphQLEnumType`
|
|
3544
|
+
- **Type Reference**: Use the enum type in your GraphQL object: `state: { type: seasonState }`
|
|
3545
|
+
- **State Machine Values**: Reference enum values with `seasonState.getValue('STATE_NAME')`
|
|
3546
|
+
- **Automatic Validation**: GraphQL validates state values against the enum
|
|
3547
|
+
- **IDE Support**: Enum values provide autocomplete and type checking
|
|
3548
|
+
|
|
3549
|
+
### Example Workflow
|
|
3550
|
+
|
|
3551
|
+
```graphql
|
|
3552
|
+
# 1. Create season (automatically SCHEDULED)
|
|
3553
|
+
mutation {
|
|
3554
|
+
addseason(input: {
|
|
3555
|
+
number: 6
|
|
3556
|
+
year: 2024
|
|
3557
|
+
serie: "series_id_here"
|
|
3558
|
+
}) {
|
|
3559
|
+
id
|
|
3560
|
+
state # Will be "SCHEDULED"
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
# 2. Activate season when airing begins
|
|
3565
|
+
mutation {
|
|
3566
|
+
activateseason(id: "season_id_here") {
|
|
3567
|
+
id
|
|
3568
|
+
state # Will be "ACTIVE"
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
# 3. Finalize season when completed
|
|
3573
|
+
mutation {
|
|
3574
|
+
finalizeseason(id: "season_id_here") {
|
|
3575
|
+
id
|
|
3576
|
+
state # Will be "FINISHED"
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
```
|
|
3580
|
+
|
|
3581
|
+
|
|
3582
|
+
## ๐ฆ Plugins for Count in Extensions
|
|
3583
|
+
|
|
3584
|
+
To include the total count in the extensions of your GraphQL response, Simfinity.js provides utility plugins for both Apollo Server and Envelop. This is particularly useful for pagination and analytics.
|
|
3585
|
+
|
|
3586
|
+
### Envelop Plugin
|
|
3587
|
+
|
|
3588
|
+
Use `simfinity.plugins.envelopCountPlugin()` to add count to extensions when using Envelop:
|
|
3589
|
+
|
|
3590
|
+
```javascript
|
|
3591
|
+
const { envelop, useSchema } = require('@envelop/core');
|
|
3592
|
+
const { makeExecutableSchema } = require('@graphql-tools/schema');
|
|
3593
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
3594
|
+
|
|
3595
|
+
const schema = makeExecutableSchema({
|
|
3596
|
+
typeDefs,
|
|
3597
|
+
resolvers,
|
|
3598
|
+
});
|
|
3599
|
+
|
|
3600
|
+
const getEnveloped = envelop({
|
|
3601
|
+
plugins: [
|
|
3602
|
+
useSchema(schema),
|
|
3603
|
+
simfinity.plugins.envelopCountPlugin(), // Add the count plugin here
|
|
3604
|
+
],
|
|
3605
|
+
});
|
|
3606
|
+
|
|
3607
|
+
// Use getEnveloped in your server setup
|
|
3608
|
+
```
|
|
3609
|
+
|
|
3610
|
+
### Apollo Server Plugin
|
|
3611
|
+
|
|
3612
|
+
Use `simfinity.plugins.apolloCountPlugin()` to add count to extensions when using Apollo Server:
|
|
3613
|
+
|
|
3614
|
+
```javascript
|
|
3615
|
+
const { ApolloServer } = require('apollo-server-express');
|
|
3616
|
+
const simfinity = require('@simtlix/simfinity-js');
|
|
3617
|
+
|
|
3618
|
+
const server = new ApolloServer({
|
|
3619
|
+
schema,
|
|
3620
|
+
plugins: [
|
|
3621
|
+
simfinity.plugins.apolloCountPlugin(), // Add the count plugin here
|
|
3622
|
+
],
|
|
3623
|
+
context: ({ req }) => {
|
|
3624
|
+
// Your context setup
|
|
3625
|
+
return {
|
|
3626
|
+
user: req.user,
|
|
3627
|
+
// count will be automatically added to extensions if present in context
|
|
3628
|
+
};
|
|
3629
|
+
},
|
|
3630
|
+
});
|
|
3631
|
+
```
|
|
3632
|
+
|
|
3633
|
+
### How to Use
|
|
3634
|
+
|
|
3635
|
+
1. **Import the Plugin**: Use `simfinity.plugins.envelopCountPlugin()` or `simfinity.plugins.apolloCountPlugin()` depending on your GraphQL server.
|
|
3636
|
+
2. **Configure Context**: Ensure that your context includes the count value when executing queries (Simfinity.js automatically sets `context.count` when `count: true` is specified in pagination).
|
|
3637
|
+
3. **Access Count**: The count will be available in the `extensions` field of the GraphQL response.
|
|
3638
|
+
|
|
3639
|
+
### Example Response
|
|
3640
|
+
|
|
3641
|
+
When the plugin is correctly set up, your GraphQL response will include the count in the extensions:
|
|
3642
|
+
|
|
3643
|
+
```json
|
|
3644
|
+
{
|
|
3645
|
+
"data": {
|
|
3646
|
+
"series": [
|
|
3647
|
+
{
|
|
3648
|
+
"id": "1",
|
|
3649
|
+
"name": "Breaking Bad",
|
|
3650
|
+
"categories": ["Crime", "Drama"],
|
|
3651
|
+
"director": {
|
|
3652
|
+
"name": "Vince Gilligan",
|
|
3653
|
+
"country": "United States"
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
]
|
|
3657
|
+
},
|
|
3658
|
+
"extensions": {
|
|
3659
|
+
"count": 15
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
```
|
|
3663
|
+
|
|
3664
|
+
This setup allows you to efficiently manage and display pagination information in your GraphQL applications.
|
|
3665
|
+
|
|
3666
|
+
## ๐ API Reference
|
|
3667
|
+
|
|
3668
|
+
Simfinity.js provides several utility methods for programmatic access to your GraphQL types and data:
|
|
3669
|
+
|
|
3670
|
+
### `getType(typeName)`
|
|
3671
|
+
|
|
3672
|
+
Retrieves a GraphQL type definition from the internal types registry.
|
|
3673
|
+
|
|
3674
|
+
**Parameters:**
|
|
3675
|
+
- `typeName` (string | GraphQLObjectType): The name of the type or a GraphQL type object
|
|
3676
|
+
|
|
3677
|
+
**Returns:**
|
|
3678
|
+
- `GraphQLObjectType | null`: The GraphQL type definition, or null if not found
|
|
3679
|
+
|
|
3680
|
+
**Examples:**
|
|
3681
|
+
|
|
3682
|
+
```javascript
|
|
3683
|
+
import { getType } from '@simtlix/simfinity-js';
|
|
3684
|
+
|
|
3685
|
+
// Get type by string name
|
|
3686
|
+
const UserType = getType('User');
|
|
3687
|
+
if (UserType) {
|
|
3688
|
+
console.log(UserType.name); // 'User'
|
|
3689
|
+
|
|
3690
|
+
// Access field definitions
|
|
3691
|
+
const fields = UserType.getFields();
|
|
3692
|
+
console.log(Object.keys(fields)); // ['id', 'name', 'email', ...]
|
|
3693
|
+
|
|
3694
|
+
// Check specific field
|
|
3695
|
+
const nameField = fields.name;
|
|
3696
|
+
console.log(nameField.type); // GraphQLString
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
// Get type by GraphQL type object
|
|
3700
|
+
const BookType = getType(SomeBookType);
|
|
3701
|
+
|
|
3702
|
+
// Safe access - returns null if not found
|
|
3703
|
+
const nonExistentType = getType('NonExistent');
|
|
3704
|
+
console.log(nonExistentType); // null
|
|
3705
|
+
```
|
|
3706
|
+
|
|
3707
|
+
**Use Cases:**
|
|
3708
|
+
- **Type introspection**: Examine type definitions programmatically
|
|
3709
|
+
- **Dynamic schema analysis**: Build tools that analyze your GraphQL schema
|
|
3710
|
+
- **Runtime type checking**: Validate types exist before operations
|
|
3711
|
+
- **Admin interfaces**: Build dynamic forms based on type definitions
|
|
3712
|
+
- **Circular reference resolution**: Prevent import cycles when types reference each other
|
|
3713
|
+
|
|
3714
|
+
### Preventing Circular References with `getType`
|
|
3715
|
+
|
|
3716
|
+
When you have types that reference each other (like User and Group), using `getType` prevents circular import issues:
|
|
3717
|
+
|
|
3718
|
+
```javascript
|
|
3719
|
+
import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
|
|
3720
|
+
import { getType } from '@simtlix/simfinity-js';
|
|
3721
|
+
|
|
3722
|
+
// User type that references Group
|
|
3723
|
+
const UserType = new GraphQLObjectType({
|
|
3724
|
+
name: 'User',
|
|
3725
|
+
fields: () => ({
|
|
3726
|
+
id: { type: GraphQLID },
|
|
3727
|
+
name: { type: GraphQLString },
|
|
3728
|
+
email: { type: GraphQLString },
|
|
3729
|
+
|
|
3730
|
+
// Reference Group type by name to avoid circular imports
|
|
3731
|
+
groups: {
|
|
3732
|
+
type: new GraphQLList(() => getType('Group')), // Use getType instead of direct import
|
|
3733
|
+
extensions: {
|
|
3734
|
+
relation: {
|
|
3735
|
+
connectionField: 'members',
|
|
3736
|
+
displayField: 'name'
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
},
|
|
3740
|
+
|
|
3741
|
+
// Single group reference
|
|
3742
|
+
primaryGroup: {
|
|
3743
|
+
type: () => getType('Group'), // Lazy evaluation with getType
|
|
3744
|
+
extensions: {
|
|
3745
|
+
relation: {
|
|
3746
|
+
connectionField: 'primaryGroupId',
|
|
3747
|
+
displayField: 'name'
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
})
|
|
3752
|
+
});
|
|
3753
|
+
|
|
3754
|
+
// Group type that references User
|
|
3755
|
+
const GroupType = new GraphQLObjectType({
|
|
3756
|
+
name: 'Group',
|
|
3757
|
+
fields: () => ({
|
|
3758
|
+
id: { type: GraphQLID },
|
|
3759
|
+
name: { type: GraphQLString },
|
|
3760
|
+
description: { type: GraphQLString },
|
|
3761
|
+
|
|
3762
|
+
// Reference User type by name to avoid circular imports
|
|
3763
|
+
members: {
|
|
3764
|
+
type: new GraphQLList(() => getType('User')), // Use getType instead of direct import
|
|
3765
|
+
extensions: {
|
|
3766
|
+
relation: {
|
|
3767
|
+
connectionField: 'groups',
|
|
3768
|
+
displayField: 'name'
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
},
|
|
3772
|
+
|
|
3773
|
+
// Single user reference (admin)
|
|
3774
|
+
admin: {
|
|
3775
|
+
type: () => getType('User'), // Lazy evaluation with getType
|
|
3776
|
+
extensions: {
|
|
3777
|
+
relation: {
|
|
3778
|
+
connectionField: 'adminId',
|
|
3779
|
+
displayField: 'name'
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
})
|
|
3784
|
+
});
|
|
3785
|
+
|
|
3786
|
+
// Register types with simfinity
|
|
3787
|
+
simfinity.connect(null, UserType, 'user', 'users');
|
|
3788
|
+
simfinity.connect(null, GroupType, 'group', 'groups');
|
|
3789
|
+
|
|
3790
|
+
// Create schema - resolvers will be auto-generated for all relationships
|
|
3791
|
+
const schema = simfinity.createSchema();
|
|
3792
|
+
```
|
|
3793
|
+
|
|
3794
|
+
**Benefits of this approach:**
|
|
3795
|
+
|
|
3796
|
+
1. **๐ No Circular Imports**: Each file can import `getType` without importing other type definitions
|
|
3797
|
+
2. **โก Lazy Resolution**: Types are resolved at schema creation time when all types are registered
|
|
3798
|
+
3. **๐ก๏ธ Type Safety**: Still maintains GraphQL type checking and validation
|
|
3799
|
+
4. **๐งน Clean Architecture**: Separates type definitions from type relationships
|
|
3800
|
+
5. **๐ฆ Better Modularity**: Each type can be in its own file without import dependencies
|
|
3801
|
+
|
|
3802
|
+
**File Structure Example:**
|
|
3803
|
+
|
|
3804
|
+
```
|
|
3805
|
+
types/
|
|
3806
|
+
โโโ User.js // Defines UserType using getType('Group')
|
|
3807
|
+
โโโ Group.js // Defines GroupType using getType('User')
|
|
3808
|
+
โโโ index.js // Registers all types and creates schema
|
|
3809
|
+
```
|
|
3810
|
+
|
|
3811
|
+
```javascript
|
|
3812
|
+
// types/User.js
|
|
3813
|
+
import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
|
|
3814
|
+
import { getType } from '@simtlix/simfinity-js';
|
|
3815
|
+
|
|
3816
|
+
export const UserType = new GraphQLObjectType({
|
|
3817
|
+
name: 'User',
|
|
3818
|
+
fields: () => ({
|
|
3819
|
+
id: { type: GraphQLID },
|
|
3820
|
+
name: { type: GraphQLString },
|
|
3821
|
+
groups: {
|
|
3822
|
+
type: new GraphQLList(() => getType('Group')),
|
|
3823
|
+
extensions: { relation: { connectionField: 'members' } }
|
|
3824
|
+
}
|
|
3825
|
+
})
|
|
3826
|
+
});
|
|
3827
|
+
|
|
3828
|
+
// types/Group.js
|
|
3829
|
+
import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
|
|
3830
|
+
import { getType } from '@simtlix/simfinity-js';
|
|
3831
|
+
|
|
3832
|
+
export const GroupType = new GraphQLObjectType({
|
|
3833
|
+
name: 'Group',
|
|
3834
|
+
fields: () => ({
|
|
3835
|
+
id: { type: GraphQLID },
|
|
3836
|
+
name: { type: GraphQLString },
|
|
3837
|
+
members: {
|
|
3838
|
+
type: new GraphQLList(() => getType('User')),
|
|
3839
|
+
extensions: { relation: { connectionField: 'groups' } }
|
|
3840
|
+
}
|
|
3841
|
+
})
|
|
3842
|
+
});
|
|
3843
|
+
|
|
3844
|
+
// types/index.js
|
|
3845
|
+
import { UserType } from './User.js';
|
|
3846
|
+
import { GroupType } from './Group.js';
|
|
3847
|
+
import simfinity from '@simtlix/simfinity-js';
|
|
3848
|
+
|
|
3849
|
+
// Register all types
|
|
3850
|
+
simfinity.connect(null, UserType, 'user', 'users');
|
|
3851
|
+
simfinity.connect(null, GroupType, 'group', 'groups');
|
|
3852
|
+
|
|
3853
|
+
// Create schema with auto-generated resolvers
|
|
3854
|
+
export const schema = simfinity.createSchema();
|
|
3855
|
+
```
|
|
3856
|
+
|
|
3857
|
+
### `getModel(gqltype)`
|
|
3858
|
+
|
|
3859
|
+
Retrieves the Mongoose model associated with a GraphQL type.
|
|
3860
|
+
|
|
3861
|
+
**Parameters:**
|
|
3862
|
+
- `gqltype` (GraphQLObjectType): The GraphQL type object
|
|
3863
|
+
|
|
3864
|
+
**Returns:**
|
|
3865
|
+
- `MongooseModel`: The associated Mongoose model
|
|
3866
|
+
|
|
3867
|
+
**Example:**
|
|
3868
|
+
|
|
3869
|
+
```javascript
|
|
3870
|
+
const BookModel = simfinity.getModel(BookType);
|
|
3871
|
+
const books = await BookModel.find({ author: 'Douglas Adams' });
|
|
3872
|
+
```
|
|
3873
|
+
|
|
3874
|
+
### `getInputType(type)`
|
|
3875
|
+
|
|
3876
|
+
Retrieves the input type for mutations associated with a GraphQL type.
|
|
3877
|
+
|
|
3878
|
+
**Parameters:**
|
|
3879
|
+
- `type` (GraphQLObjectType): The GraphQL type object
|
|
3880
|
+
|
|
3881
|
+
**Returns:**
|
|
3882
|
+
- `GraphQLInputObjectType`: The input type for mutations
|
|
3883
|
+
|
|
3884
|
+
**Example:**
|
|
3885
|
+
|
|
3886
|
+
```javascript
|
|
3887
|
+
const BookInput = simfinity.getInputType(BookType);
|
|
3888
|
+
console.log(BookInput.getFields()); // Input fields for mutations
|
|
3889
|
+
```
|
|
3890
|
+
|
|
3891
|
+
### `saveObject(typeName, args, session?, context?)`
|
|
3892
|
+
|
|
3893
|
+
Programmatically save an object outside of GraphQL mutations.
|
|
3894
|
+
|
|
3895
|
+
**Parameters:**
|
|
3896
|
+
- `typeName` (string): The name of the GraphQL type
|
|
3897
|
+
- `args` (object): The data to save
|
|
3898
|
+
- `session` (MongooseSession, optional): Database session for transactions
|
|
3899
|
+
- `context` (object, optional): GraphQL context object (includes request info, user data, etc.)
|
|
3900
|
+
|
|
3901
|
+
**Returns:**
|
|
3902
|
+
- `Promise<object>`: The saved object
|
|
3903
|
+
|
|
3904
|
+
**Example:**
|
|
3905
|
+
|
|
3906
|
+
```javascript
|
|
3907
|
+
const newBook = await simfinity.saveObject('Book', {
|
|
3908
|
+
title: 'New Book',
|
|
3909
|
+
author: 'Author Name'
|
|
3910
|
+
}, session, context);
|
|
3911
|
+
|
|
3912
|
+
// Without context (context will be undefined in controller hooks)
|
|
3913
|
+
const newBook = await simfinity.saveObject('Book', {
|
|
3914
|
+
title: 'New Book',
|
|
3915
|
+
author: 'Author Name'
|
|
3916
|
+
}, session);
|
|
3917
|
+
```
|
|
3918
|
+
|
|
3919
|
+
**Note**: When `context` is not provided, it will be `undefined` in controller hooks. This is acceptable for programmatic usage where context may not be available.
|
|
3920
|
+
|
|
3921
|
+
### `createSchema(includedQueryTypes?, includedMutationTypes?, includedCustomMutations?)`
|
|
3922
|
+
|
|
3923
|
+
Creates the final GraphQL schema with all connected types.
|
|
3924
|
+
|
|
3925
|
+
**Parameters:**
|
|
3926
|
+
- `includedQueryTypes` (array, optional): Limit query types to include
|
|
3927
|
+
- `includedMutationTypes` (array, optional): Limit mutation types to include
|
|
3928
|
+
- `includedCustomMutations` (array, optional): Limit custom mutations to include
|
|
3929
|
+
|
|
3930
|
+
**Returns:**
|
|
3931
|
+
- `GraphQLSchema`: The complete GraphQL schema
|
|
3932
|
+
|
|
3933
|
+
**Example:**
|
|
3934
|
+
|
|
3935
|
+
```javascript
|
|
3936
|
+
const schema = simfinity.createSchema();
|
|
3937
|
+
```
|
|
3938
|
+
|
|
3939
|
+
*Built with โค๏ธ by [Simtlix](https://github.com/simtlix)*
|
|
3940
|
+
|
|
3941
|
+
|