webspresso 0.0.57 → 0.0.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -4
- package/bin/commands/doctor.js +182 -0
- package/bin/commands/new.js +8 -8
- package/bin/commands/page.js +5 -5
- package/bin/commands/skill.js +165 -0
- package/bin/utils/db.js +19 -0
- package/bin/webspresso.js +4 -0
- package/core/openapi/build-from-api-routes.js +172 -0
- package/core/openapi/orm-components.js +139 -0
- package/core/orm/index.js +1 -1
- package/core/orm/json-fields.js +70 -0
- package/core/orm/query-builder.js +83 -87
- package/core/orm/repository.js +1 -62
- package/index.js +4 -1
- package/package.json +13 -7
- package/plugins/health-check.js +84 -0
- package/plugins/index.js +6 -0
- package/plugins/recaptcha/helpers.js +91 -0
- package/plugins/recaptcha/index.js +131 -0
- package/plugins/recaptcha/middleware.js +61 -0
- package/plugins/recaptcha/verify.js +150 -0
- package/plugins/schema-explorer.js +2 -133
- package/plugins/swagger.js +126 -0
- package/src/server.js +11 -0
- package/templates/skills/webspresso-usage/SKILL.md +241 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build OpenAPI 3 document from file-router API route metadata + Zod schemas
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { zodToJsonSchema } = require('zod-to-json-schema');
|
|
8
|
+
const { compileSchema } = require('../compileSchema');
|
|
9
|
+
const { generateOrmOpenApiSchemas } = require('./orm-components');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Express route pattern → OpenAPI path (e.g. /users/:id → /users/{id})
|
|
13
|
+
* @param {string} expressPath
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function expressPathToOpenApi(expressPath) {
|
|
17
|
+
return String(expressPath)
|
|
18
|
+
.replace(/:([A-Za-z0-9_]+)/g, '{$1}')
|
|
19
|
+
.replace(/\*/g, '{wildcard}');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('zod').ZodObject<any>} zodObj
|
|
24
|
+
* @param {'path' | 'query'} where
|
|
25
|
+
* @returns {object[]}
|
|
26
|
+
*/
|
|
27
|
+
function expandZodObjectToParameters(zodObj, where) {
|
|
28
|
+
if (!zodObj || zodObj._def?.typeName !== 'ZodObject') return [];
|
|
29
|
+
const shape = zodObj.shape;
|
|
30
|
+
return Object.entries(shape).map(([name, zType]) => ({
|
|
31
|
+
name,
|
|
32
|
+
in: where,
|
|
33
|
+
required: where === 'path' ? true : !zType.isOptional(),
|
|
34
|
+
schema: zodToJsonSchema(zType, { target: 'openApi3', $refStrategy: 'none' }),
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} route - { type, method, pattern, file }
|
|
40
|
+
* @param {object|null} compiled - compileSchema result
|
|
41
|
+
* @returns {object} OpenAPI Operation Object
|
|
42
|
+
*/
|
|
43
|
+
function buildOperation(route, compiled) {
|
|
44
|
+
const filePath = route.file;
|
|
45
|
+
const method = route.method.toLowerCase();
|
|
46
|
+
const summary = `${method.toUpperCase()} ${filePath}`;
|
|
47
|
+
|
|
48
|
+
const op = {
|
|
49
|
+
tags: ['api'],
|
|
50
|
+
summary,
|
|
51
|
+
operationId: String(filePath)
|
|
52
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
53
|
+
.replace(/^_|_$/g, ''),
|
|
54
|
+
responses: {
|
|
55
|
+
200: {
|
|
56
|
+
description: 'OK',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (compiled?.response) {
|
|
62
|
+
op.responses['200'] = {
|
|
63
|
+
description: 'OK',
|
|
64
|
+
content: {
|
|
65
|
+
'application/json': {
|
|
66
|
+
schema: zodToJsonSchema(compiled.response, { target: 'openApi3', $refStrategy: 'none' }),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parameters = [
|
|
73
|
+
...expandZodObjectToParameters(compiled?.params, 'path'),
|
|
74
|
+
...expandZodObjectToParameters(compiled?.query, 'query'),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
if (parameters.length) {
|
|
78
|
+
op.parameters = parameters;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (compiled?.body) {
|
|
82
|
+
op.requestBody = {
|
|
83
|
+
content: {
|
|
84
|
+
'application/json': {
|
|
85
|
+
schema: zodToJsonSchema(compiled.body, { target: 'openApi3', $refStrategy: 'none' }),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return op;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {object[]} opts.routes - route metadata from mountPages
|
|
97
|
+
* @param {string} opts.pagesDir
|
|
98
|
+
* @param {boolean} [opts.includeOrmSchemas]
|
|
99
|
+
* @param {string[]} [opts.ormExclude]
|
|
100
|
+
* @param {object} [opts.info]
|
|
101
|
+
* @param {object[]} [opts.servers]
|
|
102
|
+
* @returns {object} OpenAPI 3.0.x document
|
|
103
|
+
*/
|
|
104
|
+
function buildOpenApiDocument(opts) {
|
|
105
|
+
const {
|
|
106
|
+
routes = [],
|
|
107
|
+
pagesDir,
|
|
108
|
+
includeOrmSchemas = false,
|
|
109
|
+
ormExclude = [],
|
|
110
|
+
info = {},
|
|
111
|
+
servers = [{ url: '/' }],
|
|
112
|
+
} = opts;
|
|
113
|
+
|
|
114
|
+
if (!pagesDir) {
|
|
115
|
+
throw new Error('buildOpenApiDocument: pagesDir is required');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const paths = {};
|
|
119
|
+
const apiRoutes = routes.filter((r) => r.type === 'api');
|
|
120
|
+
const absPages = path.resolve(pagesDir);
|
|
121
|
+
|
|
122
|
+
for (const route of apiRoutes) {
|
|
123
|
+
const fullPath = path.join(absPages, route.file);
|
|
124
|
+
if (!fs.existsSync(fullPath)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let mod;
|
|
129
|
+
try {
|
|
130
|
+
mod = require(fullPath);
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let compiled = null;
|
|
136
|
+
try {
|
|
137
|
+
compiled = compileSchema(fullPath, mod);
|
|
138
|
+
} catch {
|
|
139
|
+
compiled = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const openApiPath = expressPathToOpenApi(route.pattern);
|
|
143
|
+
const method = route.method.toLowerCase();
|
|
144
|
+
|
|
145
|
+
if (!paths[openApiPath]) {
|
|
146
|
+
paths[openApiPath] = {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
paths[openApiPath][method] = buildOperation(route, compiled);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const doc = {
|
|
153
|
+
openapi: '3.0.3',
|
|
154
|
+
info: {
|
|
155
|
+
title: info.title || 'API',
|
|
156
|
+
version: info.version || '1.0.0',
|
|
157
|
+
...(info.description ? { description: info.description } : {}),
|
|
158
|
+
},
|
|
159
|
+
servers,
|
|
160
|
+
paths,
|
|
161
|
+
components: {
|
|
162
|
+
schemas: includeOrmSchemas ? generateOrmOpenApiSchemas({ exclude: ormExclude }) : {},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return doc;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
expressPathToOpenApi,
|
|
171
|
+
buildOpenApiDocument,
|
|
172
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORM column → OpenAPI schema fragments (shared by schema-explorer and swagger plugin)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getAllModels } = require('../orm/model');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate OpenAPI 3 components.schemas from registered ORM models
|
|
9
|
+
* @param {{ exclude?: string[] }} options
|
|
10
|
+
* @returns {Record<string, object>}
|
|
11
|
+
*/
|
|
12
|
+
function generateOrmOpenApiSchemas(options = {}) {
|
|
13
|
+
const { exclude = [] } = options;
|
|
14
|
+
const models = getAllModels();
|
|
15
|
+
const schemas = {};
|
|
16
|
+
|
|
17
|
+
for (const [name, model] of models) {
|
|
18
|
+
if (exclude.includes(name)) continue;
|
|
19
|
+
|
|
20
|
+
const properties = {};
|
|
21
|
+
const required = [];
|
|
22
|
+
|
|
23
|
+
if (model.columns) {
|
|
24
|
+
for (const [colName, meta] of model.columns) {
|
|
25
|
+
properties[colName] = columnToOpenApiType(meta);
|
|
26
|
+
|
|
27
|
+
if (!meta.nullable && !meta.autoIncrement && meta.default === undefined) {
|
|
28
|
+
required.push(colName);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
schemas[name] = {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties,
|
|
36
|
+
required: required.length > 0 ? required : undefined,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const inputProperties = {};
|
|
40
|
+
const inputRequired = [];
|
|
41
|
+
|
|
42
|
+
if (model.columns) {
|
|
43
|
+
for (const [colName, meta] of model.columns) {
|
|
44
|
+
if (meta.autoIncrement || meta.auto) continue;
|
|
45
|
+
|
|
46
|
+
inputProperties[colName] = columnToOpenApiType(meta);
|
|
47
|
+
|
|
48
|
+
if (!meta.nullable && meta.default === undefined) {
|
|
49
|
+
inputRequired.push(colName);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
schemas[`${name}Input`] = {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: inputProperties,
|
|
57
|
+
required: inputRequired.length > 0 ? inputRequired : undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return schemas;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Object} meta - Column metadata
|
|
66
|
+
* @returns {Object}
|
|
67
|
+
*/
|
|
68
|
+
function columnToOpenApiType(meta) {
|
|
69
|
+
const result = {};
|
|
70
|
+
|
|
71
|
+
switch (meta.type) {
|
|
72
|
+
case 'bigint':
|
|
73
|
+
case 'integer':
|
|
74
|
+
result.type = 'integer';
|
|
75
|
+
if (meta.type === 'bigint') result.format = 'int64';
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
case 'float':
|
|
79
|
+
case 'decimal':
|
|
80
|
+
result.type = 'number';
|
|
81
|
+
if (meta.type === 'decimal') result.format = 'double';
|
|
82
|
+
break;
|
|
83
|
+
|
|
84
|
+
case 'boolean':
|
|
85
|
+
result.type = 'boolean';
|
|
86
|
+
break;
|
|
87
|
+
|
|
88
|
+
case 'date':
|
|
89
|
+
result.type = 'string';
|
|
90
|
+
result.format = 'date';
|
|
91
|
+
break;
|
|
92
|
+
|
|
93
|
+
case 'datetime':
|
|
94
|
+
case 'timestamp':
|
|
95
|
+
result.type = 'string';
|
|
96
|
+
result.format = 'date-time';
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case 'uuid':
|
|
100
|
+
result.type = 'string';
|
|
101
|
+
result.format = 'uuid';
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case 'json':
|
|
105
|
+
result.type = 'object';
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case 'enum':
|
|
109
|
+
result.type = 'string';
|
|
110
|
+
if (meta.enumValues) {
|
|
111
|
+
result.enum = meta.enumValues;
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case 'text':
|
|
116
|
+
case 'string':
|
|
117
|
+
default:
|
|
118
|
+
result.type = 'string';
|
|
119
|
+
if (meta.maxLength) {
|
|
120
|
+
result.maxLength = meta.maxLength;
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (meta.nullable) {
|
|
126
|
+
result.nullable = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (meta.default !== undefined) {
|
|
130
|
+
result.default = meta.default;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
generateOrmOpenApiSchemas,
|
|
138
|
+
columnToOpenApiType,
|
|
139
|
+
};
|
package/core/orm/index.js
CHANGED
|
@@ -186,7 +186,7 @@ function createDatabase(config) {
|
|
|
186
186
|
* Get query builder for a model
|
|
187
187
|
* @param {string} modelName - Model name
|
|
188
188
|
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
189
|
-
* @returns {import('
|
|
189
|
+
* @returns {import('./query-builder').QueryBuilder}
|
|
190
190
|
*/
|
|
191
191
|
function query(modelName, scopeContext) {
|
|
192
192
|
const model = getModelInstance(modelName);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso ORM - JSON column helpers (shared by repository and query builder)
|
|
3
|
+
* @module core/orm/json-fields
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get JSON column names from model
|
|
8
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
9
|
+
* @returns {Set<string>} Set of JSON column names
|
|
10
|
+
*/
|
|
11
|
+
function getJsonColumns(model) {
|
|
12
|
+
const jsonCols = new Set();
|
|
13
|
+
if (model.columns) {
|
|
14
|
+
for (const [name, meta] of model.columns) {
|
|
15
|
+
if (meta.type === 'json') {
|
|
16
|
+
jsonCols.add(name);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return jsonCols;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Serialize JSON fields for database storage
|
|
25
|
+
* @param {Object} data - Data to serialize
|
|
26
|
+
* @param {Set<string>} jsonColumns - JSON column names
|
|
27
|
+
* @returns {Object} Serialized data
|
|
28
|
+
*/
|
|
29
|
+
function serializeJsonFields(data, jsonColumns) {
|
|
30
|
+
if (jsonColumns.size === 0) return data;
|
|
31
|
+
|
|
32
|
+
const serialized = { ...data };
|
|
33
|
+
for (const col of jsonColumns) {
|
|
34
|
+
if (col in serialized && serialized[col] !== null && serialized[col] !== undefined) {
|
|
35
|
+
if (typeof serialized[col] !== 'string') {
|
|
36
|
+
serialized[col] = JSON.stringify(serialized[col]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return serialized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Deserialize JSON fields from database
|
|
45
|
+
* @param {Object} record - Record from database
|
|
46
|
+
* @param {Set<string>} jsonColumns - JSON column names
|
|
47
|
+
* @returns {Object} Deserialized record
|
|
48
|
+
*/
|
|
49
|
+
function deserializeJsonFields(record, jsonColumns) {
|
|
50
|
+
if (!record || jsonColumns.size === 0) return record;
|
|
51
|
+
|
|
52
|
+
for (const col of jsonColumns) {
|
|
53
|
+
if (col in record && record[col] !== null && record[col] !== undefined) {
|
|
54
|
+
if (typeof record[col] === 'string') {
|
|
55
|
+
try {
|
|
56
|
+
record[col] = JSON.parse(record[col]);
|
|
57
|
+
} catch {
|
|
58
|
+
// If parsing fails, keep the original string value
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
getJsonColumns,
|
|
68
|
+
serializeJsonFields,
|
|
69
|
+
deserializeJsonFields,
|
|
70
|
+
};
|
|
@@ -5,66 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { applyScopes, createScopeContext } = require('./scopes');
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (model.columns) {
|
|
17
|
-
for (const [name, meta] of model.columns) {
|
|
18
|
-
if (meta.type === 'json') {
|
|
19
|
-
jsonCols.add(name);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return jsonCols;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Serialize JSON fields for database storage
|
|
28
|
-
* @param {Object} data - Data to serialize
|
|
29
|
-
* @param {Set<string>} jsonColumns - JSON column names
|
|
30
|
-
* @returns {Object} Serialized data
|
|
31
|
-
*/
|
|
32
|
-
function serializeJsonFields(data, jsonColumns) {
|
|
33
|
-
if (jsonColumns.size === 0) return data;
|
|
34
|
-
|
|
35
|
-
const serialized = { ...data };
|
|
36
|
-
for (const col of jsonColumns) {
|
|
37
|
-
if (col in serialized && serialized[col] !== null && serialized[col] !== undefined) {
|
|
38
|
-
if (typeof serialized[col] !== 'string') {
|
|
39
|
-
serialized[col] = JSON.stringify(serialized[col]);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return serialized;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Deserialize JSON fields from database
|
|
48
|
-
* @param {Object} record - Record from database
|
|
49
|
-
* @param {Set<string>} jsonColumns - JSON column names
|
|
50
|
-
* @returns {Object} Deserialized record
|
|
51
|
-
*/
|
|
52
|
-
function deserializeJsonFields(record, jsonColumns) {
|
|
53
|
-
if (!record || jsonColumns.size === 0) return record;
|
|
54
|
-
|
|
55
|
-
for (const col of jsonColumns) {
|
|
56
|
-
if (col in record && record[col] !== null && record[col] !== undefined) {
|
|
57
|
-
if (typeof record[col] === 'string') {
|
|
58
|
-
try {
|
|
59
|
-
record[col] = JSON.parse(record[col]);
|
|
60
|
-
} catch {
|
|
61
|
-
// If parsing fails, keep the original string value
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return record;
|
|
67
|
-
}
|
|
8
|
+
const { loadRelations } = require('./eager-loader');
|
|
9
|
+
const { ensureArray } = require('./utils');
|
|
10
|
+
const { ModelEvents, createEventContext, Hooks, HookCancellationError } = require('./events');
|
|
11
|
+
const {
|
|
12
|
+
getJsonColumns,
|
|
13
|
+
serializeJsonFields,
|
|
14
|
+
deserializeJsonFields,
|
|
15
|
+
} = require('./json-fields');
|
|
68
16
|
|
|
69
17
|
/**
|
|
70
18
|
* Create a new query builder
|
|
@@ -92,7 +40,7 @@ class QueryBuilder {
|
|
|
92
40
|
this.knex = knex;
|
|
93
41
|
this.scopeContext = initialContext || createScopeContext();
|
|
94
42
|
this.jsonColumns = getJsonColumns(model);
|
|
95
|
-
|
|
43
|
+
|
|
96
44
|
/** @type {import('./types').QueryState} */
|
|
97
45
|
this.state = {
|
|
98
46
|
wheres: [],
|
|
@@ -167,7 +115,7 @@ class QueryBuilder {
|
|
|
167
115
|
orWhere(columnOrConditions, operatorOrValue, value) {
|
|
168
116
|
const startIndex = this.state.wheres.length;
|
|
169
117
|
this.where(columnOrConditions, operatorOrValue, value);
|
|
170
|
-
|
|
118
|
+
|
|
171
119
|
// Mark the new clauses as OR
|
|
172
120
|
for (let i = startIndex; i < this.state.wheres.length; i++) {
|
|
173
121
|
this.state.wheres[i].boolean = 'or';
|
|
@@ -338,11 +286,22 @@ class QueryBuilder {
|
|
|
338
286
|
return this;
|
|
339
287
|
}
|
|
340
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Transaction handle for lifecycle hooks (matches repository pattern)
|
|
291
|
+
* @returns {import('knex').Knex.Transaction|null}
|
|
292
|
+
*/
|
|
293
|
+
_hookTrx() {
|
|
294
|
+
return this.knex.isTransaction ? this.knex : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
341
297
|
/**
|
|
342
298
|
* Build the Knex query
|
|
299
|
+
* @param {Object} [options]
|
|
300
|
+
* @param {boolean} [options.includeLimitOffset=true] - When false, omit LIMIT/OFFSET (for aggregates / pagination)
|
|
343
301
|
* @returns {import('knex').Knex.QueryBuilder}
|
|
344
302
|
*/
|
|
345
|
-
toKnex() {
|
|
303
|
+
toKnex(options = {}) {
|
|
304
|
+
const { includeLimitOffset = true } = options;
|
|
346
305
|
let qb = this.knex(this.model.table);
|
|
347
306
|
|
|
348
307
|
// Apply global scopes
|
|
@@ -364,10 +323,10 @@ class QueryBuilder {
|
|
|
364
323
|
}
|
|
365
324
|
|
|
366
325
|
const method = where.boolean === 'or' ? 'orWhere' : 'where';
|
|
367
|
-
|
|
326
|
+
|
|
368
327
|
switch (where.operator) {
|
|
369
328
|
case 'in':
|
|
370
|
-
qb = where.boolean === 'or'
|
|
329
|
+
qb = where.boolean === 'or'
|
|
371
330
|
? qb.orWhereIn(where.column, where.value)
|
|
372
331
|
: qb.whereIn(where.column, where.value);
|
|
373
332
|
break;
|
|
@@ -396,14 +355,16 @@ class QueryBuilder {
|
|
|
396
355
|
qb = qb.orderBy(orderBy.column, orderBy.direction);
|
|
397
356
|
}
|
|
398
357
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
358
|
+
if (includeLimitOffset) {
|
|
359
|
+
// Apply limit
|
|
360
|
+
if (this.state.limitValue !== undefined) {
|
|
361
|
+
qb = qb.limit(this.state.limitValue);
|
|
362
|
+
}
|
|
403
363
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
364
|
+
// Apply offset
|
|
365
|
+
if (this.state.offsetValue !== undefined) {
|
|
366
|
+
qb = qb.offset(this.state.offsetValue);
|
|
367
|
+
}
|
|
407
368
|
}
|
|
408
369
|
|
|
409
370
|
return qb;
|
|
@@ -414,12 +375,24 @@ class QueryBuilder {
|
|
|
414
375
|
* @returns {Promise<Object|null>}
|
|
415
376
|
*/
|
|
416
377
|
async first() {
|
|
378
|
+
const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
|
|
379
|
+
await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
380
|
+
if (ctx.isCancelled) {
|
|
381
|
+
throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
|
|
382
|
+
}
|
|
383
|
+
|
|
417
384
|
const qb = this.toKnex().first();
|
|
418
385
|
const result = await qb;
|
|
419
386
|
if (!result) return null;
|
|
420
|
-
|
|
421
|
-
// Deserialize JSON fields
|
|
387
|
+
|
|
422
388
|
deserializeJsonFields(result, this.jsonColumns);
|
|
389
|
+
|
|
390
|
+
const withs = this.getWiths();
|
|
391
|
+
if (withs.length > 0) {
|
|
392
|
+
await loadRelations([result], ensureArray(withs), this.model, this.knex, this.scopeContext);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, result, ctx);
|
|
423
396
|
return result;
|
|
424
397
|
}
|
|
425
398
|
|
|
@@ -428,12 +401,26 @@ class QueryBuilder {
|
|
|
428
401
|
* @returns {Promise<Object[]>}
|
|
429
402
|
*/
|
|
430
403
|
async list() {
|
|
404
|
+
const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
|
|
405
|
+
await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
406
|
+
if (ctx.isCancelled) {
|
|
407
|
+
throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
|
|
408
|
+
}
|
|
409
|
+
|
|
431
410
|
const results = await this.toKnex();
|
|
432
|
-
|
|
433
|
-
// Deserialize JSON fields
|
|
411
|
+
|
|
434
412
|
for (const record of results) {
|
|
435
413
|
deserializeJsonFields(record, this.jsonColumns);
|
|
436
414
|
}
|
|
415
|
+
|
|
416
|
+
const withs = this.getWiths();
|
|
417
|
+
if (withs.length > 0 && results.length > 0) {
|
|
418
|
+
await loadRelations(results, ensureArray(withs), this.model, this.knex, this.scopeContext);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const record of results) {
|
|
422
|
+
ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, record, ctx);
|
|
423
|
+
}
|
|
437
424
|
return results;
|
|
438
425
|
}
|
|
439
426
|
|
|
@@ -450,7 +437,7 @@ class QueryBuilder {
|
|
|
450
437
|
* @returns {Promise<number>}
|
|
451
438
|
*/
|
|
452
439
|
async count() {
|
|
453
|
-
const result = await this.toKnex().count('* as count').first();
|
|
440
|
+
const result = await this.toKnex({ includeLimitOffset: false }).count('* as count').first();
|
|
454
441
|
return parseInt(result?.count || 0, 10);
|
|
455
442
|
}
|
|
456
443
|
|
|
@@ -470,22 +457,33 @@ class QueryBuilder {
|
|
|
470
457
|
* @returns {Promise<import('./types').PaginatedResult>}
|
|
471
458
|
*/
|
|
472
459
|
async paginate(page = 1, perPage = 15) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
460
|
+
const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
|
|
461
|
+
await ModelEvents.emitAsync(this.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
462
|
+
if (ctx.isCancelled) {
|
|
463
|
+
throw new HookCancellationError(ctx.cancelReason, this.model.name, Hooks.BEFORE_FIND);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const base = this.toKnex({ includeLimitOffset: false });
|
|
467
|
+
|
|
468
|
+
const countResult = await base.clone().count('* as count').first();
|
|
478
469
|
const total = parseInt(countResult?.count || 0, 10);
|
|
479
470
|
|
|
480
|
-
// Get paginated data
|
|
481
471
|
const offset = (page - 1) * perPage;
|
|
482
|
-
const data = await
|
|
472
|
+
const data = await base.clone().limit(perPage).offset(offset);
|
|
483
473
|
|
|
484
|
-
// Deserialize JSON fields
|
|
485
474
|
for (const record of data) {
|
|
486
475
|
deserializeJsonFields(record, this.jsonColumns);
|
|
487
476
|
}
|
|
488
477
|
|
|
478
|
+
const withs = this.getWiths();
|
|
479
|
+
if (withs.length > 0 && data.length > 0) {
|
|
480
|
+
await loadRelations(data, ensureArray(withs), this.model, this.knex, this.scopeContext);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const record of data) {
|
|
484
|
+
ModelEvents.emit(this.model.name, Hooks.AFTER_FIND, record, ctx);
|
|
485
|
+
}
|
|
486
|
+
|
|
489
487
|
return {
|
|
490
488
|
data,
|
|
491
489
|
total,
|
|
@@ -509,7 +507,6 @@ class QueryBuilder {
|
|
|
509
507
|
* @returns {Promise<number>} Number of updated records
|
|
510
508
|
*/
|
|
511
509
|
async update(data) {
|
|
512
|
-
// Serialize JSON fields
|
|
513
510
|
const serialized = serializeJsonFields(data, this.jsonColumns);
|
|
514
511
|
return this.toKnex().update(serialized);
|
|
515
512
|
}
|
|
@@ -554,4 +551,3 @@ module.exports = {
|
|
|
554
551
|
createQueryBuilder,
|
|
555
552
|
QueryBuilder,
|
|
556
553
|
};
|
|
557
|
-
|