@stonyx/orm 0.2.1-beta.2 → 0.2.1-beta.4
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/code-style-rules.md +44 -0
- package/.claude/hooks.md +250 -0
- package/.claude/index.md +279 -0
- package/.claude/usage-patterns.md +217 -0
- package/.github/workflows/publish.yml +17 -1
- package/README.md +424 -15
- package/config/environment.js +26 -5
- package/improvements.md +139 -0
- package/package.json +19 -8
- package/project-structure.md +343 -0
- package/src/commands.js +170 -0
- package/src/db.js +132 -6
- package/src/hooks.js +124 -0
- package/src/index.js +2 -1
- package/src/main.js +31 -2
- package/src/manage-record.js +19 -4
- package/src/migrate.js +72 -0
- package/src/mysql/connection.js +28 -0
- package/src/mysql/migration-generator.js +188 -0
- package/src/mysql/migration-runner.js +110 -0
- package/src/mysql/mysql-db.js +320 -0
- package/src/mysql/query-builder.js +64 -0
- package/src/mysql/schema-introspector.js +158 -0
- package/src/mysql/type-map.js +37 -0
- package/src/orm-request.js +306 -52
- package/src/record.js +35 -8
- package/src/serializer.js +2 -2
- package/src/setup-rest-server.js +4 -1
- package/src/utils.js +12 -0
- package/test-events-setup.js +41 -0
- package/test-hooks-manual.js +54 -0
- package/test-hooks-with-logging.js +52 -0
- package/.claude/project-structure.md +0 -578
- package/stonyx-bootstrap.cjs +0 -30
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import Orm from '@stonyx/orm';
|
|
2
|
+
import { getMysqlType } from './type-map.js';
|
|
3
|
+
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
+
import { pluralize } from '../utils.js';
|
|
5
|
+
import { dbKey } from '../db.js';
|
|
6
|
+
|
|
7
|
+
function getRelationshipInfo(property) {
|
|
8
|
+
if (typeof property !== 'function') return null;
|
|
9
|
+
const fnStr = property.toString();
|
|
10
|
+
|
|
11
|
+
if (fnStr.includes(`getRelationships('belongsTo',`)) return 'belongsTo';
|
|
12
|
+
if (fnStr.includes(`getRelationships('hasMany',`)) return 'hasMany';
|
|
13
|
+
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function introspectModels() {
|
|
18
|
+
const { models } = Orm.instance;
|
|
19
|
+
const schemas = {};
|
|
20
|
+
|
|
21
|
+
for (const [modelKey, modelClass] of Object.entries(models)) {
|
|
22
|
+
const name = camelCaseToKebabCase(modelKey.slice(0, -5));
|
|
23
|
+
|
|
24
|
+
if (name === dbKey) continue;
|
|
25
|
+
|
|
26
|
+
const model = new modelClass(modelKey);
|
|
27
|
+
const columns = {};
|
|
28
|
+
const foreignKeys = {};
|
|
29
|
+
const relationships = { belongsTo: {}, hasMany: {} };
|
|
30
|
+
let idType = 'number';
|
|
31
|
+
|
|
32
|
+
const transforms = Orm.instance.transforms;
|
|
33
|
+
|
|
34
|
+
for (const [key, property] of Object.entries(model)) {
|
|
35
|
+
if (key.startsWith('__')) continue;
|
|
36
|
+
|
|
37
|
+
const relType = getRelationshipInfo(property);
|
|
38
|
+
|
|
39
|
+
if (relType === 'belongsTo') {
|
|
40
|
+
relationships.belongsTo[key] = true;
|
|
41
|
+
} else if (relType === 'hasMany') {
|
|
42
|
+
relationships.hasMany[key] = true;
|
|
43
|
+
} else if (property?.constructor?.name === 'ModelProperty') {
|
|
44
|
+
if (key === 'id') {
|
|
45
|
+
idType = property.type;
|
|
46
|
+
} else {
|
|
47
|
+
columns[key] = getMysqlType(property.type, transforms[property.type]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build foreign keys from belongsTo relationships
|
|
53
|
+
for (const relName of Object.keys(relationships.belongsTo)) {
|
|
54
|
+
const fkColumn = `${relName}_id`;
|
|
55
|
+
foreignKeys[fkColumn] = {
|
|
56
|
+
references: pluralize(relName),
|
|
57
|
+
column: 'id',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
schemas[name] = {
|
|
62
|
+
table: pluralize(name),
|
|
63
|
+
idType,
|
|
64
|
+
columns,
|
|
65
|
+
foreignKeys,
|
|
66
|
+
relationships,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return schemas;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildTableDDL(name, schema, allSchemas = {}) {
|
|
74
|
+
const { table, idType, columns, foreignKeys } = schema;
|
|
75
|
+
const lines = [];
|
|
76
|
+
|
|
77
|
+
// Primary key
|
|
78
|
+
if (idType === 'string') {
|
|
79
|
+
lines.push(' `id` VARCHAR(255) PRIMARY KEY');
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(' `id` INT AUTO_INCREMENT PRIMARY KEY');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Attribute columns
|
|
85
|
+
for (const [col, mysqlType] of Object.entries(columns)) {
|
|
86
|
+
lines.push(` \`${col}\` ${mysqlType}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Foreign key columns
|
|
90
|
+
for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
|
|
91
|
+
const refIdType = getReferencedIdType(fkDef.references, allSchemas);
|
|
92
|
+
lines.push(` \`${fkCol}\` ${refIdType}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Timestamps
|
|
96
|
+
lines.push(' `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP');
|
|
97
|
+
lines.push(' `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
|
98
|
+
|
|
99
|
+
// Foreign key constraints
|
|
100
|
+
for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
|
|
101
|
+
lines.push(` FOREIGN KEY (\`${fkCol}\`) REFERENCES \`${fkDef.references}\`(\`${fkDef.column}\`) ON DELETE SET NULL`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return `CREATE TABLE IF NOT EXISTS \`${table}\` (\n${lines.join(',\n')}\n)`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getReferencedIdType(tableName, allSchemas) {
|
|
108
|
+
// Look up the referenced table's PK type from schemas
|
|
109
|
+
for (const schema of Object.values(allSchemas)) {
|
|
110
|
+
if (schema.table === tableName) {
|
|
111
|
+
return schema.idType === 'string' ? 'VARCHAR(255)' : 'INT';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Default to INT if referenced table not found in schemas
|
|
116
|
+
return 'INT';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getTopologicalOrder(schemas) {
|
|
120
|
+
const visited = new Set();
|
|
121
|
+
const order = [];
|
|
122
|
+
|
|
123
|
+
function visit(name) {
|
|
124
|
+
if (visited.has(name)) return;
|
|
125
|
+
visited.add(name);
|
|
126
|
+
|
|
127
|
+
const schema = schemas[name];
|
|
128
|
+
if (!schema) return;
|
|
129
|
+
|
|
130
|
+
// Visit dependencies (belongsTo targets) first
|
|
131
|
+
for (const relName of Object.keys(schema.relationships.belongsTo)) {
|
|
132
|
+
visit(relName);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
order.push(name);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const name of Object.keys(schemas)) {
|
|
139
|
+
visit(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return order;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function schemasToSnapshot(schemas) {
|
|
146
|
+
const snapshot = {};
|
|
147
|
+
|
|
148
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
149
|
+
snapshot[name] = {
|
|
150
|
+
table: schema.table,
|
|
151
|
+
idType: schema.idType,
|
|
152
|
+
columns: { ...schema.columns },
|
|
153
|
+
foreignKeys: { ...schema.foreignKeys },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return snapshot;
|
|
158
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const typeMap = {
|
|
2
|
+
string: 'VARCHAR(255)',
|
|
3
|
+
number: 'INT',
|
|
4
|
+
float: 'FLOAT',
|
|
5
|
+
boolean: 'TINYINT(1)',
|
|
6
|
+
date: 'DATETIME',
|
|
7
|
+
timestamp: 'BIGINT',
|
|
8
|
+
passthrough: 'TEXT',
|
|
9
|
+
trim: 'VARCHAR(255)',
|
|
10
|
+
uppercase: 'VARCHAR(255)',
|
|
11
|
+
ceil: 'INT',
|
|
12
|
+
floor: 'INT',
|
|
13
|
+
round: 'INT',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolves a Stonyx ORM attribute type to a MySQL column type.
|
|
18
|
+
*
|
|
19
|
+
* For built-in types, returns the mapped MySQL type directly.
|
|
20
|
+
*
|
|
21
|
+
* For custom transforms (e.g. an `animal` transform that maps strings to ints):
|
|
22
|
+
* - If the transform function exports a `mysqlType` property, that value is used.
|
|
23
|
+
* Example: `const transform = (v) => codeMap[v]; transform.mysqlType = 'INT'; export default transform;`
|
|
24
|
+
* - Otherwise, defaults to JSON. Values are JSON-stringified on write and
|
|
25
|
+
* JSON-parsed on read. This handles primitives and plain objects correctly.
|
|
26
|
+
* Class instances will be reduced to plain objects — if a custom transform
|
|
27
|
+
* produces class instances, it must declare a `mysqlType` and handle
|
|
28
|
+
* serialization itself.
|
|
29
|
+
*/
|
|
30
|
+
export function getMysqlType(attrType, transformFn) {
|
|
31
|
+
if (typeMap[attrType]) return typeMap[attrType];
|
|
32
|
+
if (transformFn?.mysqlType) return transformFn.mysqlType;
|
|
33
|
+
|
|
34
|
+
return 'JSON';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default typeMap;
|
package/src/orm-request.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
-
import { createRecord, store } from '@stonyx/orm';
|
|
3
|
-
import {
|
|
2
|
+
import Orm, { createRecord, updateRecord, store } from '@stonyx/orm';
|
|
3
|
+
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
+
import { pluralize } from './utils.js';
|
|
5
|
+
import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
6
|
+
import config from 'stonyx/config';
|
|
4
7
|
|
|
5
8
|
const methodAccessMap = {
|
|
6
9
|
GET: 'read',
|
|
@@ -9,15 +12,62 @@ const methodAccessMap = {
|
|
|
9
12
|
PATCH: 'update',
|
|
10
13
|
};
|
|
11
14
|
|
|
15
|
+
const WRITE_OPERATIONS = new Set(['create', 'update', 'delete']);
|
|
16
|
+
|
|
17
|
+
// Helper to detect relationship type from function
|
|
18
|
+
function getRelationshipInfo(property) {
|
|
19
|
+
if (typeof property !== 'function') return null;
|
|
20
|
+
const fnStr = property.toString();
|
|
21
|
+
if (fnStr.includes(`getRelationships('belongsTo',`)) {
|
|
22
|
+
return { type: 'belongsTo', isArray: false };
|
|
23
|
+
}
|
|
24
|
+
if (fnStr.includes(`getRelationships('hasMany',`)) {
|
|
25
|
+
return { type: 'hasMany', isArray: true };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Helper to introspect model relationships
|
|
31
|
+
function getModelRelationships(modelName) {
|
|
32
|
+
const { modelClass } = Orm.instance.getRecordClasses(modelName);
|
|
33
|
+
if (!modelClass) return {};
|
|
34
|
+
|
|
35
|
+
const model = new modelClass(modelName);
|
|
36
|
+
const relationships = {};
|
|
37
|
+
|
|
38
|
+
for (const [key, property] of Object.entries(model)) {
|
|
39
|
+
if (key.startsWith('__')) continue;
|
|
40
|
+
const info = getRelationshipInfo(property);
|
|
41
|
+
if (info) {
|
|
42
|
+
relationships[key] = info;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return relationships;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Helper to build base URL from request
|
|
50
|
+
function getBaseUrl(request) {
|
|
51
|
+
const protocol = request.protocol || 'http';
|
|
52
|
+
const host = request.get('host');
|
|
53
|
+
return `${protocol}://${host}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
12
56
|
function getId({ id }) {
|
|
13
57
|
if (isNaN(id)) return id;
|
|
14
58
|
|
|
15
59
|
return parseInt(id);
|
|
16
60
|
}
|
|
17
61
|
|
|
18
|
-
function buildResponse(data, includeParam, recordOrRecords) {
|
|
62
|
+
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
63
|
+
const { links, baseUrl } = options;
|
|
19
64
|
const response = { data };
|
|
20
65
|
|
|
66
|
+
// Add top-level links
|
|
67
|
+
if (links) {
|
|
68
|
+
response.links = links;
|
|
69
|
+
}
|
|
70
|
+
|
|
21
71
|
if (!includeParam) return response;
|
|
22
72
|
|
|
23
73
|
const includes = parseInclude(includeParam);
|
|
@@ -25,7 +75,7 @@ function buildResponse(data, includeParam, recordOrRecords) {
|
|
|
25
75
|
|
|
26
76
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
27
77
|
if (includedRecords.length > 0) {
|
|
28
|
-
response.included = includedRecords.map(record => record.toJSON());
|
|
78
|
+
response.included = includedRecords.map(record => record.toJSON({ baseUrl }));
|
|
29
79
|
}
|
|
30
80
|
|
|
31
81
|
return response;
|
|
@@ -163,80 +213,284 @@ export default class OrmRequest extends Request {
|
|
|
163
213
|
constructor({ model, access }) {
|
|
164
214
|
super(...arguments);
|
|
165
215
|
|
|
216
|
+
this.model = model;
|
|
166
217
|
this.access = access;
|
|
167
218
|
const pluralizedModel = pluralize(model);
|
|
168
219
|
|
|
169
|
-
|
|
170
|
-
get: {
|
|
171
|
-
[`/${pluralizedModel}`]: (request, { filter: accessFilter }) => {
|
|
172
|
-
const allRecords = Array.from(store.get(model).values());
|
|
220
|
+
const modelRelationships = getModelRelationships(model);
|
|
173
221
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
222
|
+
// Define raw handlers first
|
|
223
|
+
const getCollectionHandler = (request, { filter: accessFilter }) => {
|
|
224
|
+
const allRecords = Array.from(store.get(model).values());
|
|
178
225
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
226
|
+
const queryFilters = parseFilters(request.query);
|
|
227
|
+
const queryFilterPredicate = createFilterPredicate(queryFilters);
|
|
228
|
+
const fieldsMap = parseFields(request.query);
|
|
229
|
+
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
182
230
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
231
|
+
let recordsToReturn = allRecords;
|
|
232
|
+
if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter);
|
|
233
|
+
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
186
234
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!record) return 404;
|
|
235
|
+
const baseUrl = getBaseUrl(request);
|
|
236
|
+
const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields, baseUrl }));
|
|
190
237
|
|
|
191
|
-
|
|
192
|
-
|
|
238
|
+
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
239
|
+
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
240
|
+
baseUrl
|
|
241
|
+
});
|
|
242
|
+
};
|
|
193
243
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
244
|
+
const getSingleHandler = (request) => {
|
|
245
|
+
const record = store.get(model, getId(request.params));
|
|
246
|
+
if (!record) return 404;
|
|
197
247
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
248
|
+
const fieldsMap = parseFields(request.query);
|
|
249
|
+
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
250
|
+
|
|
251
|
+
const baseUrl = getBaseUrl(request);
|
|
252
|
+
return buildResponse(record.toJSON({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
253
|
+
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
254
|
+
baseUrl
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const createHandler = ({ body, query }) => {
|
|
259
|
+
const { type, id, attributes, relationships: rels } = body?.data || {};
|
|
260
|
+
|
|
261
|
+
if (!type) return 400; // Bad request
|
|
202
262
|
|
|
203
|
-
|
|
263
|
+
const fieldsMap = parseFields(query);
|
|
264
|
+
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
204
265
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (!record.hasOwnProperty(key)) continue;
|
|
208
|
-
if (key === 'id') continue;
|
|
266
|
+
// Check for duplicate ID
|
|
267
|
+
if (id !== undefined && store.get(model, id)) return 409; // Conflict
|
|
209
268
|
|
|
210
|
-
|
|
211
|
-
};
|
|
269
|
+
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
212
270
|
|
|
213
|
-
|
|
271
|
+
// Extract relationship IDs from JSON:API relationships object
|
|
272
|
+
if (rels) {
|
|
273
|
+
for (const [key, value] of Object.entries(rels)) {
|
|
274
|
+
const relData = value?.data;
|
|
275
|
+
if (relData && relData.id !== undefined) {
|
|
276
|
+
sanitizedAttributes[key] = relData.id;
|
|
277
|
+
}
|
|
214
278
|
}
|
|
215
|
-
}
|
|
279
|
+
}
|
|
216
280
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const { type, attributes } = body?.data || {};
|
|
281
|
+
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
282
|
+
const record = createRecord(model, recordAttributes, { serialize: false });
|
|
220
283
|
|
|
221
|
-
|
|
284
|
+
return { data: record.toJSON({ fields: modelFields }) };
|
|
285
|
+
};
|
|
222
286
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (attributes?.id !== undefined && store.get(model, attributes.id)) return 409; // Conflict
|
|
287
|
+
const updateHandler = async ({ body, params }) => {
|
|
288
|
+
const record = store.get(model, getId(params));
|
|
289
|
+
const { attributes, relationships: rels } = body?.data || {};
|
|
227
290
|
|
|
228
|
-
|
|
291
|
+
if (!attributes && !rels) return 400; // Bad request
|
|
229
292
|
|
|
230
|
-
|
|
293
|
+
// Apply attribute updates 1 by 1 to utilize built-in transform logic, ignore id key
|
|
294
|
+
if (attributes) {
|
|
295
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
296
|
+
if (!record.hasOwnProperty(key)) continue;
|
|
297
|
+
if (key === 'id') continue;
|
|
298
|
+
|
|
299
|
+
record[key] = value
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Apply relationship updates via updateRecord to properly resolve references
|
|
304
|
+
if (rels) {
|
|
305
|
+
const relUpdates = {};
|
|
306
|
+
for (const [key, value] of Object.entries(rels)) {
|
|
307
|
+
const relData = value?.data;
|
|
308
|
+
if (relData && relData.id !== undefined) {
|
|
309
|
+
relUpdates[key] = relData.id;
|
|
310
|
+
}
|
|
231
311
|
}
|
|
232
|
-
|
|
312
|
+
if (Object.keys(relUpdates).length > 0) {
|
|
313
|
+
updateRecord(record, relUpdates);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { data: record.toJSON() };
|
|
318
|
+
};
|
|
233
319
|
|
|
320
|
+
const deleteHandler = ({ params }) => {
|
|
321
|
+
store.remove(model, getId(params));
|
|
322
|
+
return 204;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Wrap handlers with hooks
|
|
326
|
+
this.handlers = {
|
|
327
|
+
get: {
|
|
328
|
+
'/': this._withHooks('list', getCollectionHandler),
|
|
329
|
+
'/:id': this._withHooks('get', getSingleHandler),
|
|
330
|
+
...this._generateRelationshipRoutes(model, pluralizedModel, modelRelationships)
|
|
331
|
+
},
|
|
332
|
+
patch: {
|
|
333
|
+
'/:id': this._withHooks('update', updateHandler)
|
|
334
|
+
},
|
|
335
|
+
post: {
|
|
336
|
+
'/': this._withHooks('create', createHandler)
|
|
337
|
+
},
|
|
234
338
|
delete: {
|
|
235
|
-
|
|
236
|
-
|
|
339
|
+
'/:id': this._withHooks('delete', deleteHandler)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Wraps a handler with before/after hook execution
|
|
345
|
+
_withHooks(operation, handler) {
|
|
346
|
+
return async (request, state) => {
|
|
347
|
+
// Build context object for hooks
|
|
348
|
+
const context = {
|
|
349
|
+
model: this.model,
|
|
350
|
+
operation,
|
|
351
|
+
request,
|
|
352
|
+
params: request.params,
|
|
353
|
+
body: request.body,
|
|
354
|
+
query: request.query,
|
|
355
|
+
state,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Capture old state for operations that modify data
|
|
359
|
+
if (operation === 'update' || operation === 'delete') {
|
|
360
|
+
const existingRecord = store.get(this.model, getId(request.params));
|
|
361
|
+
if (existingRecord) {
|
|
362
|
+
// Deep copy the record's data to preserve old state
|
|
363
|
+
context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
|
|
237
364
|
}
|
|
365
|
+
if (operation === 'delete') {
|
|
366
|
+
context.recordId = getId(request.params);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Run before hooks sequentially (can halt by returning a value)
|
|
371
|
+
for (const hook of getBeforeHooks(operation, this.model)) {
|
|
372
|
+
const result = await hook(context);
|
|
373
|
+
if (result !== undefined) {
|
|
374
|
+
// Hook returned a value - halt operation and return result
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Execute main handler
|
|
380
|
+
const response = await handler(request, state);
|
|
381
|
+
|
|
382
|
+
// Persist to MySQL for write operations
|
|
383
|
+
if (Orm.instance.mysqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
384
|
+
await Orm.instance.mysqlDb.persist(operation, this.model, context, response);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Add response and relevant records to context
|
|
388
|
+
context.response = response;
|
|
389
|
+
|
|
390
|
+
if (operation === 'get' && response?.data && !Array.isArray(response.data)) {
|
|
391
|
+
context.record = store.get(this.model, getId(request.params));
|
|
392
|
+
} else if (operation === 'list' && response?.data) {
|
|
393
|
+
context.records = Array.from(store.get(this.model).values());
|
|
394
|
+
} else if (operation === 'create' && response?.data?.id) {
|
|
395
|
+
// For create, get the record from store using the ID from the response
|
|
396
|
+
const recordId = isNaN(response.data.id) ? response.data.id : parseInt(response.data.id);
|
|
397
|
+
context.record = store.get(this.model, recordId);
|
|
398
|
+
} else if (operation === 'update' && response?.data) {
|
|
399
|
+
context.record = store.get(this.model, getId(request.params));
|
|
400
|
+
} else if (operation === 'delete') {
|
|
401
|
+
// For delete, the record may no longer exist, but we have oldState
|
|
402
|
+
context.recordId = getId(request.params);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Run after hooks sequentially
|
|
406
|
+
for (const hook of getAfterHooks(operation, this.model)) {
|
|
407
|
+
await hook(context);
|
|
238
408
|
}
|
|
409
|
+
|
|
410
|
+
// Auto-save DB after write operations when configured
|
|
411
|
+
if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation)) {
|
|
412
|
+
await Orm.db.save();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return response;
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_generateRelationshipRoutes(model, pluralizedModel, modelRelationships) {
|
|
420
|
+
const routes = {};
|
|
421
|
+
|
|
422
|
+
for (const [relationshipName, info] of Object.entries(modelRelationships)) {
|
|
423
|
+
// Dasherize the relationship name for URL paths (e.g., accessLinks -> access-links)
|
|
424
|
+
const dasherizedName = camelCaseToKebabCase(relationshipName);
|
|
425
|
+
|
|
426
|
+
// Related resource route: GET /:id/{relationship}
|
|
427
|
+
routes[`/:id/${dasherizedName}`] = (request) => {
|
|
428
|
+
const record = store.get(model, getId(request.params));
|
|
429
|
+
if (!record) return 404;
|
|
430
|
+
|
|
431
|
+
const relatedData = record.__relationships[relationshipName];
|
|
432
|
+
const baseUrl = getBaseUrl(request);
|
|
433
|
+
|
|
434
|
+
let data;
|
|
435
|
+
if (info.isArray) {
|
|
436
|
+
// hasMany - return array
|
|
437
|
+
data = (relatedData || []).map(r => r.toJSON({ baseUrl }));
|
|
438
|
+
} else {
|
|
439
|
+
// belongsTo - return single or null
|
|
440
|
+
data = relatedData ? relatedData.toJSON({ baseUrl }) : null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
445
|
+
data
|
|
446
|
+
};
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
450
|
+
routes[`/:id/relationships/${dasherizedName}`] = (request) => {
|
|
451
|
+
const record = store.get(model, getId(request.params));
|
|
452
|
+
if (!record) return 404;
|
|
453
|
+
|
|
454
|
+
const relatedData = record.__relationships[relationshipName];
|
|
455
|
+
const baseUrl = getBaseUrl(request);
|
|
456
|
+
|
|
457
|
+
let data;
|
|
458
|
+
if (info.isArray) {
|
|
459
|
+
// hasMany - return array of linkage objects
|
|
460
|
+
data = (relatedData || []).map(r => ({ type: r.__model.__name, id: r.id }));
|
|
461
|
+
} else {
|
|
462
|
+
// belongsTo - return single linkage or null
|
|
463
|
+
data = relatedData ? { type: relatedData.__model.__name, id: relatedData.id } : null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
links: {
|
|
468
|
+
self: `${baseUrl}/${pluralizedModel}/${request.params.id}/relationships/${dasherizedName}`,
|
|
469
|
+
related: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}`
|
|
470
|
+
},
|
|
471
|
+
data
|
|
472
|
+
};
|
|
473
|
+
};
|
|
239
474
|
}
|
|
475
|
+
|
|
476
|
+
// Catch-all for invalid relationship names on related resource route
|
|
477
|
+
routes[`/:id/:relationship`] = (request) => {
|
|
478
|
+
const record = store.get(model, getId(request.params));
|
|
479
|
+
if (!record) return 404;
|
|
480
|
+
|
|
481
|
+
// If we reach here, relationship doesn't exist (valid ones were registered above)
|
|
482
|
+
return 404;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Catch-all for invalid relationship names on relationship linkage route
|
|
486
|
+
routes[`/:id/relationships/:relationship`] = (request) => {
|
|
487
|
+
const record = store.get(model, getId(request.params));
|
|
488
|
+
if (!record) return 404;
|
|
489
|
+
|
|
490
|
+
return 404;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
return routes;
|
|
240
494
|
}
|
|
241
495
|
|
|
242
496
|
auth(request, state) {
|
package/src/record.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { store } from './index.js';
|
|
2
2
|
import { getComputedProperties } from "./serializer.js";
|
|
3
|
+
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
+
import { pluralize } from './utils.js';
|
|
3
5
|
export default class Record {
|
|
4
6
|
__data = {};
|
|
5
7
|
__relationships = {};
|
|
@@ -8,6 +10,7 @@ export default class Record {
|
|
|
8
10
|
constructor(model, serializer) {
|
|
9
11
|
this.__model = model;
|
|
10
12
|
this.__serializer = serializer;
|
|
13
|
+
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
serialize(rawData, options={}) {
|
|
@@ -51,8 +54,11 @@ export default class Record {
|
|
|
51
54
|
toJSON(options = {}) {
|
|
52
55
|
if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
|
|
53
56
|
|
|
57
|
+
const { fields, baseUrl } = options;
|
|
54
58
|
const { __data:data } = this;
|
|
55
|
-
const
|
|
59
|
+
const modelName = this.__model.__name;
|
|
60
|
+
const pluralizedModelName = pluralize(modelName);
|
|
61
|
+
const recordId = data.id;
|
|
56
62
|
const relationships = {};
|
|
57
63
|
const attributes = {};
|
|
58
64
|
|
|
@@ -69,19 +75,40 @@ export default class Record {
|
|
|
69
75
|
|
|
70
76
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
71
77
|
if (fields && !fields.has(key)) continue;
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
|
|
79
|
+
const relationshipData = Array.isArray(childRecord)
|
|
74
80
|
? childRecord.map(r => ({ type: r.__model.__name, id: r.id }))
|
|
75
|
-
: childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null
|
|
76
|
-
|
|
81
|
+
: childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null;
|
|
82
|
+
|
|
83
|
+
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
84
|
+
const dasherizedKey = camelCaseToKebabCase(key);
|
|
85
|
+
|
|
86
|
+
relationships[dasherizedKey] = { data: relationshipData };
|
|
87
|
+
|
|
88
|
+
// Add links to relationship if baseUrl provided
|
|
89
|
+
if (baseUrl) {
|
|
90
|
+
relationships[dasherizedKey].links = {
|
|
91
|
+
self: `${baseUrl}/${pluralizedModelName}/${recordId}/relationships/${dasherizedKey}`,
|
|
92
|
+
related: `${baseUrl}/${pluralizedModelName}/${recordId}/${dasherizedKey}`
|
|
93
|
+
};
|
|
94
|
+
}
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
|
|
97
|
+
const result = {
|
|
80
98
|
attributes,
|
|
81
99
|
relationships,
|
|
82
|
-
id:
|
|
83
|
-
type:
|
|
100
|
+
id: recordId,
|
|
101
|
+
type: modelName,
|
|
84
102
|
};
|
|
103
|
+
|
|
104
|
+
// Add resource links if baseUrl provided
|
|
105
|
+
if (baseUrl) {
|
|
106
|
+
result.links = {
|
|
107
|
+
self: `${baseUrl}/${pluralizedModelName}/${recordId}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
unload(options={}) {
|