Skip to content

Commit f6836ed

Browse files
authored
Merge pull request #23 from ichiriac/main
Update the website documentation
2 parents 3f9e70a + 48fedd0 commit f6836ed

23 files changed

+2751
-68
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ repo.register({ Payment, CardPayment });
229229
- Simple class with `static _name`, `static table`, `static fields`.
230230
- Extension system: register multiple times with same `static _name` to add/override fields and behavior.
231231
- Inheritance with discriminators for polymorphic models.
232+
- **NEW**: Model scopes for reusable query filters and eager loading patterns.
232233
- Fields
233234
- Built-ins: primary, integer/float, string/text, boolean, date/datetime, enum, json, reference.
234235
- Constraints: `default`, `required`, `unique`, `index`.
@@ -239,6 +240,12 @@ repo.register({ Payment, CardPayment });
239240
- n:m via paired `many-to-many` (auto-join table).
240241
- Relation proxies on instances: `add`, `remove`, `load`.
241242
- **NEW**: Automatic join generation for relational filters (e.g., `where({ 'author.organization.name': 'ACME' })`).
243+
- Scopes
244+
- Define reusable query patterns at the model level.
245+
- Support for `defaultScope` applied to all queries unless bypassed.
246+
- Parameterized scopes via functions.
247+
- Composable scopes with deterministic merging.
248+
- **NEW**: See `docs/scopes.md` for comprehensive guide.
242249
- Transactions
243250
- `repo.transaction(async (tx) => { /* ... */ })` gives an isolated tx-bound repository.
244251
- Post-commit cache flush of changed records.
@@ -260,6 +267,7 @@ See full field reference in `docs/fields.md`.
260267
261268
- `docs/models.md` — Model definitions, inheritance, and extension system.
262269
- `docs/fields.md` — Built-in field types and options.
270+
- `docs/scopes.md` — **NEW**: Model scopes for reusable query filters.
263271
- `docs/requests.md` — Request API, criteria DSL, and request-level caching.
264272
- `docs/relational-filters.md` — **NEW**: Automatic joins for relational field filters.
265273
- `docs/cache.md` — Cache architecture, connection options, discovery, and model cache options.

demo/mixins/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Mixins Demo
2+
3+
This demo showcases NormalJS mixin patterns for reusable model behavior.
4+
5+
## What's Included
6+
7+
### Mixins
8+
9+
1. **Timestampable** - Automatically manages `created_at` and `updated_at` fields
10+
2. **SoftDeletable** - Implements soft delete by overriding `unlink()` method
11+
12+
### Models
13+
14+
1. **User** - Uses Timestampable mixin
15+
2. **Document** - Uses both Timestampable and SoftDeletable mixins
16+
17+
## Running the Demo
18+
19+
```bash
20+
cd demo/mixins
21+
node index.js
22+
```
23+
24+
## Key Concepts Demonstrated
25+
26+
### 1. Timestampable Mixin
27+
28+
- Adds `created_at` and `updated_at` fields
29+
- Automatically updates `updated_at` on record changes
30+
- Uses lifecycle hooks (`pre_create`, `pre_update`)
31+
32+
### 2. SoftDeletable Mixin
33+
34+
- Adds `deleted_at` field
35+
- Overrides `unlink()` to set `deleted_at` instead of deleting
36+
- Provides `restore()` method to undelete records
37+
- Provides `forceUnlink()` for hard deletes
38+
- Includes `isDeleted` getter
39+
- Uses default scope to hide soft-deleted records
40+
41+
### 3. Combining Mixins
42+
43+
- Models can use multiple mixins
44+
- Fields and methods are merged
45+
- Lifecycle hooks are called in order
46+
47+
## Learn More
48+
49+
See the [Mixins Documentation](../../docs/mixins.md) for detailed usage and examples.

demo/mixins/index.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
const { Connection, Repository } = require('../../dist/index');
2+
3+
// Import mixins
4+
const Timestampable = require('./models/Timestampable');
5+
const SoftDeletable = require('./models/SoftDeletable');
6+
7+
// Import models
8+
const User = require('./models/User');
9+
const Document = require('./models/Document');
10+
11+
async function main() {
12+
console.log('=== NormalJS Mixins Demo ===\n');
13+
14+
// Setup connection and repository
15+
const conn = new Connection({
16+
client: 'sqlite3',
17+
connection: { filename: ':memory:' },
18+
useNullAsDefault: true
19+
});
20+
21+
const repo = new Repository(conn);
22+
23+
// Register mixins first
24+
repo.register(Timestampable);
25+
repo.register(SoftDeletable);
26+
27+
// Register models
28+
repo.register(User);
29+
repo.register(Document);
30+
31+
// Sync schema
32+
await repo.sync({ force: true });
33+
34+
// Get models
35+
const Users = repo.get('User');
36+
const Documents = repo.get('Document');
37+
38+
console.log('1. Testing Timestampable Mixin');
39+
console.log('================================\n');
40+
41+
// Create a user (timestamps are automatically set)
42+
const user = await Users.create({
43+
email: 'john@example.com',
44+
name: 'John Doe'
45+
});
46+
47+
console.log(`Created user: ${user.displayName}`);
48+
console.log(` created_at: ${user.created_at.toISOString()}`);
49+
console.log(` updated_at: ${user.updated_at.toISOString()}`);
50+
51+
// Wait a bit to see the timestamp difference
52+
await new Promise(resolve => setTimeout(resolve, 100));
53+
54+
// Update the user (updated_at is automatically updated)
55+
await user.write({ name: 'Jane Doe' });
56+
57+
console.log(`\nUpdated user: ${user.displayName}`);
58+
console.log(` created_at: ${user.created_at.toISOString()}`);
59+
console.log(` updated_at: ${user.updated_at.toISOString()}`);
60+
console.log(' (Note: updated_at changed, created_at stayed the same)\n');
61+
62+
console.log('2. Testing SoftDeletable Mixin');
63+
console.log('================================\n');
64+
65+
// Create a document
66+
const doc = await Documents.create({
67+
title: 'Important Document',
68+
content: 'This is important content.',
69+
author_id: user.id
70+
});
71+
72+
console.log(`Created document: ${doc.title}`);
73+
console.log(` id: ${doc.id}`);
74+
console.log(` deleted_at: ${doc.deleted_at}`);
75+
console.log(` isDeleted: ${doc.isDeleted}\n`);
76+
77+
// Soft delete the document
78+
await doc.unlink();
79+
80+
console.log('After soft delete:');
81+
console.log(` deleted_at: ${doc.deleted_at?.toISOString()}`);
82+
console.log(` isDeleted: ${doc.isDeleted}\n`);
83+
84+
// Try to find the document (should not be found due to default scope)
85+
const foundDoc = await Documents.where({ id: doc.id }).first();
86+
console.log(`Document found with normal query: ${foundDoc !== null}`);
87+
88+
// Find with unscoped (bypasses default scope)
89+
const unscopedDoc = await Documents.unscoped().where({ id: doc.id }).first();
90+
console.log(`Document found with unscoped query: ${unscopedDoc !== null}\n`);
91+
92+
// Restore the document
93+
await unscopedDoc.restore();
94+
95+
console.log('After restore:');
96+
console.log(` deleted_at: ${unscopedDoc.deleted_at}`);
97+
console.log(` isDeleted: ${unscopedDoc.isDeleted}\n`);
98+
99+
// Now it should be found with normal queries
100+
const restoredDoc = await Documents.where({ id: doc.id }).first();
101+
console.log(`Document found after restore: ${restoredDoc !== null}\n`);
102+
103+
console.log('3. Testing Hard Delete');
104+
console.log('================================\n');
105+
106+
// Soft delete again
107+
await restoredDoc.unlink();
108+
console.log('Soft deleted again');
109+
110+
// Get the document via unscoped
111+
const docToDelete = await Documents.unscoped().where({ id: doc.id }).first();
112+
113+
// Hard delete (actually remove from database)
114+
await docToDelete.forceUnlink();
115+
console.log('Hard deleted (removed from database)');
116+
117+
// Try to find with unscoped (should not be found)
118+
const finalCheck = await Documents.unscoped().where({ id: doc.id }).first();
119+
console.log(`Document found after hard delete: ${finalCheck !== null}\n`);
120+
121+
console.log('4. Testing Combined Mixins');
122+
console.log('================================\n');
123+
124+
// Create another document to show both mixins working together
125+
const doc2 = await Documents.create({
126+
title: 'Test Document',
127+
content: 'Testing timestamps + soft delete',
128+
author_id: user.id
129+
});
130+
131+
console.log(`Created document: ${doc2.title}`);
132+
console.log(` created_at: ${doc2.created_at.toISOString()}`);
133+
console.log(` updated_at: ${doc2.updated_at.toISOString()}`);
134+
console.log(` deleted_at: ${doc2.deleted_at}\n`);
135+
136+
await new Promise(resolve => setTimeout(resolve, 100));
137+
138+
// Update it
139+
await doc2.write({ title: 'Updated Test Document' });
140+
141+
console.log('After update:');
142+
console.log(` created_at: ${doc2.created_at.toISOString()}`);
143+
console.log(` updated_at: ${doc2.updated_at.toISOString()}`);
144+
console.log(' (updated_at changed automatically)\n');
145+
146+
// Soft delete it
147+
await doc2.unlink();
148+
149+
console.log('After soft delete:');
150+
console.log(` deleted_at: ${doc2.deleted_at?.toISOString()}`);
151+
console.log(` isDeleted: ${doc2.isDeleted}\n`);
152+
153+
console.log('=== Demo Complete ===');
154+
155+
await conn.destroy();
156+
}
157+
158+
if (require.main === module) {
159+
main().catch(console.error);
160+
}
161+
162+
module.exports = main;

demo/mixins/models/Document.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Document Model
3+
*
4+
* Example model that uses both Timestampable and SoftDeletable mixins.
5+
*/
6+
class Document {
7+
static _name = 'Document';
8+
static table = 'documents';
9+
static mixins = ['Timestampable', 'SoftDeletable'];
10+
11+
static fields = {
12+
id: 'primary',
13+
title: { type: 'string', required: true },
14+
content: 'text',
15+
author_id: { type: 'many-to-one', model: 'User' }
16+
};
17+
}
18+
19+
// Define name property to override readonly built-in
20+
Object.defineProperty(Document, 'name', {
21+
value: 'Document',
22+
writable: false,
23+
enumerable: false,
24+
configurable: true,
25+
});
26+
27+
module.exports = Document;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* SoftDeletable Mixin
3+
*
4+
* Implements soft delete functionality by overriding the unlink() method.
5+
* Records are marked as deleted instead of being removed from the database.
6+
* Includes a default scope to hide soft-deleted records from queries.
7+
*/
8+
class SoftDeletable {
9+
static _name = 'SoftDeletable';
10+
static abstract = true;
11+
12+
static fields = {
13+
deleted_at: { type: 'datetime', default: null }
14+
};
15+
16+
// Use a default scope to hide soft-deleted records
17+
static defaultScope = {
18+
where: { deleted_at: null }
19+
};
20+
21+
/**
22+
* Override unlink to perform soft delete.
23+
* Sets deleted_at timestamp instead of removing the record.
24+
*/
25+
async unlink() {
26+
if (this.deleted_at) {
27+
// Already soft-deleted, perform hard delete
28+
return await this.forceUnlink();
29+
}
30+
31+
// Soft delete: just set deleted_at
32+
await this.write({ deleted_at: new Date() });
33+
return this;
34+
}
35+
36+
/**
37+
* Perform a hard delete (actually remove from database).
38+
* This bypasses the soft delete mechanism.
39+
*/
40+
async forceUnlink() {
41+
// Call the original unlink implementation
42+
const model = this._model;
43+
if (!model) return this;
44+
45+
this._model = null;
46+
await this.pre_unlink();
47+
await this.pre_validate();
48+
49+
const pre_unlink = [];
50+
for (const field of Object.values(model.fields)) {
51+
pre_unlink.push(field.pre_unlink(this));
52+
}
53+
await Promise.all(pre_unlink);
54+
55+
// Delete from database
56+
await model.query().where({ id: this.id }).delete();
57+
58+
if (this._parent) {
59+
await this._parent.unlink();
60+
}
61+
62+
// Run post hooks
63+
await this.post_unlink();
64+
const post_unlink = [];
65+
for (const field of Object.values(model.fields)) {
66+
post_unlink.push(field.post_unlink(this));
67+
}
68+
await Promise.all(post_unlink);
69+
70+
return this;
71+
}
72+
73+
/**
74+
* Restore a soft-deleted record by clearing deleted_at.
75+
*/
76+
async restore() {
77+
if (!this.deleted_at) {
78+
throw new Error('Record is not deleted');
79+
}
80+
await this.write({ deleted_at: null });
81+
return this;
82+
}
83+
84+
/**
85+
* Check if record is soft-deleted.
86+
*/
87+
get isDeleted() {
88+
return !!this.deleted_at;
89+
}
90+
}
91+
92+
// Define name property to override readonly built-in
93+
Object.defineProperty(SoftDeletable, 'name', {
94+
value: 'SoftDeletable',
95+
writable: false,
96+
enumerable: false,
97+
configurable: true,
98+
});
99+
100+
module.exports = SoftDeletable;

0 commit comments

Comments
 (0)