@appweaver/cli 1.1.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/generate/generate-schema.js +125 -24
- package/generate/generate-types.js +29 -7
- package/package.json +1 -1
- package/skill/GUIDELINES.md +1 -1
- package/skill/SKILL.md +106 -0
- package/skill/references/client.md +13 -8
- package/skill/references/resources.md +321 -60
- package/skill/references/security.md +1 -1
|
@@ -21,6 +21,14 @@ const utils_1 = require("../utils");
|
|
|
21
21
|
async function generateSchema(models, schemaPath, clientPath, quiet = false) {
|
|
22
22
|
const cwd = process.cwd();
|
|
23
23
|
try {
|
|
24
|
+
const relationErrors = validateRelations(models);
|
|
25
|
+
if (relationErrors.length > 0) {
|
|
26
|
+
for (const relationError of relationErrors) {
|
|
27
|
+
console.error(relationError);
|
|
28
|
+
}
|
|
29
|
+
console.error('Schema generation failed due to inconsistent relation definitions.');
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
24
32
|
await (0, utils_1.ensureDirExists)(node_path_1.default.join(cwd, schemaPath));
|
|
25
33
|
const prismaModels = {};
|
|
26
34
|
const prismaEnums = {};
|
|
@@ -80,37 +88,67 @@ async function generateSchema(models, schemaPath, clientPath, quiet = false) {
|
|
|
80
88
|
continue;
|
|
81
89
|
}
|
|
82
90
|
const relationConfig = modelSchema?.config.relations?.[relation.name];
|
|
91
|
+
if (!relationConfig) {
|
|
92
|
+
// Skip auto-generated back reference fields added by other models
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
83
95
|
const referencedName = relation.type
|
|
84
96
|
.replaceAll('[]', '')
|
|
85
97
|
.replaceAll('?', '');
|
|
86
98
|
const referencedModel = prismaModels[referencedName];
|
|
87
|
-
const mappedField = referencedModel.relations.find((r) => r.name === relationConfig
|
|
99
|
+
const mappedField = referencedModel.relations.find((r) => r.name === relationConfig.mappedBy);
|
|
88
100
|
if (mappedField?.type.startsWith('Int')) {
|
|
89
101
|
continue;
|
|
90
102
|
}
|
|
91
103
|
if (mappedField && mappedField.attributes?.length) {
|
|
92
|
-
// Ensure that mapped fields have the same reference name
|
|
104
|
+
// Ensure that mapped fields have the same reference name. The name of
|
|
105
|
+
// the relation is replaced while any foreign key configuration on the
|
|
106
|
+
// owning side is preserved.
|
|
93
107
|
const relationAttribute = mappedField.attributes[0];
|
|
94
108
|
const relationParts = relationAttribute.split('"');
|
|
95
109
|
const referenceName = relation.attributes?.[0].split('"')[1];
|
|
96
110
|
relationParts.splice(1, 1, `${referenceName}`);
|
|
97
|
-
|
|
98
|
-
mappedField.attributes[0] = `@relation("${referenceName}")`;
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
mappedField.attributes[0] = relationParts.join('"');
|
|
102
|
-
}
|
|
111
|
+
mappedField.attributes[0] = relationParts.join('"');
|
|
103
112
|
}
|
|
104
113
|
else {
|
|
105
|
-
// For unmapped relations add a default
|
|
106
|
-
// reference name
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
// For unmapped relations add a default back reference field matching
|
|
115
|
+
// the relation type, using the same reference name
|
|
116
|
+
const referenceName = relation.attributes?.[0].split('"')[1];
|
|
117
|
+
const owner = (0, common_1.isRelationOwner)(relationConfig);
|
|
118
|
+
if (relationConfig.type === 'manyToMany' || owner) {
|
|
119
|
+
// Inverse of a manyToMany relation or of an owning side: for
|
|
120
|
+
// oneToOne a single optional field, otherwise a list field
|
|
121
|
+
const single = relationConfig.type === 'oneToOne';
|
|
122
|
+
const refName = single
|
|
123
|
+
? (0, common_1.uncapitalize)(name)
|
|
124
|
+
: (0, common_1.uncapitalize)((0, common_1.plural)(name));
|
|
125
|
+
if (!referencedModel.relations.some((r) => r.name === refName)) {
|
|
126
|
+
referencedModel.relations.push({
|
|
127
|
+
name: refName,
|
|
128
|
+
type: single ? `${name}?` : `${name}[]`,
|
|
129
|
+
attributes: [`@relation("${referenceName}")`]
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// A non-owning side declared alone: the referenced model holds the
|
|
135
|
+
// foreign key column
|
|
136
|
+
const refName = (0, common_1.uncapitalize)(name);
|
|
137
|
+
const refFieldName = `${refName}Id`;
|
|
138
|
+
if (!referencedModel.relations.some((r) => r.name === refName)) {
|
|
139
|
+
referencedModel.relations.push({
|
|
140
|
+
name: refName,
|
|
141
|
+
type: `${name}?`,
|
|
142
|
+
attributes: [
|
|
143
|
+
`@relation("${referenceName}", fields: [${refFieldName}], references: [id])`
|
|
144
|
+
]
|
|
145
|
+
});
|
|
146
|
+
referencedModel.relations.push({
|
|
147
|
+
name: refFieldName,
|
|
148
|
+
type: 'Int?',
|
|
149
|
+
attributes: relationConfig.type === 'oneToOne' ? ['@unique'] : []
|
|
150
|
+
});
|
|
151
|
+
}
|
|
114
152
|
}
|
|
115
153
|
}
|
|
116
154
|
}
|
|
@@ -339,6 +377,59 @@ function createScalarSchema(name, modelName, scalar) {
|
|
|
339
377
|
attributes
|
|
340
378
|
};
|
|
341
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Validates the consistency of every bidirectional relation pair linked through
|
|
382
|
+
* `mappedBy`. Both sides must declare the same relation type, reference each
|
|
383
|
+
* other's model, and for `oneToOne` and `oneToMany` relations exactly one side
|
|
384
|
+
* must be the owner. Single-sided relations (where the mapped field does not
|
|
385
|
+
* exist) are left to the back reference generation and are not validated.
|
|
386
|
+
*
|
|
387
|
+
* @param {Record<string, ResourceModel>} models - All resource models keyed by name.
|
|
388
|
+
* @return {string[]} A list of human-readable error messages, empty when all
|
|
389
|
+
* relation pairs are consistent.
|
|
390
|
+
*/
|
|
391
|
+
function validateRelations(models) {
|
|
392
|
+
const errors = [];
|
|
393
|
+
const visited = new Set();
|
|
394
|
+
for (const [name, model] of Object.entries(models)) {
|
|
395
|
+
const relations = model.config?.relations ?? {};
|
|
396
|
+
for (const [fieldName, relation] of Object.entries(relations)) {
|
|
397
|
+
if (!relation.mappedBy) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const mapped = models[relation.model]?.config?.relations?.[relation.mappedBy];
|
|
401
|
+
if (!mapped) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const relationLabel = `${name}.${fieldName}`;
|
|
405
|
+
const mappedLabel = `${relation.model}.${relation.mappedBy}`;
|
|
406
|
+
// Each pair is reachable from both of its sides, so report it only once
|
|
407
|
+
const pairKey = [relationLabel, mappedLabel].sort().join('|');
|
|
408
|
+
if (visited.has(pairKey)) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
visited.add(pairKey);
|
|
412
|
+
if (mapped.model !== name) {
|
|
413
|
+
errors.push(`Relation '${relationLabel}' is mapped by '${mappedLabel}', which references model '${mapped.model}' instead of '${name}'.`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (mapped.type !== relation.type) {
|
|
417
|
+
errors.push(`Relation type mismatch: '${relationLabel}' is declared as '${relation.type}' but '${mappedLabel}' is declared as '${mapped.type}'.`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (relation.type !== 'manyToMany') {
|
|
421
|
+
const ownerCount = [relation, mapped].filter(common_1.isRelationOwner).length;
|
|
422
|
+
if (ownerCount === 0) {
|
|
423
|
+
errors.push(`Relation owner missing: neither '${relationLabel}' nor '${mappedLabel}' declares 'owner: true' for the '${relation.type}' relation.`);
|
|
424
|
+
}
|
|
425
|
+
else if (ownerCount === 2) {
|
|
426
|
+
errors.push(`Relation owner conflict: both '${relationLabel}' and '${mappedLabel}' declare 'owner: true' for the '${relation.type}' relation.`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return errors;
|
|
432
|
+
}
|
|
342
433
|
function createRelationsSchema(modelName, relations = {}) {
|
|
343
434
|
const fields = [];
|
|
344
435
|
for (const [name, relation] of Object.entries(relations)) {
|
|
@@ -351,13 +442,20 @@ function createRelationSchema(name, modelName, relation) {
|
|
|
351
442
|
const relationName = `${modelName}${(0, common_1.capitalize)(name)}${relation.model}`;
|
|
352
443
|
const relationFieldName = `${name}Id`;
|
|
353
444
|
const relationSuffix = relation.required === false ? '?' : '';
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
|
|
445
|
+
const array = (0, common_1.isRelationArray)(relation);
|
|
446
|
+
const owner = (0, common_1.isRelationOwner)(relation);
|
|
447
|
+
let type;
|
|
448
|
+
if (array) {
|
|
449
|
+
type = `${relation.model}[]`;
|
|
450
|
+
}
|
|
451
|
+
else if (owner) {
|
|
452
|
+
type = `${relation.model}${relationSuffix}`;
|
|
359
453
|
}
|
|
360
454
|
else {
|
|
455
|
+
// The inverse side of a oneToOne relation must be optional in Prisma
|
|
456
|
+
type = `${relation.model}?`;
|
|
457
|
+
}
|
|
458
|
+
if (owner) {
|
|
361
459
|
const referentialActions = [];
|
|
362
460
|
if (relation.onDelete) {
|
|
363
461
|
referentialActions.push(`onDelete: ${(0, common_1.capitalize)(relation.onDelete)}`);
|
|
@@ -368,6 +466,9 @@ function createRelationSchema(name, modelName, relation) {
|
|
|
368
466
|
const referentialConfig = referentialActions.length > 0 ? `, ${referentialActions.join(', ')}` : '';
|
|
369
467
|
attributes.push(`@relation("${relationName}", fields: [${relationFieldName}], references: [id]${referentialConfig})`);
|
|
370
468
|
}
|
|
469
|
+
else {
|
|
470
|
+
attributes.push(`@relation("${relationName}")`);
|
|
471
|
+
}
|
|
371
472
|
const relationFields = [
|
|
372
473
|
{
|
|
373
474
|
name,
|
|
@@ -375,11 +476,11 @@ function createRelationSchema(name, modelName, relation) {
|
|
|
375
476
|
attributes
|
|
376
477
|
}
|
|
377
478
|
];
|
|
378
|
-
if (
|
|
479
|
+
if (owner) {
|
|
379
480
|
relationFields.push({
|
|
380
481
|
name: relationFieldName,
|
|
381
482
|
type: `Int${relationSuffix}`,
|
|
382
|
-
attributes: relation.
|
|
483
|
+
attributes: relation.type === 'oneToOne' ? ['@unique'] : []
|
|
383
484
|
});
|
|
384
485
|
}
|
|
385
486
|
return relationFields;
|
|
@@ -24,23 +24,45 @@ async function generateTypes(models, typesPath, quiet = false) {
|
|
|
24
24
|
try {
|
|
25
25
|
await (0, utils_1.ensureDirExists)(node_path_1.default.join(cwd, typesPath));
|
|
26
26
|
const resourceModels = {};
|
|
27
|
+
// The generated type names of every model, in the order they are emitted,
|
|
28
|
+
// so each model group can be written out followed by its own query type
|
|
29
|
+
const modelTypeNames = {};
|
|
27
30
|
for (const [name, schema] of Object.entries(models)) {
|
|
28
31
|
if (schema.config.generateTypes === false) {
|
|
29
32
|
continue;
|
|
30
33
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const modelSchemas = [
|
|
35
|
+
[name, schema.readModel],
|
|
36
|
+
[`${name}Single`, schema.readOneModel],
|
|
37
|
+
[`${name}Multiple`, schema.readManyModel],
|
|
38
|
+
[`${name}Create`, schema.createOneModel],
|
|
39
|
+
[`${name}Update`, schema.updateOneModel],
|
|
40
|
+
[`${name}RelationCreate`, schema.relationCreateModel],
|
|
41
|
+
[`${name}RelationUpdate`, schema.relationUpdateModel],
|
|
42
|
+
[`${name}RelationInput`, schema.relationInputModel]
|
|
43
|
+
];
|
|
44
|
+
for (const [typeName, typeSchema] of modelSchemas) {
|
|
45
|
+
resourceModels[typeName] = transformUnsafeTypes(typeSchema);
|
|
46
|
+
}
|
|
47
|
+
modelTypeNames[name] = modelSchemas.map(([typeName]) => typeName);
|
|
36
48
|
}
|
|
37
49
|
const module = typebox_1.Type.Module(resourceModels);
|
|
38
50
|
const typesContent = [
|
|
39
51
|
`// Generated by Appweaver. Please do not edit this file manually.`,
|
|
40
52
|
``
|
|
41
53
|
];
|
|
42
|
-
|
|
43
|
-
typesContent.push(
|
|
54
|
+
if (Object.keys(modelTypeNames).length > 0) {
|
|
55
|
+
typesContent.push(`import { AggregateSelect, QueryFilter, QuerySort } from '@appweaver/common';`, ``);
|
|
56
|
+
}
|
|
57
|
+
for (const [name, typeNames] of Object.entries(modelTypeNames)) {
|
|
58
|
+
for (const typeName of typeNames) {
|
|
59
|
+
typesContent.push(generateTypeScriptType(module, typeName), ``);
|
|
60
|
+
}
|
|
61
|
+
typesContent.push(`export type ${name}Query = QueryFilter<${name}>;`, ``);
|
|
62
|
+
// The sort type is built from the query output model, so it only offers
|
|
63
|
+
// the relations included in a query response and their count fields
|
|
64
|
+
typesContent.push(`export type ${name}Sort = QuerySort<${name}Multiple>;`, ``);
|
|
65
|
+
typesContent.push(`export type ${name}Aggregate = AggregateSelect<${name}>;`, ``);
|
|
44
66
|
}
|
|
45
67
|
const outputPath = node_path_1.default.join(cwd, typesPath);
|
|
46
68
|
const prettierConfig = await prettier_1.default.resolveConfig(outputPath);
|
package/package.json
CHANGED
package/skill/GUIDELINES.md
CHANGED
|
@@ -73,7 +73,7 @@ export default createModel({
|
|
|
73
73
|
enabled: { type: 'boolean', default: true }
|
|
74
74
|
},
|
|
75
75
|
relations: {
|
|
76
|
-
category: { model: 'Category', mappedBy: 'products', owner: true, output: { type: 'always' } }
|
|
76
|
+
category: { model: 'Category', type: 'oneToMany', mappedBy: 'products', owner: true, output: { type: 'always' } }
|
|
77
77
|
},
|
|
78
78
|
files: {
|
|
79
79
|
photo: { mimeType: 'image/*', maxSize: '2 MB' }
|
package/skill/SKILL.md
CHANGED
|
@@ -103,6 +103,12 @@ bun weaver migration new init
|
|
|
103
103
|
bun run seed
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
**Install scripts:** npm 12+ blocks dependency install scripts by default. The scaffolded `package.json` ships an
|
|
107
|
+
`allowScripts` field (Bun: `trustedDependencies`) covering the packages Appweaver needs to build. Without it,
|
|
108
|
+
`npm install` skips the native builds and `weaver generate` fails. To approve a newly added dependency, run
|
|
109
|
+
`npm install-scripts approve <pkg>`; it writes the entry to the root `package.json`. Note that a `package.json`
|
|
110
|
+
`allowScripts` field makes npm ignore `.npmrc` `allow-scripts` entirely.
|
|
111
|
+
|
|
106
112
|
### Creating and starting the application server
|
|
107
113
|
|
|
108
114
|
The main entrypoint to the application. This function creates an application object and initializes all resources and
|
|
@@ -203,6 +209,7 @@ export default createModel({
|
|
|
203
209
|
relations: {
|
|
204
210
|
category: {
|
|
205
211
|
model: 'Category',
|
|
212
|
+
type: 'oneToMany',
|
|
206
213
|
mappedBy: 'products',
|
|
207
214
|
owner: true,
|
|
208
215
|
output: {
|
|
@@ -355,6 +362,84 @@ export default createAuthService({
|
|
|
355
362
|
});
|
|
356
363
|
```
|
|
357
364
|
|
|
365
|
+
#### Querying resources with filters
|
|
366
|
+
|
|
367
|
+
The `filter` argument of the `query`, `aggregate`, and `export` service methods (and of the matching `POST /query`,
|
|
368
|
+
`POST /aggregate`, `POST /export` routes) mirrors the WHERE part of a database query. It combines `_`-prefixed operators
|
|
369
|
+
with plain value shorthands and nests through relations:
|
|
370
|
+
|
|
371
|
+
- **Logical**: `_and`, `_or`, `_not`, `_nor` — take a filter object (each entry becomes one condition) or a list of
|
|
372
|
+
filter objects.
|
|
373
|
+
- **Comparison**: `_eq`, `_ne`, `_gt`, `_gte`, `_lt`, `_lte`, `_in`, `_nin`, `_between`, `_like`, `_ilike`, `_starts`,
|
|
374
|
+
`_ends`, `_contains`, `_exists`, `_not`. Operators combined in one object must all match.
|
|
375
|
+
- **List fields**: `_has`, `_hasSome`, `_hasEvery`, `_isEmpty`.
|
|
376
|
+
- **Relations**: `_some`, `_every`, `_none` for list relations, `_exists` for any relation.
|
|
377
|
+
- **Shorthands**: a bare value matches by equality, a list by inclusion, a two-value list on a numeric or date field as
|
|
378
|
+
an inclusive range, and a bare value or list on a relation matches by id.
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
import { injectService } from '@appweaver/core';
|
|
382
|
+
import { UserQuery } from '@/types/generated';
|
|
383
|
+
|
|
384
|
+
const filter: UserQuery = {
|
|
385
|
+
_and: {
|
|
386
|
+
firstName: { _eq: 'John', _exists: true },
|
|
387
|
+
avatar: { _or: { title: { _eq: 'Avatar' }, description: { _like: '%avatar%' } } }
|
|
388
|
+
},
|
|
389
|
+
_or: [{ firstName: { _like: 'Jo%' } }, { lastName: 'Doe' }],
|
|
390
|
+
roles: { _some: { name: { _contains: 'Admin' } } }
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const users = await injectService('User').query(filter, 1, 50, '-createdAt,id');
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Filters are typed by `QueryFilter<T>` from `@appweaver/common`, and `weaver generate` emits a
|
|
397
|
+
`<Model>Query = QueryFilter<Model>` alias per model. Over HTTP, they are validated against a generated per-model
|
|
398
|
+
`<Model>QueryFilter` JSON schema, which strips unknown and hidden fields.
|
|
399
|
+
|
|
400
|
+
### Sorting
|
|
401
|
+
|
|
402
|
+
The `sort` argument of `query` and `export`, and the `sort` property of the `POST /query` and `POST /export` bodies,
|
|
403
|
+
accept either a comma-separated field list, where a `-` prefix sorts descending, or an object of `asc` and `desc` field
|
|
404
|
+
directions. Both sort by a field of an included to-one relation and by the record count of a to-many relation:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
await injectService('Post').query({}, 1, 50, '-author.createdAt,tagsCount,id');
|
|
408
|
+
await injectService('Post').query({}, 1, 50, {
|
|
409
|
+
author: { createdAt: 'desc' },
|
|
410
|
+
tagsCount: 'asc',
|
|
411
|
+
id: 'asc'
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
A hidden, virtual, or array scalar field, a field of a to-many relation, or a relation the action does not include is
|
|
416
|
+
rejected with a `400` error. Sort inputs are typed by `QuerySort<T>` from `@appweaver/common`, with a `<Model>Sort`
|
|
417
|
+
alias emitted per model, and validated over HTTP against a generated `<Model>QuerySort` JSON schema. The default is
|
|
418
|
+
`-createdAt,id`. See [resources.md](./references/resources.md) for the full rules.
|
|
419
|
+
|
|
420
|
+
### Aggregating
|
|
421
|
+
|
|
422
|
+
The required `select` argument of `aggregate` (and of the `POST /aggregate` body) holds the operators to apply per
|
|
423
|
+
field. Only the numeric fields (`count`, `sum`, `avg`, `min`, `max`, `first`, `last`), the date fields (all but `sum`
|
|
424
|
+
and `avg`), and the numeric `id` and audit fields of the model can be aggregated:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
await injectService('Post').aggregate({}, {
|
|
428
|
+
counter: { count: true, sum: true, avg: true, first: true, last: true },
|
|
429
|
+
publishedAt: { min: true, max: true }
|
|
430
|
+
}, 'createdAt', '2026-01-01T00:00:00.000Z', '2026-01-08T00:00:00.000Z');
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`first` and `last` take the value held by the earliest and the latest record of a period, ordered by the aggregated
|
|
434
|
+
`dateField` (ties broken by `id`). The database cannot aggregate them, so each period requesting them costs up to two
|
|
435
|
+
additional queries, skipped for the periods holding no record.
|
|
436
|
+
|
|
437
|
+
Any other field (string, boolean, enum, JSON, array, hidden, virtual, or a relation), an operator its type does not
|
|
438
|
+
support, an empty selection, or a `dateField` that is not a date field is rejected with a `400` error. Selections are
|
|
439
|
+
typed by `AggregateSelect<T>` from `@appweaver/common`, with a `<Model>Aggregate` alias emitted per model, and
|
|
440
|
+
validated over HTTP against a generated `<Model>AggregateSelect` JSON schema. The response stays untyped JSON, since
|
|
441
|
+
its shape follows the selection.
|
|
442
|
+
|
|
358
443
|
### Registering a custom route
|
|
359
444
|
|
|
360
445
|
Use `registerRoute` to register a custom [Fastify route](https://fastify.dev/docs/latest/Reference/Routes/) handler. The
|
|
@@ -572,6 +657,27 @@ npm run e2e # e2e tests
|
|
|
572
657
|
Test files must use the **`.test.ts`** extension. Place unit tests in `test/unit/` and end-to-end tests in `test/e2e/`,
|
|
573
658
|
naming each file after its module. Add or update tests whenever a feature is added or existing behaviour changes.
|
|
574
659
|
|
|
660
|
+
The e2e setup and teardown are wired automatically, but **each e2e test file must register the per-file database reset
|
|
661
|
+
itself**, after the hook that stops the application:
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
import { resetTestData } from './support/reset';
|
|
665
|
+
|
|
666
|
+
describe('My e2e test', () => {
|
|
667
|
+
let app: Application;
|
|
668
|
+
|
|
669
|
+
beforeAll(async () => {
|
|
670
|
+
app = await createApp({ autoStartServer: false });
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
afterAll(async () => {
|
|
674
|
+
await app.stop();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
afterAll(resetTestData, 10_000);
|
|
678
|
+
});
|
|
679
|
+
```
|
|
680
|
+
|
|
575
681
|
### Format code
|
|
576
682
|
|
|
577
683
|
```sh
|
|
@@ -49,9 +49,9 @@ Reads an OpenAPI v3 schema and generates TypeScript types and a typed client cla
|
|
|
49
49
|
|
|
50
50
|
**Arguments:**
|
|
51
51
|
|
|
52
|
-
| Argument | Description
|
|
53
|
-
|
|
54
|
-
| `<schemaPath>` | Path to the OpenAPI schema. Accepts a file path or URL (`http://`, `https://`, `file://`). JSON and YAML formats are both supported. |
|
|
52
|
+
| Argument | Description |
|
|
53
|
+
|----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
54
|
+
| `<schemaPath>` | Path to the OpenAPI schema. Accepts a relative or absolute file path (including a Windows drive path such as `C:\api\openapi.json`) or a URL (`http://`, `https://`, `file://`). JSON and YAML formats are both supported. |
|
|
55
55
|
|
|
56
56
|
**Options:**
|
|
57
57
|
|
|
@@ -313,15 +313,20 @@ Exposes CRUD and file operations for a single resource endpoint.
|
|
|
313
313
|
// Find a single record
|
|
314
314
|
const post = await client.post.find(1);
|
|
315
315
|
|
|
316
|
-
// Query with filters and pagination
|
|
316
|
+
// Query with filters, sorting and pagination. The sort accepts a comma-separated
|
|
317
|
+
// field list ('-createdAt,id') or an object of field directions
|
|
317
318
|
const result = await client.post.query({
|
|
318
319
|
filter: { published: true },
|
|
319
|
-
sort:
|
|
320
|
-
page:
|
|
320
|
+
sort: { author: { lastName: 'asc' }, createdAt: 'desc' },
|
|
321
|
+
page: 1,
|
|
322
|
+
size: 20
|
|
321
323
|
});
|
|
322
324
|
|
|
323
|
-
// Aggregate
|
|
324
|
-
const stats = await client.post.aggregate({
|
|
325
|
+
// Aggregate. The select holds the operators to apply per numeric or date field
|
|
326
|
+
const stats = await client.post.aggregate({
|
|
327
|
+
select: { counter: { count: true, sum: true }, createdAt: { min: true } },
|
|
328
|
+
dateField: 'createdAt'
|
|
329
|
+
});
|
|
325
330
|
|
|
326
331
|
// Create
|
|
327
332
|
const newPost = await client.post.create({ title: 'Hello', body: '...' });
|
|
@@ -248,6 +248,7 @@ const config = {
|
|
|
248
248
|
relations: {
|
|
249
249
|
category: {
|
|
250
250
|
model: 'Category',
|
|
251
|
+
type: 'oneToMany',
|
|
251
252
|
mappedBy: 'products',
|
|
252
253
|
owner: true,
|
|
253
254
|
output: {
|
|
@@ -256,8 +257,8 @@ const config = {
|
|
|
256
257
|
},
|
|
257
258
|
reviews: {
|
|
258
259
|
model: 'Review',
|
|
260
|
+
type: 'oneToMany',
|
|
259
261
|
mappedBy: 'product',
|
|
260
|
-
array: true,
|
|
261
262
|
output: {
|
|
262
263
|
type: 'single',
|
|
263
264
|
count: true
|
|
@@ -273,8 +274,8 @@ const config = {
|
|
|
273
274
|
relations: {
|
|
274
275
|
products: {
|
|
275
276
|
model: 'Product',
|
|
277
|
+
type: 'oneToMany',
|
|
276
278
|
mappedBy: 'category',
|
|
277
|
-
array: true,
|
|
278
279
|
output: {
|
|
279
280
|
type: 'single'
|
|
280
281
|
}
|
|
@@ -289,6 +290,7 @@ const config = {
|
|
|
289
290
|
relations: {
|
|
290
291
|
product: {
|
|
291
292
|
model: 'Product',
|
|
293
|
+
type: 'oneToMany',
|
|
292
294
|
mappedBy: 'reviews',
|
|
293
295
|
owner: true,
|
|
294
296
|
input: {
|
|
@@ -299,30 +301,29 @@ const config = {
|
|
|
299
301
|
};
|
|
300
302
|
```
|
|
301
303
|
|
|
302
|
-
| Property
|
|
303
|
-
|
|
304
|
-
| `model`
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
307
|
-
| `
|
|
308
|
-
| `
|
|
309
|
-
| `
|
|
310
|
-
| `
|
|
311
|
-
| `
|
|
312
|
-
| `
|
|
313
|
-
| `
|
|
314
|
-
| `
|
|
315
|
-
| `input` | RelationInput | - | Input DTO configuration. |
|
|
316
|
-
| `output` | RelationOutput | - | Output DTO configuration. |
|
|
304
|
+
| Property | Type | Default | Description |
|
|
305
|
+
|-----------------|-------------------------------------------------|--------------|--------------------------------------------------------------------------|
|
|
306
|
+
| `model` | string | **required** | Target model name. |
|
|
307
|
+
| `type` | `'oneToOne'` \| `'oneToMany'` \| `'manyToMany'` | **required** | Relation cardinality between the two models. |
|
|
308
|
+
| `owner` | boolean | `false` | This side owns the foreign key column (only one side should be owner). |
|
|
309
|
+
| `mappedBy` | string | - | Name of the inverse relation on the target model. |
|
|
310
|
+
| `required` | boolean | `true` | Whether the relation is required (nullable foreign key if not required). |
|
|
311
|
+
| `minItems` | number | - | Minimum items for list relations. |
|
|
312
|
+
| `orphanRemoval` | boolean | `false` | Delete orphaned records when parent is deleted. |
|
|
313
|
+
| `onDelete` | ReferentialAction | - | Foreign key action on delete. |
|
|
314
|
+
| `onUpdate` | ReferentialAction | - | Foreign key action on update. |
|
|
315
|
+
| `input` | RelationInput | - | Input DTO configuration. |
|
|
316
|
+
| `output` | RelationOutput | - | Output DTO configuration. |
|
|
317
317
|
|
|
318
318
|
**ReferentialAction values**: `'cascade'`, `'restrict'`, `'noAction'`, `'setNull'`, `'setDefault'`
|
|
319
319
|
|
|
320
320
|
#### Relationship types
|
|
321
321
|
|
|
322
|
-
The
|
|
322
|
+
The `type` property declares the relation cardinality explicitly, and `owner` marks the side that holds the foreign key
|
|
323
|
+
column in the generated table:
|
|
323
324
|
|
|
324
|
-
**One-to-One
|
|
325
|
-
|
|
325
|
+
**One-to-One** (`type: 'oneToOne'`): Both sides reference a single record. The side with `owner: true` holds a unique
|
|
326
|
+
foreign key; the inverse side is always optional.
|
|
326
327
|
|
|
327
328
|
```ts
|
|
328
329
|
// User model
|
|
@@ -330,9 +331,9 @@ const config = {
|
|
|
330
331
|
relations: {
|
|
331
332
|
profile: {
|
|
332
333
|
model: 'Profile',
|
|
334
|
+
type: 'oneToOne',
|
|
333
335
|
mappedBy: 'user',
|
|
334
336
|
owner: true,
|
|
335
|
-
unique: true,
|
|
336
337
|
required: false // otherwise the Profile DTO must be sent when creating the user resource
|
|
337
338
|
}
|
|
338
339
|
}
|
|
@@ -345,34 +346,36 @@ const config = {
|
|
|
345
346
|
relations: {
|
|
346
347
|
user: {
|
|
347
348
|
model: 'User',
|
|
349
|
+
type: 'oneToOne',
|
|
348
350
|
mappedBy: 'profile'
|
|
349
351
|
}
|
|
350
352
|
}
|
|
351
353
|
};
|
|
352
354
|
```
|
|
353
355
|
|
|
354
|
-
**One-to-Many
|
|
355
|
-
|
|
356
|
+
**One-to-Many** (`type: 'oneToMany'`): The "many" side (which holds the foreign key) has `owner: true` and references a
|
|
357
|
+
single record; the "one" side has no `owner` and holds a list of related records.
|
|
356
358
|
|
|
357
359
|
```ts
|
|
358
|
-
// Category model (one)
|
|
360
|
+
// Category model (one, list side)
|
|
359
361
|
const config = {
|
|
360
362
|
relations: {
|
|
361
363
|
products: {
|
|
362
364
|
model: 'Product',
|
|
363
|
-
|
|
364
|
-
|
|
365
|
+
type: 'oneToMany',
|
|
366
|
+
mappedBy: 'category'
|
|
365
367
|
}
|
|
366
368
|
}
|
|
367
369
|
};
|
|
368
370
|
```
|
|
369
371
|
|
|
370
372
|
```ts
|
|
371
|
-
// Product model (many)
|
|
373
|
+
// Product model (many, foreign key side)
|
|
372
374
|
const config = {
|
|
373
375
|
relations: {
|
|
374
376
|
category: {
|
|
375
377
|
model: 'Category',
|
|
378
|
+
type: 'oneToMany',
|
|
376
379
|
mappedBy: 'products',
|
|
377
380
|
owner: true
|
|
378
381
|
}
|
|
@@ -380,8 +383,8 @@ const config = {
|
|
|
380
383
|
};
|
|
381
384
|
```
|
|
382
385
|
|
|
383
|
-
**Many-to-Many
|
|
384
|
-
|
|
386
|
+
**Many-to-Many** (`type: 'manyToMany'`): Both sides hold lists of related records, joined through an implicit join
|
|
387
|
+
table. The `owner` property has no effect on this relation type.
|
|
385
388
|
|
|
386
389
|
```ts
|
|
387
390
|
// Post model
|
|
@@ -389,9 +392,8 @@ const config = {
|
|
|
389
392
|
relations: {
|
|
390
393
|
tags: {
|
|
391
394
|
model: 'Tag',
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
array: true
|
|
395
|
+
type: 'manyToMany',
|
|
396
|
+
mappedBy: 'posts'
|
|
395
397
|
}
|
|
396
398
|
}
|
|
397
399
|
};
|
|
@@ -403,20 +405,69 @@ const config = {
|
|
|
403
405
|
relations: {
|
|
404
406
|
posts: {
|
|
405
407
|
model: 'Post',
|
|
406
|
-
|
|
407
|
-
|
|
408
|
+
type: 'manyToMany',
|
|
409
|
+
mappedBy: 'tags'
|
|
408
410
|
}
|
|
409
411
|
}
|
|
410
412
|
};
|
|
411
413
|
```
|
|
412
414
|
|
|
415
|
+
#### Relation pair validation
|
|
416
|
+
|
|
417
|
+
`weaver generate` validates every bidirectional relation pair linked through `mappedBy` and fails schema generation with
|
|
418
|
+
a descriptive error when the two sides are inconsistent:
|
|
419
|
+
|
|
420
|
+
- Both sides must declare the same relation `type`.
|
|
421
|
+
- The mapped relation must reference the declaring model back via its `model` property.
|
|
422
|
+
- For `oneToOne` and `oneToMany` relations, exactly one side must declare `owner: true` (neither or both is an error).
|
|
423
|
+
|
|
424
|
+
A relation whose `mappedBy` field does not exist on the target model is treated as single-sided and skipped by the
|
|
425
|
+
validation; an inverse field is generated automatically in the Prisma schema.
|
|
426
|
+
|
|
413
427
|
#### Relation input
|
|
414
428
|
|
|
415
|
-
| Property
|
|
416
|
-
|
|
417
|
-
| `type`
|
|
418
|
-
| `
|
|
419
|
-
| `
|
|
429
|
+
| Property | Type | Description |
|
|
430
|
+
|---------------|-------------------------------------------------|-------------------------------------------------------------------------------------------------------|
|
|
431
|
+
| `type` | `'all'` \| `'create'` \| `'update'` \| `'none'` | When the relation field is available as input. |
|
|
432
|
+
| `allowCreate` | boolean | Allow creating related records inline (input objects without an `id`). |
|
|
433
|
+
| `allowUpdate` | boolean | Allow updating related records inline on parent update requests (input objects with a required `id`). |
|
|
434
|
+
| `uniqueKey` | string | Unique field matching existing records, turning an inline create into a connect-or-create. |
|
|
435
|
+
|
|
436
|
+
Both flags are off by default: a relation only connects existing records unless `allowCreate` / `allowUpdate` is set.
|
|
437
|
+
|
|
438
|
+
By default, a relation input only connects existing records. It accepts an id value, an `{ id }` object, or an array of
|
|
439
|
+
either for list relations. The `allowCreate` and `allowUpdate` flags also accept the related model's own data:
|
|
440
|
+
|
|
441
|
+
- **`allowCreate: true`** — input objects **without** an `id` create the related record inline. The accepted fields are
|
|
442
|
+
the related model's create data, without its own relations and files (`<Model>RelationCreate`).
|
|
443
|
+
- **`allowUpdate: true`** — input objects **with** an `id` and further fields update the related record inline
|
|
444
|
+
(`<Model>RelationUpdate`). Objects carrying only an `id` are connected instead. This applies to parent **update**
|
|
445
|
+
requests only. On parent **create** requests every object with an `id` is connected, since the database updates
|
|
446
|
+
relations only within an update action.
|
|
447
|
+
|
|
448
|
+
Relations that accept inline writes document their request shape as `<Model>RelationInput`. It holds the id and the
|
|
449
|
+
fields of both shapes above, all optional. The shape stays permissive on purpose, since the server strips the properties
|
|
450
|
+
that the matched schema does not declare. The service applies the restrictions instead. Fields excluded by the related
|
|
451
|
+
model's `create` or `update` config are dropped. A missing required create field fails with a `400` error naming the
|
|
452
|
+
field.
|
|
453
|
+
|
|
454
|
+
Connect, create, and update inputs can be mixed within one list relation request:
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
// PUT /api/users/1
|
|
458
|
+
{
|
|
459
|
+
posts: [
|
|
460
|
+
5, // connect post 5 by id
|
|
461
|
+
{ id: 7, title: 'Renamed' }, // update post 7 inline
|
|
462
|
+
{ title: 'Fresh post', slug: 'new' } // create a new post inline
|
|
463
|
+
]
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Records without an `id` require `allowCreate: true`. Otherwise, the request fails with a `400` error and the related
|
|
468
|
+
record has to be created through its own endpoint first. With `allowCreate` set, a `uniqueKey` matches an existing
|
|
469
|
+
record by that field before creating a new one, so the inline create becomes a connect-or-create. Without
|
|
470
|
+
`allowCreate` the `uniqueKey` has no effect. Plain connect and inline update always match related records by `id`.
|
|
420
471
|
|
|
421
472
|
#### Relation output
|
|
422
473
|
|
|
@@ -632,6 +683,11 @@ const config = {
|
|
|
632
683
|
| `exclude` | boolean | Exclude this field from exports. |
|
|
633
684
|
| `mapValue` | string \| function | Transform the value during export. |
|
|
634
685
|
|
|
686
|
+
A `string` `mapValue` names the field to read the column value from. On a relation or file field it is read off the
|
|
687
|
+
related record (and off every item for array relations, joined with `,`); on a scalar field it is read off the exported
|
|
688
|
+
record itself. A function `mapValue` receives the field value (or each item of an array field) and returns the column
|
|
689
|
+
value.
|
|
690
|
+
|
|
635
691
|
### Index config
|
|
636
692
|
|
|
637
693
|
Define database indexes as a flat array (single-field indexes) or nested arrays (composite indexes):
|
|
@@ -693,7 +749,7 @@ function createService(config: ResourceServiceConfig, override ?: Partial<Resour
|
|
|
693
749
|
|-------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|
|
|
694
750
|
| `modelName` | string | Model name to bind this service to (required). |
|
|
695
751
|
| `beforeFind` | `(id) => void` | Hook called before finding a single resource. |
|
|
696
|
-
| `beforeQuery` | `(filter, page, size, sort) => void` | Hook called before querying resources.
|
|
752
|
+
| `beforeQuery` | `(filter, page, size, sort) => void` | Hook called before querying resources. `sort` is a field list string or a sort object. |
|
|
697
753
|
| `beforeAggregate` | `(filter, select, dateField, from?, to?, step?, safeIncrement?) => void` | Hook called before aggregation. |
|
|
698
754
|
| `beforeCreate` | `(data) => void` | Hook called before creating a resource. Mutate `data` to modify input. |
|
|
699
755
|
| `beforeUpdate` | `(id, data) => void` | Hook called before updating a resource. |
|
|
@@ -712,14 +768,167 @@ All hooks can be synchronous or return a `Promise`.
|
|
|
712
768
|
|
|
713
769
|
The created service exposes the following methods:
|
|
714
770
|
|
|
715
|
-
| Method | Signature | Description
|
|
716
|
-
|
|
717
|
-
| `find` | `(id) => Promise<ReadOne>` | Find a single resource by ID.
|
|
718
|
-
| `query` | `(filter?, page?, size?, sort?) => Promise<QueryResponse>` | Query resources with filtering, pagination, and sorting.
|
|
719
|
-
| `aggregate` | `(filter?, select?, dateField?, from?, to?, step?, safeIncrement?) => Promise<AggregateResponse>` | Aggregate resources with time-series grouping.
|
|
720
|
-
| `create` | `(data) => Promise<ReadOne>` | Create a new resource.
|
|
721
|
-
| `update` | `(id, data) => Promise<ReadOne>` | Update an existing resource.
|
|
722
|
-
| `delete` | `(id) => Promise<ReadOne>` | Delete a resource.
|
|
771
|
+
| Method | Signature | Description |
|
|
772
|
+
|-------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|
|
|
773
|
+
| `find` | `(id) => Promise<ReadOne>` | Find a single resource by ID. |
|
|
774
|
+
| `query` | `(filter?, page?, size?, sort?) => Promise<QueryResponse>` | Query resources with filtering, pagination, and sorting (see [Query sorting](#query-sorting)). |
|
|
775
|
+
| `aggregate` | `(filter?, select?, dateField?, from?, to?, step?, safeIncrement?) => Promise<AggregateResponse>` | Aggregate resources with time-series grouping (see [Aggregate selection](#aggregate-selection)). |
|
|
776
|
+
| `create` | `(data) => Promise<ReadOne>` | Create a new resource. |
|
|
777
|
+
| `update` | `(id, data) => Promise<ReadOne>` | Update an existing resource. |
|
|
778
|
+
| `delete` | `(id) => Promise<ReadOne>` | Delete a resource. |
|
|
779
|
+
|
|
780
|
+
### Query filters
|
|
781
|
+
|
|
782
|
+
The `filter` argument of `query`, `aggregate`, and `export` mirrors the WHERE part of a database query. The matching
|
|
783
|
+
`POST /query`, `POST /aggregate`, and `POST /export` routes accept the same structure, validated against a generated
|
|
784
|
+
per-model `<Model>QueryFilter` schema that strips unknown and hidden fields.
|
|
785
|
+
|
|
786
|
+
**Logical operators** (filter level) — take a single filter object (each entry becomes one condition) or a list of them:
|
|
787
|
+
|
|
788
|
+
| Operator | Description |
|
|
789
|
+
|----------|-------------------------------------------|
|
|
790
|
+
| `_and` | All nested conditions must match. |
|
|
791
|
+
| `_or` | At least one nested condition must match. |
|
|
792
|
+
| `_not` | No nested condition may match. |
|
|
793
|
+
| `_nor` | Alias of `_not`. |
|
|
794
|
+
|
|
795
|
+
**Comparison operators** (field level) — combined inside one object, all must match:
|
|
796
|
+
|
|
797
|
+
| Operator | Description |
|
|
798
|
+
|---------------------------------|------------------------------------------------------------------------------------------------------------------------------|
|
|
799
|
+
| `_eq` | Equal to the given value. |
|
|
800
|
+
| `_ne` | Not equal to the given value. |
|
|
801
|
+
| `_gt`, `_gte`, `_lt`, `_lte` | Greater/lower than (or equal to) the given value. |
|
|
802
|
+
| `_in`, `_nin` | Included / not included in the given list. |
|
|
803
|
+
| `_between` | Inside the inclusive `[min, max]` range. |
|
|
804
|
+
| `_like` | SQL LIKE pattern with `%` wildcards (`Luk%` → starts with, `%avatar%` → contains, `%png` → ends with, no wildcard → equals). |
|
|
805
|
+
| `_ilike` | Case-insensitive `_like` (uses `mode: 'insensitive'`, PostgreSQL and MongoDB only). |
|
|
806
|
+
| `_starts`, `_ends`, `_contains` | Starts with / ends with / contains the given string. |
|
|
807
|
+
| `_exists` | Not null (`true`) or null (`false`). |
|
|
808
|
+
| `_not` | Negates a nested operator object or plain value. |
|
|
809
|
+
|
|
810
|
+
**List (array scalar) operators**: `_has`, `_hasSome`, `_hasEvery`, `_isEmpty`.
|
|
811
|
+
|
|
812
|
+
**Relation operators**: `_some`, `_every`, `_none` take a filter of the related model; `_exists` maps to an `is`/`isNot`
|
|
813
|
+
null check on a single relation and to `some`/`none` on a list relation.
|
|
814
|
+
|
|
815
|
+
**Plain value shorthands**: a bare value matches by equality, a list by inclusion, a two-value list on a numeric or date
|
|
816
|
+
field as an inclusive range, a value or list on a relation by id, an array field uses `has`/`hasSome`, and `null`
|
|
817
|
+
matches missing values or related records.
|
|
818
|
+
|
|
819
|
+
```json
|
|
820
|
+
{
|
|
821
|
+
"filter": {
|
|
822
|
+
"_and": {
|
|
823
|
+
"firstName": {
|
|
824
|
+
"_eq": "Luka",
|
|
825
|
+
"_exists": true
|
|
826
|
+
},
|
|
827
|
+
"avatar": {
|
|
828
|
+
"_or": {
|
|
829
|
+
"title": {
|
|
830
|
+
"_eq": "New user avatar"
|
|
831
|
+
},
|
|
832
|
+
"description": {
|
|
833
|
+
"_like": "%avatar%"
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
"originalName": {
|
|
837
|
+
"_eq": "new_user_avatar.png"
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
"_or": [
|
|
842
|
+
{
|
|
843
|
+
"firstName": {
|
|
844
|
+
"_like": "Luk%"
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
"lastName": "Matošević"
|
|
849
|
+
}
|
|
850
|
+
],
|
|
851
|
+
"tags": {
|
|
852
|
+
"_some": {
|
|
853
|
+
"name": {
|
|
854
|
+
"_contains": "news"
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
"page": 1,
|
|
860
|
+
"size": 50,
|
|
861
|
+
"sort": "-createdAt,id"
|
|
862
|
+
}
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
The `QueryFilter<T>` type from `@appweaver/common` provides code completion, and `weaver generate` emits a
|
|
866
|
+
`<Model>Query = QueryFilter<Model>` alias per model:
|
|
867
|
+
|
|
868
|
+
```ts
|
|
869
|
+
import { QueryFilter } from '@appweaver/common';
|
|
870
|
+
import { User, UserQuery } from '@/types/generated';
|
|
871
|
+
|
|
872
|
+
const filter: UserQuery = {
|
|
873
|
+
_and: {
|
|
874
|
+
firstName: { _eq: 'Luka' },
|
|
875
|
+
loginAt: { _exists: true }
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
const users = await userService.query(filter);
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
### Query sorting
|
|
882
|
+
|
|
883
|
+
The `sort` argument of `query` and `export` (and the `sort` property of the `POST /query` and `POST /export` request
|
|
884
|
+
bodies) accepts two interchangeable forms, both applying their fields in the declared order:
|
|
885
|
+
|
|
886
|
+
```json
|
|
887
|
+
{
|
|
888
|
+
"sort": "-author.createdAt,tagsCount,id"
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
```json
|
|
893
|
+
{
|
|
894
|
+
"sort": {
|
|
895
|
+
"author": {
|
|
896
|
+
"createdAt": "desc"
|
|
897
|
+
},
|
|
898
|
+
"tagsCount": "asc",
|
|
899
|
+
"id": "asc"
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
In the string form a `-` prefix sorts descending (`+` or no prefix ascending) and a dot notation path targets a relation
|
|
905
|
+
field. In the object form a relation takes a nested object, and the only accepted directions are the lower case `asc`
|
|
906
|
+
and `desc`.
|
|
907
|
+
|
|
908
|
+
| Field | String form | Object form | Notes |
|
|
909
|
+
|------------------------|---------------------|-------------------------------------|----------------------------------------------------------------------------------------|
|
|
910
|
+
| Scalar, `id`, audit | `title`, `-id` | `{ title: 'asc' }` | Hidden scalars, array scalars, and virtual fields cannot be sorted by. |
|
|
911
|
+
| To-one relation field | `-author.createdAt` | `{ author: { createdAt: 'desc' } }` | The relation must be included in the response of the action, at any nesting depth. |
|
|
912
|
+
| To-many relation count | `-tagsCount` | `{ tagsCount: 'desc' }` | Sorts by the number of related records; the relation name alone (`-tags`) is an alias. |
|
|
913
|
+
|
|
914
|
+
Anything else — a relation the action does not include, a field of a to-many relation, a hidden or virtual field, an
|
|
915
|
+
unknown sort direction — is rejected with a `400` error naming the offending field instead of reaching the database.
|
|
916
|
+
Over HTTP the sort object is additionally validated against a generated per-model `<Model>QuerySort` schema, which
|
|
917
|
+
strips unknown fields the same way the query filter schema does.
|
|
918
|
+
|
|
919
|
+
The default sort is `-createdAt,id`, and its `createdAt` part is dropped for models configured with
|
|
920
|
+
`audit: { createdAt: false }`.
|
|
921
|
+
|
|
922
|
+
Sort inputs are typed by `QuerySort<T>` from `@appweaver/common`, and `weaver generate` emits a
|
|
923
|
+
`<Model>Sort = QuerySort<<Model>Multiple>` alias per model, built from the query output model so it only offers the
|
|
924
|
+
relations a query response includes:
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
import { PostSort } from '@/types/generated';
|
|
928
|
+
|
|
929
|
+
const sort: PostSort = { author: { lastName: 'asc' }, createdAt: 'desc' };
|
|
930
|
+
const posts = await postService.query({}, 1, 50, sort);
|
|
931
|
+
```
|
|
723
932
|
|
|
724
933
|
### Query response
|
|
725
934
|
|
|
@@ -731,28 +940,80 @@ const config = {
|
|
|
731
940
|
};
|
|
732
941
|
```
|
|
733
942
|
|
|
943
|
+
### Aggregate selection
|
|
944
|
+
|
|
945
|
+
The `select` argument of `aggregate` (and the required `select` property of the `POST /aggregate` request body) holds
|
|
946
|
+
the operators to apply per field. Only the fields the database can aggregate are accepted, which are the numeric and
|
|
947
|
+
date scalars of the model together with its numeric `id` and audit fields:
|
|
948
|
+
|
|
949
|
+
| Field kind | Operators |
|
|
950
|
+
|------------------------------------|------------------------------------------------------|
|
|
951
|
+
| Numeric (`int`, `bigInt`, `float`) | `count`, `sum`, `avg`, `min`, `max`, `first`, `last` |
|
|
952
|
+
| Date (`dateTime`) | `count`, `min`, `max`, `first`, `last` |
|
|
953
|
+
|
|
954
|
+
```json
|
|
955
|
+
{
|
|
956
|
+
"select": {
|
|
957
|
+
"counter": {
|
|
958
|
+
"count": true,
|
|
959
|
+
"sum": true,
|
|
960
|
+
"avg": true,
|
|
961
|
+
"first": true,
|
|
962
|
+
"last": true
|
|
963
|
+
},
|
|
964
|
+
"publishedAt": {
|
|
965
|
+
"min": true,
|
|
966
|
+
"max": true
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
"dateField": "createdAt",
|
|
970
|
+
"from": "2026-01-01T00:00:00.000Z",
|
|
971
|
+
"to": "2026-01-08T00:00:00.000Z"
|
|
972
|
+
}
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
**`first` and `last`** take the value held by the earliest and the latest record of a period, ordered by the aggregated
|
|
976
|
+
`dateField` (ties broken by `id`), or `null` for a period holding no record. The database cannot aggregate them, so each
|
|
977
|
+
non-empty period requesting them costs up to two extra queries.
|
|
978
|
+
|
|
979
|
+
Any other field, an operator its field kind does not support, and an empty selection are rejected with a `400` error.
|
|
980
|
+
Over HTTP the selection is also validated against a generated per-model `<Model>AggregateSelect` schema. The `dateField`
|
|
981
|
+
must be a date field of the model (`createdAt` by default).
|
|
982
|
+
|
|
983
|
+
Selections are typed by `AggregateSelect<T>` from `@appweaver/common`, with a `<Model>Aggregate` alias emitted per
|
|
984
|
+
model:
|
|
985
|
+
|
|
986
|
+
```ts
|
|
987
|
+
import { PostAggregate } from '@/types/generated';
|
|
988
|
+
|
|
989
|
+
const select: PostAggregate = { counter: { sum: true }, createdAt: { max: true } };
|
|
990
|
+
const stats = await postService.aggregate({}, select);
|
|
991
|
+
```
|
|
992
|
+
|
|
734
993
|
### Aggregate response
|
|
735
994
|
|
|
995
|
+
The response is untyped JSON, since its shape follows whatever was selected. Each aggregated field holds one property
|
|
996
|
+
per operator applied to it:
|
|
997
|
+
|
|
736
998
|
```ts
|
|
737
999
|
const resp = {
|
|
738
|
-
total: AggregateValue,
|
|
739
|
-
items:
|
|
1000
|
+
total: AggregateValue, // Overall aggregation
|
|
1001
|
+
items: Array<AggregateResult> // Per-period results
|
|
740
1002
|
};
|
|
741
1003
|
|
|
742
1004
|
// Each AggregateResult:
|
|
743
1005
|
const result = {
|
|
744
1006
|
date: 'Date',
|
|
745
1007
|
result: {
|
|
746
|
-
[field]:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
1008
|
+
[field]: {
|
|
1009
|
+
count: 123,
|
|
1010
|
+
min: 123, // an ISO date string for a date field
|
|
1011
|
+
max: 123, // an ISO date string for a date field
|
|
1012
|
+
avg: 123, // numeric fields only
|
|
1013
|
+
sum: 123, // numeric fields only
|
|
1014
|
+
first: 123, // value of the earliest record of the period
|
|
1015
|
+
last: 123 // value of the latest record of the period
|
|
1016
|
+
}
|
|
756
1017
|
}
|
|
757
1018
|
};
|
|
758
1019
|
```
|
|
@@ -64,7 +64,7 @@ if `SECURITY_JWT_SECRET` is set.
|
|
|
64
64
|
|
|
65
65
|
| Scope | Purpose | Access |
|
|
66
66
|
|-----------|-----------------------|------------------------------------------------------------------------|
|
|
67
|
-
| `Auth` | Full API access | All routes except `/refresh`, `/2fa-
|
|
67
|
+
| `Auth` | Full API access | All routes except `/refresh`, `/send-2fa-code`, `/verify-2fa-code` |
|
|
68
68
|
| `Refresh` | Token renewal only | Only `POST /auth/refresh` |
|
|
69
69
|
| `TwoFA` | 2FA verification only | Only `POST /account/send-2fa-code` and `POST /account/verify-2fa-code` |
|
|
70
70
|
|