@stonyx/orm 0.2.1-alpha.0 → 0.2.1-alpha.10
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 +281 -0
- package/.claude/usage-patterns.md +234 -0
- package/.github/workflows/ci.yml +5 -25
- package/.github/workflows/publish.yml +24 -116
- package/README.md +440 -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 +8 -1
- package/src/main.js +47 -3
- package/src/manage-record.js +19 -4
- package/src/migrate.js +72 -0
- package/src/model.js +11 -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 +422 -0
- package/src/mysql/query-builder.js +64 -0
- package/src/mysql/schema-introspector.js +160 -0
- package/src/mysql/type-map.js +37 -0
- package/src/orm-request.js +355 -41
- package/src/plural-registry.js +12 -0
- package/src/record.js +47 -12
- package/src/serializer.js +2 -2
- package/src/setup-rest-server.js +4 -1
- package/src/store.js +105 -0
- 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
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 { getPluralName } from './plural-registry.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;
|
|
@@ -114,69 +164,333 @@ function parseInclude(includeParam) {
|
|
|
114
164
|
.map(rel => rel.split('.')); // Parse nested paths: "owner.pets" → ["owner", "pets"]
|
|
115
165
|
}
|
|
116
166
|
|
|
167
|
+
function parseFields(query) {
|
|
168
|
+
const fields = new Map();
|
|
169
|
+
if (!query) return fields;
|
|
170
|
+
|
|
171
|
+
for (const [key, value] of Object.entries(query)) {
|
|
172
|
+
const match = key.match(/^fields\[(\w+)\]$/);
|
|
173
|
+
if (match && typeof value === 'string') {
|
|
174
|
+
const modelName = match[1];
|
|
175
|
+
const fieldNames = value.split(',').map(f => f.trim()).filter(f => f);
|
|
176
|
+
fields.set(modelName, new Set(fieldNames));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return fields;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseFilters(query) {
|
|
184
|
+
const filters = [];
|
|
185
|
+
if (!query) return filters;
|
|
186
|
+
|
|
187
|
+
for (const [key, value] of Object.entries(query)) {
|
|
188
|
+
const match = key.match(/^filter\[(.+)\]$/);
|
|
189
|
+
if (match && typeof value === 'string') {
|
|
190
|
+
filters.push({ path: match[1].split('.'), value });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return filters;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function createFilterPredicate(filters) {
|
|
198
|
+
if (filters.length === 0) return null;
|
|
199
|
+
|
|
200
|
+
return (record) => filters.every(({ path, value }) => {
|
|
201
|
+
let current = record;
|
|
202
|
+
|
|
203
|
+
for (const segment of path) {
|
|
204
|
+
if (current == null) return false;
|
|
205
|
+
current = current[segment];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return String(current) === value;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
117
212
|
export default class OrmRequest extends Request {
|
|
118
213
|
constructor({ model, access }) {
|
|
119
214
|
super(...arguments);
|
|
120
215
|
|
|
216
|
+
this.model = model;
|
|
121
217
|
this.access = access;
|
|
122
|
-
const pluralizedModel =
|
|
218
|
+
const pluralizedModel = getPluralName(model);
|
|
123
219
|
|
|
124
|
-
|
|
125
|
-
get: {
|
|
126
|
-
[`/${pluralizedModel}`]: (request, { filter }) => {
|
|
127
|
-
const allRecords = Array.from(store.get(model).values());
|
|
128
|
-
const recordsToReturn = filter ? allRecords.filter(filter) : allRecords;
|
|
129
|
-
const data = recordsToReturn.map(record => record.toJSON());
|
|
220
|
+
const modelRelationships = getModelRelationships(model);
|
|
130
221
|
|
|
131
|
-
|
|
132
|
-
|
|
222
|
+
// Define raw handlers first
|
|
223
|
+
const getCollectionHandler = async (request, { filter: accessFilter }) => {
|
|
224
|
+
const allRecords = await store.findAll(model);
|
|
133
225
|
|
|
134
|
-
|
|
135
|
-
|
|
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);
|
|
136
230
|
|
|
137
|
-
|
|
231
|
+
let recordsToReturn = allRecords;
|
|
232
|
+
if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter);
|
|
233
|
+
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
138
234
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
},
|
|
235
|
+
const baseUrl = getBaseUrl(request);
|
|
236
|
+
const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields, baseUrl }));
|
|
142
237
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
238
|
+
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
239
|
+
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
240
|
+
baseUrl
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const getSingleHandler = async (request) => {
|
|
245
|
+
const record = await store.find(model, getId(request.params));
|
|
246
|
+
if (!record) return 404;
|
|
247
|
+
|
|
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
|
+
};
|
|
147
257
|
|
|
148
|
-
|
|
258
|
+
const createHandler = async ({ body, query }) => {
|
|
259
|
+
const { type, id, attributes, relationships: rels } = body?.data || {};
|
|
149
260
|
|
|
150
|
-
|
|
151
|
-
for (const [key, value] of Object.entries(attributes)) {
|
|
152
|
-
if (!record.hasOwnProperty(key)) continue;
|
|
153
|
-
if (key === 'id') continue;
|
|
261
|
+
if (!type) return 400; // Bad request
|
|
154
262
|
|
|
155
|
-
|
|
156
|
-
|
|
263
|
+
const fieldsMap = parseFields(query);
|
|
264
|
+
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
157
265
|
|
|
158
|
-
|
|
266
|
+
// Check for duplicate ID
|
|
267
|
+
if (id !== undefined && await store.find(model, id)) return 409; // Conflict
|
|
268
|
+
|
|
269
|
+
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
270
|
+
|
|
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
|
+
}
|
|
159
278
|
}
|
|
160
|
-
}
|
|
279
|
+
}
|
|
161
280
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
281
|
+
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
282
|
+
const record = createRecord(model, recordAttributes, { serialize: false });
|
|
283
|
+
|
|
284
|
+
return { data: record.toJSON({ fields: modelFields }) };
|
|
285
|
+
};
|
|
165
286
|
|
|
166
|
-
|
|
287
|
+
const updateHandler = async ({ body, params }) => {
|
|
288
|
+
const record = await store.find(model, getId(params));
|
|
289
|
+
const { attributes, relationships: rels } = body?.data || {};
|
|
167
290
|
|
|
168
|
-
|
|
291
|
+
if (!attributes && !rels) return 400; // Bad request
|
|
169
292
|
|
|
170
|
-
|
|
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
|
+
}
|
|
171
311
|
}
|
|
172
|
-
|
|
312
|
+
if (Object.keys(relUpdates).length > 0) {
|
|
313
|
+
updateRecord(record, relUpdates);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
173
316
|
|
|
317
|
+
return { data: record.toJSON() };
|
|
318
|
+
};
|
|
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
|
+
},
|
|
174
338
|
delete: {
|
|
175
|
-
|
|
176
|
-
|
|
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 = await store.find(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));
|
|
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;
|
|
177
376
|
}
|
|
178
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 = await store.find(this.model, getId(request.params));
|
|
392
|
+
} else if (operation === 'list' && response?.data) {
|
|
393
|
+
context.records = await store.findAll(this.model);
|
|
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);
|
|
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}`] = async (request) => {
|
|
428
|
+
const record = await store.find(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}`] = async (request) => {
|
|
451
|
+
const record = await store.find(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
|
+
};
|
|
179
474
|
}
|
|
475
|
+
|
|
476
|
+
// Catch-all for invalid relationship names on related resource route
|
|
477
|
+
routes[`/:id/:relationship`] = async (request) => {
|
|
478
|
+
const record = await store.find(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`] = async (request) => {
|
|
487
|
+
const record = await store.find(model, getId(request.params));
|
|
488
|
+
if (!record) return 404;
|
|
489
|
+
|
|
490
|
+
return 404;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
return routes;
|
|
180
494
|
}
|
|
181
495
|
|
|
182
496
|
auth(request, state) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { pluralize } from './utils.js';
|
|
2
|
+
|
|
3
|
+
const registry = new Map();
|
|
4
|
+
|
|
5
|
+
export function registerPluralName(modelName, modelClass) {
|
|
6
|
+
const plural = modelClass.pluralName || pluralize(modelName);
|
|
7
|
+
registry.set(modelName, plural);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getPluralName(modelName) {
|
|
11
|
+
return registry.get(modelName) || pluralize(modelName);
|
|
12
|
+
}
|
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 { getPluralName } from './plural-registry.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={}) {
|
|
@@ -48,32 +51,64 @@ export default class Record {
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
// Formats record for JSON API output
|
|
51
|
-
toJSON() {
|
|
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;
|
|
59
|
+
const modelName = this.__model.__name;
|
|
60
|
+
const pluralizedModelName = getPluralName(modelName);
|
|
61
|
+
const recordId = data.id;
|
|
55
62
|
const relationships = {};
|
|
56
|
-
const attributes = {
|
|
57
|
-
|
|
63
|
+
const attributes = {};
|
|
64
|
+
|
|
65
|
+
for (const [key, value] of Object.entries(data)) {
|
|
66
|
+
if (key === 'id') continue;
|
|
67
|
+
if (fields && !fields.has(key)) continue;
|
|
68
|
+
attributes[key] = value;
|
|
69
|
+
}
|
|
58
70
|
|
|
59
71
|
for (const [key, getter] of getComputedProperties(this.__model)) {
|
|
72
|
+
if (fields && !fields.has(key)) continue;
|
|
60
73
|
attributes[key] = getter.call(this);
|
|
61
74
|
}
|
|
62
75
|
|
|
63
|
-
for (const [
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
77
|
+
if (fields && !fields.has(key)) continue;
|
|
78
|
+
|
|
79
|
+
const relationshipData = Array.isArray(childRecord)
|
|
66
80
|
? childRecord.map(r => ({ type: r.__model.__name, id: r.id }))
|
|
67
|
-
: childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null
|
|
68
|
-
|
|
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
|
+
}
|
|
69
95
|
}
|
|
70
96
|
|
|
71
|
-
|
|
97
|
+
const result = {
|
|
72
98
|
attributes,
|
|
73
99
|
relationships,
|
|
74
|
-
id:
|
|
75
|
-
type:
|
|
100
|
+
id: recordId,
|
|
101
|
+
type: modelName,
|
|
76
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;
|
|
77
112
|
}
|
|
78
113
|
|
|
79
114
|
unload(options={}) {
|
package/src/serializer.js
CHANGED
|
@@ -71,8 +71,8 @@ export default class Serializer {
|
|
|
71
71
|
const handler = model[key];
|
|
72
72
|
const data = query(rawData, pathPrefix, subPath);
|
|
73
73
|
|
|
74
|
-
// Ignore null values on updates (TODO: What if we want it set to null?)
|
|
75
|
-
if (data === null && options.update) continue;
|
|
74
|
+
// Ignore null/undefined values on updates (TODO: What if we want it set to null?)
|
|
75
|
+
if ((data === null || data === undefined) && options.update) continue;
|
|
76
76
|
|
|
77
77
|
// Relationship handling
|
|
78
78
|
if (typeof handler === 'function') {
|
package/src/setup-rest-server.js
CHANGED
|
@@ -5,6 +5,7 @@ import MetaRequest from './meta-request.js';
|
|
|
5
5
|
import RestServer from '@stonyx/rest-server';
|
|
6
6
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
7
7
|
import { dbKey } from './db.js';
|
|
8
|
+
import { getPluralName } from './plural-registry.js';
|
|
8
9
|
import log from 'stonyx/log';
|
|
9
10
|
|
|
10
11
|
export default async function(route, accessPath, metaRoute) {
|
|
@@ -42,7 +43,9 @@ export default async function(route, accessPath, metaRoute) {
|
|
|
42
43
|
|
|
43
44
|
// Configure endpoints for models with access configuration
|
|
44
45
|
for (const [model, access] of Object.entries(accessFiles)) {
|
|
45
|
-
|
|
46
|
+
const pluralizedModel = getPluralName(model);
|
|
47
|
+
const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
|
|
48
|
+
RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
// Mount the meta route when metaRoute config is enabled
|