@stonyx/orm 0.1.0 → 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/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 Stone Costa
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the 'License');
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import Model from './model.js';
|
|
18
|
+
import Serializer from './serializer.js';
|
|
19
|
+
|
|
20
|
+
import attr from './attr.js';
|
|
21
|
+
import belongsTo from './belongs-to.js';
|
|
22
|
+
import hasMany from './has-many.js';
|
|
23
|
+
import { createRecord, updateRecord } from './manage-record.js';
|
|
24
|
+
|
|
25
|
+
export { default } from './main.js';
|
|
26
|
+
export { store, relationships } from './main.js';
|
|
27
|
+
export { Model, Serializer }; // base classes
|
|
28
|
+
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
package/src/main.js
CHANGED
|
@@ -14,50 +14,85 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import DB from
|
|
18
|
-
import config from
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
import
|
|
26
|
-
|
|
17
|
+
import DB from './db.js';
|
|
18
|
+
import config from 'stonyx/config';
|
|
19
|
+
import log from 'stonyx/log';
|
|
20
|
+
import { forEachFileImport } from '@stonyx/utils/file';
|
|
21
|
+
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
22
|
+
import setupRestServer from './setup-rest-server.js';
|
|
23
|
+
import baseTransforms from './transforms.js';
|
|
24
|
+
import Store from './store.js';
|
|
25
|
+
import Serializer from './serializer.js';
|
|
26
|
+
|
|
27
|
+
const defaultOptions = {
|
|
28
|
+
dbType: 'json'
|
|
29
|
+
}
|
|
27
30
|
|
|
28
31
|
export default class Orm {
|
|
29
|
-
initialized = false;
|
|
32
|
+
static initialized = false;
|
|
33
|
+
static relationships = new Map();
|
|
34
|
+
static store = new Store();
|
|
30
35
|
|
|
31
36
|
models = {};
|
|
32
37
|
serializers = {};
|
|
33
38
|
transforms = { ...baseTransforms };
|
|
39
|
+
warnings = new Set();
|
|
34
40
|
|
|
35
|
-
constructor() {
|
|
41
|
+
constructor(options={}) {
|
|
36
42
|
if (Orm.instance) return Orm.instance;
|
|
37
43
|
|
|
44
|
+
const { relationships } = Orm;
|
|
45
|
+
|
|
46
|
+
// Declare relationship maps
|
|
47
|
+
for (const key of ['hasMany', 'belongsTo', 'global', 'pending', 'pendingBelongsTo']) {
|
|
48
|
+
relationships.set(key, new Map());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.options = { ...defaultOptions, ...options };
|
|
52
|
+
|
|
38
53
|
Orm.instance = this;
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
async init() {
|
|
42
|
-
const { paths } = config.orm;
|
|
57
|
+
const { paths, restServer } = config.orm;
|
|
43
58
|
const promises = ['Model', 'Serializer', 'Transform'].map(type => {
|
|
44
59
|
const lowerCaseType = type.toLowerCase();
|
|
45
60
|
const path = paths[lowerCaseType];
|
|
61
|
+
|
|
46
62
|
if (!path) throw new Error(`Configuration Error: ORM path for "${type}" must be defined.`);
|
|
47
63
|
|
|
48
64
|
return forEachFileImport(path, (exported, { name }) => {
|
|
49
65
|
// Transforms keep their original name, everything else gets converted to PascalCase with the type suffix
|
|
50
66
|
const alias = type === 'Transform' ? name : `${kebabCaseToPascalCase(name)}${type}`;
|
|
51
67
|
|
|
68
|
+
if (type === 'Model') Orm.store.set(name, new Map());
|
|
69
|
+
|
|
52
70
|
return this[pluralize(lowerCaseType)][alias] = exported;
|
|
53
|
-
}, { ignoreAccessFailure: true, rawName: true });
|
|
71
|
+
}, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
|
|
54
72
|
});
|
|
55
73
|
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
// Wait for imports before db & rest server setup
|
|
58
75
|
await Promise.all(promises);
|
|
59
|
-
|
|
60
|
-
this.
|
|
76
|
+
|
|
77
|
+
if (this.options.dbType !== 'none') {
|
|
78
|
+
const db = new DB();
|
|
79
|
+
this.db = db;
|
|
80
|
+
|
|
81
|
+
promises.push(db.init());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (restServer.enabled === 'true') {
|
|
85
|
+
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Orm.ready = await Promise.all(promises);
|
|
89
|
+
Orm.initialized = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static get db() {
|
|
93
|
+
if (!Orm.initialized) throw new Error('ORM has not been initialized yet');
|
|
94
|
+
|
|
95
|
+
return Orm.instance.db;
|
|
61
96
|
}
|
|
62
97
|
|
|
63
98
|
getRecordClasses(modelName) {
|
|
@@ -65,36 +100,20 @@ export default class Orm {
|
|
|
65
100
|
|
|
66
101
|
return {
|
|
67
102
|
modelClass: this.models[`${modelClassPrefix}Model`],
|
|
68
|
-
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] ||
|
|
103
|
+
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
69
104
|
};
|
|
70
105
|
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export { BaseModel, BaseSerializer };
|
|
74
106
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
export function belongsTo(modelName) {
|
|
80
|
-
return rawData => createRecord(modelName, rawData);
|
|
81
|
-
}
|
|
107
|
+
// Queue warnings to avoid the same error from being logged in the same iteration
|
|
108
|
+
warn(message) {
|
|
109
|
+
this.warnings.add(message);
|
|
82
110
|
|
|
83
|
-
|
|
84
|
-
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
this.warnings.forEach(warning => log.warn(warning));
|
|
113
|
+
this.warnings.clear();
|
|
114
|
+
}, 0);
|
|
115
|
+
}
|
|
85
116
|
}
|
|
86
117
|
|
|
87
|
-
export
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const { modelClass, serializerClass } = Orm.instance.getRecordClasses(modelName);
|
|
91
|
-
|
|
92
|
-
if (!modelClass) throw new Error(`A model named "${modelName}" does not exist`);
|
|
93
|
-
|
|
94
|
-
const model = new modelClass(modelName);
|
|
95
|
-
const serializer = new serializerClass(model);
|
|
96
|
-
const record = new Record(model, serializer);
|
|
97
|
-
|
|
98
|
-
record.serialize(rawData);
|
|
99
|
-
return record;
|
|
100
|
-
}
|
|
118
|
+
export const store = Orm.store;
|
|
119
|
+
export const relationships = Orm.relationships;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import Orm, { store, relationships } from '@stonyx/orm';
|
|
2
|
+
import Record from './record.js';
|
|
3
|
+
|
|
4
|
+
const defaultOptions = {
|
|
5
|
+
isDbRecord: false,
|
|
6
|
+
serialize: true,
|
|
7
|
+
transform: true
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createRecord(modelName, rawData={}, userOptions={}) {
|
|
11
|
+
const orm = Orm.instance;
|
|
12
|
+
const { initialized } = Orm;
|
|
13
|
+
const options = { ...defaultOptions, ...userOptions };
|
|
14
|
+
|
|
15
|
+
if (!initialized && !options.isDbRecord) throw new Error('ORM is not ready');
|
|
16
|
+
|
|
17
|
+
const modelStore = store.get(modelName);
|
|
18
|
+
const globalRelationships = relationships.get('global');
|
|
19
|
+
const pendingRelationships = relationships.get('pending');
|
|
20
|
+
|
|
21
|
+
assignRecordId(modelName, rawData);
|
|
22
|
+
if (modelStore.has(rawData.id)) return modelStore.get(rawData.id);
|
|
23
|
+
|
|
24
|
+
const { modelClass, serializerClass } = orm.getRecordClasses(modelName);
|
|
25
|
+
|
|
26
|
+
if (!modelClass) throw new Error(`A model named '${modelName}' does not exist`);
|
|
27
|
+
|
|
28
|
+
const model = new modelClass(modelName);
|
|
29
|
+
const serializer = new serializerClass(model);
|
|
30
|
+
const record = new Record(model, serializer);
|
|
31
|
+
|
|
32
|
+
record.serialize(rawData, options);
|
|
33
|
+
modelStore.set(record.id, record);
|
|
34
|
+
|
|
35
|
+
// populate global hasMany relationships
|
|
36
|
+
const globalHasMany = globalRelationships.get(modelName);
|
|
37
|
+
if (globalHasMany) for (const relationship of globalHasMany) relationship.push(record);
|
|
38
|
+
|
|
39
|
+
// populate pending hasMany relationships and clear the queue
|
|
40
|
+
const pendingHasMany = pendingRelationships.get(modelName)?.get(record.id);
|
|
41
|
+
if (pendingHasMany) {
|
|
42
|
+
for (const relationship of pendingHasMany) relationship.push(record);
|
|
43
|
+
pendingHasMany.splice(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fulfill pending belongsTo relationships
|
|
47
|
+
const pendingBelongsToQueue = relationships.get('pendingBelongsTo');
|
|
48
|
+
const pendingBelongsTo = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
49
|
+
|
|
50
|
+
if (pendingBelongsTo) {
|
|
51
|
+
const belongsToReg = relationships.get('belongsTo');
|
|
52
|
+
const hasManyReg = relationships.get('hasMany');
|
|
53
|
+
|
|
54
|
+
for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
|
|
55
|
+
// Update the belongsTo relationship on the source record
|
|
56
|
+
sourceRecord.__relationships[relationshipKey] = record;
|
|
57
|
+
sourceRecord[relationshipKey] = record; // Also update the direct property
|
|
58
|
+
|
|
59
|
+
// Update the belongsTo relationship registry
|
|
60
|
+
const sourceModelReg = belongsToReg.get(sourceModelName);
|
|
61
|
+
if (sourceModelReg) {
|
|
62
|
+
const targetModelReg = sourceModelReg.get(modelName);
|
|
63
|
+
if (targetModelReg) {
|
|
64
|
+
targetModelReg.set(relationshipId, record);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Wire inverse hasMany if it exists
|
|
69
|
+
const inverseHasMany = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
|
|
70
|
+
|
|
71
|
+
if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
|
|
72
|
+
inverseHasMany.push(sourceRecord);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Clear the pending queue
|
|
77
|
+
pendingBelongsTo.length = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return record;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function updateRecord(record, rawData, userOptions={}) {
|
|
84
|
+
if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
|
|
85
|
+
|
|
86
|
+
const options = { ...defaultOptions, ...userOptions, update:true };
|
|
87
|
+
|
|
88
|
+
record.serialize(rawData, options);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* gets the next available id based on last record entry.
|
|
93
|
+
*
|
|
94
|
+
* Note/TODO: Records going into a db should get their id from the db instead
|
|
95
|
+
* Atm, i think the best way to do that would be as an id override that happens after the
|
|
96
|
+
* record is created
|
|
97
|
+
*/
|
|
98
|
+
function assignRecordId(modelName, rawData) {
|
|
99
|
+
if (rawData.id) return;
|
|
100
|
+
|
|
101
|
+
const modelStore = Array.from(store.get(modelName).values());
|
|
102
|
+
rawData.id = modelStore.length ? modelStore.at(-1).id + 1 : 1;
|
|
103
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import Orm from '@stonyx/orm';
|
|
3
|
+
import config from 'stonyx/config';
|
|
4
|
+
import { dbKey } from './db.js';
|
|
5
|
+
|
|
6
|
+
export default class MetaRequest extends Request {
|
|
7
|
+
constructor() {
|
|
8
|
+
super(...arguments);
|
|
9
|
+
|
|
10
|
+
this.handlers = {
|
|
11
|
+
get: {
|
|
12
|
+
'/meta': () => {
|
|
13
|
+
try {
|
|
14
|
+
const { models } = Orm.instance;
|
|
15
|
+
const metadata = {};
|
|
16
|
+
|
|
17
|
+
for (const [modelName, modelClass] of Object.entries(models)) {
|
|
18
|
+
const name = modelName.slice(0, -5).toLowerCase();
|
|
19
|
+
|
|
20
|
+
if (name === dbKey) continue;
|
|
21
|
+
|
|
22
|
+
const model = new modelClass(modelName);
|
|
23
|
+
const properties = {};
|
|
24
|
+
|
|
25
|
+
// Get regular properties and relationships
|
|
26
|
+
for (const [key, property] of Object.entries(model)) {
|
|
27
|
+
// Skip internal properties
|
|
28
|
+
if (key.startsWith('__')) continue;
|
|
29
|
+
|
|
30
|
+
if (property?.constructor?.name === 'ModelProperty') {
|
|
31
|
+
properties[key] = { type: property.type };
|
|
32
|
+
} else if (typeof property === 'function') {
|
|
33
|
+
const isBelongsTo = property.toString().includes(`getRelationships('belongsTo',`);
|
|
34
|
+
const isHasMany = property.toString().includes(`getRelationships('hasMany',`);
|
|
35
|
+
|
|
36
|
+
if (isBelongsTo || isHasMany) properties[key] = { [isBelongsTo ? 'belongsTo' : 'hasMany']: name };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
metadata[name] = properties;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return metadata;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { error: error.message };
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
auth() {
|
|
53
|
+
if (!config.orm.restServer.metaRoute) return 403;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/model-property.js
CHANGED
|
@@ -17,6 +17,11 @@ export default class ModelProperty {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
set value(newValue) {
|
|
20
|
+
if (this.ignoreFirstTransform) {
|
|
21
|
+
delete this.ignoreFirstTransform;
|
|
22
|
+
return this._value = newValue;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
this._value = Orm.instance.transforms[this.type](newValue);
|
|
21
26
|
}
|
|
22
27
|
}
|
package/src/model.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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'];
|