@stonyx/orm 0.1.0 → 0.2.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ import { Request } from '@stonyx/rest-server';
2
+ import { createRecord, store } from '@stonyx/orm';
3
+ import { pluralize } from '@stonyx/utils/string';
4
+
5
+ const methodAccessMap = {
6
+ GET: 'read',
7
+ POST: 'create',
8
+ DELETE: 'delete',
9
+ PATCH: 'update',
10
+ };
11
+
12
+ function getId({ id }) {
13
+ if (isNaN(id)) return id;
14
+
15
+ return parseInt(id);
16
+ }
17
+
18
+ function buildResponse(data, includeParam, recordOrRecords) {
19
+ const response = { data };
20
+
21
+ if (!includeParam) return response;
22
+
23
+ const includes = parseInclude(includeParam);
24
+ if (includes.length === 0) return response;
25
+
26
+ const includedRecords = collectIncludedRecords(recordOrRecords, includes);
27
+ if (includedRecords.length > 0) {
28
+ response.included = includedRecords.map(record => record.toJSON());
29
+ }
30
+
31
+ return response;
32
+ }
33
+
34
+ /**
35
+ * Recursively traverse an include path and collect related records
36
+ * @param {Array<Record>} currentRecords - Records to process at current depth
37
+ * @param {Array<string>} includePath - Full path array (e.g., ['owner', 'pets', 'traits'])
38
+ * @param {number} depth - Current depth in the path
39
+ * @param {Map} seen - Deduplication map
40
+ * @param {Array} included - Accumulator for included records
41
+ */
42
+ function traverseIncludePath(currentRecords, includePath, depth, seen, included) {
43
+ if (depth >= includePath.length) return; // Reached end of path
44
+
45
+ const relationshipName = includePath[depth];
46
+ const nextRecords = [];
47
+
48
+ for (const record of currentRecords) {
49
+ if (!record.__relationships) continue;
50
+ if (!(relationshipName in record.__relationships)) continue;
51
+
52
+ const relatedRecords = record.__relationships[relationshipName];
53
+ if (!relatedRecords) continue;
54
+
55
+ // Handle both belongsTo (single) and hasMany (array)
56
+ const recordsToProcess = Array.isArray(relatedRecords)
57
+ ? relatedRecords
58
+ : [relatedRecords];
59
+
60
+ for (const relatedRecord of recordsToProcess) {
61
+ if (!relatedRecord) continue;
62
+
63
+ const type = relatedRecord.__model.__name;
64
+ const id = relatedRecord.id;
65
+
66
+ // Initialize Set for this type if needed
67
+ if (!seen.has(type)) {
68
+ seen.set(type, new Set());
69
+ }
70
+
71
+ // Check if we've already seen this type+id combination
72
+ if (!seen.get(type).has(id)) {
73
+ seen.get(type).add(id);
74
+ included.push(relatedRecord);
75
+ nextRecords.push(relatedRecord); // Prepare for next depth level
76
+ } else if (depth < includePath.length - 1) {
77
+ // Even if we've seen this record, we might need it for deeper traversal
78
+ nextRecords.push(relatedRecord);
79
+ }
80
+ }
81
+ }
82
+
83
+ // If there are more segments in the path, recursively process
84
+ if (depth < includePath.length - 1 && nextRecords.length > 0) {
85
+ traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
86
+ }
87
+ }
88
+
89
+ function collectIncludedRecords(data, includes) {
90
+ if (!includes || includes.length === 0) return [];
91
+ if (!data) return [];
92
+
93
+ const seen = new Map(); // Map<type, Set<id>> for deduplication
94
+ const included = [];
95
+
96
+ // Normalize to array for consistent processing
97
+ const records = Array.isArray(data) ? data : [data];
98
+
99
+ // Process each include path
100
+ for (const includePath of includes) {
101
+ traverseIncludePath(records, includePath, 0, seen, included);
102
+ }
103
+
104
+ return included;
105
+ }
106
+
107
+ function parseInclude(includeParam) {
108
+ if (!includeParam || typeof includeParam !== 'string') return [];
109
+
110
+ return includeParam
111
+ .split(',')
112
+ .map(rel => rel.trim())
113
+ .filter(rel => rel.length > 0)
114
+ .map(rel => rel.split('.')); // Parse nested paths: "owner.pets" → ["owner", "pets"]
115
+ }
116
+
117
+ export default class OrmRequest extends Request {
118
+ constructor({ model, access }) {
119
+ super(...arguments);
120
+
121
+ this.access = access;
122
+ const pluralizedModel = pluralize(model);
123
+
124
+ this.handlers = {
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());
130
+
131
+ return buildResponse(data, request.query?.include, recordsToReturn);
132
+ },
133
+
134
+ [`/${pluralizedModel}/:id`]: (request) => {
135
+ const record = store.get(model, getId(request.params));
136
+
137
+ if (!record) return 404; // Record not found
138
+
139
+ return buildResponse(record.toJSON(), request.query?.include, record);
140
+ }
141
+ },
142
+
143
+ patch: {
144
+ [`/${pluralizedModel}/:id`]: async ({ body, params }) => {
145
+ const record = store.get(model, getId(params));
146
+ const { attributes } = body?.data || {};
147
+
148
+ if (!attributes) return 400; // Bad request
149
+
150
+ // Apply updates 1 by 1 to utilize built-in transform logic, ignore id key
151
+ for (const [key, value] of Object.entries(attributes)) {
152
+ if (!record.hasOwnProperty(key)) continue;
153
+ if (key === 'id') continue;
154
+
155
+ record[key] = value
156
+ };
157
+
158
+ return { data: record.toJSON() };
159
+ }
160
+ },
161
+
162
+ post: {
163
+ [`/${pluralizedModel}`]: ({ body }) => {
164
+ const { attributes } = body?.data || {};
165
+
166
+ if (!attributes) return 400; // Bad request
167
+
168
+ const record = createRecord(model, attributes, { serialize: false });
169
+
170
+ return { data: record.toJSON() };
171
+ }
172
+ },
173
+
174
+ delete: {
175
+ [`/${pluralizedModel}/:id`]: ({ params }) => {
176
+ store.remove(model, getId(params));
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ auth(request, state) {
183
+ const access = this.access(request);
184
+
185
+ if (!access) return 403;
186
+ if (Array.isArray(access) && !access.includes(methodAccessMap[request.method])) return 403;
187
+ if (typeof access === 'function') state.filter = access;
188
+ }
189
+ }
package/src/record.js CHANGED
@@ -1,7 +1,8 @@
1
- import DB from "@stonyx/orm/db";
2
-
1
+ import { store } from './index.js';
2
+ import { getComputedProperties } from "./serializer.js";
3
3
  export default class Record {
4
4
  __data = {};
5
+ __relationships = {};
5
6
  __serialized = false;
6
7
 
7
8
  constructor(model, serializer) {
@@ -9,20 +10,83 @@ export default class Record {
9
10
  this.__serializer = serializer;
10
11
  }
11
12
 
12
- serialize(rawData) {
13
+ serialize(rawData, options={}) {
13
14
  const { __data:data } = this;
14
15
 
15
- if (this.__serialized) return data;
16
+ if (this.__serialized && !options.update) {
17
+ const relatedIds = {};
18
+
19
+ for (const [ key, childRecord ] of Object.entries(this.__relationships)) {
20
+ relatedIds[key] = Array.isArray(childRecord)
21
+ ? childRecord.map(r => r.id)
22
+ : childRecord?.id ?? null;
23
+ }
24
+
25
+ return { ...data, ...relatedIds };
26
+ }
16
27
 
17
28
  const normalizedData = this.__serializer.normalize(rawData);
18
- this.__serializer.setProperties(normalizedData, this);
29
+ this.__serializer.setProperties(normalizedData, this, options);
19
30
 
20
31
  return data;
21
32
  }
22
33
 
23
- save() {
24
- const { __name:key } = this.__model;
34
+ // Similar to serialize, but preserves top level relationship records
35
+ format() {
36
+ if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
37
+
38
+ const { __data:data } = this;
39
+ const records = {};
40
+
41
+ for (const [ key, childRecord ] of Object.entries(this.__relationships)) {
42
+ records[key] = Array.isArray(childRecord)
43
+ ? childRecord.map(r => r.serialize())
44
+ : childRecord?.serialize() ?? null;
45
+ }
46
+
47
+ return { ...data, ...records };
48
+ }
49
+
50
+ // Formats record for JSON API output
51
+ toJSON() {
52
+ if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
25
53
 
26
- new DB().data[key] = this.serialize();
54
+ const { __data:data } = this;
55
+ const relationships = {};
56
+ const attributes = { ...data };
57
+ delete attributes.id;
58
+
59
+ for (const [key, getter] of getComputedProperties(this.__model)) {
60
+ attributes[key] = getter.call(this);
61
+ }
62
+
63
+ for (const [ key, childRecord ] of Object.entries(this.__relationships)) {
64
+ relationships[key] = {
65
+ data: Array.isArray(childRecord)
66
+ ? childRecord.map(r => ({ type: r.__model.__name, id: r.id }))
67
+ : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null
68
+ };
69
+ }
70
+
71
+ return {
72
+ attributes,
73
+ relationships,
74
+ id: data.id,
75
+ type: this.__model.__name,
76
+ };
77
+ }
78
+
79
+ unload(options={}) {
80
+ store.unloadRecord(this.__model.__name, this.id, options);
81
+ }
82
+
83
+ clean() {
84
+ try {
85
+ for (const key of Object.keys(this)) {
86
+ delete this[key];
87
+ }
88
+ } catch {
89
+ // Ignore errors during cleanup, as some keys may not be deletable
90
+ }
27
91
  }
28
92
  }
@@ -0,0 +1,43 @@
1
+ import { relationships } from "@stonyx/orm";
2
+
3
+ export default class Relationships {
4
+ constructor() {
5
+ if (Relationships.instance) return Relationships.instance;
6
+ Relationships.instance = this;
7
+
8
+ this.data = new Map();
9
+ }
10
+
11
+ get(key) {
12
+ return this.data.get(key);
13
+ }
14
+
15
+ set(key, value) {
16
+ this.data.set(key, value);
17
+ }
18
+ }
19
+
20
+ // TODO: Refactor mapping to remove a level of iteration
21
+ export function getRelationships(type, sourceModel, targetModel, relationshipId) {
22
+ const allRelationships = relationships.get(type);
23
+
24
+ // create relationship map for this type of it doesn't already exist
25
+ if (!allRelationships.has(sourceModel)) allRelationships.set(sourceModel, new Map());
26
+
27
+ const modelRelationship = allRelationships.get(sourceModel);
28
+
29
+ if (!modelRelationship.has(targetModel)) modelRelationship.set(targetModel, new Map());
30
+
31
+ const relationship = modelRelationship.get(targetModel);
32
+
33
+ // TODO: Determine whether already having id should be handled differently
34
+ //if (relationship.has(relationshipId)) return;
35
+
36
+ return relationship;
37
+ }
38
+
39
+ export function getHasManyRelationships(sourceModel, targetModel) {
40
+ return relationships.get('hasMany').get(sourceModel)?.get(targetModel);
41
+ }
42
+
43
+ export const TYPES = ['global', 'hasMany', 'belongsTo', 'pending'];
package/src/serializer.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import config from 'stonyx/config';
2
- import { makeArray } from '@stonyx/utils/object';
3
- import { get, getComputedProperties } from '@stonyx/orm/utils';
2
+ import { get, makeArray } from '@stonyx/utils/object';
4
3
 
5
4
  const RESERVED_KEYS = ['__name'];
6
5
 
@@ -48,7 +47,7 @@ function query(rawData, pathPrefix, subPath) {
48
47
  }
49
48
  }
50
49
 
51
- export default class BaseSerializer {
50
+ export default class Serializer {
52
51
  map = {};
53
52
  path = '';
54
53
 
@@ -61,25 +60,28 @@ export default class BaseSerializer {
61
60
  * the ModelProperty object, while setting parsed values to the record's
62
61
  * __data property, which represents the serialized version of the data
63
62
  */
64
- setProperties(rawData, record) {
63
+ setProperties(rawData, record, options) {
65
64
  const { path, model } = this;
66
65
  const keys = Object.keys(model).filter(key => !RESERVED_KEYS.includes(key));
67
66
  const pathPrefix = path ? `${path}.` : '';
68
- const { __data:parsedData } = record;
69
- const { postTransform } = model;
67
+ const { __data:parsedData, __relationships:relatedRecords } = record;
70
68
 
71
69
  for (const key of keys) {
72
- const subPath = this.map[key] || key;
70
+ const subPath = options.serialize ? (this.map[key] || key) : key;
73
71
  const handler = model[key];
74
72
  const data = query(rawData, pathPrefix, subPath);
75
73
 
74
+ // Ignore null values on updates (TODO: What if we want it set to null?)
75
+ if (data === null && options.update) continue;
76
+
76
77
  // Relationship handling
77
78
  if (typeof handler === 'function') {
78
- const childRecord = handler(data);
79
+ // Pass relationship key name to handler for pending fulfillment
80
+ const handlerOptions = { ...options, _relationshipKey: key };
81
+ const childRecord = handler(record, data, handlerOptions);
82
+
79
83
  record[key] = childRecord
80
- parsedData[key] = Array.isArray(childRecord)
81
- ? childRecord.map(record => record.serialize())
82
- : childRecord.serialize();
84
+ relatedRecords[key] = childRecord;
83
85
 
84
86
  continue;
85
87
  }
@@ -91,22 +93,28 @@ export default class BaseSerializer {
91
93
  continue;
92
94
  }
93
95
 
94
- // We access modelProperty.value twice due to getter/setter in ModelProperty class
95
- handler.value = data;
96
- let transformedValue = handler.value;
97
-
98
- if (postTransform) transformedValue = postTransform(transformedValue);
99
-
100
- parsedData[key] = transformedValue;
101
- record[key] = handler;
96
+ Object.defineProperty(record, key, {
97
+ enumerable: true,
98
+ configurable: true,
99
+ get: () => handler.value,
100
+ set(newValue) {
101
+ handler.ignoreFirstTransform = !options.transform;
102
+ handler.value = newValue;
103
+ parsedData[key] = handler.value;
104
+ }
105
+ });
106
+
107
+ record[key] = data;
102
108
  }
103
109
 
110
+ if (options.update) return;
111
+
104
112
  // Serialize computed properties
105
- for (const [key, method] of getComputedProperties(this.model)) {
106
- const value = method.bind(parsedData)();
107
-
108
- parsedData[key] = value;
109
- record[key] = value;
113
+ for (const [key, getter] of getComputedProperties(this.model)) {
114
+ Object.defineProperty(record, key, {
115
+ enumerable: true,
116
+ get: () => getter.call(record)
117
+ });
110
118
  }
111
119
 
112
120
  record.__serialized = true;
@@ -119,3 +127,12 @@ export default class BaseSerializer {
119
127
  return data;
120
128
  }
121
129
  }
130
+
131
+ export function getComputedProperties(classInstance) {
132
+ const proto = Object.getPrototypeOf(classInstance);
133
+ if (!proto || proto === Object.prototype) return [];
134
+
135
+ return Object.entries(Object.getOwnPropertyDescriptors(proto))
136
+ .filter(([key, descriptor]) => key !== 'constructor' && descriptor.get)
137
+ .map(([key, descriptor]) => [key, descriptor.get]);
138
+ }
@@ -0,0 +1,57 @@
1
+ import { waitForModule } from 'stonyx';
2
+ import { store } from '@stonyx/orm';
3
+ import OrmRequest from './orm-request.js';
4
+ import MetaRequest from './meta-request.js';
5
+ import RestServer from '@stonyx/rest-server';
6
+ import { forEachFileImport } from '@stonyx/utils/file';
7
+ import { dbKey } from './db.js';
8
+ import log from 'stonyx/log';
9
+
10
+ export default async function(route, accessPath, metaRoute) {
11
+ let accessFiles = {};
12
+
13
+ try {
14
+ await forEachFileImport(accessPath, accessClass => {
15
+ const accessInstance = new accessClass();
16
+ const { models } = accessInstance;
17
+
18
+ if (!models) throw new Error(`Access class "${accessClass.name}" must define a "models" list`);
19
+
20
+ if (models.length === 0) return; // No models to assign access to
21
+ if (typeof accessInstance.access !== 'function') throw new Error(`Access class "${accessClass.name}" must declare an "access" method`);
22
+
23
+ const availableModels = Array.from(store.data.keys());
24
+
25
+ for (const model of models === '*' ? availableModels : models) {
26
+ if (model === dbKey) continue;
27
+ if (!store.data.has(model)) throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
28
+ if (accessFiles[model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
29
+
30
+ accessFiles[model] = accessInstance.access;
31
+ }
32
+ });
33
+ } catch (error) {
34
+ log.error(error.message);
35
+ log.warn('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
36
+ }
37
+
38
+ await waitForModule('rest-server');
39
+
40
+ // Remove "/" prefix and name mount point accordingly
41
+ const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
42
+
43
+ // Configure endpoints for models with access configuration
44
+ for (const [model, access] of Object.entries(accessFiles)) {
45
+ RestServer.instance.mountRoute(OrmRequest, { name, options: { model, access } });
46
+ }
47
+
48
+ // Mount the meta route when metaRoute config is enabled
49
+ if (metaRoute) {
50
+ log.warn('SECURITY RISK! - Meta route is enabled via metaRoute config. This feature is intended for development purposes only!');
51
+
52
+ RestServer.instance.mountRoute(MetaRequest, { name });
53
+ }
54
+
55
+ // Cleanup references
56
+ accessFiles = null;
57
+ }