@stonyx/orm 0.2.1-beta.8 → 0.2.1-beta.80
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/README.md +64 -6
- package/config/environment.js +3 -1
- package/package.json +20 -6
- package/src/aggregates.js +93 -0
- package/src/belongs-to.js +11 -4
- package/src/cli.js +177 -0
- package/src/db.js +14 -4
- package/src/has-many.js +8 -1
- package/src/index.js +11 -2
- package/src/main.js +47 -3
- package/src/manage-record.js +13 -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 +7 -2
- package/src/serializer.js +9 -2
- package/src/setup-rest-server.js +3 -3
- package/src/standalone-db.js +176 -0
- package/src/store.js +130 -1
- package/src/view-resolver.js +183 -0
- package/src/view.js +21 -0
- package/.claude/code-style-rules.md +0 -44
- package/.claude/hooks.md +0 -250
- package/.claude/index.md +0 -279
- package/.claude/usage-patterns.md +0 -217
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/improvements.md +0 -139
- package/project-structure.md +0 -343
- package/test-events-setup.js +0 -41
- package/test-hooks-manual.js +0 -54
- package/test-hooks-with-logging.js +0 -52
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
[](https://github.com/abofs/stonyx-orm/actions/workflows/ci.yml)
|
|
2
|
+
[](https://www.npmjs.com/package/@stonyx/orm)
|
|
3
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
4
|
+
|
|
1
5
|
# @stonyx/orm
|
|
2
6
|
|
|
3
7
|
A lightweight ORM for Stonyx projects, featuring model definitions, serializers, relationships, transforms, and optional REST server integration.
|
|
@@ -13,6 +17,23 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
|
|
|
13
17
|
- **REST Server Integration**: Automatic route setup with customizable access control.
|
|
14
18
|
- **Lifecycle Hooks**: Middleware-based before/after hooks for validation, authorization, side effects, and auditing.
|
|
15
19
|
|
|
20
|
+
## Public API vs Internals
|
|
21
|
+
|
|
22
|
+
Records use a proxy that exposes model attributes as direct properties. Always use direct property access for reading and writing field values:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
// Correct: read/write via the proxy
|
|
26
|
+
const age = record.age;
|
|
27
|
+
record.age = 5;
|
|
28
|
+
|
|
29
|
+
// Correct: iterate fields using the record directly
|
|
30
|
+
for (const key of Object.keys(record.serialize())) {
|
|
31
|
+
console.log(key, record[key]);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
All properties prefixed with `__` (`__data`, `__relationships`, `__model`, `__serializer`, `__serialized`) are **internal implementation details** and must not be accessed by consumer code. Bypassing the proxy by reading or writing `__data` directly skips type transforms and change tracking, which can lead to silent data corruption.
|
|
36
|
+
|
|
16
37
|
## Installation
|
|
17
38
|
|
|
18
39
|
```bash
|
|
@@ -109,6 +130,22 @@ export default class OwnerModel extends Model {
|
|
|
109
130
|
}
|
|
110
131
|
```
|
|
111
132
|
|
|
133
|
+
### Overriding Plural Names
|
|
134
|
+
|
|
135
|
+
By default, model names are auto-pluralized for REST routes, JSON:API URLs, and DB table names (e.g., `animal` → `animals`). When auto-pluralization produces the wrong result, override it with `static pluralName`:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { Model, attr } from '@stonyx/orm';
|
|
139
|
+
|
|
140
|
+
export default class PersonModel extends Model {
|
|
141
|
+
static pluralName = 'people';
|
|
142
|
+
|
|
143
|
+
name = attr('string');
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The override is picked up automatically during ORM initialization. All routes, JSON:API type references, and MySQL table names will use the overridden value.
|
|
148
|
+
|
|
112
149
|
## Serializers
|
|
113
150
|
|
|
114
151
|
Based on the following sample payload structure which represents a poorly structure third-party data source:
|
|
@@ -207,6 +244,27 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
|
|
|
207
244
|
| `stonyx db:migrate:rollback` | Rollback the most recent migration |
|
|
208
245
|
| `stonyx db:migrate:status` | Show migration status |
|
|
209
246
|
|
|
247
|
+
### Running MySQL Tests
|
|
248
|
+
|
|
249
|
+
The ORM includes integration tests that run against a real MySQL database. These are optional — all other tests work without MySQL.
|
|
250
|
+
|
|
251
|
+
**One-time setup:**
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# Requires local MySQL 8.0+ running
|
|
255
|
+
./scripts/setup-test-db.sh
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This creates a `stonyx_orm_test` database with a `stonyx_test` user. Safe to re-run.
|
|
259
|
+
|
|
260
|
+
**Running tests:**
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
npm test
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
MySQL integration tests run automatically when MySQL is available. In CI (where `CI=true`), they skip gracefully.
|
|
267
|
+
|
|
210
268
|
## REST Server Integration
|
|
211
269
|
|
|
212
270
|
The ORM can automatically register REST routes using your access classes.
|
|
@@ -376,7 +434,7 @@ Each hook receives a context object with comprehensive information:
|
|
|
376
434
|
- It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
|
|
377
435
|
- The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
|
|
378
436
|
- For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
|
|
379
|
-
- `oldState` is captured
|
|
437
|
+
- `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
|
|
380
438
|
|
|
381
439
|
### Usage Examples
|
|
382
440
|
|
|
@@ -479,8 +537,8 @@ afterHook('update', 'animal', async (context) => {
|
|
|
479
537
|
|
|
480
538
|
// Track multiple field changes
|
|
481
539
|
const changedFields = [];
|
|
482
|
-
for (const key
|
|
483
|
-
if (context.oldState[key] !== context.record
|
|
540
|
+
for (const key of Object.keys(context.oldState)) {
|
|
541
|
+
if (context.oldState[key] !== context.record[key]) {
|
|
484
542
|
changedFields.push(key);
|
|
485
543
|
}
|
|
486
544
|
}
|
|
@@ -519,9 +577,9 @@ afterHook('update', 'animal', async (context) => {
|
|
|
519
577
|
// Compare oldState with current record to capture exact changes
|
|
520
578
|
const changes = {};
|
|
521
579
|
if (context.oldState) {
|
|
522
|
-
for (const
|
|
523
|
-
if (context.oldState[key] !==
|
|
524
|
-
changes[key] = { from: context.oldState[key], to:
|
|
580
|
+
for (const key of Object.keys(context.oldState)) {
|
|
581
|
+
if (context.oldState[key] !== context.record[key]) {
|
|
582
|
+
changes[key] = { from: context.oldState[key], to: context.record[key] };
|
|
525
583
|
}
|
|
526
584
|
}
|
|
527
585
|
}
|
package/config/environment.js
CHANGED
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
ORM_REST_ROUTE,
|
|
5
5
|
ORM_SERIALIZER_PATH,
|
|
6
6
|
ORM_TRANSFORM_PATH,
|
|
7
|
+
ORM_VIEW_PATH,
|
|
7
8
|
ORM_USE_REST_SERVER,
|
|
8
9
|
DB_AUTO_SAVE,
|
|
9
10
|
DB_FILE,
|
|
@@ -36,7 +37,8 @@ export default {
|
|
|
36
37
|
access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
|
|
37
38
|
model: ORM_MODEL_PATH ?? './models',
|
|
38
39
|
serializer: ORM_SERIALIZER_PATH ?? './serializers',
|
|
39
|
-
transform: ORM_TRANSFORM_PATH ?? './transforms'
|
|
40
|
+
transform: ORM_TRANSFORM_PATH ?? './transforms',
|
|
41
|
+
view: ORM_VIEW_PATH ?? './views'
|
|
40
42
|
},
|
|
41
43
|
mysql: MYSQL_HOST ? {
|
|
42
44
|
host: MYSQL_HOST ?? 'localhost',
|
package/package.json
CHANGED
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.2.1-beta.
|
|
7
|
+
"version": "0.2.1-beta.80",
|
|
8
8
|
"description": "",
|
|
9
9
|
"main": "src/main.js",
|
|
10
10
|
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"stonyx-orm": "./src/cli.js"
|
|
13
|
+
},
|
|
11
14
|
"exports": {
|
|
12
15
|
".": "./src/index.js",
|
|
13
16
|
"./db": "./src/exports/db.js",
|
|
17
|
+
"./standalone-db": "./src/standalone-db.js",
|
|
14
18
|
"./migrate": "./src/migrate.js",
|
|
15
19
|
"./commands": "./src/commands.js",
|
|
16
20
|
"./hooks": "./src/hooks.js"
|
|
@@ -24,6 +28,11 @@
|
|
|
24
28
|
"contributors": [
|
|
25
29
|
"Stone Costa <stone.costa@synamicd.com>"
|
|
26
30
|
],
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"config",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
27
36
|
"publishConfig": {
|
|
28
37
|
"access": "public",
|
|
29
38
|
"provenance": true
|
|
@@ -33,21 +42,26 @@
|
|
|
33
42
|
},
|
|
34
43
|
"homepage": "https://github.com/abofs/stonyx-orm#readme",
|
|
35
44
|
"dependencies": {
|
|
36
|
-
"stonyx": "0.2.
|
|
37
|
-
"@stonyx/events": "0.1.1-beta.
|
|
38
|
-
"
|
|
45
|
+
"@stonyx/cron": "0.2.1-beta.29",
|
|
46
|
+
"@stonyx/events": "0.1.1-beta.9",
|
|
47
|
+
"stonyx": "0.2.3-beta.11"
|
|
39
48
|
},
|
|
40
49
|
"peerDependencies": {
|
|
50
|
+
"@stonyx/rest-server": ">=0.2.1-beta.14",
|
|
41
51
|
"mysql2": "^3.0.0"
|
|
42
52
|
},
|
|
43
53
|
"peerDependenciesMeta": {
|
|
44
54
|
"mysql2": {
|
|
45
55
|
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@stonyx/rest-server": {
|
|
58
|
+
"optional": true
|
|
46
59
|
}
|
|
47
60
|
},
|
|
48
61
|
"devDependencies": {
|
|
49
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
50
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
62
|
+
"@stonyx/rest-server": "0.2.1-beta.30",
|
|
63
|
+
"@stonyx/utils": "0.2.3-beta.7",
|
|
64
|
+
"mysql2": "^3.20.0",
|
|
51
65
|
"qunit": "^2.24.1",
|
|
52
66
|
"sinon": "^21.0.0"
|
|
53
67
|
},
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export class AggregateProperty {
|
|
2
|
+
constructor(aggregateType, relationship, field) {
|
|
3
|
+
this.aggregateType = aggregateType;
|
|
4
|
+
this.relationship = relationship;
|
|
5
|
+
this.field = field;
|
|
6
|
+
this.mysqlFunction = aggregateType.toUpperCase();
|
|
7
|
+
this.resultType = aggregateType === 'avg' ? 'float' : 'number';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
compute(relatedRecords) {
|
|
11
|
+
if (!relatedRecords || !Array.isArray(relatedRecords) || relatedRecords.length === 0) {
|
|
12
|
+
if (this.aggregateType === 'min' || this.aggregateType === 'max') return null;
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
switch (this.aggregateType) {
|
|
17
|
+
case 'count':
|
|
18
|
+
return relatedRecords.length;
|
|
19
|
+
|
|
20
|
+
case 'sum':
|
|
21
|
+
return relatedRecords.reduce((acc, record) => {
|
|
22
|
+
const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
|
|
23
|
+
return acc + (isNaN(val) ? 0 : val);
|
|
24
|
+
}, 0);
|
|
25
|
+
|
|
26
|
+
case 'avg': {
|
|
27
|
+
let sum = 0;
|
|
28
|
+
let count = 0;
|
|
29
|
+
for (const record of relatedRecords) {
|
|
30
|
+
const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
|
|
31
|
+
if (!isNaN(val)) {
|
|
32
|
+
sum += val;
|
|
33
|
+
count++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return count === 0 ? 0 : sum / count;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'min': {
|
|
40
|
+
let min = null;
|
|
41
|
+
for (const record of relatedRecords) {
|
|
42
|
+
const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
|
|
43
|
+
if (!isNaN(val) && (min === null || val < min)) min = val;
|
|
44
|
+
}
|
|
45
|
+
return min;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'max': {
|
|
49
|
+
let max = null;
|
|
50
|
+
for (const record of relatedRecords) {
|
|
51
|
+
const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
|
|
52
|
+
if (!isNaN(val) && (max === null || val > max)) max = val;
|
|
53
|
+
}
|
|
54
|
+
return max;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
default:
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function count(relationship) {
|
|
64
|
+
return new AggregateProperty('count', relationship);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function avg(relationshipOrField, field) {
|
|
68
|
+
if (field !== undefined) {
|
|
69
|
+
return new AggregateProperty('avg', relationshipOrField, field);
|
|
70
|
+
}
|
|
71
|
+
return new AggregateProperty('avg', undefined, relationshipOrField);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sum(relationshipOrField, field) {
|
|
75
|
+
if (field !== undefined) {
|
|
76
|
+
return new AggregateProperty('sum', relationshipOrField, field);
|
|
77
|
+
}
|
|
78
|
+
return new AggregateProperty('sum', undefined, relationshipOrField);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function min(relationshipOrField, field) {
|
|
82
|
+
if (field !== undefined) {
|
|
83
|
+
return new AggregateProperty('min', relationshipOrField, field);
|
|
84
|
+
}
|
|
85
|
+
return new AggregateProperty('min', undefined, relationshipOrField);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function max(relationshipOrField, field) {
|
|
89
|
+
if (field !== undefined) {
|
|
90
|
+
return new AggregateProperty('max', relationshipOrField, field);
|
|
91
|
+
}
|
|
92
|
+
return new AggregateProperty('max', undefined, relationshipOrField);
|
|
93
|
+
}
|
package/src/belongs-to.js
CHANGED
|
@@ -11,7 +11,7 @@ export default function belongsTo(modelName) {
|
|
|
11
11
|
const pendingHasManyQueue = relationships.get('pending');
|
|
12
12
|
const pendingBelongsToQueue = relationships.get('pendingBelongsTo');
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const fn = (sourceRecord, rawData, options) => {
|
|
15
15
|
if (!rawData) return null;
|
|
16
16
|
|
|
17
17
|
const { __name: sourceModelName } = sourceRecord.__model;
|
|
@@ -21,9 +21,13 @@ export default function belongsTo(modelName) {
|
|
|
21
21
|
const modelStore = store.get(modelName);
|
|
22
22
|
|
|
23
23
|
// Try to get existing record
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
let output;
|
|
25
|
+
|
|
26
|
+
if (typeof rawData === 'object') {
|
|
27
|
+
output = createRecord(modelName, rawData, options);
|
|
28
|
+
} else if (modelStore) {
|
|
29
|
+
output = modelStore.get(rawData);
|
|
30
|
+
}
|
|
27
31
|
|
|
28
32
|
// If not found and is a string ID, register as pending
|
|
29
33
|
if (!output && typeof rawData !== 'object') {
|
|
@@ -60,4 +64,7 @@ export default function belongsTo(modelName) {
|
|
|
60
64
|
|
|
61
65
|
return output;
|
|
62
66
|
}
|
|
67
|
+
|
|
68
|
+
Object.defineProperty(fn, '__relatedModelName', { value: modelName });
|
|
69
|
+
return fn;
|
|
63
70
|
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standalone CLI for ORM database operations.
|
|
5
|
+
*
|
|
6
|
+
* Performs CRUD operations on the JSON database without requiring
|
|
7
|
+
* the full Stonyx bootstrap. Supports both file and directory modes.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* stonyx-orm create <collection> <json-data>
|
|
11
|
+
* stonyx-orm list <collection>
|
|
12
|
+
* stonyx-orm get <collection> <id>
|
|
13
|
+
* stonyx-orm delete <collection> <id>
|
|
14
|
+
*
|
|
15
|
+
* Configuration (environment variables):
|
|
16
|
+
* DB_MODE — 'file' or 'directory' (default: 'directory')
|
|
17
|
+
* DB_PATH — Path to db.json (default: 'db.json')
|
|
18
|
+
* DB_DIRECTORY — Directory name for collection files (default: 'db')
|
|
19
|
+
*
|
|
20
|
+
* Configuration (CLI flag):
|
|
21
|
+
* --config <path> — Path to a JSON config file with { mode, dbPath, directory }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import StandaloneDB from './standalone-db.js';
|
|
25
|
+
import fs from 'fs/promises';
|
|
26
|
+
|
|
27
|
+
const USAGE = `Usage: stonyx-orm <command> [options]
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
create <collection> <json-data> Create a record
|
|
31
|
+
list <collection> List all records
|
|
32
|
+
get <collection> <id> Get a record by ID
|
|
33
|
+
delete <collection> <id> Delete a record by ID
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--config <path> Path to JSON config file
|
|
37
|
+
--help Show this help message
|
|
38
|
+
|
|
39
|
+
Environment variables:
|
|
40
|
+
DB_MODE 'file' or 'directory' (default: 'directory')
|
|
41
|
+
DB_PATH Path to db.json (default: 'db.json')
|
|
42
|
+
DB_DIRECTORY Directory name for collection files (default: 'db')`;
|
|
43
|
+
|
|
44
|
+
async function loadConfig(args) {
|
|
45
|
+
const config = {};
|
|
46
|
+
|
|
47
|
+
// Check for --config flag
|
|
48
|
+
const configIndex = args.indexOf('--config');
|
|
49
|
+
|
|
50
|
+
if (configIndex !== -1 && args[configIndex + 1]) {
|
|
51
|
+
const configPath = args[configIndex + 1];
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
55
|
+
Object.assign(config, JSON.parse(content));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`Error reading config file '${configPath}': ${err.message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Remove --config and its value from args
|
|
62
|
+
args.splice(configIndex, 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Environment variables override config file, config file overrides defaults
|
|
66
|
+
return {
|
|
67
|
+
mode: process.env.DB_MODE || config.mode || 'directory',
|
|
68
|
+
dbPath: process.env.DB_PATH || config.dbPath || 'db.json',
|
|
69
|
+
directory: process.env.DB_DIRECTORY || config.directory || 'db',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseArgs(argv) {
|
|
74
|
+
// Strip node binary and script path
|
|
75
|
+
const args = argv.slice(2);
|
|
76
|
+
|
|
77
|
+
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
|
78
|
+
console.log(USAGE);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return args;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function run() {
|
|
86
|
+
const args = parseArgs(process.argv);
|
|
87
|
+
const config = await loadConfig(args);
|
|
88
|
+
const db = new StandaloneDB(config);
|
|
89
|
+
|
|
90
|
+
const [command, collection, ...rest] = args;
|
|
91
|
+
|
|
92
|
+
if (!command) {
|
|
93
|
+
console.error('Error: No command specified.\n');
|
|
94
|
+
console.log(USAGE);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!collection && command !== '--help') {
|
|
99
|
+
console.error(`Error: No collection specified for '${command}' command.\n`);
|
|
100
|
+
console.log(USAGE);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
switch (command) {
|
|
106
|
+
case 'list': {
|
|
107
|
+
const records = await db.list(collection);
|
|
108
|
+
console.log(JSON.stringify(records, null, 2));
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case 'get': {
|
|
113
|
+
const id = rest[0];
|
|
114
|
+
|
|
115
|
+
if (!id) {
|
|
116
|
+
console.error("Error: 'get' command requires an <id> argument.");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const record = await db.get(collection, id);
|
|
121
|
+
|
|
122
|
+
if (!record) {
|
|
123
|
+
console.error(`Record with id '${id}' not found in '${collection}'.`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(JSON.stringify(record, null, 2));
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case 'create': {
|
|
132
|
+
const jsonStr = rest.join(' ');
|
|
133
|
+
|
|
134
|
+
if (!jsonStr) {
|
|
135
|
+
console.error("Error: 'create' command requires <json-data> argument.");
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let data;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
data = JSON.parse(jsonStr);
|
|
143
|
+
} catch {
|
|
144
|
+
console.error(`Error: Invalid JSON data: ${jsonStr}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const created = await db.create(collection, data);
|
|
149
|
+
console.log(JSON.stringify(created, null, 2));
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'delete': {
|
|
154
|
+
const deleteId = rest[0];
|
|
155
|
+
|
|
156
|
+
if (!deleteId) {
|
|
157
|
+
console.error("Error: 'delete' command requires an <id> argument.");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const removed = await db.delete(collection, deleteId);
|
|
162
|
+
console.log(JSON.stringify(removed, null, 2));
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
default:
|
|
167
|
+
console.error(`Error: Unknown command '${command}'.\n`);
|
|
168
|
+
console.log(USAGE);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`Error: ${err.message}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
run();
|
package/src/db.js
CHANGED
|
@@ -147,15 +147,25 @@ export default class DB {
|
|
|
147
147
|
const collectionKeys = this.getCollectionKeys();
|
|
148
148
|
|
|
149
149
|
// Write each collection to its own file in parallel
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
// Use createFile for new files, updateFile for existing ones
|
|
151
|
+
await Promise.all(collectionKeys.map(async key => {
|
|
152
|
+
const filePath = path.join(dirPath, `${key}.json`);
|
|
153
|
+
const exists = await fileExists(filePath);
|
|
154
|
+
const data = jsonData[key] || [];
|
|
155
|
+
|
|
156
|
+
if (exists) await updateFile(filePath, data, { json: true });
|
|
157
|
+
else await createFile(filePath, data, { json: true });
|
|
158
|
+
}));
|
|
153
159
|
|
|
154
160
|
// Write empty-array skeleton to db.json
|
|
155
161
|
const skeleton = {};
|
|
156
162
|
for (const key of collectionKeys) skeleton[key] = [];
|
|
157
163
|
|
|
158
|
-
|
|
164
|
+
const dbFilePath = `${config.rootPath}/${file}`;
|
|
165
|
+
const dbFileExists = await fileExists(dbFilePath);
|
|
166
|
+
|
|
167
|
+
if (dbFileExists) await updateFile(dbFilePath, skeleton, { json: true });
|
|
168
|
+
else await createFile(dbFilePath, skeleton, { json: true });
|
|
159
169
|
|
|
160
170
|
log.db(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
|
|
161
171
|
return;
|
package/src/has-many.js
CHANGED
|
@@ -16,7 +16,7 @@ export default function hasMany(modelName) {
|
|
|
16
16
|
const globalRelationships = relationships.get('global');
|
|
17
17
|
const pendingRelationships = relationships.get('pending');
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const fn = (sourceRecord, rawData, options) => {
|
|
20
20
|
const { __name: sourceModelName } = sourceRecord.__model;
|
|
21
21
|
const relationshipId = sourceRecord.id;
|
|
22
22
|
const relationship = getRelationships('hasMany', sourceModelName, modelName, relationshipId);
|
|
@@ -27,6 +27,10 @@ export default function hasMany(modelName) {
|
|
|
27
27
|
let record;
|
|
28
28
|
|
|
29
29
|
if (typeof elementData !== 'object') {
|
|
30
|
+
if (!modelStore) {
|
|
31
|
+
return queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, elementData);
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
record = modelStore.get(elementData);
|
|
31
35
|
|
|
32
36
|
if (!record) {
|
|
@@ -58,4 +62,7 @@ export default function hasMany(modelName) {
|
|
|
58
62
|
|
|
59
63
|
return output;
|
|
60
64
|
}
|
|
65
|
+
|
|
66
|
+
Object.defineProperty(fn, '__relatedModelName', { value: modelName });
|
|
67
|
+
return fn;
|
|
61
68
|
}
|
package/src/index.js
CHANGED
|
@@ -15,15 +15,24 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import Model from './model.js';
|
|
18
|
+
import View from './view.js';
|
|
18
19
|
import Serializer from './serializer.js';
|
|
19
20
|
|
|
20
21
|
import attr from './attr.js';
|
|
21
22
|
import belongsTo from './belongs-to.js';
|
|
22
23
|
import hasMany from './has-many.js';
|
|
23
24
|
import { createRecord, updateRecord } from './manage-record.js';
|
|
25
|
+
import { count, avg, sum, min, max } from './aggregates.js';
|
|
24
26
|
|
|
25
27
|
export { default } from './main.js';
|
|
26
28
|
export { store, relationships } from './main.js';
|
|
27
|
-
export { Model, Serializer }; // base classes
|
|
29
|
+
export { Model, View, Serializer }; // base classes
|
|
28
30
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
29
|
-
export {
|
|
31
|
+
export { count, avg, sum, min, max }; // aggregate helpers
|
|
32
|
+
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
33
|
+
|
|
34
|
+
// Store API:
|
|
35
|
+
// store.get(model, id) — sync, memory-only
|
|
36
|
+
// store.find(model, id) — async, MySQL for memory:false models
|
|
37
|
+
// store.findAll(model) — async, all records
|
|
38
|
+
// store.query(model, conditions) — async, always hits MySQL
|
package/src/main.js
CHANGED
|
@@ -19,6 +19,7 @@ import config from 'stonyx/config';
|
|
|
19
19
|
import log from 'stonyx/log';
|
|
20
20
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
21
21
|
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
22
|
+
import { registerPluralName } from './plural-registry.js';
|
|
22
23
|
import setupRestServer from './setup-rest-server.js';
|
|
23
24
|
import baseTransforms from './transforms.js';
|
|
24
25
|
import Store from './store.js';
|
|
@@ -36,6 +37,7 @@ export default class Orm {
|
|
|
36
37
|
|
|
37
38
|
models = {};
|
|
38
39
|
serializers = {};
|
|
40
|
+
views = {};
|
|
39
41
|
transforms = { ...baseTransforms };
|
|
40
42
|
warnings = new Set();
|
|
41
43
|
|
|
@@ -66,7 +68,10 @@ export default class Orm {
|
|
|
66
68
|
// Transforms keep their original name, everything else gets converted to PascalCase with the type suffix
|
|
67
69
|
const alias = type === 'Transform' ? name : `${kebabCaseToPascalCase(name)}${type}`;
|
|
68
70
|
|
|
69
|
-
if (type === 'Model')
|
|
71
|
+
if (type === 'Model') {
|
|
72
|
+
Orm.store.set(name, new Map());
|
|
73
|
+
registerPluralName(name, exported);
|
|
74
|
+
}
|
|
70
75
|
|
|
71
76
|
return this[pluralize(lowerCaseType)][alias] = exported;
|
|
72
77
|
}, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
|
|
@@ -75,14 +80,28 @@ export default class Orm {
|
|
|
75
80
|
// Wait for imports before db & rest server setup
|
|
76
81
|
await Promise.all(promises);
|
|
77
82
|
|
|
83
|
+
// Discover views from paths.view (separate from model/serializer/transform)
|
|
84
|
+
if (paths.view) {
|
|
85
|
+
await forEachFileImport(paths.view, (exported, { name }) => {
|
|
86
|
+
const alias = `${kebabCaseToPascalCase(name)}View`;
|
|
87
|
+
Orm.store.set(name, new Map());
|
|
88
|
+
registerPluralName(name, exported);
|
|
89
|
+
this.views[alias] = exported;
|
|
90
|
+
}, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
// Setup event names for hooks after models are loaded
|
|
79
94
|
const eventNames = [];
|
|
80
95
|
const operations = ['list', 'get', 'create', 'update', 'delete'];
|
|
96
|
+
const viewOperations = ['list', 'get'];
|
|
81
97
|
const timings = ['before', 'after'];
|
|
82
98
|
|
|
83
99
|
for (const modelName of Orm.store.data.keys()) {
|
|
100
|
+
const isView = this.isView(modelName);
|
|
101
|
+
const ops = isView ? viewOperations : operations;
|
|
102
|
+
|
|
84
103
|
for (const timing of timings) {
|
|
85
|
-
for (const operation of
|
|
104
|
+
for (const operation of ops) {
|
|
86
105
|
eventNames.push(`${timing}:${operation}:${modelName}`);
|
|
87
106
|
}
|
|
88
107
|
}
|
|
@@ -106,6 +125,17 @@ export default class Orm {
|
|
|
106
125
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
107
126
|
}
|
|
108
127
|
|
|
128
|
+
// Wire up memory resolver so store.find() can check model memory flags
|
|
129
|
+
Orm.store._memoryResolver = (modelName) => {
|
|
130
|
+
const { modelClass } = this.getRecordClasses(modelName);
|
|
131
|
+
return modelClass?.memory === true;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Wire up MySQL reference for on-demand queries from store.find()/findAll()
|
|
135
|
+
if (this.mysqlDb) {
|
|
136
|
+
Orm.store._mysqlDb = this.mysqlDb;
|
|
137
|
+
}
|
|
138
|
+
|
|
109
139
|
Orm.ready = await Promise.all(promises);
|
|
110
140
|
Orm.initialized = true;
|
|
111
141
|
}
|
|
@@ -126,13 +156,27 @@ export default class Orm {
|
|
|
126
156
|
|
|
127
157
|
getRecordClasses(modelName) {
|
|
128
158
|
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
129
|
-
|
|
159
|
+
|
|
160
|
+
// Check views first, then models
|
|
161
|
+
const viewClass = this.views[`${modelClassPrefix}View`];
|
|
162
|
+
if (viewClass) {
|
|
163
|
+
return {
|
|
164
|
+
modelClass: viewClass,
|
|
165
|
+
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
130
169
|
return {
|
|
131
170
|
modelClass: this.models[`${modelClassPrefix}Model`],
|
|
132
171
|
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
133
172
|
};
|
|
134
173
|
}
|
|
135
174
|
|
|
175
|
+
isView(modelName) {
|
|
176
|
+
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
177
|
+
return !!this.views[`${modelClassPrefix}View`];
|
|
178
|
+
}
|
|
179
|
+
|
|
136
180
|
// Queue warnings to avoid the same error from being logged in the same iteration
|
|
137
181
|
warn(message) {
|
|
138
182
|
this.warnings.add(message);
|