@stonyx/orm 0.2.1-alpha.3 → 0.2.1-alpha.30

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.
@@ -1,6 +1,9 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
- import { createRecord, store } from '@stonyx/orm';
3
- import { pluralize as basePluralize } from '@stonyx/utils/string';
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,45 @@ const methodAccessMap = {
9
12
  PATCH: 'update',
10
13
  };
11
14
 
12
- function pluralize(word) {
13
- if (word.includes('-')) {
14
- const parts = word.split('-');
15
- const pluralizedLast = basePluralize(parts.pop());
15
+ const WRITE_OPERATIONS = new Set(['create', 'update', 'delete']);
16
16
 
17
- return [...parts, pluralizedLast].join('-');
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 };
18
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
+ }
19
48
 
20
- return basePluralize(word);
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}`;
21
54
  }
22
55
 
23
56
  function getId({ id }) {
@@ -26,9 +59,15 @@ function getId({ id }) {
26
59
  return parseInt(id);
27
60
  }
28
61
 
29
- function buildResponse(data, includeParam, recordOrRecords) {
62
+ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
63
+ const { links, baseUrl } = options;
30
64
  const response = { data };
31
65
 
66
+ // Add top-level links
67
+ if (links) {
68
+ response.links = links;
69
+ }
70
+
32
71
  if (!includeParam) return response;
33
72
 
34
73
  const includes = parseInclude(includeParam);
@@ -36,7 +75,7 @@ function buildResponse(data, includeParam, recordOrRecords) {
36
75
 
37
76
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
38
77
  if (includedRecords.length > 0) {
39
- response.included = includedRecords.map(record => record.toJSON());
78
+ response.included = includedRecords.map(record => record.toJSON({ baseUrl }));
40
79
  }
41
80
 
42
81
  return response;
@@ -174,80 +213,290 @@ export default class OrmRequest extends Request {
174
213
  constructor({ model, access }) {
175
214
  super(...arguments);
176
215
 
216
+ this.model = model;
177
217
  this.access = access;
178
- const pluralizedModel = pluralize(model);
218
+ const pluralizedModel = getPluralName(model);
179
219
 
180
- this.handlers = {
181
- get: {
182
- [`/${pluralizedModel}`]: (request, { filter: accessFilter }) => {
183
- const allRecords = Array.from(store.get(model).values());
220
+ const modelRelationships = getModelRelationships(model);
184
221
 
185
- const queryFilters = parseFilters(request.query);
186
- const queryFilterPredicate = createFilterPredicate(queryFilters);
187
- const fieldsMap = parseFields(request.query);
188
- const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
222
+ // Define raw handlers first
223
+ const getCollectionHandler = async (request, { filter: accessFilter }) => {
224
+ const allRecords = await store.findAll(model);
189
225
 
190
- let recordsToReturn = allRecords;
191
- if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter);
192
- if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
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);
193
230
 
194
- const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields }));
195
- return buildResponse(data, request.query?.include, recordsToReturn);
196
- },
231
+ let recordsToReturn = allRecords;
232
+ if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter);
233
+ if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
197
234
 
198
- [`/${pluralizedModel}/:id`]: (request) => {
199
- const record = store.get(model, getId(request.params));
200
- if (!record) return 404;
235
+ const baseUrl = getBaseUrl(request);
236
+ const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields, baseUrl }));
201
237
 
202
- const fieldsMap = parseFields(request.query);
203
- const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
238
+ return buildResponse(data, request.query?.include, recordsToReturn, {
239
+ links: { self: `${baseUrl}/${pluralizedModel}` },
240
+ baseUrl
241
+ });
242
+ };
204
243
 
205
- return buildResponse(record.toJSON({ fields: modelFields }), request.query?.include, record);
206
- }
207
- },
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);
208
250
 
209
- patch: {
210
- [`/${pluralizedModel}/:id`]: async ({ body, params }) => {
211
- const record = store.get(model, getId(params));
212
- const { attributes } = body?.data || {};
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
+ };
213
257
 
214
- if (!attributes) return 400; // Bad request
258
+ const createHandler = async ({ body, query }) => {
259
+ const { type, id, attributes, relationships: rels } = body?.data || {};
215
260
 
216
- // Apply updates 1 by 1 to utilize built-in transform logic, ignore id key
217
- for (const [key, value] of Object.entries(attributes)) {
218
- if (!record.hasOwnProperty(key)) continue;
219
- if (key === 'id') continue;
261
+ if (!type) return 400; // Bad request
220
262
 
221
- record[key] = value
222
- };
263
+ const fieldsMap = parseFields(query);
264
+ const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
223
265
 
224
- return { data: record.toJSON() };
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
+ }
225
278
  }
226
- },
279
+ }
227
280
 
228
- post: {
229
- [`/${pluralizedModel}`]: ({ body, query }) => {
230
- const { type, attributes } = body?.data || {};
281
+ const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
282
+ const record = createRecord(model, recordAttributes, { serialize: false });
231
283
 
232
- if (!type) return 400; // Bad request
284
+ return { data: record.toJSON({ fields: modelFields }) };
285
+ };
233
286
 
234
- const fieldsMap = parseFields(query);
235
- const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
236
- // Check for duplicate ID
237
- if (attributes?.id !== undefined && store.get(model, attributes.id)) return 409; // Conflict
287
+ const updateHandler = async ({ body, params }) => {
288
+ const record = await store.find(model, getId(params));
289
+ const { attributes, relationships: rels } = body?.data || {};
238
290
 
239
- const record = createRecord(model, attributes, { serialize: false });
291
+ if (!attributes && !rels) return 400; // Bad request
240
292
 
241
- return { data: record.toJSON({ fields: modelFields }) };
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
+ }
311
+ }
312
+ if (Object.keys(relUpdates).length > 0) {
313
+ updateRecord(record, relUpdates);
242
314
  }
315
+ }
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
+ const isView = Orm.instance?.isView?.(model);
327
+
328
+ this.handlers = {
329
+ get: {
330
+ '/': this._withHooks('list', getCollectionHandler),
331
+ '/:id': this._withHooks('get', getSingleHandler),
332
+ ...this._generateRelationshipRoutes(model, pluralizedModel, modelRelationships)
243
333
  },
334
+ };
335
+
336
+ // Views are read-only — no write endpoints
337
+ if (!isView) {
338
+ this.handlers.patch = {
339
+ '/:id': this._withHooks('update', updateHandler)
340
+ };
341
+ this.handlers.post = {
342
+ '/': this._withHooks('create', createHandler)
343
+ };
344
+ this.handlers.delete = {
345
+ '/:id': this._withHooks('delete', deleteHandler)
346
+ };
347
+ }
348
+ }
349
+
350
+ // Wraps a handler with before/after hook execution
351
+ _withHooks(operation, handler) {
352
+ return async (request, state) => {
353
+ // Build context object for hooks
354
+ const context = {
355
+ model: this.model,
356
+ operation,
357
+ request,
358
+ params: request.params,
359
+ body: request.body,
360
+ query: request.query,
361
+ state,
362
+ };
363
+
364
+ // Capture old state for operations that modify data
365
+ if (operation === 'update' || operation === 'delete') {
366
+ const existingRecord = await store.find(this.model, getId(request.params));
367
+ if (existingRecord) {
368
+ // Deep copy the record's data to preserve old state
369
+ context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
370
+ }
371
+ if (operation === 'delete') {
372
+ context.recordId = getId(request.params);
373
+ }
374
+ }
244
375
 
245
- delete: {
246
- [`/${pluralizedModel}/:id`]: ({ params }) => {
247
- store.remove(model, getId(params));
376
+ // Run before hooks sequentially (can halt by returning a value)
377
+ for (const hook of getBeforeHooks(operation, this.model)) {
378
+ const result = await hook(context);
379
+ if (result !== undefined) {
380
+ // Hook returned a value - halt operation and return result
381
+ return result;
248
382
  }
249
383
  }
384
+
385
+ // Execute main handler
386
+ const response = await handler(request, state);
387
+
388
+ // Persist to MySQL for write operations
389
+ if (Orm.instance.mysqlDb && WRITE_OPERATIONS.has(operation)) {
390
+ await Orm.instance.mysqlDb.persist(operation, this.model, context, response);
391
+ }
392
+
393
+ // Add response and relevant records to context
394
+ context.response = response;
395
+
396
+ if (operation === 'get' && response?.data && !Array.isArray(response.data)) {
397
+ context.record = await store.find(this.model, getId(request.params));
398
+ } else if (operation === 'list' && response?.data) {
399
+ context.records = await store.findAll(this.model);
400
+ } else if (operation === 'create' && response?.data?.id) {
401
+ // For create, get the record from store using the ID from the response
402
+ const recordId = isNaN(response.data.id) ? response.data.id : parseInt(response.data.id);
403
+ context.record = store.get(this.model, recordId);
404
+ } else if (operation === 'update' && response?.data) {
405
+ context.record = store.get(this.model, getId(request.params));
406
+ } else if (operation === 'delete') {
407
+ // For delete, the record may no longer exist, but we have oldState
408
+ context.recordId = getId(request.params);
409
+ }
410
+
411
+ // Run after hooks sequentially
412
+ for (const hook of getAfterHooks(operation, this.model)) {
413
+ await hook(context);
414
+ }
415
+
416
+ // Auto-save DB after write operations when configured
417
+ if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation)) {
418
+ await Orm.db.save();
419
+ }
420
+
421
+ return response;
422
+ };
423
+ }
424
+
425
+ _generateRelationshipRoutes(model, pluralizedModel, modelRelationships) {
426
+ const routes = {};
427
+
428
+ for (const [relationshipName, info] of Object.entries(modelRelationships)) {
429
+ // Dasherize the relationship name for URL paths (e.g., accessLinks -> access-links)
430
+ const dasherizedName = camelCaseToKebabCase(relationshipName);
431
+
432
+ // Related resource route: GET /:id/{relationship}
433
+ routes[`/:id/${dasherizedName}`] = async (request) => {
434
+ const record = await store.find(model, getId(request.params));
435
+ if (!record) return 404;
436
+
437
+ const relatedData = record.__relationships[relationshipName];
438
+ const baseUrl = getBaseUrl(request);
439
+
440
+ let data;
441
+ if (info.isArray) {
442
+ // hasMany - return array
443
+ data = (relatedData || []).map(r => r.toJSON({ baseUrl }));
444
+ } else {
445
+ // belongsTo - return single or null
446
+ data = relatedData ? relatedData.toJSON({ baseUrl }) : null;
447
+ }
448
+
449
+ return {
450
+ links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
451
+ data
452
+ };
453
+ };
454
+
455
+ // Relationship linkage route: GET /:id/relationships/{relationship}
456
+ routes[`/:id/relationships/${dasherizedName}`] = async (request) => {
457
+ const record = await store.find(model, getId(request.params));
458
+ if (!record) return 404;
459
+
460
+ const relatedData = record.__relationships[relationshipName];
461
+ const baseUrl = getBaseUrl(request);
462
+
463
+ let data;
464
+ if (info.isArray) {
465
+ // hasMany - return array of linkage objects
466
+ data = (relatedData || []).map(r => ({ type: r.__model.__name, id: r.id }));
467
+ } else {
468
+ // belongsTo - return single linkage or null
469
+ data = relatedData ? { type: relatedData.__model.__name, id: relatedData.id } : null;
470
+ }
471
+
472
+ return {
473
+ links: {
474
+ self: `${baseUrl}/${pluralizedModel}/${request.params.id}/relationships/${dasherizedName}`,
475
+ related: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}`
476
+ },
477
+ data
478
+ };
479
+ };
250
480
  }
481
+
482
+ // Catch-all for invalid relationship names on related resource route
483
+ routes[`/:id/:relationship`] = async (request) => {
484
+ const record = await store.find(model, getId(request.params));
485
+ if (!record) return 404;
486
+
487
+ // If we reach here, relationship doesn't exist (valid ones were registered above)
488
+ return 404;
489
+ };
490
+
491
+ // Catch-all for invalid relationship names on relationship linkage route
492
+ routes[`/:id/relationships/:relationship`] = async (request) => {
493
+ const record = await store.find(model, getId(request.params));
494
+ if (!record) return 404;
495
+
496
+ return 404;
497
+ };
498
+
499
+ return routes;
251
500
  }
252
501
 
253
502
  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,13 +1,21 @@
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 {
6
+ /** @private */
4
7
  __data = {};
8
+ /** @private */
5
9
  __relationships = {};
10
+ /** @private */
6
11
  __serialized = false;
7
12
 
8
13
  constructor(model, serializer) {
14
+ /** @private */
9
15
  this.__model = model;
16
+ /** @private */
10
17
  this.__serializer = serializer;
18
+
11
19
  }
12
20
 
13
21
  serialize(rawData, options={}) {
@@ -51,8 +59,11 @@ export default class Record {
51
59
  toJSON(options = {}) {
52
60
  if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
53
61
 
62
+ const { fields, baseUrl } = options;
54
63
  const { __data:data } = this;
55
- const { fields } = options;
64
+ const modelName = this.__model.__name;
65
+ const pluralizedModelName = getPluralName(modelName);
66
+ const recordId = data.id;
56
67
  const relationships = {};
57
68
  const attributes = {};
58
69
 
@@ -69,19 +80,40 @@ export default class Record {
69
80
 
70
81
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
71
82
  if (fields && !fields.has(key)) continue;
72
- relationships[key] = {
73
- data: Array.isArray(childRecord)
83
+
84
+ const relationshipData = Array.isArray(childRecord)
74
85
  ? childRecord.map(r => ({ type: r.__model.__name, id: r.id }))
75
- : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null
76
- };
86
+ : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null;
87
+
88
+ // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
89
+ const dasherizedKey = camelCaseToKebabCase(key);
90
+
91
+ relationships[dasherizedKey] = { data: relationshipData };
92
+
93
+ // Add links to relationship if baseUrl provided
94
+ if (baseUrl) {
95
+ relationships[dasherizedKey].links = {
96
+ self: `${baseUrl}/${pluralizedModelName}/${recordId}/relationships/${dasherizedKey}`,
97
+ related: `${baseUrl}/${pluralizedModelName}/${recordId}/${dasherizedKey}`
98
+ };
99
+ }
77
100
  }
78
101
 
79
- return {
102
+ const result = {
80
103
  attributes,
81
104
  relationships,
82
- id: data.id,
83
- type: this.__model.__name,
105
+ id: recordId,
106
+ type: modelName,
84
107
  };
108
+
109
+ // Add resource links if baseUrl provided
110
+ if (baseUrl) {
111
+ result.links = {
112
+ self: `${baseUrl}/${pluralizedModelName}/${recordId}`
113
+ };
114
+ }
115
+
116
+ return result;
85
117
  }
86
118
 
87
119
  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
+ // Skip fields not present in the update payload (undefined = not provided)
75
+ if (data === undefined && options.update) continue;
76
76
 
77
77
  // Relationship handling
78
78
  if (typeof handler === 'function') {
@@ -86,6 +86,13 @@ export default class Serializer {
86
86
  continue;
87
87
  }
88
88
 
89
+ // Aggregate property handling — use the rawData value, not the aggregate descriptor
90
+ if (handler?.constructor?.name === 'AggregateProperty') {
91
+ parsedData[key] = data;
92
+ record[key] = data;
93
+ continue;
94
+ }
95
+
89
96
  // Direct assignment handling
90
97
  if (handler?.constructor?.name !== 'ModelProperty') {
91
98
  parsedData[key] = handler;
@@ -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) {
@@ -40,9 +41,11 @@ export default async function(route, accessPath, metaRoute) {
40
41
  // Remove "/" prefix and name mount point accordingly
41
42
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
42
43
 
43
- // Configure endpoints for models with access configuration
44
+ // Configure endpoints for models and views with access configuration
44
45
  for (const [model, access] of Object.entries(accessFiles)) {
45
- RestServer.instance.mountRoute(OrmRequest, { name, options: { model, access } });
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