@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/.claude/CLAUDE.md +447 -0
- package/.github/workflows/ci.yml +36 -0
- package/README.md +301 -9
- package/config/environment.js +8 -0
- package/package.json +13 -9
- package/src/attr.js +28 -0
- package/src/belongs-to.js +63 -0
- package/src/db.js +42 -68
- package/src/exports/db.js +7 -0
- package/src/has-many.js +61 -0
- package/src/index.js +28 -0
- package/src/main.js +64 -45
- package/src/manage-record.js +103 -0
- package/src/meta-request.js +55 -0
- package/src/model-property.js +5 -0
- package/src/model.js +5 -1
- package/src/orm-request.js +189 -0
- package/src/record.js +72 -8
- package/src/relationships.js +43 -0
- package/src/serializer.js +41 -24
- package/src/setup-rest-server.js +57 -0
- package/src/store.js +211 -0
- package/src/transforms.js +4 -1
- package/stonyx-bootstrap.cjs +30 -0
- package/.nvmrc +0 -1
- package/src/utils.js +0 -19
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
}
|