Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ repo.register({ Payment, CardPayment });
- Simple class with `static _name`, `static table`, `static fields`.
- Extension system: register multiple times with same `static _name` to add/override fields and behavior.
- Inheritance with discriminators for polymorphic models.
- **NEW**: Model scopes for reusable query filters and eager loading patterns.
- Fields
- Built-ins: primary, integer/float, string/text, boolean, date/datetime, enum, json, reference.
- Constraints: `default`, `required`, `unique`, `index`.
Expand All @@ -239,6 +240,12 @@ repo.register({ Payment, CardPayment });
- n:m via paired `many-to-many` (auto-join table).
- Relation proxies on instances: `add`, `remove`, `load`.
- **NEW**: Automatic join generation for relational filters (e.g., `where({ 'author.organization.name': 'ACME' })`).
- Scopes
- Define reusable query patterns at the model level.
- Support for `defaultScope` applied to all queries unless bypassed.
- Parameterized scopes via functions.
- Composable scopes with deterministic merging.
- **NEW**: See `docs/scopes.md` for comprehensive guide.
- Transactions
- `repo.transaction(async (tx) => { /* ... */ })` gives an isolated tx-bound repository.
- Post-commit cache flush of changed records.
Expand All @@ -260,6 +267,7 @@ See full field reference in `docs/fields.md`.

- `docs/models.md` — Model definitions, inheritance, and extension system.
- `docs/fields.md` — Built-in field types and options.
- `docs/scopes.md` — **NEW**: Model scopes for reusable query filters.
- `docs/requests.md` — Request API, criteria DSL, and request-level caching.
- `docs/relational-filters.md` — **NEW**: Automatic joins for relational field filters.
- `docs/cache.md` — Cache architecture, connection options, discovery, and model cache options.
Expand Down
49 changes: 49 additions & 0 deletions demo/mixins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Mixins Demo

This demo showcases NormalJS mixin patterns for reusable model behavior.

## What's Included

### Mixins

1. **Timestampable** - Automatically manages `created_at` and `updated_at` fields
2. **SoftDeletable** - Implements soft delete by overriding `unlink()` method

### Models

1. **User** - Uses Timestampable mixin
2. **Document** - Uses both Timestampable and SoftDeletable mixins

## Running the Demo

```bash
cd demo/mixins
node index.js
```

## Key Concepts Demonstrated

### 1. Timestampable Mixin

- Adds `created_at` and `updated_at` fields
- Automatically updates `updated_at` on record changes
- Uses lifecycle hooks (`pre_create`, `pre_update`)

### 2. SoftDeletable Mixin

- Adds `deleted_at` field
- Overrides `unlink()` to set `deleted_at` instead of deleting
- Provides `restore()` method to undelete records
- Provides `forceUnlink()` for hard deletes
- Includes `isDeleted` getter
- Uses default scope to hide soft-deleted records

### 3. Combining Mixins

- Models can use multiple mixins
- Fields and methods are merged
- Lifecycle hooks are called in order

## Learn More

See the [Mixins Documentation](../../docs/mixins.md) for detailed usage and examples.
162 changes: 162 additions & 0 deletions demo/mixins/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
const { Connection, Repository } = require('../../dist/index');

// Import mixins
const Timestampable = require('./models/Timestampable');
const SoftDeletable = require('./models/SoftDeletable');

// Import models
const User = require('./models/User');
const Document = require('./models/Document');

async function main() {
console.log('=== NormalJS Mixins Demo ===\n');

// Setup connection and repository
const conn = new Connection({
client: 'sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true
});

const repo = new Repository(conn);

// Register mixins first
repo.register(Timestampable);
repo.register(SoftDeletable);

// Register models
repo.register(User);
repo.register(Document);

// Sync schema
await repo.sync({ force: true });

// Get models
const Users = repo.get('User');
const Documents = repo.get('Document');

console.log('1. Testing Timestampable Mixin');
console.log('================================\n');

// Create a user (timestamps are automatically set)
const user = await Users.create({
email: 'john@example.com',
name: 'John Doe'
});

console.log(`Created user: ${user.displayName}`);
console.log(` created_at: ${user.created_at.toISOString()}`);
console.log(` updated_at: ${user.updated_at.toISOString()}`);

// Wait a bit to see the timestamp difference
await new Promise(resolve => setTimeout(resolve, 100));

// Update the user (updated_at is automatically updated)
await user.write({ name: 'Jane Doe' });

console.log(`\nUpdated user: ${user.displayName}`);
console.log(` created_at: ${user.created_at.toISOString()}`);
console.log(` updated_at: ${user.updated_at.toISOString()}`);
console.log(' (Note: updated_at changed, created_at stayed the same)\n');

console.log('2. Testing SoftDeletable Mixin');
console.log('================================\n');

// Create a document
const doc = await Documents.create({
title: 'Important Document',
content: 'This is important content.',
author_id: user.id
});

console.log(`Created document: ${doc.title}`);
console.log(` id: ${doc.id}`);
console.log(` deleted_at: ${doc.deleted_at}`);
console.log(` isDeleted: ${doc.isDeleted}\n`);

// Soft delete the document
await doc.unlink();

console.log('After soft delete:');
console.log(` deleted_at: ${doc.deleted_at?.toISOString()}`);
console.log(` isDeleted: ${doc.isDeleted}\n`);

// Try to find the document (should not be found due to default scope)
const foundDoc = await Documents.where({ id: doc.id }).first();
console.log(`Document found with normal query: ${foundDoc !== null}`);

// Find with unscoped (bypasses default scope)
const unscopedDoc = await Documents.unscoped().where({ id: doc.id }).first();
console.log(`Document found with unscoped query: ${unscopedDoc !== null}\n`);

// Restore the document
await unscopedDoc.restore();

console.log('After restore:');
console.log(` deleted_at: ${unscopedDoc.deleted_at}`);
console.log(` isDeleted: ${unscopedDoc.isDeleted}\n`);

// Now it should be found with normal queries
const restoredDoc = await Documents.where({ id: doc.id }).first();
console.log(`Document found after restore: ${restoredDoc !== null}\n`);

console.log('3. Testing Hard Delete');
console.log('================================\n');

// Soft delete again
await restoredDoc.unlink();
console.log('Soft deleted again');

// Get the document via unscoped
const docToDelete = await Documents.unscoped().where({ id: doc.id }).first();

// Hard delete (actually remove from database)
await docToDelete.forceUnlink();
console.log('Hard deleted (removed from database)');

// Try to find with unscoped (should not be found)
const finalCheck = await Documents.unscoped().where({ id: doc.id }).first();
console.log(`Document found after hard delete: ${finalCheck !== null}\n`);

console.log('4. Testing Combined Mixins');
console.log('================================\n');

// Create another document to show both mixins working together
const doc2 = await Documents.create({
title: 'Test Document',
content: 'Testing timestamps + soft delete',
author_id: user.id
});

console.log(`Created document: ${doc2.title}`);
console.log(` created_at: ${doc2.created_at.toISOString()}`);
console.log(` updated_at: ${doc2.updated_at.toISOString()}`);
console.log(` deleted_at: ${doc2.deleted_at}\n`);

await new Promise(resolve => setTimeout(resolve, 100));

// Update it
await doc2.write({ title: 'Updated Test Document' });

console.log('After update:');
console.log(` created_at: ${doc2.created_at.toISOString()}`);
console.log(` updated_at: ${doc2.updated_at.toISOString()}`);
console.log(' (updated_at changed automatically)\n');

// Soft delete it
await doc2.unlink();

console.log('After soft delete:');
console.log(` deleted_at: ${doc2.deleted_at?.toISOString()}`);
console.log(` isDeleted: ${doc2.isDeleted}\n`);

console.log('=== Demo Complete ===');

await conn.destroy();
}

if (require.main === module) {
main().catch(console.error);
}

module.exports = main;
27 changes: 27 additions & 0 deletions demo/mixins/models/Document.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Document Model
*
* Example model that uses both Timestampable and SoftDeletable mixins.
*/
class Document {
static _name = 'Document';
static table = 'documents';
static mixins = ['Timestampable', 'SoftDeletable'];

static fields = {
id: 'primary',
title: { type: 'string', required: true },
content: 'text',
author_id: { type: 'many-to-one', model: 'User' }
};
}

// Define name property to override readonly built-in
Object.defineProperty(Document, 'name', {
value: 'Document',
writable: false,
enumerable: false,
configurable: true,
});

module.exports = Document;
100 changes: 100 additions & 0 deletions demo/mixins/models/SoftDeletable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* SoftDeletable Mixin
*
* Implements soft delete functionality by overriding the unlink() method.
* Records are marked as deleted instead of being removed from the database.
* Includes a default scope to hide soft-deleted records from queries.
*/
class SoftDeletable {
static _name = 'SoftDeletable';
static abstract = true;

static fields = {
deleted_at: { type: 'datetime', default: null }
};

// Use a default scope to hide soft-deleted records
static defaultScope = {
where: { deleted_at: null }
};

/**
* Override unlink to perform soft delete.
* Sets deleted_at timestamp instead of removing the record.
*/
async unlink() {
if (this.deleted_at) {
// Already soft-deleted, perform hard delete
return await this.forceUnlink();
}

// Soft delete: just set deleted_at
await this.write({ deleted_at: new Date() });
return this;
}

/**
* Perform a hard delete (actually remove from database).
* This bypasses the soft delete mechanism.
*/
async forceUnlink() {
// Call the original unlink implementation
const model = this._model;
if (!model) return this;

this._model = null;
await this.pre_unlink();
await this.pre_validate();

const pre_unlink = [];
for (const field of Object.values(model.fields)) {
pre_unlink.push(field.pre_unlink(this));
}
await Promise.all(pre_unlink);

// Delete from database
await model.query().where({ id: this.id }).delete();

if (this._parent) {
await this._parent.unlink();
}

// Run post hooks
await this.post_unlink();
const post_unlink = [];
for (const field of Object.values(model.fields)) {
post_unlink.push(field.post_unlink(this));
}
await Promise.all(post_unlink);

return this;
}

/**
* Restore a soft-deleted record by clearing deleted_at.
*/
async restore() {
if (!this.deleted_at) {
throw new Error('Record is not deleted');
}
await this.write({ deleted_at: null });
return this;
}

/**
* Check if record is soft-deleted.
*/
get isDeleted() {
return !!this.deleted_at;
}
}

// Define name property to override readonly built-in
Object.defineProperty(SoftDeletable, 'name', {
value: 'SoftDeletable',
writable: false,
enumerable: false,
configurable: true,
});

module.exports = SoftDeletable;
Loading