@stonyx/orm 0.2.1-beta.7 → 0.2.1-beta.70
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/index.md +16 -3
- package/.claude/usage-patterns.md +83 -0
- package/.claude/views.md +292 -0
- package/README.md +37 -0
- package/config/environment.js +3 -1
- package/package.json +11 -6
- package/scripts/setup-test-db.sh +21 -0
- package/src/aggregates.js +93 -0
- package/src/belongs-to.js +4 -1
- package/src/has-many.js +4 -1
- package/src/index.js +11 -2
- package/src/main.js +47 -3
- package/src/manage-record.js +11 -0
- package/src/model-property.js +2 -2
- package/src/model.js +11 -0
- package/src/mysql/migration-generator.js +103 -5
- package/src/mysql/mysql-db.js +161 -8
- package/src/mysql/schema-introspector.js +182 -15
- package/src/orm-request.js +32 -26
- package/src/plural-registry.js +12 -0
- package/src/record.js +2 -2
- package/src/serializer.js +9 -2
- package/src/setup-rest-server.js +3 -3
- package/src/store.js +130 -1
- package/src/view-resolver.js +183 -0
- package/src/view.js +21 -0
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import Orm from '@stonyx/orm';
|
|
2
2
|
import { getMysqlType } from './type-map.js';
|
|
3
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
-
import {
|
|
4
|
+
import { getPluralName } from '../plural-registry.js';
|
|
5
5
|
import { dbKey } from '../db.js';
|
|
6
|
+
import { AggregateProperty } from '../aggregates.js';
|
|
6
7
|
|
|
7
8
|
function getRelationshipInfo(property) {
|
|
8
9
|
if (typeof property !== 'function') return null;
|
|
9
10
|
const fnStr = property.toString();
|
|
11
|
+
const modelName = property.__relatedModelName || null;
|
|
10
12
|
|
|
11
|
-
if (fnStr.includes(`getRelationships('belongsTo',`)) return 'belongsTo';
|
|
12
|
-
if (fnStr.includes(`getRelationships('hasMany',`)) return 'hasMany';
|
|
13
|
+
if (fnStr.includes(`getRelationships('belongsTo',`)) return { type: 'belongsTo', modelName };
|
|
14
|
+
if (fnStr.includes(`getRelationships('hasMany',`)) return { type: 'hasMany', modelName };
|
|
13
15
|
|
|
14
16
|
return null;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
function sanitizeTableName(name) {
|
|
20
|
+
return name.replace(/[-/]/g, '_');
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export function introspectModels() {
|
|
18
24
|
const { models } = Orm.instance;
|
|
19
25
|
const schemas = {};
|
|
@@ -34,12 +40,12 @@ export function introspectModels() {
|
|
|
34
40
|
for (const [key, property] of Object.entries(model)) {
|
|
35
41
|
if (key.startsWith('__')) continue;
|
|
36
42
|
|
|
37
|
-
const
|
|
43
|
+
const relInfo = getRelationshipInfo(property);
|
|
38
44
|
|
|
39
|
-
if (
|
|
40
|
-
relationships.belongsTo[key] =
|
|
41
|
-
} else if (
|
|
42
|
-
relationships.hasMany[key] =
|
|
45
|
+
if (relInfo?.type === 'belongsTo') {
|
|
46
|
+
relationships.belongsTo[key] = relInfo.modelName;
|
|
47
|
+
} else if (relInfo?.type === 'hasMany') {
|
|
48
|
+
relationships.hasMany[key] = relInfo.modelName;
|
|
43
49
|
} else if (property?.constructor?.name === 'ModelProperty') {
|
|
44
50
|
if (key === 'id') {
|
|
45
51
|
idType = property.type;
|
|
@@ -50,20 +56,21 @@ export function introspectModels() {
|
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
// Build foreign keys from belongsTo relationships
|
|
53
|
-
for (const relName of Object.
|
|
59
|
+
for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
|
|
54
60
|
const fkColumn = `${relName}_id`;
|
|
55
61
|
foreignKeys[fkColumn] = {
|
|
56
|
-
references:
|
|
62
|
+
references: sanitizeTableName(getPluralName(targetModelName)),
|
|
57
63
|
column: 'id',
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
schemas[name] = {
|
|
62
|
-
table:
|
|
68
|
+
table: sanitizeTableName(getPluralName(name)),
|
|
63
69
|
idType,
|
|
64
70
|
columns,
|
|
65
71
|
foreignKeys,
|
|
66
72
|
relationships,
|
|
73
|
+
memory: modelClass.memory === true,
|
|
67
74
|
};
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -71,7 +78,8 @@ export function introspectModels() {
|
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export function buildTableDDL(name, schema, allSchemas = {}) {
|
|
74
|
-
const {
|
|
81
|
+
const { idType, columns, foreignKeys } = schema;
|
|
82
|
+
const table = sanitizeTableName(schema.table);
|
|
75
83
|
const lines = [];
|
|
76
84
|
|
|
77
85
|
// Primary key
|
|
@@ -98,7 +106,8 @@ export function buildTableDDL(name, schema, allSchemas = {}) {
|
|
|
98
106
|
|
|
99
107
|
// Foreign key constraints
|
|
100
108
|
for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
|
|
101
|
-
|
|
109
|
+
const refTable = sanitizeTableName(fkDef.references);
|
|
110
|
+
lines.push(` FOREIGN KEY (\`${fkCol}\`) REFERENCES \`${refTable}\`(\`${fkDef.column}\`) ON DELETE SET NULL`);
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
return `CREATE TABLE IF NOT EXISTS \`${table}\` (\n${lines.join(',\n')}\n)`;
|
|
@@ -128,8 +137,8 @@ export function getTopologicalOrder(schemas) {
|
|
|
128
137
|
if (!schema) return;
|
|
129
138
|
|
|
130
139
|
// Visit dependencies (belongsTo targets) first
|
|
131
|
-
for (const
|
|
132
|
-
visit(
|
|
140
|
+
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
141
|
+
visit(targetModelName);
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
order.push(name);
|
|
@@ -142,6 +151,164 @@ export function getTopologicalOrder(schemas) {
|
|
|
142
151
|
return order;
|
|
143
152
|
}
|
|
144
153
|
|
|
154
|
+
export function introspectViews() {
|
|
155
|
+
const orm = Orm.instance;
|
|
156
|
+
if (!orm.views) return {};
|
|
157
|
+
|
|
158
|
+
const schemas = {};
|
|
159
|
+
|
|
160
|
+
for (const [viewKey, viewClass] of Object.entries(orm.views)) {
|
|
161
|
+
const name = camelCaseToKebabCase(viewKey.slice(0, -4)); // Remove 'View' suffix
|
|
162
|
+
|
|
163
|
+
const source = viewClass.source;
|
|
164
|
+
if (!source) continue;
|
|
165
|
+
|
|
166
|
+
const model = new viewClass(name);
|
|
167
|
+
const columns = {};
|
|
168
|
+
const foreignKeys = {};
|
|
169
|
+
const aggregates = {};
|
|
170
|
+
const relationships = { belongsTo: {}, hasMany: {} };
|
|
171
|
+
|
|
172
|
+
for (const [key, property] of Object.entries(model)) {
|
|
173
|
+
if (key.startsWith('__')) continue;
|
|
174
|
+
if (key === 'id') continue;
|
|
175
|
+
|
|
176
|
+
if (property instanceof AggregateProperty) {
|
|
177
|
+
aggregates[key] = property;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const relInfo = getRelationshipInfo(property);
|
|
182
|
+
|
|
183
|
+
if (relInfo?.type === 'belongsTo') {
|
|
184
|
+
relationships.belongsTo[key] = relInfo.modelName;
|
|
185
|
+
const fkColumn = `${key}_id`;
|
|
186
|
+
foreignKeys[fkColumn] = {
|
|
187
|
+
references: sanitizeTableName(getPluralName(relInfo.modelName)),
|
|
188
|
+
column: 'id',
|
|
189
|
+
};
|
|
190
|
+
} else if (relInfo?.type === 'hasMany') {
|
|
191
|
+
relationships.hasMany[key] = relInfo.modelName;
|
|
192
|
+
} else if (property?.constructor?.name === 'ModelProperty') {
|
|
193
|
+
const transforms = Orm.instance.transforms;
|
|
194
|
+
columns[key] = getMysqlType(property.type, transforms[property.type]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
schemas[name] = {
|
|
199
|
+
viewName: sanitizeTableName(getPluralName(name)),
|
|
200
|
+
source,
|
|
201
|
+
groupBy: viewClass.groupBy || undefined,
|
|
202
|
+
columns,
|
|
203
|
+
foreignKeys,
|
|
204
|
+
aggregates,
|
|
205
|
+
relationships,
|
|
206
|
+
isView: true,
|
|
207
|
+
memory: viewClass.memory !== false ? false : false, // Views default to memory:false
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return schemas;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildViewDDL(name, viewSchema, modelSchemas = {}) {
|
|
215
|
+
if (!viewSchema.source) {
|
|
216
|
+
throw new Error(`View '${name}' must define a source model`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const sourceModelName = viewSchema.source;
|
|
220
|
+
const sourceSchema = modelSchemas[sourceModelName];
|
|
221
|
+
const sourceTable = sanitizeTableName(sourceSchema
|
|
222
|
+
? sourceSchema.table
|
|
223
|
+
: getPluralName(sourceModelName));
|
|
224
|
+
|
|
225
|
+
const selectColumns = [];
|
|
226
|
+
const joins = [];
|
|
227
|
+
const hasAggregates = Object.keys(viewSchema.aggregates || {}).length > 0;
|
|
228
|
+
const groupByField = viewSchema.groupBy;
|
|
229
|
+
|
|
230
|
+
// ID column: groupBy field or source table PK
|
|
231
|
+
if (groupByField) {
|
|
232
|
+
selectColumns.push(`\`${sourceTable}\`.\`${groupByField}\` AS \`id\``);
|
|
233
|
+
} else {
|
|
234
|
+
selectColumns.push(`\`${sourceTable}\`.\`id\` AS \`id\``);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Aggregate columns
|
|
238
|
+
for (const [key, aggProp] of Object.entries(viewSchema.aggregates || {})) {
|
|
239
|
+
if (aggProp.relationship === undefined) {
|
|
240
|
+
// Field-level aggregate (groupBy views)
|
|
241
|
+
if (aggProp.aggregateType === 'count') {
|
|
242
|
+
selectColumns.push(`COUNT(*) AS \`${key}\``);
|
|
243
|
+
} else {
|
|
244
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${sourceTable}\`.\`${aggProp.field}\`) AS \`${key}\``);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// Relationship aggregate
|
|
248
|
+
const relName = aggProp.relationship;
|
|
249
|
+
const relTable = sanitizeTableName(getPluralName(relName));
|
|
250
|
+
|
|
251
|
+
if (aggProp.aggregateType === 'count') {
|
|
252
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`id\`) AS \`${key}\``);
|
|
253
|
+
} else {
|
|
254
|
+
const field = aggProp.field;
|
|
255
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`${field}\`) AS \`${key}\``);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Add LEFT JOIN for the relationship if not already added
|
|
259
|
+
const joinKey = `${relTable}`;
|
|
260
|
+
if (!joins.find(j => j.table === joinKey)) {
|
|
261
|
+
const fkColumn = `${sourceModelName}_id`;
|
|
262
|
+
joins.push({
|
|
263
|
+
table: relTable,
|
|
264
|
+
condition: `\`${relTable}\`.\`${fkColumn}\` = \`${sourceTable}\`.\`id\``
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Regular columns (from resolve map string paths or direct attr fields)
|
|
271
|
+
for (const [key, mysqlType] of Object.entries(viewSchema.columns || {})) {
|
|
272
|
+
selectColumns.push(`\`${sourceTable}\`.\`${key}\` AS \`${key}\``);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Build JOIN clauses
|
|
276
|
+
const joinClauses = joins.map(j =>
|
|
277
|
+
`LEFT JOIN \`${j.table}\` ON ${j.condition}`
|
|
278
|
+
).join('\n ');
|
|
279
|
+
|
|
280
|
+
// Build GROUP BY
|
|
281
|
+
let groupBy = '';
|
|
282
|
+
if (groupByField) {
|
|
283
|
+
groupBy = `\nGROUP BY \`${sourceTable}\`.\`${groupByField}\``;
|
|
284
|
+
} else if (hasAggregates) {
|
|
285
|
+
groupBy = `\nGROUP BY \`${sourceTable}\`.\`id\``;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const viewName = sanitizeTableName(viewSchema.viewName);
|
|
289
|
+
const sql = `CREATE OR REPLACE VIEW \`${viewName}\` AS\nSELECT\n ${selectColumns.join(',\n ')}\nFROM \`${sourceTable}\`${joinClauses ? '\n ' + joinClauses : ''}${groupBy}`;
|
|
290
|
+
|
|
291
|
+
return sql;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function viewSchemasToSnapshot(viewSchemas) {
|
|
295
|
+
const snapshot = {};
|
|
296
|
+
|
|
297
|
+
for (const [name, schema] of Object.entries(viewSchemas)) {
|
|
298
|
+
snapshot[name] = {
|
|
299
|
+
viewName: schema.viewName,
|
|
300
|
+
source: schema.source,
|
|
301
|
+
...(schema.groupBy ? { groupBy: schema.groupBy } : {}),
|
|
302
|
+
columns: { ...schema.columns },
|
|
303
|
+
foreignKeys: { ...schema.foreignKeys },
|
|
304
|
+
isView: true,
|
|
305
|
+
viewQuery: buildViewDDL(name, schema),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return snapshot;
|
|
310
|
+
}
|
|
311
|
+
|
|
145
312
|
export function schemasToSnapshot(schemas) {
|
|
146
313
|
const snapshot = {};
|
|
147
314
|
|
package/src/orm-request.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
2
|
import Orm, { createRecord, updateRecord, store } from '@stonyx/orm';
|
|
3
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
-
import {
|
|
4
|
+
import { getPluralName } from './plural-registry.js';
|
|
5
5
|
import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
6
6
|
import config from 'stonyx/config';
|
|
7
7
|
|
|
@@ -215,13 +215,13 @@ export default class OrmRequest extends Request {
|
|
|
215
215
|
|
|
216
216
|
this.model = model;
|
|
217
217
|
this.access = access;
|
|
218
|
-
const pluralizedModel =
|
|
218
|
+
const pluralizedModel = getPluralName(model);
|
|
219
219
|
|
|
220
220
|
const modelRelationships = getModelRelationships(model);
|
|
221
221
|
|
|
222
222
|
// Define raw handlers first
|
|
223
|
-
const getCollectionHandler = (request, { filter: accessFilter }) => {
|
|
224
|
-
const allRecords =
|
|
223
|
+
const getCollectionHandler = async (request, { filter: accessFilter }) => {
|
|
224
|
+
const allRecords = await store.findAll(model);
|
|
225
225
|
|
|
226
226
|
const queryFilters = parseFilters(request.query);
|
|
227
227
|
const queryFilterPredicate = createFilterPredicate(queryFilters);
|
|
@@ -241,8 +241,8 @@ export default class OrmRequest extends Request {
|
|
|
241
241
|
});
|
|
242
242
|
};
|
|
243
243
|
|
|
244
|
-
const getSingleHandler = (request) => {
|
|
245
|
-
const record = store.
|
|
244
|
+
const getSingleHandler = async (request) => {
|
|
245
|
+
const record = await store.find(model, getId(request.params));
|
|
246
246
|
if (!record) return 404;
|
|
247
247
|
|
|
248
248
|
const fieldsMap = parseFields(request.query);
|
|
@@ -255,7 +255,7 @@ export default class OrmRequest extends Request {
|
|
|
255
255
|
});
|
|
256
256
|
};
|
|
257
257
|
|
|
258
|
-
const createHandler = ({ body, query }) => {
|
|
258
|
+
const createHandler = async ({ body, query }) => {
|
|
259
259
|
const { type, id, attributes, relationships: rels } = body?.data || {};
|
|
260
260
|
|
|
261
261
|
if (!type) return 400; // Bad request
|
|
@@ -264,7 +264,7 @@ export default class OrmRequest extends Request {
|
|
|
264
264
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
265
265
|
|
|
266
266
|
// Check for duplicate ID
|
|
267
|
-
if (id !== undefined && store.
|
|
267
|
+
if (id !== undefined && await store.find(model, id)) return 409; // Conflict
|
|
268
268
|
|
|
269
269
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
270
270
|
|
|
@@ -285,7 +285,7 @@ export default class OrmRequest extends Request {
|
|
|
285
285
|
};
|
|
286
286
|
|
|
287
287
|
const updateHandler = async ({ body, params }) => {
|
|
288
|
-
const record = store.
|
|
288
|
+
const record = await store.find(model, getId(params));
|
|
289
289
|
const { attributes, relationships: rels } = body?.data || {};
|
|
290
290
|
|
|
291
291
|
if (!attributes && !rels) return 400; // Bad request
|
|
@@ -323,21 +323,27 @@ export default class OrmRequest extends Request {
|
|
|
323
323
|
};
|
|
324
324
|
|
|
325
325
|
// Wrap handlers with hooks
|
|
326
|
+
const isView = Orm.instance?.isView?.(model);
|
|
327
|
+
|
|
326
328
|
this.handlers = {
|
|
327
329
|
get: {
|
|
328
330
|
'/': this._withHooks('list', getCollectionHandler),
|
|
329
331
|
'/:id': this._withHooks('get', getSingleHandler),
|
|
330
332
|
...this._generateRelationshipRoutes(model, pluralizedModel, modelRelationships)
|
|
331
333
|
},
|
|
332
|
-
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Views are read-only — no write endpoints
|
|
337
|
+
if (!isView) {
|
|
338
|
+
this.handlers.patch = {
|
|
333
339
|
'/:id': this._withHooks('update', updateHandler)
|
|
334
|
-
}
|
|
335
|
-
post
|
|
340
|
+
};
|
|
341
|
+
this.handlers.post = {
|
|
336
342
|
'/': this._withHooks('create', createHandler)
|
|
337
|
-
}
|
|
338
|
-
delete
|
|
343
|
+
};
|
|
344
|
+
this.handlers.delete = {
|
|
339
345
|
'/:id': this._withHooks('delete', deleteHandler)
|
|
340
|
-
}
|
|
346
|
+
};
|
|
341
347
|
}
|
|
342
348
|
}
|
|
343
349
|
|
|
@@ -357,7 +363,7 @@ export default class OrmRequest extends Request {
|
|
|
357
363
|
|
|
358
364
|
// Capture old state for operations that modify data
|
|
359
365
|
if (operation === 'update' || operation === 'delete') {
|
|
360
|
-
const existingRecord = store.
|
|
366
|
+
const existingRecord = await store.find(this.model, getId(request.params));
|
|
361
367
|
if (existingRecord) {
|
|
362
368
|
// Deep copy the record's data to preserve old state
|
|
363
369
|
context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
|
|
@@ -388,9 +394,9 @@ export default class OrmRequest extends Request {
|
|
|
388
394
|
context.response = response;
|
|
389
395
|
|
|
390
396
|
if (operation === 'get' && response?.data && !Array.isArray(response.data)) {
|
|
391
|
-
context.record = store.
|
|
397
|
+
context.record = await store.find(this.model, getId(request.params));
|
|
392
398
|
} else if (operation === 'list' && response?.data) {
|
|
393
|
-
context.records =
|
|
399
|
+
context.records = await store.findAll(this.model);
|
|
394
400
|
} else if (operation === 'create' && response?.data?.id) {
|
|
395
401
|
// For create, get the record from store using the ID from the response
|
|
396
402
|
const recordId = isNaN(response.data.id) ? response.data.id : parseInt(response.data.id);
|
|
@@ -424,8 +430,8 @@ export default class OrmRequest extends Request {
|
|
|
424
430
|
const dasherizedName = camelCaseToKebabCase(relationshipName);
|
|
425
431
|
|
|
426
432
|
// Related resource route: GET /:id/{relationship}
|
|
427
|
-
routes[`/:id/${dasherizedName}`] = (request) => {
|
|
428
|
-
const record = store.
|
|
433
|
+
routes[`/:id/${dasherizedName}`] = async (request) => {
|
|
434
|
+
const record = await store.find(model, getId(request.params));
|
|
429
435
|
if (!record) return 404;
|
|
430
436
|
|
|
431
437
|
const relatedData = record.__relationships[relationshipName];
|
|
@@ -447,8 +453,8 @@ export default class OrmRequest extends Request {
|
|
|
447
453
|
};
|
|
448
454
|
|
|
449
455
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
450
|
-
routes[`/:id/relationships/${dasherizedName}`] = (request) => {
|
|
451
|
-
const record = store.
|
|
456
|
+
routes[`/:id/relationships/${dasherizedName}`] = async (request) => {
|
|
457
|
+
const record = await store.find(model, getId(request.params));
|
|
452
458
|
if (!record) return 404;
|
|
453
459
|
|
|
454
460
|
const relatedData = record.__relationships[relationshipName];
|
|
@@ -474,8 +480,8 @@ export default class OrmRequest extends Request {
|
|
|
474
480
|
}
|
|
475
481
|
|
|
476
482
|
// Catch-all for invalid relationship names on related resource route
|
|
477
|
-
routes[`/:id/:relationship`] = (request) => {
|
|
478
|
-
const record = store.
|
|
483
|
+
routes[`/:id/:relationship`] = async (request) => {
|
|
484
|
+
const record = await store.find(model, getId(request.params));
|
|
479
485
|
if (!record) return 404;
|
|
480
486
|
|
|
481
487
|
// If we reach here, relationship doesn't exist (valid ones were registered above)
|
|
@@ -483,8 +489,8 @@ export default class OrmRequest extends Request {
|
|
|
483
489
|
};
|
|
484
490
|
|
|
485
491
|
// Catch-all for invalid relationship names on relationship linkage route
|
|
486
|
-
routes[`/:id/relationships/:relationship`] = (request) => {
|
|
487
|
-
const record = store.
|
|
492
|
+
routes[`/:id/relationships/:relationship`] = async (request) => {
|
|
493
|
+
const record = await store.find(model, getId(request.params));
|
|
488
494
|
if (!record) return 404;
|
|
489
495
|
|
|
490
496
|
return 404;
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
import { store } from './index.js';
|
|
2
2
|
import { getComputedProperties } from "./serializer.js";
|
|
3
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
|
-
import {
|
|
4
|
+
import { getPluralName } from './plural-registry.js';
|
|
5
5
|
export default class Record {
|
|
6
6
|
__data = {};
|
|
7
7
|
__relationships = {};
|
|
@@ -57,7 +57,7 @@ export default class Record {
|
|
|
57
57
|
const { fields, baseUrl } = options;
|
|
58
58
|
const { __data:data } = this;
|
|
59
59
|
const modelName = this.__model.__name;
|
|
60
|
-
const pluralizedModelName =
|
|
60
|
+
const pluralizedModelName = getPluralName(modelName);
|
|
61
61
|
const recordId = data.id;
|
|
62
62
|
const relationships = {};
|
|
63
63
|
const attributes = {};
|
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
|
-
//
|
|
75
|
-
if (
|
|
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;
|
package/src/setup-rest-server.js
CHANGED
|
@@ -5,7 +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 {
|
|
8
|
+
import { getPluralName } from './plural-registry.js';
|
|
9
9
|
import log from 'stonyx/log';
|
|
10
10
|
|
|
11
11
|
export default async function(route, accessPath, metaRoute) {
|
|
@@ -41,9 +41,9 @@ export default async function(route, accessPath, metaRoute) {
|
|
|
41
41
|
// Remove "/" prefix and name mount point accordingly
|
|
42
42
|
const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
|
|
43
43
|
|
|
44
|
-
// Configure endpoints for models with access configuration
|
|
44
|
+
// Configure endpoints for models and views with access configuration
|
|
45
45
|
for (const [model, access] of Object.entries(accessFiles)) {
|
|
46
|
-
const pluralizedModel =
|
|
46
|
+
const pluralizedModel = getPluralName(model);
|
|
47
47
|
const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
|
|
48
48
|
RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
|
|
49
49
|
}
|
package/src/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { relationships } from '@stonyx/orm';
|
|
1
|
+
import Orm, { relationships } from '@stonyx/orm';
|
|
2
2
|
import { TYPES } from './relationships.js';
|
|
3
|
+
import ViewResolver from './view-resolver.js';
|
|
3
4
|
|
|
4
5
|
export default class Store {
|
|
5
6
|
constructor() {
|
|
@@ -9,17 +10,145 @@ export default class Store {
|
|
|
9
10
|
this.data = new Map();
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Synchronous memory-only access.
|
|
15
|
+
* Returns the record if it exists in the in-memory store, undefined otherwise.
|
|
16
|
+
* Does NOT query the database. For memory:false models, use find() instead.
|
|
17
|
+
*/
|
|
12
18
|
get(key, id) {
|
|
13
19
|
if (!id) return this.data.get(key);
|
|
14
20
|
|
|
15
21
|
return this.data.get(key)?.get(id);
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Async authoritative read. Always queries MySQL for memory: false models.
|
|
26
|
+
* For memory: true models, returns from store (already loaded on boot).
|
|
27
|
+
* @param {string} modelName - The model name
|
|
28
|
+
* @param {string|number} id - The record ID
|
|
29
|
+
* @returns {Promise<Record|undefined>}
|
|
30
|
+
*/
|
|
31
|
+
async find(modelName, id) {
|
|
32
|
+
// For views in non-MySQL mode, use view resolver
|
|
33
|
+
if (Orm.instance?.isView?.(modelName) && !this._mysqlDb) {
|
|
34
|
+
const resolver = new ViewResolver(modelName);
|
|
35
|
+
return resolver.resolveOne(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// For memory: true models, the store is authoritative
|
|
39
|
+
if (this._isMemoryModel(modelName)) {
|
|
40
|
+
return this.get(modelName, id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// For memory: false models, always query MySQL
|
|
44
|
+
if (this._mysqlDb) {
|
|
45
|
+
return this._mysqlDb.findRecord(modelName, id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback to store (JSON mode or no MySQL)
|
|
49
|
+
return this.get(modelName, id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Async read for all records of a model. Always queries MySQL for memory: false models.
|
|
54
|
+
* For memory: true models, returns from store.
|
|
55
|
+
* @param {string} modelName - The model name
|
|
56
|
+
* @param {Object} [conditions] - Optional WHERE conditions
|
|
57
|
+
* @returns {Promise<Record[]>}
|
|
58
|
+
*/
|
|
59
|
+
async findAll(modelName, conditions) {
|
|
60
|
+
// For views in non-MySQL mode, use view resolver
|
|
61
|
+
if (Orm.instance?.isView?.(modelName) && !this._mysqlDb) {
|
|
62
|
+
const resolver = new ViewResolver(modelName);
|
|
63
|
+
const records = await resolver.resolveAll();
|
|
64
|
+
|
|
65
|
+
if (!conditions || Object.keys(conditions).length === 0) return records;
|
|
66
|
+
|
|
67
|
+
return records.filter(record =>
|
|
68
|
+
Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// For memory: true models without conditions, return from store
|
|
73
|
+
if (this._isMemoryModel(modelName) && !conditions) {
|
|
74
|
+
const modelStore = this.get(modelName);
|
|
75
|
+
return modelStore ? Array.from(modelStore.values()) : [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// For memory: false models (or filtered queries), always query MySQL
|
|
79
|
+
if (this._mysqlDb) {
|
|
80
|
+
return this._mysqlDb.findAll(modelName, conditions);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback to store (JSON mode) — apply conditions in-memory if provided
|
|
84
|
+
const modelStore = this.get(modelName);
|
|
85
|
+
if (!modelStore) return [];
|
|
86
|
+
|
|
87
|
+
const records = Array.from(modelStore.values());
|
|
88
|
+
|
|
89
|
+
if (!conditions || Object.keys(conditions).length === 0) return records;
|
|
90
|
+
|
|
91
|
+
return records.filter(record =>
|
|
92
|
+
Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Async query — always hits MySQL, never reads from memory cache.
|
|
98
|
+
* Use for complex queries, aggregations, or when you need guaranteed freshness.
|
|
99
|
+
* @param {string} modelName - The model name
|
|
100
|
+
* @param {Object} conditions - WHERE conditions
|
|
101
|
+
* @returns {Promise<Record[]>}
|
|
102
|
+
*/
|
|
103
|
+
async query(modelName, conditions = {}) {
|
|
104
|
+
if (this._mysqlDb) {
|
|
105
|
+
return this._mysqlDb.findAll(modelName, conditions);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fallback: filter in-memory store
|
|
109
|
+
const modelStore = this.get(modelName);
|
|
110
|
+
if (!modelStore) return [];
|
|
111
|
+
|
|
112
|
+
const records = Array.from(modelStore.values());
|
|
113
|
+
|
|
114
|
+
if (Object.keys(conditions).length === 0) return records;
|
|
115
|
+
|
|
116
|
+
return records.filter(record =>
|
|
117
|
+
Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Set by Orm during init — resolves memory flag for a model name.
|
|
123
|
+
* @type {Function|null}
|
|
124
|
+
*/
|
|
125
|
+
_memoryResolver = null;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Set by Orm during init — reference to the MysqlDB instance for on-demand queries.
|
|
129
|
+
* @type {MysqlDB|null}
|
|
130
|
+
*/
|
|
131
|
+
_mysqlDb = null;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if a model is configured for in-memory storage.
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
_isMemoryModel(modelName) {
|
|
138
|
+
if (this._memoryResolver) return this._memoryResolver(modelName);
|
|
139
|
+
return false; // default to non-memory if resolver not set yet
|
|
140
|
+
}
|
|
141
|
+
|
|
18
142
|
set(key, value) {
|
|
19
143
|
this.data.set(key, value);
|
|
20
144
|
}
|
|
21
145
|
|
|
22
146
|
remove(key, id) {
|
|
147
|
+
// Guard: read-only views cannot have records removed
|
|
148
|
+
if (Orm.instance?.isView?.(key)) {
|
|
149
|
+
throw new Error(`Cannot remove records from read-only view '${key}'`);
|
|
150
|
+
}
|
|
151
|
+
|
|
23
152
|
if (id) return this.unloadRecord(key, id);
|
|
24
153
|
|
|
25
154
|
this.unloadAllRecords(key);
|