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
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;
36 changes: 36 additions & 0 deletions demo/mixins/models/Timestampable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Timestampable Mixin
*
* Automatically manages created_at and updated_at fields.
* Include this mixin in any model that needs timestamp tracking.
*/
class Timestampable {
static _name = 'Timestampable';
static abstract = true;

static fields = {
created_at: { type: 'datetime', default: () => new Date() },
updated_at: { type: 'datetime', default: () => new Date() }
};

// Automatically update timestamp on record changes
async pre_update() {
this.updated_at = new Date();
}

async pre_create() {
const now = new Date();
if (!this.created_at) this.created_at = now;
if (!this.updated_at) this.updated_at = now;
}
}

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

module.exports = Timestampable;
Loading