@stonyx/orm 0.0.7 → 0.2.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.
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
+ }
package/src/store.js ADDED
@@ -0,0 +1,211 @@
1
+ import { relationships } from '@stonyx/orm';
2
+ import { TYPES } from './relationships.js';
3
+
4
+ export default class Store {
5
+ constructor() {
6
+ if (Store.instance) return Store.instance;
7
+ Store.instance = this;
8
+
9
+ this.data = new Map();
10
+ }
11
+
12
+ get(key, id) {
13
+ if (!id) return this.data.get(key);
14
+
15
+ return this.data.get(key)?.get(id);
16
+ }
17
+
18
+ set(key, value) {
19
+ this.data.set(key, value);
20
+ }
21
+
22
+ remove(key, id) {
23
+ if (id) return this.unloadRecord(key, id);
24
+
25
+ this.unloadAllRecords(key);
26
+ }
27
+
28
+ unloadRecord(model, id, options={}) {
29
+ const modelStore = this.data.get(model);
30
+
31
+ if (!modelStore) {
32
+ console.warn(`[Store] Cannot unload record: model "${model}" not found in store`);
33
+ return;
34
+ }
35
+
36
+ const record = modelStore.get(id);
37
+
38
+ if (!record) {
39
+ console.warn(`[Store] Cannot unload record: ${model}:${id} not found in store`);
40
+ return;
41
+ }
42
+
43
+ const { toUnload, visited } = options.includeChildren
44
+ ? this._buildUnloadQueue(record, options)
45
+ : { toUnload: [{ record, modelName: model, recordId: id }], visited: new Set([`${model}:${id}`]) };
46
+
47
+ for (const item of toUnload.reverse()) {
48
+ const { record: recordToUnload, modelName, recordId } = item;
49
+
50
+ this._removeFromHasManyArrays(modelName, recordId, visited);
51
+ this._nullifyBelongsToReferences(modelName, recordId, visited);
52
+ this._cleanupRelationshipRegistries(modelName, recordId);
53
+ recordToUnload.clean();
54
+
55
+ this.data.get(modelName).delete(recordId);
56
+ }
57
+ }
58
+
59
+ unloadAllRecords(model, options={}) {
60
+ const modelStore = this.data.get(model);
61
+
62
+ if (!modelStore) {
63
+ console.warn(`[Store] Cannot unload all records: model "${model}" not found in store`);
64
+ return;
65
+ }
66
+
67
+ const recordIds = Array.from(modelStore.keys());
68
+
69
+ for (const id of recordIds) {
70
+ if (modelStore.has(id)) {
71
+ this.unloadRecord(model, id, options);
72
+ }
73
+ }
74
+
75
+ for (const relationshipType of TYPES) relationships.get(relationshipType).delete(model);
76
+ }
77
+
78
+ _removeFromHasManyArrays(modelName, recordId, visited) {
79
+ const hasManyRegistry = relationships.get('hasMany');
80
+
81
+ for (const [sourceModel, targetModels] of hasManyRegistry) {
82
+ const targetModelMap = targetModels.get(modelName);
83
+ if (!targetModelMap) continue;
84
+
85
+ for (const [sourceRecordId, hasManyArray] of targetModelMap) {
86
+ const sourceKey = `${sourceModel}:${sourceRecordId}`;
87
+
88
+ // Don't modify arrays of records being deleted
89
+ if (visited.has(sourceKey)) continue;
90
+
91
+ const index = hasManyArray.findIndex(r => r && r.id === recordId);
92
+ if (index !== -1) hasManyArray.splice(index, 1);
93
+ }
94
+ }
95
+ }
96
+
97
+ _nullifyBelongsToReferences(modelName, recordId, visited) {
98
+ const belongsToRegistry = relationships.get('belongsTo');
99
+
100
+ for (const [sourceModel, targetModels] of belongsToRegistry) {
101
+ const targetModelMap = targetModels.get(modelName);
102
+ if (!targetModelMap) continue;
103
+
104
+ for (const [sourceRecordId, belongsToRecord] of targetModelMap) {
105
+ if (belongsToRecord && belongsToRecord.id === recordId) {
106
+ const sourceKey = `${sourceModel}:${sourceRecordId}`;
107
+
108
+ if (visited.has(sourceKey)) continue;
109
+ targetModelMap.set(sourceRecordId, null);
110
+
111
+ const sourceRecord = this.get(sourceModel, sourceRecordId);
112
+ if (sourceRecord && sourceRecord.__relationships) {
113
+ for (const [key, value] of Object.entries(sourceRecord.__relationships)) {
114
+ if (value && value.id === recordId) {
115
+ sourceRecord.__relationships[key] = null;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ _cleanupRelationshipRegistries(modelName, recordId) {
125
+ const hasManyMap = relationships.get('hasMany').get(modelName);
126
+ if (hasManyMap) {
127
+ for (const [, recordMap] of hasManyMap) recordMap.delete(recordId);
128
+ }
129
+
130
+ const belongsToMap = relationships.get('belongsTo').get(modelName);
131
+ if (belongsToMap) {
132
+ for (const [, recordMap] of belongsToMap) recordMap.delete(recordId);
133
+ }
134
+
135
+ const pendingMap = relationships.get('pending').get(modelName);
136
+ if (pendingMap) pendingMap.delete(recordId);
137
+ }
138
+
139
+ /**
140
+ * Extracts hasMany and non-bidirectional belongsTo children from a record
141
+ * @private
142
+ */
143
+ _getChildren(record) {
144
+ const children = [];
145
+
146
+ if (!record.__relationships) return children;
147
+
148
+ for (const [key, value] of Object.entries(record.__relationships)) {
149
+ // hasMany children - always include
150
+ if (Array.isArray(value)) {
151
+ for (const childRecord of value) {
152
+ if (childRecord) children.push({ childRecord, relationshipKey: key, type: 'hasMany' });
153
+ }
154
+ } else if (value && !this._isBidirectionalRelationship(
155
+ record.__model.__name,
156
+ value.__model.__name
157
+ )) {
158
+ children.push({ childRecord: value, relationshipKey: key, type: 'belongsTo' });
159
+ }
160
+ }
161
+
162
+ return children;
163
+ }
164
+
165
+ _isBidirectionalRelationship(sourceModel, targetModel) {
166
+ const hasManyRegistry = relationships.get('hasMany');
167
+ const inverseMap = hasManyRegistry.get(targetModel)?.get(sourceModel);
168
+
169
+ return inverseMap && inverseMap.size > 0;
170
+ }
171
+
172
+ _buildUnloadQueue(record, options) {
173
+ const visited = new Set();
174
+ const toUnload = [];
175
+ const queue = [{
176
+ record,
177
+ modelName: record.__model.__name,
178
+ recordId: record.id,
179
+ isRoot: true,
180
+ depth: 0
181
+ }];
182
+
183
+ while (queue.length > 0) {
184
+ const item = queue.shift();
185
+ const key = `${item.modelName}:${item.recordId}`;
186
+
187
+ if (visited.has(key)) continue;
188
+ visited.add(key);
189
+
190
+ toUnload.push(item);
191
+
192
+ // Add children to queue if includeChildren is enabled
193
+ if (options.includeChildren) {
194
+ const children = this._getChildren(item.record);
195
+ for (const { childRecord } of children) {
196
+ if (childRecord) {
197
+ queue.push({
198
+ record: childRecord,
199
+ modelName: childRecord.__model.__name,
200
+ recordId: childRecord.id,
201
+ isRoot: false,
202
+ depth: item.depth + 1
203
+ });
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return { toUnload, visited };
210
+ }
211
+ }
package/src/transforms.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { getTimestamp } from "@stonyx/utils/date";
2
+
1
3
  const transforms = {
2
4
  boolean: value => typeof value === 'string' ? value.trim().toLowerCase() === 'true' : !!value,
3
5
  date: value => value ? new Date(value) : null,
@@ -5,13 +7,14 @@ const transforms = {
5
7
  number: value => parseInt(value),
6
8
  passthrough: value => value,
7
9
  string: value => String(value),
10
+ timestamp: value => getTimestamp(value),
8
11
  trim: value => value?.trim(),
9
12
  uppercase: value => value?.toUpperCase(),
10
13
  };
11
14
 
12
15
  // Math Proxies
13
16
  ['ceil', 'floor', 'round'].forEach(method => {
14
- transforms[method] = value => Math[method](value)();
17
+ transforms[method] = value => Math[method](value);
15
18
  });
16
19
 
17
20
  export default transforms;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * commonJS Bootstrap loading - Stonyx must be loaded first, prior to the rest of the application
3
+ */
4
+ const { default:Stonyx } = require('stonyx');
5
+ const { default:config } = require('./config/environment.js');
6
+
7
+ // Override paths for tests
8
+ Object.assign(config.paths, {
9
+ access: './test/sample/access',
10
+ model: './test/sample/models',
11
+ serializer: './test/sample/serializers',
12
+ transform: './test/sample/transforms'
13
+ })
14
+
15
+ // Override db settings for tests
16
+ Object.assign(config.db, {
17
+ file: './test/sample/db.json',
18
+ schema: './test/sample/db-schema.js'
19
+ });
20
+
21
+ // Create restServer module path for tests
22
+ config.modules = {
23
+ restServer: {
24
+ dir: './test/sample/requests'
25
+ }
26
+ }
27
+
28
+ new Stonyx(config, __dirname);
29
+
30
+ module.exports = Stonyx;
package/.nvmrc DELETED
@@ -1 +0,0 @@
1
- v22.18.0
package/src/utils.js DELETED
@@ -1,19 +0,0 @@
1
- export function get(obj, path) {
2
- if (arguments.length !== 2) return console.error('Get must be called with two arguments; an object and a property key.');
3
- if (!obj) return console.error(`Cannot call get with '${path}' on an undefined object.`);
4
- if (typeof path !== 'string') return console.error('The path provided to get must be a string.');
5
-
6
- for (const key of path.split('.')) {
7
- if (obj[key] === undefined) return null;
8
-
9
- obj = obj[key];
10
- }
11
-
12
- return obj;
13
- }
14
-
15
- export function getComputedProperties(classInstance) {
16
- return Object.entries(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(classInstance))).filter(
17
- ([ key, descriptor ]) => key !== 'constructor' && descriptor.get
18
- ).map(([ key, descriptor ]) => [ key, descriptor.get ]);
19
- }