diff --git a/demo/mixins/README.md b/demo/mixins/README.md new file mode 100644 index 0000000..43809b8 --- /dev/null +++ b/demo/mixins/README.md @@ -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. diff --git a/demo/mixins/index.js b/demo/mixins/index.js new file mode 100644 index 0000000..9170a4e --- /dev/null +++ b/demo/mixins/index.js @@ -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; diff --git a/demo/mixins/models/Document.js b/demo/mixins/models/Document.js new file mode 100644 index 0000000..42f31dc --- /dev/null +++ b/demo/mixins/models/Document.js @@ -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; diff --git a/demo/mixins/models/SoftDeletable.js b/demo/mixins/models/SoftDeletable.js new file mode 100644 index 0000000..3fc9ea0 --- /dev/null +++ b/demo/mixins/models/SoftDeletable.js @@ -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; diff --git a/demo/mixins/models/Timestampable.js b/demo/mixins/models/Timestampable.js new file mode 100644 index 0000000..be5b10e --- /dev/null +++ b/demo/mixins/models/Timestampable.js @@ -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; diff --git a/demo/mixins/models/User.js b/demo/mixins/models/User.js new file mode 100644 index 0000000..4b403a7 --- /dev/null +++ b/demo/mixins/models/User.js @@ -0,0 +1,31 @@ +/** + * User Model + * + * Example model that uses the Timestampable mixin. + */ +class User { + static _name = 'User'; + static table = 'users'; + static mixins = ['Timestampable']; + + static fields = { + id: 'primary', + email: { type: 'string', required: true, unique: true }, + name: 'string', + documents: { type: 'one-to-many', foreign: 'Document.author_id' } + }; + + get displayName() { + return this.name || this.email; + } +} + +// Define name property to override readonly built-in +Object.defineProperty(User, 'name', { + value: 'User', + writable: false, + enumerable: false, + configurable: true, +}); + +module.exports = User; diff --git a/docs/mixins.md b/docs/mixins.md index 250e800..b067732 100644 --- a/docs/mixins.md +++ b/docs/mixins.md @@ -3,21 +3,407 @@ id: mixins title: Mixins (Extensions) --- -Extend models by registering multiple classes with the same static _name. Instance methods and fields are merged; statics are attached with super support. +# Mixins (Extensions) + +Mixins in NormalJS allow you to create reusable bundles of fields, methods, and behavior that can be shared across multiple models. This is particularly useful for common patterns like timestamps, soft deletes, and audit trails. + +## Overview + +There are two ways to create mixins: + +1. **Extension Pattern**: Register multiple classes with the same `static _name` to extend a model +2. **Composition Pattern**: Use `static mixins = [...]` to compose behavior from other models + +## Extension Pattern + +Extend models by registering multiple classes with the same `static _name`. Instance methods and fields are merged; statics are attached with super support. ```js class Users { static _name = 'Users'; - static fields = { id: 'primary' }; + static fields = { + id: 'primary', + email: 'string' + }; } + class UsersExtra { - static _name = 'Users'; + static _name = 'Users'; // Same name = extension + static fields = { + picture: 'string' // Additional fields + }; + get label() { return this.email; } } + repo.register(Users); -repo.register(UsersExtra); +repo.register(UsersExtra); // Fields and methods are merged +``` + +## Composition Pattern + +Use `static mixins` to compose behavior from other registered models: + +```js +class Timestampable { + static _name = 'Timestampable'; + static abstract = true; // Mark as mixin-only + static fields = { + created_at: { type: 'datetime', default: () => new Date() }, + updated_at: { type: 'datetime', default: () => new Date() } + }; +} + +class Posts { + static _name = 'Posts'; + static mixins = ['Timestampable']; // Include the mixin + static fields = { + id: 'primary', + title: 'string' + }; +} + +repo.register(Timestampable); +repo.register(Posts); +// Posts now has id, title, created_at, and updated_at fields +``` + +## Common Mixin Patterns + +### Timestamps Mixin + +Automatically manage `created_at` and `updated_at` fields: + +```js +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; + } +} + +// Use in models +class Users { + static _name = 'Users'; + static mixins = ['Timestampable']; + static fields = { + id: 'primary', + email: { type: 'string', unique: true } + }; +} + +class Posts { + static _name = 'Posts'; + static mixins = ['Timestampable']; + static fields = { + id: 'primary', + title: 'string', + content: 'text' + }; +} + +repo.register(Timestampable); +repo.register(Users); +repo.register(Posts); + +// Usage +const user = await Users.create({ email: 'john@example.com' }); +console.log(user.created_at); // Automatically set + +// Update automatically updates updated_at +await user.write({ email: 'jane@example.com' }); +console.log(user.updated_at); // Updated timestamp +``` + +### Soft Delete Mixin + +Implement soft deletes by overriding the `unlink()` method: + +```js +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 set deleted_at instead of deleting + 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; + } + + // Method for hard delete + async forceUnlink() { + // Call parent unlink by accessing Record's original unlink + // We need to call the original 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 + 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; + } +} + +// Use in models +class Documents { + static _name = 'Documents'; + static mixins = ['SoftDeletable']; + static fields = { + id: 'primary', + title: 'string', + content: 'text' + }; + + // Add scope to access deleted records + static scopes = { + withDeleted: { + // Remove the default scope filter + } + }; +} + +repo.register(SoftDeletable); +repo.register(Documents); + +// Usage +const doc = await Documents.create({ + title: 'Important Document', + content: 'Content here' +}); + +// Soft delete (sets deleted_at) +await doc.unlink(); +console.log(doc.deleted_at); // Set to current time +console.log(doc.isDeleted); // true + +// Normal queries don't find soft-deleted records +const found = await Documents.where({ id: doc.id }).first(); +console.log(found); // null (due to defaultScope) + +// Access soft-deleted records +const allDocs = await Documents.unscoped().where({ id: doc.id }).first(); +console.log(allDocs); // Found! + +// Restore the record +await doc.restore(); +console.log(doc.deleted_at); // null + +// Hard delete (actually removes from database) +await doc.unlink(); // Soft delete first +await doc.forceUnlink(); // Now hard delete ``` -See tests around extendModel for conflict-avoidance and performance. +### Activity Tracking Mixin + +Track related activities on any model: + +```js +class ActivityMixin { + static _name = 'ActivityMixin'; + static abstract = true; + + static fields = { + activities: { + type: 'one-to-many', + foreign: 'Activity', + where: function (record) { + return { + res_model: record._model.name, + res_id: record.id + }; + } + } + }; + + /** + * Add an activity linked to this record + */ + async addActivity({ subject, description, due_date, user_id }) { + const Activity = this._repo.get('Activity'); + return await Activity.create({ + subject, + description, + due_date, + user_id, + res_model: this._model.name, + res_id: this.id + }); + } + + /** + * Get pending activities + */ + async getPendingActivities() { + await this.activities.load(); + return this.activities.items.filter(a => !a.completed); + } +} + +// Activity model +class Activity { + static _name = 'Activity'; + static fields = { + id: 'primary', + subject: 'string', + description: 'text', + due_date: 'datetime', + user_id: { type: 'many-to-one', model: 'Users' }, + res_model: 'string', + res_id: 'integer', + completed: { type: 'boolean', default: false } + }; +} + +// Use in models +class Leads { + static _name = 'Leads'; + static mixins = ['ActivityMixin']; + static fields = { + id: 'primary', + name: 'string', + email: 'string' + }; +} + +class Opportunities { + static _name = 'Opportunities'; + static mixins = ['ActivityMixin']; + static fields = { + id: 'primary', + name: 'string', + value: 'float' + }; +} + +repo.register(ActivityMixin); +repo.register(Activity); +repo.register(Leads); +repo.register(Opportunities); + +// Usage +const lead = await Leads.create({ + name: 'John Doe', + email: 'john@example.com' +}); + +// Add activity +await lead.addActivity({ + subject: 'Follow up call', + description: 'Call to discuss pricing', + due_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now + user_id: 1 +}); + +// Get activities +const pending = await lead.getPendingActivities(); +console.log(pending.length); // 1 +``` + +### Combining Multiple Mixins + +Models can use multiple mixins together: + +```js +class Tasks { + static _name = 'Tasks'; + static mixins = ['Timestampable', 'SoftDeletable', 'ActivityMixin']; + static fields = { + id: 'primary', + title: 'string', + description: 'text', + priority: { type: 'integer', default: 0 } + }; +} + +repo.register(Tasks); + +// Now Tasks has: +// - created_at, updated_at (from Timestampable) +// - deleted_at, unlink(), restore(), isDeleted (from SoftDeletable) +// - activities, addActivity(), getPendingActivities() (from ActivityMixin) +// - id, title, description, priority (from Tasks itself) +``` + +## Mixin Best Practices + +1. **Mark mixins as abstract**: Use `static abstract = true` to prevent direct instantiation +2. **Use descriptive names**: End mixin names with "Mixin" or "able" for clarity +3. **Keep mixins focused**: Each mixin should handle one concern +4. **Document dependencies**: If a mixin requires other models, document it clearly +5. **Test mixins independently**: Create unit tests for mixin behavior + +## Mixin Limitations + +- Mixins are applied during model registration +- Method conflicts are resolved by last-registered-wins +- Field conflicts will throw an error if types don't match +- Mixin models must be registered before models that use them + +See tests around `extendModel` for conflict-avoidance and performance details.