diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index af2678b..115d84f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,66 +1,416 @@ # NormalJS ORM - AI Coding Agent Instructions +## Quick Start Template + +When creating a new NormalJS project, **always start with this pattern**: + +```javascript +const { Connection, Repository } = require('normaljs'); + +// 1. Create connection (choose your database) +const conn = new Connection({ + client: 'sqlite3', // or 'pg' for PostgreSQL, 'mysql', 'mssql' + connection: { filename: ':memory:' } // or real DB config +}); + +// 2. Create repository (connection managed automatically) +const repo = new Repository(conn); + +// 3. Define models as ES6 classes +class Users { + static _name = 'Users'; // REQUIRED: Registry key + static table = 'users'; // Optional: defaults to snake_case of _name + static fields = { + id: 'primary', // Shorthand for auto-increment primary key + email: { type: 'string', unique: true, required: true }, + active: { type: 'boolean', default: true }, + }; + + // Instance methods work on active records + get domain() { + return this.email?.split('@')[1]; + } + + // Static methods for custom queries (scopes) + static activeUsers() { + return this.where({ active: true }); + } +} + +// 4. Register models +repo.register(Users); + +// 5. Sync schema (development only!) +await repo.sync(); + +// 6. Use models via repository +const UsersModel = repo.get('Users'); +const user = await UsersModel.create({ email: 'test@example.com' }); +``` + +## ✅ DO / ❌ DON'T Checklist + +**When defining models:** + +✅ **DO:** +- Always include `static _name` (required for registration) +- Use `static fields` object for schema definition +- Add instance methods/getters directly on the class +- Add static methods for custom query scopes +- Use descriptive PascalCase names for models (`Users`, `BlogPosts`) + +❌ **DON'T:** +- Never instantiate models with `new Users()` directly +- Don't forget `static _name` (causes registration errors) +- Don't use async getters (use methods instead) +- Don't define fields outside the `static fields` object + +**When working with relations:** + +✅ **DO:** +```javascript +// Define relations as fields +class Posts { + static fields = { + author_id: { type: 'many-to-one', model: 'Users' }, // Creates FK column + comments: { type: 'one-to-many', foreign: 'Comments.post_id' }, + tags: { type: 'many-to-many', model: 'Tags' } + }; +} + +// Load relations explicitly +const post = await Posts.findById(1); +await post.comments.load(); +await post.tags.load(); + +// Use include for eager loading +const post = await Posts.where({ id: 1 }).include('author', 'tags').first(); +``` + +❌ **DON'T:** +```javascript +// Don't try to access unloaded relations +const post = await Posts.findById(1); +console.log(post.comments); // May be undefined if not loaded! + +// Don't manually join tables for relations +const query = Posts.query().join('users', 'posts.author_id', 'users.id'); // Use include instead! +``` + +**When querying:** + +✅ **DO:** +```javascript +// Access models via repository +const Users = repo.get('Users'); + +// Use method chaining +const users = await Users.where({ active: true }).limit(10).orderBy('created_at', 'desc').find(); + +// Use findById for single records +const user = await Users.findById(1); + +// Use first() for single results +const user = await Users.where({ email }).first(); + +// Use transactions for writes +await repo.transaction(async tx => { + const Users = tx.get('Users'); + await Users.create({ email: 'test@example.com' }); +}); +``` + +❌ **DON'T:** +```javascript +// Don't query without going through repository +const users = await Users.query().find(); // Users is the class, not the model! + +// Don't mix transaction contexts +await repo.transaction(async tx => { + await repo.get('Users').create({ email }); // Wrong! Use tx.get('Users') +}); + +// Don't use .sync({ force: true }) in production +await repo.sync({ force: true }); // ONLY for development/testing +``` + +**When handling records:** + +✅ **DO:** +```javascript +// Method 1: Direct modification (optimal - persists automatically) +const user = await Users.findById(1); +user.email = 'new@example.com'; +user.updated_at = new Date(); +// Changes persist automatically on next query or transaction flush + +// Method 2: write() with key/value pairs (immediate flush) +const user = await Users.findById(1); +await user.write({ email: 'new@example.com', updated_at: new Date() }); + +// Delete records +await user.unlink(); + +// Check for null/undefined +const user = await Users.where({ email }).first(); +if (!user) { + throw new Error('User not found'); +} +``` + +❌ **DON'T:** +```javascript +// Don't use update() method +await user.update({ email: 'new@example.com' }); // Method doesn't exist! + +// Don't set properties then call write() without arguments +user.email = 'new@example.com'; +await user.write(); // Anti-pattern! Use write({ email: ... }) instead + +// Don't use save() method +await user.save(); // Doesn't exist! + +// Don't use delete() method +await user.delete(); // Use unlink() instead +``` + +❌ **DON'T:** +```javascript +// Don't use update() method +await user.update({ email: 'new@example.com' }); // Method doesn't exist! + +// Don't use save() method +await user.save(); // Doesn't exist! + +// Don't use delete() method +await user.delete(); // Use unlink() instead +``` + ## Architecture Overview NormalJS is a full-featured Node.js ORM with active record patterns, built on Knex.js. Core components: -- **Repository**: Model registry and transaction coordinator (`src/Repository.js`) -- **Model**: Query builder and schema management (`src/Model.js`) -- **Record**: Active record instances with lazy field access (`src/Record.js`) -- **Connection**: Knex wrapper supporting PostgreSQL/SQLite (`src/Connection.js`) -- **Fields**: Type system with schema inference (`src/Fields.js`, `src/fields/`) +- **Repository**: Model registry and transaction coordinator +- **Model**: Query builder with fluent API, returns active records +- **Record**: Active record instances with lazy field access +- **Connection**: Knex wrapper supporting PostgreSQL/SQLite/MySQL +- **Fields**: Type system with validation, serialization, and relations + +## Field Type Reference + +```javascript +static fields = { + // Primary key (shorthand) + id: 'primary', // → { type: 'number', primary: true, generated: true } + + // Basic types (shorthand) + name: 'string', // → { type: 'string' } + age: 'integer', // → { type: 'integer' } + price: 'float', // → { type: 'float' } + active: 'boolean', // → { type: 'boolean' } + bio: 'text', // → { type: 'text' } + + // Full definitions with constraints + email: { type: 'string', unique: true, required: true, size: 255 }, + count: { type: 'integer', default: 0, index: true }, + score: { type: 'float', precision: 2 }, + metadata: { type: 'json' }, + created_at: { type: 'datetime', default: () => new Date() }, + status: { type: 'enum', values: ['draft', 'published', 'archived'] }, + + // Relations (create foreign key) + author_id: { type: 'many-to-one', model: 'Users', cascade: true }, + + // Relations (virtual - no column) + posts: { type: 'one-to-many', foreign: 'Posts.author_id' }, + tags: { type: 'many-to-many', model: 'Tags', joinTable: 'posts_tags' } +} +``` -## Coding patterns +## Relation Patterns -- Use SRP (Single Responsibility Principle) to keep models focused and manageable. -- DRY (Don't Repeat Yourself): Abstract common logic into base classes or utility functions. -- Avoid too high complexity in methods, favoring smaller, testable functions. -- Each method should have a clear purpose and single responsibility. -- Document with jsdoc annotations for functions and classes. -- Update tests during code changes to ensure coverage and correctness. -- We target at least 80% test coverage across the codebase +**Many-to-One** (belongs to - creates FK column): +```javascript +class Posts { + static fields = { + id: 'primary', + author_id: { type: 'many-to-one', model: 'Users' }, // Creates author_id column + }; +} -## Model Definition Patterns +// Usage +const post = await Posts.findById(1); +const author = await post.author.load(); // Loads related user -Models are ES6 classes with static metadata: +// Change relation - Method 1: Direct modification +post.author_id = newUserId; +// Persists automatically +// Change relation - Method 2: write() with key/value pairs +await post.write({ author_id: newUserId }); +``` + +**One-to-Many** (has many - virtual field): ```javascript class Users { - static _name = 'Users'; // Registry key (required) - static table = 'users'; // DB table name (optional, inferred from name) static fields = { - id: 'primary', // Shorthand for { type: "number", primary: true, generated: true } - email: { type: 'string', unique: true, required: true }, - posts: { type: 'one-to-many', foreign: 'Posts.author_id' }, // Relations - tags: { type: 'many-to-many', model: 'Tags' }, // Auto-creates join table + id: 'primary', + posts: { type: 'one-to-many', foreign: 'Posts.author_id' }, // No column created }; +} - // Instance methods/getters work on active records - get name() { - return `${this.firstname} ${this.lastname}`; - } +// Usage +const user = await Users.findById(1); +await user.posts.load(); // Loads all posts where author_id = user.id +const posts = user.posts.items; // Access loaded collection +``` + +**Many-to-Many** (auto-creates join table): +```javascript +class Posts { + static fields = { + id: 'primary', + tags: { type: 'many-to-many', model: 'Tags' }, // Creates rel_posts_tags table + }; } + +class Tags { + static fields = { + id: 'primary', + posts: { type: 'many-to-many', model: 'Posts' }, // Same join table + }; +} + +// Usage +const post = await Posts.findById(1); +await post.tags.load(); +await post.tags.add(tagId); // Add relation +await post.tags.remove(tagId); // Remove relation +await post.tags.set([id1, id2, id3]); // Replace all relations ``` -## Relation Patterns +## Query Patterns & Best Practices + +```javascript +const Users = repo.get('Users'); + +// ✅ Find by ID (uses cache when enabled) +const user = await Users.findById(1); + +// ✅ Find one by condition +const user = await Users.where({ email: 'test@example.com' }).first(); + +// ✅ Find many with filters +const users = await Users + .where({ active: true }) + .where('created_at', '>', new Date('2024-01-01')) + .orderBy('created_at', 'desc') + .limit(10) + .find(); + +// ✅ Complex JSON criteria +const users = await Users.where({ + and: [ + ['active', '=', true], + ['age', '>', 18], + { or: [['role', '=', 'admin'], ['role', '=', 'moderator']] } + ] +}).find(); + +// ✅ Eager loading relations +const users = await Users + .where({ active: true }) + .include('posts', 'profile') + .find(); + +// ✅ Count records +const count = await Users.where({ active: true }).count(); + +// ✅ Request-level caching (60 seconds) +const users = await Users.where({ active: true }).cache(60).find(); + +// ✅ Create with relations +const post = await Posts.create({ + title: 'Hello World', + content: 'Post content', + author_id: userId, + tags: [tagId1, tagId2] // Automatically creates many-to-many relations +}); + +// ✅ Update records - Method 1: Direct modification +const user = await Users.findById(1); +user.email = 'new@example.com'; +user.updated_at = new Date(); +// Persists automatically on next query or transaction flush + +// ✅ Update records - Method 2: write() with key/value pairs (immediate) +await user.write({ email: 'new@example.com', updated_at: new Date() }); + +// ✅ Delete records +await user.unlink(); +``` -- **One-to-Many**: `{ type: "one-to-many", foreign: "ChildModel.fk_column" }` -- **Many-to-One**: `{ type: "many-to-one", model: "ParentModel" }` (creates FK column) -- **Many-to-Many**: Both sides use `{ type: "many-to-many", model: "OtherModel" }` (auto-creates join table) +## Transaction Patterns + +**Always use transactions for multi-step operations:** + +```javascript +// ✅ Correct: Transaction-scoped repository +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Posts = tx.get('Posts'); + + const user = await Users.create({ email: 'author@example.com' }); + const post = await Posts.create({ + title: 'First Post', + author_id: user.id + }); + + await post.tags.add(tagId); + + // Commits automatically on success, rolls back on error +}); + +// ❌ Wrong: Mixing contexts +await repo.transaction(async tx => { + const user = await repo.get('Users').create({ email }); // Uses wrong context! + const post = await tx.get('Posts').create({ title, author_id: user.id }); +}); +``` ## Model Extension System Register multiple classes with the same `static _name` to extend models: ```javascript -// Base model -class Users { static _name = "Users"; static fields = { id: "primary" }; } +// Base model (required fields) +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: { type: 'string', required: true } + }; +} -// Extension (adds fields + methods) -class Users { static _name = "Users"; static fields = { picture: "string" }; get profileUrl() {...} } +// Extension (adds features without modifying base) +class Users { + static _name = 'Users'; // Same name merges! + static fields = { + picture: 'string', // Additional fields + profile_url: 'string' + }; + + get hasProfile() { // Additional methods + return !!this.profile_url; + } + + static verified() { // Additional scopes + return this.where({ verified: true }); + } +} -repo.register(BaseUsers); -repo.register(ExtendedUsers); // Merged into single model +repo.register(Users); // Register base +repo.register(Users); // Register extension - merges into one model ``` ## Development Workflow diff --git a/DOCUMENTATION_CORRECTIONS.md b/DOCUMENTATION_CORRECTIONS.md new file mode 100644 index 0000000..4f6134a --- /dev/null +++ b/DOCUMENTATION_CORRECTIONS.md @@ -0,0 +1,138 @@ +# Documentation Corrections - Direct Property Modification + +## Critical Fix Applied + +The documentation has been corrected to show the **proper NormalJS pattern**: direct property modification. + +## What Was Wrong + +❌ **Incorrect (shown before):** +```js +const user = await Users.findById(1); +await user.update({ email: 'new@example.com' }); // update() doesn't exist! +``` + +## What Is Correct + +✅ **Correct (optimal NormalJS way):** +```js +const user = await Users.findById(1); +user.email = 'new@example.com'; // Direct modification +// Changes persist automatically on next query or transaction flush + +// Or force immediate write: +await user.write(); +``` + +## How NormalJS Active Records Work + +1. **Direct Property Modification** (Optimal) + - Modify properties directly: `record.field = newValue` + - Changes are tracked automatically + - Persist on next query or when transaction flushes + - This is the most efficient way + +2. **Manual Persistence** (When Needed) + - Use `await record.write()` to force immediate persistence + - No `update()` method exists + - No `save()` method exists + +3. **Deletion** + - Use `await record.unlink()` to delete + - No `delete()` method exists + +## Files Corrected + +### 1. `.github/copilot-instructions.md` +- ✅ Fixed "When handling records" section +- ✅ Fixed "Query Patterns" section +- ✅ Removed all incorrect `update()` examples +- ✅ Added correct direct modification patterns +- ✅ Documented `write()` method for manual persistence + +### 2. `docs/index.md` +- ✅ Fixed "Complete Working Example" (step 9) +- ✅ Fixed "How to: Update and Delete" section +- ✅ Fixed "How to: Use Transactions" section +- ✅ Removed all `update()` calls +- ✅ Shows direct property modification throughout + +### 3. `docs/cookbook.md` +- ✅ Fixed "Update Records" section in CRUD Operations +- ✅ Fixed transaction examples (inventory deduction, order totals) +- ✅ Fixed authentication `setPassword()` method +- ✅ Fixed timestamps example +- ✅ Fixed soft delete methods (`softDelete()` and `restore()`) +- ✅ Fixed slug generation `post_create()` hook +- ✅ Fixed search `post_update()` hook +- ✅ All examples now use direct modification + +### 4. `docs/models.md` +- ✅ Fixed "Update Records" section +- ✅ Fixed many-to-one relation usage +- ✅ Shows ❌ DON'T use `update()` explicitly +- ✅ Shows ✅ DO use direct modification + +## Key Patterns Documented + +### Pattern 1: Simple Update +```js +const user = await Users.findById(1); +user.email = 'new@example.com'; +user.name = 'New Name'; +// Persists automatically +``` + +### Pattern 2: Update with Immediate Write +```js +const user = await Users.findById(1); +user.email = 'new@example.com'; +await user.write(); // Force immediate persistence +``` + +### Pattern 3: Update in Transaction +```js +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const user = await Users.findById(1); + + user.email = 'new@example.com'; + user.updated_at = new Date(); + // Persists on transaction commit +}); +``` + +### Pattern 4: Update with Validation +```js +const user = await Users.findById(1); +if (user.role !== 'admin') { + user.email = newEmail; + await user.write(); +} +``` + +## Anti-Patterns Now Documented + +The documentation now explicitly shows what **NOT** to do: + +❌ `await user.update({ ... })` - Method doesn't exist +❌ `await user.save()` - Method doesn't exist +❌ `await user.delete()` - Use `unlink()` instead +❌ Thinking changes won't persist - They do! + +## Build Status + +✅ Documentation builds successfully +✅ All examples corrected +✅ Consistent patterns throughout +✅ AI agents will now learn the correct NormalJS way + +## For AI Coding Agents + +When working with NormalJS records: + +1. **Always modify properties directly** - This is the optimal pattern +2. **Changes persist automatically** - On next query or transaction flush +3. **Use `write()` for immediate persistence** - Only when you need it right away +4. **No `update()` method exists** - Don't look for it +5. **No `save()` method exists** - Direct modification handles it diff --git a/DOCUMENTATION_IMPROVEMENTS.md b/DOCUMENTATION_IMPROVEMENTS.md new file mode 100644 index 0000000..56dc4f9 --- /dev/null +++ b/DOCUMENTATION_IMPROVEMENTS.md @@ -0,0 +1,159 @@ +# Documentation Improvements Summary + +The NormalJS documentation has been enhanced to be more AI-agent-friendly and developer-friendly. + +## What Was Improved + +### 1. ✅ Task-Oriented Structure + +**Before:** Documentation was reference-style with minimal examples +**After:** Each page now includes "How to" sections with specific tasks: +- "How to: Define a Model" +- "How to: Create Records" +- "How to: Query Records" +- "How to: Work with Relations" + +### 2. ✅ Pattern-Based Examples + +**Before:** Single examples without context +**After:** Every concept includes: +- ✅ DO patterns (correct usage) +- ❌ DON'T patterns (common mistakes) +- Multiple working examples +- Real-world scenarios + +Example from models.md: +```js +// ❌ DON'T: Create without transaction for multi-step ops +const user = await Users.create({ email }); +const profile = await Profiles.create({ user_id: user.id }); // Not atomic! + +// ✅ DO: Use transactions +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Profiles = tx.get('Profiles'); + + const user = await Users.create({ email }); + await Profiles.create({ user_id: user.id }); +}); +``` + +### 3. ✅ Complete Runnable Code + +**Before:** Code snippets with placeholders +**After:** Every example is: +- Complete and copy-pasteable +- Includes setup/teardown when needed +- Uses realistic data +- Shows expected output + +### 4. ✅ Error Prevention + +**Before:** Only showing the "happy path" +**After:** Documentation explicitly shows: +- Common mistakes and why they fail +- Null checks and error handling +- Transaction context pitfalls +- Relation loading issues + +### 5. ✅ Searchability + +**Before:** Minimal keywords, basic structure +**After:** +- Added keywords to all frontmatter +- Clear table of contents +- Organized by categories (Getting Started, Core Concepts, Advanced, Migration Guides) +- Cross-referenced related docs + +## Files Enhanced + +### `.github/copilot-instructions.md` +- **Added:** Quick Start Template with complete setup +- **Added:** ✅ DO / ❌ DON'T Checklist for all major operations +- **Added:** Complete field type reference with examples +- **Added:** Relation patterns with usage examples +- **Added:** Query patterns & best practices +- **Added:** Transaction patterns with error handling +- **Total:** Expanded from 98 lines to 414 lines + +### `docs/index.md` +- **Before:** Basic feature list + minimal quickstart +- **After:** + - Key features with descriptions + - Complete working example with 10 steps + - "Common Tasks" section with 6 recipes + - Organized navigation to other docs + - Real-world usage examples +- **Total:** Expanded from 49 lines to 320+ lines + +### `docs/cookbook.md` +- **Before:** 4 basic recipes (40 lines) +- **After:** 12 comprehensive sections: + 1. CRUD Operations (Create, Read, Update, Delete) + 2. Queries & Filtering (Simple, Complex, JSON criteria) + 3. Relations (All 3 types with management) + 4. Transactions (Basic, Error Handling, Nested) + 5. Authentication (Password hashing, Sessions) + 6. Pagination (Simple & Cursor-based) + 7. Timestamps (Auto & Hooks) + 8. Soft Deletes + 9. Slugs & SEO + 10. File Uploads + 11. Validation + 12. Search (Simple & PostgreSQL full-text) +- **Total:** Expanded from 40 lines to 1000+ lines + +### `docs/models.md` +- **Before:** Reference-style documentation +- **After:** + - Quick Reference checklist + - ✅ DO / ❌ DON'T examples for every operation + - Complete relation pattern guide + - Query examples with anti-patterns + - Instance methods best practices + - Lifecycle hooks documentation +- **Total:** Enhanced with practical examples throughout + +### `docs/adoption-sequelize.md` +- **Added:** Keywords for better discoverability +- **Added:** To sidebar under "Migration Guides" category + +### `docs/assets/sidebars.js` +- **Added:** "Migration Guides" category +- **Moved:** adoption-sequelize to dedicated section +- **Better:** Organization for progressive learning + +## Key Improvements for AI Agents + +1. **Context-Rich Examples:** Every code block includes full context (imports, setup, usage) + +2. **Anti-Pattern Warnings:** Shows what NOT to do, helping agents avoid common mistakes + +3. **Progressive Complexity:** Simple examples first, then more complex scenarios + +4. **Pattern Recognition:** Consistent structure (✅ DO / ❌ DON'T) helps agents learn faster + +5. **Cross-References:** Links between related concepts help agents navigate + +6. **Keywords:** Rich metadata for semantic search and discovery + +## Usage for AI Agents + +When an AI coding agent works with NormalJS: + +1. **Starting a new project:** Refer to `.github/copilot-instructions.md` → Quick Start Template + +2. **Implementing a feature:** Check `docs/cookbook.md` for the specific recipe + +3. **Understanding concepts:** Read `docs/models.md` or relevant core concept doc + +4. **Avoiding mistakes:** Look for ❌ DON'T patterns in examples + +5. **Migrating from Sequelize:** Use `docs/adoption-sequelize.md` + +## Build Status + +✅ Documentation builds successfully with no errors +✅ All internal links are valid +✅ Sidebar navigation is properly structured +✅ Keywords added for better search diff --git a/docs/adoption-sequelize.md b/docs/adoption-sequelize.md index cbab96c..9ea6b93 100644 --- a/docs/adoption-sequelize.md +++ b/docs/adoption-sequelize.md @@ -1,6 +1,7 @@ --- id: adoption-sequelize -title: Adoption guide: Sequelize → NormalJS +title: "Adoption guide: Sequelize → NormalJS" +keywords: [migration, sequelize, porting, adoption, conversion, from sequelize] --- This guide helps you migrate an existing Sequelize codebase to NormalJS. It maps the core concepts and shows equivalent code for models, associations, queries, transactions, hooks, and more. diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..a6c5cbc --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,1024 @@ +--- +id: api-reference +title: API Reference +keywords: [api, reference, methods, classes, documentation] +--- + +# API Reference + +Complete API documentation for NormalJS classes and methods. + +## Connection + +### `new Connection(config)` + +Creates a Knex database connection instance. + +**Parameters:** +- `config` (object): Knex configuration object + - `client` (string): Database client ('pg', 'mysql2', 'sqlite3', 'mssql') + - `connection` (object or string): Connection details or connection string + - `pool` (object): Connection pool settings + - `min` (number): Minimum pool size (default: 2) + - `max` (number): Maximum pool size (default: 10) + - `debug` (boolean): Enable query logging (default: false) + +**Returns:** Connection instance + +**Example:** +```js +const conn = new Connection({ + client: 'pg', + connection: { + host: 'localhost', + port: 5432, + database: 'myapp', + user: 'postgres', + password: 'secret' + }, + pool: { min: 2, max: 10 } +}); +``` + +**Note:** Connection is managed automatically by transactions. No need to call `connect()` or `disconnect()`. + +--- + +## Repository + +### `new Repository(connection)` + +Creates a repository instance for managing models and transactions. + +**Parameters:** +- `connection` (Connection): Knex connection instance + +**Returns:** Repository instance + +**Example:** +```js +const repo = new Repository(conn); +``` + +### `repo.register(...models)` + +Registers one or more model classes with the repository. + +**Parameters:** +- `...models` (Class): One or more model classes to register + +**Returns:** void + +**Example:** +```js +repo.register(Users); +repo.register(Users, Posts, Tags); // Multiple at once +``` + +### `repo.get(name)` + +Retrieves a registered model by its name. + +**Parameters:** +- `name` (string): Model name (from `static _name` property) + +**Returns:** Model class instance + +**Throws:** Error if model not found in registry + +**Example:** +```js +const Users = repo.get('Users'); +const Posts = repo.get('Posts'); +``` + +### `repo.transaction(callback)` + +Executes operations within a database transaction. Automatically commits on success or rolls back on error. + +**Parameters:** +- `callback` (Function): Async function receiving transaction-scoped repository + - `tx` (Repository): Transaction-scoped repository + +**Returns:** Promise resolving to callback return value + +**Example:** +```js +const result = await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Posts = tx.get('Posts'); + + const user = await Users.create({ email: 'test@example.com' }); + const post = await Posts.create({ title: 'Hello', author_id: user.id }); + + return { user, post }; +}); +``` + +### `repo.sync(options)` + +Synchronizes database schema from model definitions. **Use only in development!** + +**Parameters:** +- `options` (object): Sync options + - `force` (boolean): Drop and recreate all tables (default: false) + +**Returns:** Promise`Promise`lt;void`Promise`gt; + +**Warning:** Never use in production! Use database migrations instead. + +**Example:** +```js +// Development only! +await repo.sync({ force: true }); +``` + +--- + +## Model - Query Methods + +All query methods are chainable and return a query builder until a terminal method is called. + +### `Model.findById(id)` + +Finds a single record by primary key. Uses cache if enabled. + +**Parameters:** +- `id` (number or string): Primary key value + +**Returns:** Promise resolving to Record or null + +**Example:** +```js +const user = await Users.findById(1); +if (!user) { + throw new Error('User not found'); +} +``` + +### `Model.where(criteria, [operator], [value])` + +Adds WHERE conditions to the query. Can be called multiple times to add AND conditions. + +**Signatures:** +1. `where(object)` - Object with field/value pairs +2. `where(field, value)` - Field equals value +3. `where(field, operator, value)` - Field with operator + +**Parameters:** +- `criteria` (object or string): Filter criteria or field name +- `operator` (string): Comparison operator (`=`, `>`, `<`, `>=`, `<=`, `!=`, `like`, `in`, `is`, `is not`) +- `value` (any): Comparison value + +**Returns:** Query builder (chainable) + +**Examples:** +```js +// Object syntax +const users = await Users.where({ active: true, role: 'admin' }).find(); + +// Field/value syntax +const users = await Users.where('active', true).find(); + +// Field/operator/value syntax +const users = await Users.where('age', '>', 18).find(); + +// Multiple conditions (AND) +const users = await Users + .where({ active: true }) + .where('created_at', '>', lastWeek) + .find(); + +// JSON criteria (complex AND/OR) +const users = await Users.where({ + and: [ + ['active', '=', true], + { + or: [ + ['role', '=', 'admin'], + ['role', '=', 'moderator'] + ] + } + ] +}).find(); +``` + +### `Model.whereIn(field, values)` + +Filters records where field is in array of values. + +**Parameters:** +- `field` (string): Field name +- `values` (array): Array of values + +**Returns:** Query builder (chainable) + +**Example:** +```js +const users = await Users.whereIn('role', ['admin', 'moderator']).find(); +``` + +### `Model.orderBy(field, direction)` + +Adds ORDER BY clause. Can be called multiple times for multi-column sorting. + +**Parameters:** +- `field` (string): Field name +- `direction` (string): Sort direction ('asc' or 'desc', default: 'asc') + +**Returns:** Query builder (chainable) + +**Example:** +```js +// Single order +const users = await Users.orderBy('created_at', 'desc').find(); + +// Multiple orders +const users = await Users + .orderBy('role', 'asc') + .orderBy('created_at', 'desc') + .find(); +``` + +### `Model.limit(count)` + +Limits the number of results returned. + +**Parameters:** +- `count` (number): Maximum number of records + +**Returns:** Query builder (chainable) + +**Example:** +```js +const recentPosts = await Posts + .orderBy('created_at', 'desc') + .limit(10) + .find(); +``` + +### `Model.offset(count)` + +Skips the specified number of records (for pagination). + +**Parameters:** +- `count` (number): Number of records to skip + +**Returns:** Query builder (chainable) + +**Example:** +```js +// Page 2, 20 items per page +const page = 2; +const perPage = 20; +const users = await Users + .limit(perPage) + .offset((page - 1) * perPage) + .find(); +``` + +### `Model.include(...relations)` + +Eager loads related records to avoid N+1 queries. + +**Parameters:** +- `...relations` (string): One or more relation names + +**Returns:** Query builder (chainable) + +**Example:** +```js +// Single relation +const users = await Users.include('posts').find(); + +// Multiple relations +const posts = await Posts + .include('author', 'tags', 'comments') + .find(); + +// Access loaded relations +posts.forEach(post => { + console.log(post.author.name); + console.log(post.tags.items.length); +}); +``` + +### `Model.cache(ttl)` + +Enables request-level caching for this query. + +**Parameters:** +- `ttl` (number): Cache time-to-live in seconds (0 to disable) + +**Returns:** Query builder (chainable) + +**Example:** +```js +// Cache for 60 seconds +const users = await Users + .where({ active: true }) + .cache(60) + .find(); + +// Disable cache for this query +const users = await Users.where({ active: true }).cache(0).find(); +``` + +### `Model.find()` + +Executes the query and returns an array of records. Terminal method. + +**Returns:** Promise`Promise`lt;Record[]`Promise`gt; + +**Example:** +```js +const users = await Users + .where({ active: true }) + .orderBy('created_at', 'desc') + .limit(10) + .find(); + +console.log(`Found ${users.length} users`); +``` + +### `Model.first()` + +Executes the query and returns the first record or null. Terminal method. + +**Returns:** Promise resolving to Record or null + +**Example:** +```js +const user = await Users.where({ email: 'john@example.com' }).first(); +if (!user) { + throw new Error('User not found'); +} +``` + +### `Model.count()` + +Returns the count of records matching the query. Terminal method. + +**Returns:** Promise`Promise`lt;number`Promise`gt; + +**Example:** +```js +const activeCount = await Users.where({ active: true }).count(); +const total = await Users.count(); + +console.log(`${activeCount} of ${total} users are active`); +``` + +### `Model.create(data)` + +Creates a new record in the database. + +**Parameters:** +- `data` (object): Field values for the new record + +**Returns:** Promise`Promise`lt;Record`Promise`gt; + +**Example:** +```js +const user = await Users.create({ + email: 'john@example.com', + name: 'John Doe', + active: true +}); + +console.log(`Created user with ID: ${user.id}`); + +// With relations (many-to-many) +const post = await Posts.create({ + title: 'Hello World', + author_id: userId, + tags: [tagId1, tagId2] // Automatically creates relations +}); +``` + +### `Model.query()` + +Returns the underlying Knex query builder for advanced operations. + +**Returns:** Knex query builder + +**Warning:** Using Knex directly bypasses NormalJS hooks, validation, and cache invalidation. Use with caution. + +**Example:** +```js +// Advanced query with Knex +const count = await Users.query() + .where('created_at', '>', lastMonth) + .andWhere(function() { + this.where('role', 'admin').orWhere('role', 'moderator'); + }) + .count('* as total') + .first(); +``` + +--- + +## Record - Instance Methods + +Methods available on individual record instances. + +### `record.write(data)` + +Updates record fields and immediately flushes changes to the database. + +**Parameters:** +- `data` (object): Key/value pairs to update + +**Returns:** Promise`Promise`lt;void`Promise`gt; + +**Example:** +```js +const user = await Users.findById(1); + +// Update multiple fields +await user.write({ + email: 'newemail@example.com', + name: 'New Name', + updated_at: new Date() +}); +``` + +**Note:** For simple updates, you can also modify properties directly (changes auto-persist): +```js +user.email = 'newemail@example.com'; +// Auto-persists on next query or transaction flush +``` + +### `record.unlink()` + +Deletes the record from the database. + +**Returns:** Promise`Promise`lt;void`Promise`gt; + +**Example:** +```js +const user = await Users.findById(1); +await user.unlink(); + +console.log('User deleted'); +``` + +--- + +## Record - Relation Methods + +Methods available on relation collections (one-to-many and many-to-many). + +### `record.relation.load()` + +Loads the relation collection from the database. + +**Returns:** Promise`Promise`lt;void`Promise`gt; + +**Example:** +```js +const user = await Users.findById(1); + +// Load posts +await user.posts.load(); + +// Access items +console.log(`User has ${user.posts.items.length} posts`); +user.posts.items.forEach(post => { + console.log(post.title); +}); +``` + +### `record.relation.where(criteria)` + +Filters the relation query before loading. + +**Parameters:** +- `criteria` (object): Filter criteria + +**Returns:** Promise`Promise`lt;Record[]`Promise`gt; + +**Example:** +```js +const user = await Users.findById(1); + +// Get only published posts +const publishedPosts = await user.posts.where({ published: true }); + +console.log(`User has ${publishedPosts.length} published posts`); +``` + +### `record.relation.add(id)` + +Adds a relation (many-to-many or one-to-many). + +**Parameters:** +- `id` (number or Record): Related record ID or record instance + +**Returns:** Promise`Promise`lt;void`Promise`gt; + +**Example:** +```js +const post = await Posts.findById(1); + +// Add a tag (many-to-many) +await post.tags.add(tagId); +await post.tags.add(tagObject); + +console.log('Tag added to post'); +``` + +### `record.relation.remove(id)` + +Removes a relation (many-to-many or one-to-many). + +**Parameters:** +- `id` (number or Record): Related record ID or record instance + +**Returns:** Promise`Promise`lt;void`Promise`gt; + +**Example:** +```js +const post = await Posts.findById(1); + +// Remove a tag +await post.tags.remove(tagId); + +console.log('Tag removed from post'); +``` + +### `record.relation.set(ids)` + +Replaces all relations with the specified IDs. + +**Parameters:** +- `ids` (number[]): Array of related record IDs + +**Returns:** Promise`Promise`lt;void`Promise`gt; + +**Example:** +```js +const post = await Posts.findById(1); + +// Replace all tags +await post.tags.set([tag1Id, tag2Id, tag3Id]); + +// Clear all tags +await post.tags.set([]); + +console.log('Tags updated'); +``` + +### `record.relation.items` + +Access array of loaded relation records. + +**Type:** Record[] + +**Note:** Only available after calling `.load()` or using `.include()` + +**Example:** +```js +const user = await Users + .where({ id: 1 }) + .include('posts') + .first(); + +// Access loaded items +console.log(user.posts.items.length); +user.posts.items.forEach(post => { + console.log(post.title); +}); +``` + +--- + +## Model Definition - Static Properties + +Properties used when defining model classes. + +### `static _name` (required) + +Registry name for the model. **Required for registration.** + +**Type:** string + +**Example:** +```js +class Users { + static _name = 'Users'; // Required! +} +``` + +### `static table` + +Database table name. Defaults to snake_case of `_name`. + +**Type:** string + +**Default:** Snake case of `_name` + +**Example:** +```js +class BlogPosts { + static _name = 'BlogPosts'; + static table = 'blog_posts'; // Optional: defaults to 'blog_posts' +} +``` + +### `static fields` + +Field definitions for the model schema. + +**Type:** object + +**Example:** +```js +class Users { + static fields = { + id: 'primary', + email: { type: 'string', unique: true, required: true }, + name: 'string', + age: { type: 'integer', default: 0 }, + active: { type: 'boolean', default: true }, + created_at: { type: 'datetime', default: () => new Date() } + }; +} +``` + +See [Field Types](fields) for complete field options. + +### `static cache` + +Entry-level cache TTL in seconds. Enables automatic caching for `findById()`. + +**Type:** number + +**Default:** 0 (disabled) + +**Example:** +```js +class Countries { + static cache = 3600; // Cache entries for 1 hour +} +``` + +### `static cacheInvalidation` + +Enable automatic cache invalidation on writes. + +**Type:** boolean + +**Default:** false + +**Example:** +```js +class Users { + static cache = 300; + static cacheInvalidation = true; // Clear cache on update/delete +} +``` + +--- + +## Model Definition - Lifecycle Hooks + +Methods called during record lifecycle. Define these as methods on your model class. + +### `async pre_create()` + +Called before a record is created. + +**Context:** `this` is the record being created + +**Example:** +```js +class Users { + async pre_create() { + // Validate email + if (!this.email.includes('@')) { + throw new Error('Invalid email format'); + } + + // Set defaults + this.created_at = new Date(); + } +} +``` + +### `async post_create()` + +Called after a record is created. + +**Context:** `this` is the newly created record (has ID) + +**Example:** +```js +class Users { + async post_create() { + console.log(`User ${this.id} created: ${this.email}`); + + // Trigger side effects + await sendWelcomeEmail(this.email); + } +} +``` + +### `async pre_update()` + +Called before a record is updated. + +**Context:** `this` is the record being updated + +**Example:** +```js +class Users { + async pre_update() { + // Auto-update timestamp + this.updated_at = new Date(); + + // Validate changes + if ('email' in this._changes) { + if (!this.email.includes('@')) { + throw new Error('Invalid email format'); + } + } + } +} +``` + +### `async post_update()` + +Called after a record is updated. + +**Context:** `this` is the updated record + +**Example:** +```js +class Users { + async post_update() { + console.log(`User ${this.id} updated`); + + // Invalidate related caches + if ('email' in this._changes) { + await cache.del(`user:${this.id}:profile`); + } + } +} +``` + +### `async pre_delete()` + +Called before a record is deleted. + +**Context:** `this` is the record being deleted + +**Example:** +```js +class Users { + async pre_delete() { + // Prevent deletion of protected users + if (this.is_protected) { + throw new Error('Cannot delete protected user'); + } + + console.log(`Deleting user ${this.id}`); + } +} +``` + +### `async post_delete()` + +Called after a record is deleted. + +**Context:** `this` is the deleted record (still has data) + +**Example:** +```js +class Users { + async post_delete() { + console.log(`User ${this.id} deleted`); + + // Clean up related resources + await deleteUserFiles(this.id); + } +} +``` + +--- + +## Field Types Reference + +Quick reference for field type definitions. + +### Shorthand Types + +```js +static fields = { + id: 'primary', // Auto-increment primary key + name: 'string', // VARCHAR(255) + age: 'integer', // INTEGER + price: 'float', // FLOAT + active: 'boolean', // BOOLEAN + bio: 'text', // TEXT +} +``` + +### Full Definitions + +```js +static fields = { + // String with constraints + email: { + type: 'string', + size: 255, + unique: true, + required: true, + index: true + }, + + // Integer with default + count: { + type: 'integer', + default: 0, + index: true + }, + + // Float with precision + price: { + type: 'float', + precision: 2 + }, + + // Boolean with default + active: { + type: 'boolean', + default: true + }, + + // DateTime with default + created_at: { + type: 'datetime', + default: () => new Date() + }, + + // JSON field + metadata: { + type: 'json' + }, + + // Enum (app-level validation) + status: { + type: 'enum', + values: ['draft', 'published', 'archived'] + }, + + // Many-to-one (creates FK column) + author_id: { + type: 'many-to-one', + model: 'Users', + cascade: true + }, + + // One-to-many (virtual field) + posts: { + type: 'one-to-many', + foreign: 'Posts.author_id' + }, + + // Many-to-many (auto join table) + tags: { + type: 'many-to-many', + model: 'Tags', + joinTable: 'posts_tags' // Optional custom name + } +} +``` + +See [Field Types](fields) for complete documentation. + +--- + +## Error Handling + +Common errors and how to handle them. + +### Model Not Found + +```js +try { + const Users = repo.get('Users'); +} catch (err) { + console.error('Model not registered:', err.message); +} +``` + +### Record Not Found + +```js +const user = await Users.findById(999); +if (!user) { + throw new Error('User not found'); +} +``` + +### Validation Errors + +```js +try { + const user = await Users.create({ name: 'John' }); // Missing required 'email' +} catch (err) { + console.error('Validation failed:', err.message); +} +``` + +### Transaction Errors + +```js +try { + await repo.transaction(async tx => { + const Users = tx.get('Users'); + await Users.create({ email: 'test@example.com' }); + throw new Error('Something went wrong'); + }); +} catch (err) { + console.error('Transaction rolled back:', err.message); +} +``` + +### Unique Constraint Violations + +```js +try { + await Users.create({ email: 'existing@example.com' }); +} catch (err) { + if (err.code === 'SQLITE_CONSTRAINT' || err.code === '23505') { + throw new Error('Email already exists'); + } + throw err; +} +``` + +--- + +## Best Practices + +### ✅ DO + +```js +// Use transactions for multi-step operations +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Posts = tx.get('Posts'); + + const user = await Users.create({ email }); + await Posts.create({ title, author_id: user.id }); +}); + +// Use include for eager loading +const users = await Users.include('posts').find(); + +// Check for null/undefined +const user = await Users.where({ email }).first(); +if (!user) { + throw new Error('User not found'); +} + +// Update with write() or direct modification +await user.write({ email: 'new@example.com' }); +// OR +user.email = 'new@example.com'; // Auto-persists +``` + +### ❌ DON'T + +```js +// Don't access unloaded relations +const user = await Users.findById(1); +console.log(user.posts.items); // May be undefined! + +// Don't mix transaction contexts +await repo.transaction(async tx => { + const user = await repo.get('Users').create({ email }); // Wrong! + const post = await tx.get('Posts').create({ author_id: user.id }); +}); + +// Don't use bulk operations with Knex directly (bypasses hooks!) +await Users.query().where('active', false).delete(); // Anti-pattern! + +// Don't use methods that don't exist +await user.update({ email }); // No such method! +await user.save(); // No such method! +await user.delete(); // Use unlink() instead! +``` + +--- + +## See Also + +- [Models](models) - Complete model guide +- [Field Types](fields) - All field types and options +- [Requests & Queries](requests) - Advanced querying +- [Transactions](transactions) - Transaction patterns +- [Hooks](hooks) - Lifecycle hooks in depth +- [Cookbook](cookbook) - Common recipes diff --git a/docs/assets/sidebars.js b/docs/assets/sidebars.js index 400071e..23aa307 100644 --- a/docs/assets/sidebars.js +++ b/docs/assets/sidebars.js @@ -1,21 +1,43 @@ /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ const sidebars = { docs: [ + 'index', + { type: 'category', label: 'Getting Started', collapsed: false, - items: ['index', 'use-cases', 'cookbook'], + items: ['use-cases', 'cookbook'], }, + { type: 'category', label: 'Core Concepts', - items: ['models', 'fields', 'requests', 'filtering', 'mixins', 'inheritance'], + items: ['models', 'fields', 'requests', 'filtering'], + }, + + { + type: 'category', + label: 'Working with Data', + items: ['transactions', 'cache', 'hooks'], }, + + { + type: 'category', + label: 'Advanced Patterns', + items: ['mixins', 'inheritance', 'custom-fields', 'relational-filters'], + }, + + { + type: 'category', + label: 'Reference', + items: ['api-reference'], + }, + { type: 'category', - label: 'Advanced', - items: ['cache', 'transactions', 'hooks', 'custom-fields', 'relational-filters'], + label: 'Migration Guides', + items: ['adoption-sequelize'], }, ], }; diff --git a/docs/cookbook.md b/docs/cookbook.md index df63799..1fc1cfd 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,41 +1,1138 @@ --- id: cookbook -title: Cookbook +title: Cookbook - Common Recipes +keywords: [recipes, examples, howto, patterns, code samples] --- -Recipes you can copy-paste: +# Cookbook -- Create and fetch +Copy-paste ready recipes for common tasks with NormalJS. All examples are production-ready and follow best practices. + +## Table of Contents + +- [CRUD Operations](#crud-operations) +- [Queries & Filtering](#queries--filtering) +- [Relations](#relations) +- [Transactions](#transactions) +- [Authentication](#authentication) +- [Pagination](#pagination) +- [Timestamps](#timestamps) +- [Soft Deletes](#soft-deletes) +- [Slugs & SEO](#slugs--seo) +- [File Uploads](#file-uploads) +- [Validation](#validation) +- [Search](#search) + +## CRUD Operations + +### Create a Record + +```js +const Users = repo.get('Users'); + +// ✅ Simple create +const user = await Users.create({ + email: 'john@example.com', + name: 'John Doe' +}); + +// ✅ Create with default values +const user = await Users.create({ + email: 'jane@example.com' + // 'active' field uses default: true + // 'created_at' uses default: () => new Date() +}); + +// ❌ DON'T: Create without transaction for important data +const user = await Users.create({ email }); // Risky if part of larger operation + +// ✅ DO: Use transactions for important creates +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const user = await Users.create({ email, name }); +}); +``` + +### Read Records + +```js +const Users = repo.get('Users'); + +// ✅ Find by ID (uses cache if enabled) +const user = await Users.findById(1); +if (!user) { + throw new Error('User not found'); +} + +// ✅ Find one by criteria +const user = await Users.where({ email: 'john@example.com' }).first(); + +// ✅ Find many with filters +const activeUsers = await Users + .where({ active: true }) + .orderBy('created_at', 'desc') + .limit(20) + .find(); + +// ✅ Find with multiple conditions +const users = await Users + .where({ active: true }) + .where('created_at', '>', lastWeek) + .find(); + +// ✅ Check if exists +const exists = await Users.where({ email }).count() > 0; +``` + +### Update Records ```js const Users = repo.get('Users'); -const u = await Users.create({ email: 'a@example.com' }); -const found = await Users.findById(u.id); + +// ✅ Method 1: Direct modification (optimal for auto-persist) +const user = await Users.findById(1); +user.name = 'Jane Smith'; +user.updated_at = new Date(); +// Changes persist automatically on next query or transaction flush + +// ✅ Method 2: write() with key/value pairs (immediate flush) +const user = await Users.findById(1); +await user.write({ + name: 'Jane Smith', + updated_at: new Date() +}); + +// ✅ Update with validation +const user = await Users.findById(1); +if (user.role !== 'admin') { + await user.write({ email: newEmail }); +} + +// ❌ DON'T: Bulk update using Knex directly (bypasses hooks!) +await Users.query() + .where('last_login', '<', sixMonthsAgo) + .update({ active: false }); // Bypasses validation, hooks, NormalJS internals! + +// ✅ DO: Use transaction with individual updates (hooks run correctly) +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const users = await Users.where('last_login', '<', sixMonthsAgo).find(); + for (const user of users) { + await user.write({ active: false }); + } +}); + +// ❌ DON'T: Use update() method +await user.update({ email: 'new@example.com' }); // Method doesn't exist! + +// ❌ DON'T: Modify then call write() without arguments +user.email = 'new@example.com'; +await user.write(); // Anti-pattern! + +// ✅ DO: Use one of the two correct methods +user.email = 'new@example.com'; // Direct (auto-persists) +// OR +await user.write({ email: 'new@example.com' }); // Immediate flush +``` + +### Delete Records + +```js +const Users = repo.get('Users'); + +// ✅ Delete single record +const user = await Users.findById(1); +await user.unlink(); + +// ✅ Delete with check +const user = await Users.where({ email }).first(); +if (user && !user.is_protected) { + await user.unlink(); +} + +// ❌ DON'T: Bulk delete using Knex directly (bypasses hooks!) +await Users.query() + .where('created_at', '<', oneYearAgo) + .where('active', false) + .delete(); // Bypasses hooks and cascade logic! + +// ✅ DO: Use transaction with individual deletes (hooks run correctly) +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const users = await Users + .where('created_at', '<', oneYearAgo) + .where('active', false) + .find(); + for (const user of users) { + await user.unlink(); + } +}); +``` + +## Queries & Filtering + +### Simple Filters + +```js +const Posts = repo.get('Posts'); + +// ✅ Single condition +const posts = await Posts.where({ published: true }).find(); + +// ✅ Multiple conditions (AND) +const posts = await Posts + .where({ published: true, featured: true }) + .find(); + +// ✅ Comparison operators +const posts = await Posts + .where('views', '>', 1000) + .where('created_at', '>=', lastMonth) + .find(); + +// ✅ LIKE queries +const posts = await Posts + .where('title', 'like', '%tutorial%') + .find(); + +// ✅ IN queries +const posts = await Posts + .whereIn('category_id', [1, 2, 3]) + .find(); ``` -- Simple filtering +### Complex Filters with JSON Criteria ```js -const posts = await repo.get('Posts').where({ +const Posts = repo.get('Posts'); + +// ✅ OR conditions +const posts = await Posts.where({ + or: [ + ['published', '=', true], + ['author_id', '=', currentUserId] + ] +}).find(); + +// ✅ Nested AND/OR +const posts = await Posts.where({ + and: [ + ['published', '=', true], + { + or: [ + ['featured', '=', true], + ['views', '>', 1000] + ] + } + ] +}).find(); + +// ✅ Complex search +const posts = await Posts.where({ and: [ - ['author_id', '=', u.id], ['published', '=', true], - ], + { + or: [ + ['title', 'like', `%${searchTerm}%`], + ['content', 'like', `%${searchTerm}%`] + ] + }, + ['created_at', '>', startDate] + ] +}).orderBy('views', 'desc').find(); +``` + +### Sorting and Limiting + +```js +const Posts = repo.get('Posts'); + +// ✅ Order by single field +const posts = await Posts + .where({ published: true }) + .orderBy('created_at', 'desc') + .find(); + +// ✅ Order by multiple fields +const posts = await Posts + .where({ published: true }) + .orderBy('featured', 'desc') + .orderBy('views', 'desc') + .find(); + +// ✅ Pagination +const page = 2; +const perPage = 20; +const posts = await Posts + .where({ published: true }) + .limit(perPage) + .offset((page - 1) * perPage) + .find(); +``` + +### Counting + +```js +const Posts = repo.get('Posts'); + +// ✅ Count all +const total = await Posts.count(); + +// ✅ Count with filters +const publishedCount = await Posts + .where({ published: true }) + .count(); + +// ✅ Check existence +const hasUnpublished = await Posts + .where({ published: false }) + .count() > 0; +``` + +## Relations + +### Defining Relations + +```js +// ✅ Many-to-One (belongs to) +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + author_id: { type: 'many-to-one', model: 'Users' } // Creates FK column + }; +} + +// ✅ One-to-Many (has many) +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: 'string', + posts: { type: 'one-to-many', foreign: 'Posts.author_id' } // Virtual field + }; +} + +// ✅ Many-to-Many +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + tags: { type: 'many-to-many', model: 'Tags' } // Auto-creates join table + }; +} + +class Tags { + static _name = 'Tags'; + static fields = { + id: 'primary', + posts: { type: 'many-to-many', model: 'Posts' } // Same join table + }; +} +``` + +### Eager Loading Relations + +```js +const Posts = repo.get('Posts'); +const Users = repo.get('Users'); + +// ✅ Load single relation +const post = await Posts + .where({ id: 1 }) + .include('author') + .first(); +console.log(post.author.name); + +// ✅ Load multiple relations +const post = await Posts + .where({ id: 1 }) + .include('author', 'tags', 'comments') + .first(); + +// ✅ Load relation on collection +const posts = await Posts + .where({ published: true }) + .include('author') + .find(); +posts.forEach(post => console.log(post.author.name)); + +// ❌ DON'T: Access unloaded relations +const post = await Posts.findById(1); +console.log(post.author.name); // May be undefined! + +// ✅ DO: Load explicitly or use include +const post = await Posts.findById(1); +await post.author.load(); +console.log(post.author.name); +``` + +### Lazy Loading Relations + +```js +const Posts = repo.get('Posts'); +const Users = repo.get('Users'); + +// ✅ Load one-to-many +const user = await Users.findById(1); +await user.posts.load(); +console.log(`User has ${user.posts.items.length} posts`); + +// ✅ Load with filters +const user = await Users.findById(1); +const publishedPosts = await user.posts.where({ published: true }); + +// ✅ Load many-to-many +const post = await Posts.findById(1); +await post.tags.load(); +post.tags.items.forEach(tag => console.log(tag.name)); +``` + +### Managing Many-to-Many Relations + +```js +const Posts = repo.get('Posts'); + +// ✅ Add relation +const post = await Posts.findById(1); +await post.tags.add(tagId); +await post.tags.add(tagObject); + +// ✅ Remove relation +await post.tags.remove(tagId); + +// ✅ Replace all relations +await post.tags.set([tag1Id, tag2Id, tag3Id]); + +// ✅ Clear all relations +await post.tags.set([]); + +// ✅ Check if related +await post.tags.load(); +const hasTag = post.tags.items.some(t => t.id === tagId); +``` + +## Transactions + +### Basic Transaction + +```js +// ✅ Simple transaction +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Posts = tx.get('Posts'); + + const user = await Users.create({ email: 'author@example.com' }); + await Posts.create({ title: 'First Post', author_id: user.id }); + + // Commits automatically on success +}); + +// ❌ DON'T: Mix transaction contexts +await repo.transaction(async tx => { + const user = await repo.get('Users').create({ email }); // Wrong context! + const post = await tx.get('Posts').create({ author_id: user.id }); +}); + +// ✅ DO: Use tx consistently +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Posts = tx.get('Posts'); + + const user = await Users.create({ email }); + const post = await Posts.create({ author_id: user.id }); }); ``` -- Relations +### Transaction with Error Handling ```js -// One-to-many -const posts = await u.posts.where({ published: true }); +// ✅ Transaction with rollback on error +try { + await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Accounts = tx.get('Accounts'); + + const user = await Users.create({ email }); + + // This will rollback if it throws + if (someCondition) { + throw new Error('Invalid operation'); + } + + await Accounts.create({ user_id: user.id, balance: 0 }); + }); +} catch (err) { + console.error('Transaction failed:', err.message); + // All changes rolled back +} ``` -- Transactions +### Nested Operations in Transaction ```js -await repo.transaction(async (tx) => { +// ✅ Complex multi-step transaction +await repo.transaction(async tx => { const Users = tx.get('Users'); - await Users.create({ email: 'tx@example.com' }); + const Orders = tx.get('Orders'); + const Products = tx.get('Products'); + const OrderItems = tx.get('OrderItems'); + + // 1. Get user + const user = await Users.findById(userId); + if (!user.active) { + throw new Error('User is not active'); + } + + // 2. Create order + const order = await Orders.create({ + customer_id: user.id, + status: 'pending', + total: 0 + }); + + // 3. Process items + let total = 0; + for (const item of cartItems) { + const product = await Products.findById(item.product_id); + + // Check stock + if (product.stock < item.quantity) { + throw new Error(`Not enough stock for ${product.name}`); + } + + // Deduct inventory + await product.write({ stock: product.stock - item.quantity }); + + // Create order item + await OrderItems.create({ + order_id: order.id, + product_id: product.id, + quantity: item.quantity, + price: product.price + }); + + total += product.price * item.quantity; + } + + // 4. Update order total + await order.write({ total }); +}); +``` + +## Authentication + +### Password Hashing (with bcrypt) + +```js +// ✅ User model with password hashing +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: { type: 'string', unique: true, required: true }, + password_hash: 'string' + }; + + // Set password with hashing + async setPassword(password) { + const bcrypt = require('bcrypt'); + const hash = await bcrypt.hash(password, 10); + await this.write({ password_hash: hash }); + } + + // Verify password + async verifyPassword(password) { + const bcrypt = require('bcrypt'); + return bcrypt.compare(password, this.password_hash); + } + + // Static method for authentication + static async authenticate(email, password) { + const user = await this.where({ email }).first(); + if (!user) return null; + + const valid = await user.verifyPassword(password); + return valid ? user : null; + } +} + +// Usage +const Users = repo.get('Users'); + +// Register +const user = await Users.create({ email: 'john@example.com' }); +await user.setPassword('secretpassword'); + +// Login +const user = await Users.authenticate('john@example.com', 'secretpassword'); +if (!user) { + throw new Error('Invalid credentials'); +} +``` + +### Session Management + +```js +// ✅ Session model +class Sessions { + static _name = 'Sessions'; + static fields = { + id: 'primary', + user_id: { type: 'many-to-one', model: 'Users' }, + token: { type: 'string', unique: true }, + expires_at: 'datetime', + created_at: { type: 'datetime', default: () => new Date() } + }; + + // Check if session is valid + get isValid() { + return this.expires_at > new Date(); + } + + // Create new session + static async createForUser(userId, ttlSeconds = 86400) { + const crypto = require('crypto'); + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + + return this.create({ + user_id: userId, + token, + expires_at: expiresAt + }); + } + + // Find by token + static async findByToken(token) { + const session = await this.where({ token }).first(); + if (!session || !session.isValid) { + return null; + } + return session; + } +} + +// Usage +const Sessions = repo.get('Sessions'); + +// Create session +const session = await Sessions.createForUser(user.id); +console.log('Session token:', session.token); + +// Validate session +const session = await Sessions.findByToken(token); +if (!session) { + throw new Error('Invalid or expired session'); +} +``` + +## Pagination + +### Simple Pagination + +```js +// ✅ Basic pagination helper +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + published: 'boolean' + }; + + // Static pagination method + static async paginate(page = 1, perPage = 20, filters = {}) { + const offset = (page - 1) * perPage; + + // Build base query + let query = this.where(filters); + + // Get items and total count in parallel + const [items, total] = await Promise.all([ + query.limit(perPage).offset(offset).find(), + query.count() + ]); + + return { + items, + total, + page, + perPage, + totalPages: Math.ceil(total / perPage), + hasNext: page * perPage < total, + hasPrev: page > 1 + }; + } +} + +// Usage +const Posts = repo.get('Posts'); +const result = await Posts.paginate(2, 20, { published: true }); + +console.log(`Page ${result.page} of ${result.totalPages}`); +console.log(`Showing ${result.items.length} of ${result.total} posts`); +result.items.forEach(post => console.log(post.title)); +``` + +### Cursor-Based Pagination + +```js +// ✅ Cursor pagination for infinite scroll +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + created_at: 'datetime' + }; + + // Cursor-based pagination + static async paginateByCursor(cursor = null, limit = 20) { + let query = this.where({ published: true }); + + if (cursor) { + // Get posts created before cursor + query = query.where('created_at', '<', cursor); + } + + const items = await query + .orderBy('created_at', 'desc') + .limit(limit + 1) // Get one extra to check if there's more + .find(); + + const hasMore = items.length > limit; + if (hasMore) items.pop(); // Remove the extra item + + const nextCursor = items.length > 0 + ? items[items.length - 1].created_at + : null; + + return { + items, + nextCursor, + hasMore + }; + } +} + +// Usage +const Posts = repo.get('Posts'); + +// First page +const page1 = await Posts.paginateByCursor(); +console.log(`Loaded ${page1.items.length} posts`); + +// Next page +if (page1.hasMore) { + const page2 = await Posts.paginateByCursor(page1.nextCursor); + console.log(`Loaded ${page2.items.length} more posts`); +} +``` + +## Timestamps + +### Auto Timestamps + +```js +// ✅ Model with automatic timestamps +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + created_at: { type: 'datetime', default: () => new Date() }, + updated_at: { type: 'datetime', default: () => new Date() } + }; +} + +// ✅ Update with timestamp +const post = await Posts.findById(1); +await post.write({ + title: 'New Title', + updated_at: new Date() }); ``` + +### Post-Create Hook for Timestamps + +```js +// ✅ Automatic updated_at using hooks +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + created_at: { type: 'datetime', default: () => new Date() }, + updated_at: { type: 'datetime', default: () => new Date() } + }; + + // Post-create hook + async post_create() { + // Run after record is created + console.log(`Post ${this.id} created at ${this.created_at}`); + } + + // Pre-update hook + async pre_update() { + this.updated_at = new Date(); + } +} +``` + +## Soft Deletes + +### Implementing Soft Deletes + +```js +// ✅ Soft delete pattern +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + deleted_at: 'datetime' + }; + + // Soft delete + async softDelete() { + await this.write({ deleted_at: new Date() }); + } + + // Restore + async restore() { + await this.write({ deleted_at: null }); + } + + // Check if deleted + get isDeleted() { + return this.deleted_at !== null; + } + + // Query only active records + static active() { + return this.where({ deleted_at: null }); + } + + // Query only deleted records + static deleted() { + return this.where('deleted_at', 'is not', null); + } +} + +// Usage +const Posts = repo.get('Posts'); + +// Get only active posts +const activePosts = await Posts.active().find(); + +// Soft delete +const post = await Posts.findById(1); +await post.softDelete(); + +// Restore +await post.restore(); + +// Get deleted posts +const deletedPosts = await Posts.deleted().find(); +``` + +## Slugs & SEO + +### Auto-Generate Slugs + +```js +// ✅ Slug generation +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: { type: 'string', required: true }, + slug: { type: 'string', unique: true } + }; + + // Generate slug from title + static generateSlug(title) { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + } + + // Ensure unique slug + static async ensureUniqueSlug(slug, excludeId = null) { + let uniqueSlug = slug; + let counter = 1; + + while (true) { + let query = this.where({ slug: uniqueSlug }); + if (excludeId) { + query = query.where('id', '!=', excludeId); + } + + const exists = await query.count() > 0; + if (!exists) break; + + uniqueSlug = `${slug}-${counter++}`; + } + + return uniqueSlug; + } + + // Post-create hook to generate slug + async post_create() { + if (!this.slug) { + const baseSlug = Posts.generateSlug(this.title); + const uniqueSlug = await Posts.ensureUniqueSlug(baseSlug, this.id); + await this.write({ slug: uniqueSlug }); + } + } + + // Find by slug + static bySlug(slug) { + return this.where({ slug }).first(); + } +} + +// Usage +const Posts = repo.get('Posts'); + +// Create with auto-slug +const post = await Posts.create({ + title: 'Hello World!' +}); +console.log(post.slug); // "hello-world" + +// Find by slug +const post = await Posts.bySlug('hello-world'); +``` + +## File Uploads + +### File Metadata Model + +```js +// ✅ File upload model +class Uploads { + static _name = 'Uploads'; + static fields = { + id: 'primary', + filename: 'string', + original_name: 'string', + mime_type: 'string', + size: 'integer', + path: 'string', + user_id: { type: 'many-to-one', model: 'Users' }, + created_at: { type: 'datetime', default: () => new Date() } + }; + + // Get public URL + get url() { + return `/uploads/${this.filename}`; + } + + // Get human-readable size + get humanSize() { + const bytes = this.size; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + // Create from file + static async createFromFile(filePath, originalName, userId) { + const fs = require('fs'); + const path = require('path'); + const crypto = require('crypto'); + + const stats = fs.statSync(filePath); + const ext = path.extname(originalName); + const filename = crypto.randomBytes(16).toString('hex') + ext; + + // You would move/copy the file here + // fs.renameSync(filePath, `/uploads/${filename}`); + + return this.create({ + filename, + original_name: originalName, + mime_type: 'application/octet-stream', // Detect from file + size: stats.size, + path: `/uploads/${filename}`, + user_id: userId + }); + } +} + +// Usage +const Uploads = repo.get('Uploads'); + +const upload = await Uploads.createFromFile( + '/tmp/upload-abc123', + 'document.pdf', + userId +); + +console.log(`File uploaded: ${upload.url}`); +console.log(`Size: ${upload.humanSize}`); +``` + +## Validation + +### Field-Level Validation + +```js +// ✅ Custom validation in model +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: { type: 'string', unique: true, required: true }, + age: 'integer' + }; + + // Pre-create validation + async pre_create() { + this.validateEmail(); + this.validateAge(); + } + + // Pre-update validation + async pre_update() { + if ('email' in this._changes) { + this.validateEmail(); + } + if ('age' in this._changes) { + this.validateAge(); + } + } + + validateEmail() { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(this.email)) { + throw new Error('Invalid email format'); + } + } + + validateAge() { + if (this.age !== null && this.age !== undefined) { + if (this.age < 0 || this.age > 150) { + throw new Error('Age must be between 0 and 150'); + } + } + } +} + +// Usage (validation happens automatically) +try { + const user = await Users.create({ + email: 'invalid-email', + age: 200 + }); +} catch (err) { + console.error(err.message); // "Invalid email format" +} +``` + +## Search + +### Simple Full-Text Search + +```js +// ✅ Basic search across multiple fields +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + content: 'text', + published: 'boolean' + }; + + // Search method + static search(term, filters = {}) { + const searchPattern = `%${term}%`; + + return this.where({ + and: [ + ...Object.entries(filters).map(([k, v]) => [k, '=', v]), + { + or: [ + ['title', 'like', searchPattern], + ['content', 'like', searchPattern] + ] + } + ] + }); + } +} + +// Usage +const Posts = repo.get('Posts'); + +const results = await Posts + .search('javascript', { published: true }) + .orderBy('created_at', 'desc') + .limit(10) + .find(); + +console.log(`Found ${results.length} posts matching "javascript"`); +``` + +### Search with Relevance Scoring (PostgreSQL) + +```js +// ✅ Full-text search with PostgreSQL +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + content: 'text', + search_vector: 'text' // tsvector column + }; + + // Search with ranking + static async searchFullText(term, limit = 20) { + const query = this.query() + .select('*') + .select(this.query().raw( + "ts_rank(search_vector, plainto_tsquery('english', ?)) as rank", + [term] + )) + .whereRaw( + "search_vector @@ plainto_tsquery('english', ?)", + [term] + ) + .orderBy('rank', 'desc') + .limit(limit); + + return query; + } + + // Update search vector (trigger or hook) + async post_update() { + if ('title' in this._changes || 'content' in this._changes) { + // Update search vector for full-text search + await this.query() + .where({ id: this.id }) + .update({ + search_vector: this.query().raw( + "to_tsvector('english', ? || ' ' || ?)", + [this.title, this.content] + ) + }); + } + } +} +``` + +--- + +## More Recipes + +For more advanced patterns, see: +- [Use Cases](use-cases) - Real-world application examples +- [Transactions](transactions) - Advanced transaction patterns +- [Caching](cache) - Performance optimization +- [Custom Fields](custom-fields) - Build your own field types diff --git a/docs/index.md b/docs/index.md index 734ede6..6afde96 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,47 +2,329 @@ id: index title: NormalJS ORM slug: / +keywords: [orm, node.js, knex, active record, sql, database, postgresql, mysql, sqlite] --- -NormalJS is a full-featured Node.js ORM built on Knex.js with an active record pattern. +# NormalJS ORM -- Define models with a simple fields DSL -- Query with a fluent API or JSON criteria -- Lazy-loading with batched lookups and optional cache -- Relations: one-to-many, many-to-one, many-to-many -- Model extensions (mixins) and inheritance -- Native cache support (support forks and clustering synchronisation) -- Schema sync to create/update tables +NormalJS is a full-featured Node.js ORM built on Knex.js with an active record pattern. It provides a simple, expressive API for working with relational databases. -Quickstart +## Key Features + +- **Simple Model Definition** - Define models with ES6 classes and a declarative fields DSL +- **Fluent Query API** - Chain methods to build complex queries with ease +- **Active Record Pattern** - Work with records as objects with methods and getters +- **Smart Relations** - One-to-many, many-to-one, and many-to-many with eager loading +- **Built-in Caching** - In-memory cache with clustering support for production +- **Lazy Loading** - Optimized queries that load only what you need +- **Model Extensions** - Mixins and inheritance for code reuse +- **Schema Sync** - Auto-create/update tables from model definitions (dev only) +- **Transactions** - First-class support with proper context isolation + +## Quick Start + +### Installation + +```bash +npm install normaljs knex sqlite3 +# or for PostgreSQL: npm install normaljs knex pg +# or for MySQL: npm install normaljs knex mysql2 +``` + +### Complete Working Example ```js const { Connection, Repository } = require('normaljs'); -const conn = new Connection({ client: 'sqlite3', connection: { filename: ':memory:' } }); -await conn.connect(); +// 1. Setup connection and repository +const conn = new Connection({ + client: 'sqlite3', + connection: { filename: ':memory:' } +}); const repo = new Repository(conn); +// 2. Define models with relations class Users { static _name = 'Users'; - static fields = { id: 'primary', email: 'string' }; + static fields = { + id: 'primary', + email: { type: 'string', unique: true, required: true }, + name: 'string', + active: { type: 'boolean', default: true }, + posts: { type: 'one-to-many', foreign: 'Posts.author_id' } + }; + + // Add instance methods + get domain() { + return this.email.split('@')[1]; + } + + // Add static query helpers + static activeUsers() { + return this.where({ active: true }); + } +} + +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: { type: 'string', required: true }, + content: 'text', + published: { type: 'boolean', default: false }, + author_id: { type: 'many-to-one', model: 'Users' }, + tags: { type: 'many-to-many', model: 'Tags' } + }; } -repo.register(Users); -await repo.sync({ force: true }); -const u = await repo.get('Users').create({ email: 'a@example.com' }); +class Tags { + static _name = 'Tags'; + static fields = { + id: 'primary', + name: { type: 'string', unique: true } + }; +} + +// 3. Register models and sync schema +repo.register(Users, Posts, Tags); +await repo.sync({ force: true }); // Dev only! + +// 4. Create records +const UsersModel = repo.get('Users'); +const PostsModel = repo.get('Posts'); +const TagsModel = repo.get('Tags'); + +const user = await UsersModel.create({ + email: 'john@example.com', + name: 'John Doe' +}); + +// 5. Create with relations in a transaction +await repo.transaction(async tx => { + const Posts = tx.get('Posts'); + const Tags = tx.get('Tags'); + + const tag = await Tags.create({ name: 'tutorial' }); + + const post = await Posts.create({ + title: 'Getting Started with NormalJS', + content: 'This is a comprehensive guide...', + author_id: user.id, + tags: [tag.id] // Creates many-to-many relations + }); +}); + +// 6. Query with eager loading +const userWithPosts = await UsersModel + .where({ email: 'john@example.com' }) + .include('posts') + .first(); + +console.log(`${userWithPosts.name} has ${userWithPosts.posts.items.length} posts`); + +// 7. Query posts with filters +const publishedPosts = await PostsModel + .where({ published: true }) + .orderBy('created_at', 'desc') + .limit(10) + .include('author', 'tags') + .find(); + +// 8. Use instance methods +console.log(`User domain: ${user.domain}`); // "example.com" + +// 9. Update records - Two ways: +// Method 1: Direct modification (persists automatically) +user.name = 'John Smith'; + +// Method 2: write() with key/value pairs (immediate flush) +await user.write({ name: 'John Smith', updated_at: new Date() }); + +// 10. Work with relations +const post = await PostsModel.findById(1); +await post.tags.load(); +await post.tags.add(newTagId); +``` + +## Common Tasks + +### How to: Define a Model + +```js +class Products { + static _name = 'Products'; // Required: registry key + static table = 'products'; // Optional: DB table name + static cache = 300; // Optional: cache TTL in seconds + + static fields = { + id: 'primary', + name: { type: 'string', required: true }, + price: { type: 'float', default: 0 }, + in_stock: { type: 'boolean', default: true } + }; +} +``` + +### How to: Create Records + +```js +const Products = repo.get('Products'); + +// Single record +const product = await Products.create({ + name: 'Laptop', + price: 999.99 +}); + +// In a transaction (recommended for writes) +await repo.transaction(async tx => { + const Products = tx.get('Products'); + await Products.create({ name: 'Mouse', price: 29.99 }); + await Products.create({ name: 'Keyboard', price: 79.99 }); +}); ``` -What’s next +### How to: Query Records + +```js +const Products = repo.get('Products'); + +// Find by ID +const product = await Products.findById(1); + +// Find one by condition +const product = await Products.where({ name: 'Laptop' }).first(); + +// Find many with filters +const products = await Products + .where({ in_stock: true }) + .where('price', '<', 100) + .orderBy('price', 'asc') + .limit(10) + .find(); + +// Complex conditions with JSON criteria +const products = await Products.where({ + and: [ + ['in_stock', '=', true], + { or: [ + ['price', '<', 50], + ['name', 'like', '%sale%'] + ]} + ] +}).find(); + +// Count records +const count = await Products.where({ in_stock: true }).count(); +``` + +### How to: Work with Relations + +```js +// Define relations +class Orders { + static _name = 'Orders'; + static fields = { + id: 'primary', + customer_id: { type: 'many-to-one', model: 'Customers' }, + items: { type: 'one-to-many', foreign: 'OrderItems.order_id' } + }; +} + +// Eager load relations +const order = await Orders + .where({ id: 1 }) + .include('customer', 'items') + .first(); + +// Lazy load relations +const order = await Orders.findById(1); +await order.items.load(); + +// Manage many-to-many +const post = await Posts.findById(1); +await post.tags.add(tagId); // Add relation +await post.tags.remove(tagId); // Remove relation +await post.tags.set([id1, id2]); // Replace all +``` + +### How to: Update and Delete + +```js +// Update - Method 1: Direct modification (optimal) +const product = await Products.findById(1); +product.price = 899.99; +product.updated_at = new Date(); +// Changes persist automatically on next query or transaction flush + +// Update - Method 2: write() with key/value pairs (immediate flush) +const product = await Products.findById(1); +await product.write({ + price: 899.99, + updated_at: new Date() +}); + +// Delete a record +await product.unlink(); + +// Bulk operations (use Knex query builder) +await Products.query() + .where('price', '<', 10) + .update({ in_stock: false }); +``` + +### How to: Use Transactions + +```js +// Always use transactions for multi-step operations +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Orders = tx.get('Orders'); + const Products = tx.get('Products'); + + const user = await Users.findById(userId); + const product = await Products.findById(productId); + + // Deduct inventory + await product.write({ stock: product.stock - 1 }); + + // Create order + const order = await Orders.create({ + customer_id: user.id, + product_id: product.id, + total: product.price + }); + + // Commits automatically on success, rolls back on error +}); +``` + +## What's Next? + +### Essential Reading + +- **[Common Use Cases](use-cases)** - Real-world examples and patterns +- **[Cookbook](cookbook)** - Copy-paste recipes for common tasks +- **[Model Definitions](models)** - Complete model reference +- **[Field Types](fields)** - All available field types and options + +### Advanced Topics + +- **[Requests & Queries](requests)** - Advanced querying techniques +- **[Filtering](filtering)** - JSON criteria and complex filters +- **[Mixins](mixins)** - Extend models with reusable behavior +- **[Inheritance](inheritance)** - Model inheritance with discriminators +- **[Transactions](transactions)** - Transaction patterns and locking +- **[Caching](cache)** - Performance optimization with caching +- **[Hooks](hooks)** - Lifecycle hooks and events +- **[Custom Fields](custom-fields)** - Create your own field types + +### Migration Guides -- See common [use cases](use-cases) -- Try the [cookbook](cookbook) -- Learn [model definitions](models) and [fields](fields) -- Explore [requests](requests), [mixins](mixins) and [inheritance](inheritance) -- Implement [custom fields](custom-fields) -- Use [lifecycle hooks and events](hooks) -- Manage [transactions and locking](transactions) -- Use JSON [filtering](filtering) -- Migrate from Sequelize: [adoption guide](adoption-sequelize) +- **[From Sequelize](adoption-sequelize)** - Complete migration guide with code comparisons +## Need Help? +- Browse the [cookbook](cookbook) for ready-to-use examples +- Check [use cases](use-cases) for common scenarios +- Read the [model reference](models) for detailed API documentation diff --git a/docs/models.md b/docs/models.md index 401dee8..1865d0b 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,87 +1,568 @@ --- id: models title: Models Reference +keywords: [models, schema, definition, class, fields, relations, queries, active record] --- # Models NormalJS models are ES6 classes that declare metadata via static properties. They map to database tables, expose a fluent query API, and return active record instances (objects with getters/methods). -See fields reference in `docs/FIELDS.md` for all column types and relation options. +## Quick Reference -## Minimal example +✅ **DO:** +- Always include `static _name` (required for registration) +- Use `static fields` for schema definition +- Add instance methods/getters for computed properties +- Add static methods for custom query scopes +- Use transactions for multi-step operations + +❌ **DON'T:** +- Never instantiate with `new ModelClass()` +- Don't forget `static _name` +- Don't modify record properties directly (use `update()`) +- Don't access relations without loading them first +- Don't use `sync({ force: true })` in production + +## Basic Model Definition + +### Minimal Example ```js class Users { - static _name = 'Users'; // Registry key (required) - static table = 'users'; // DB table (optional; inferred from name) - static cache = true; // Enable cache (true=default TTL, or a number in seconds) + static _name = 'Users'; // REQUIRED: Registry key + static table = 'users'; // Optional: DB table (defaults to snake_case of _name) + static cache = 300; // Optional: Enable cache with 300s TTL static fields = { - id: 'primary', + id: 'primary', // Auto-increment primary key email: { type: 'string', unique: true, required: true }, active: { type: 'boolean', default: true }, created_at: { type: 'datetime', default: () => new Date() }, }; - // Instance API works on active records + // Instance methods work on active records get isStaff() { - return this.email.endsWith('@example.com'); + return this.email?.endsWith('@example.com'); + } + + // Static methods for query scopes + static activeUsers() { + return this.where({ active: true }); } } + +// Register the model +repo.register(Users); + +// Access via repository +const Users = repo.get('Users'); ``` -- `static _name` is mandatory and used as the model key in the repository registry. -- `static table` defaults to a snake_cased form of `name` (e.g. `Users` -> `users`). -- `static cache` can be `true` (uses default TTL of 300s) or a number (TTL seconds). Disable per model by omitting or setting falsy. The global cache must also be enabled at the repository level via env; see `src/Repository.js`. +### Model Properties -## Defining fields and relations +| Property | Required | Description | Example | +|----------|----------|-------------|---------| +| `static _name` | ✅ Yes | Registry key for the model | `'Users'`, `'BlogPosts'` | +| `static table` | ❌ No | Database table name | `'users'`, `'blog_posts'` | +| `static fields` | ✅ Yes | Field definitions | `{ id: 'primary', ... }` | +| `static cache` | ❌ No | Cache TTL (seconds) or `true` for default (300s) | `300`, `true`, `false` | +| `static indexes` | ❌ No | Index definitions | `['email', ['name', 'age']]` | -Declare columns and relations under `static fields`. See `docs/FIELDS.md` for full details. Quick summary: +### Field Types -- Primitives: `primary`, `integer|number`, `float`, `boolean`, `string`, `text`, `date`, `datetime|timestamp`, `enum`, `json`, `reference`. -- Relations: - - Many-to-one: `{ type: 'many-to-one', model: 'OtherModel', cascade?: boolean }` - - One-to-many: `{ type: 'one-to-many', foreign: 'ChildModel.fkField' }` - - Many-to-many: `{ type: 'many-to-many', model: 'OtherModel', joinTable?: 'rel_custom' }` +Quick reference (see [Fields](fields) for complete documentation): -Example with relations: +```js +static fields = { + // Primary key (shorthand) + id: 'primary', // Auto-increment integer PK + + // Basic types + name: 'string', // VARCHAR(255) + age: 'integer', // INTEGER + price: 'float', // FLOAT/DOUBLE + active: 'boolean', // BOOLEAN + bio: 'text', // TEXT + data: 'json', // JSON/JSONB + created: 'datetime', // DATETIME/TIMESTAMP + birthday: 'date', // DATE + + // With constraints + email: { type: 'string', unique: true, required: true, size: 255 }, + count: { type: 'integer', default: 0, index: true }, + score: { type: 'float', precision: 2 }, + status: { type: 'enum', values: ['draft', 'published', 'archived'] }, + + // Relations + author_id: { type: 'many-to-one', model: 'Users' }, // FK column + posts: { type: 'one-to-many', foreign: 'Posts.author_id' }, // Virtual + tags: { type: 'many-to-many', model: 'Tags' } // Auto join table +} +``` + +## Defining Relations + +### Many-to-One (Belongs To) + +Creates a foreign key column on the current model. ```js +// ✅ Correct: Define many-to-one relation class Posts { static _name = 'Posts'; static fields = { id: 'primary', - title: { type: 'string', unique: true }, - content: { type: 'text', required: true }, - author: { type: 'many-to-one', model: 'Users' }, - tags: { type: 'many-to-many', model: 'Tags' }, - comments: { type: 'one-to-many', foreign: 'Comments.post' }, + title: 'string', + author_id: { type: 'many-to-one', model: 'Users' }, // Creates FK column }; } + +// Usage +const post = await Posts.findById(1); +await post.author.load(); // Load related user +console.log(post.author.email); + +// Change relation - Method 1: Direct modification +post.author_id = newUserId; +// Persists automatically + +// Change relation - Method 2: write() with key/value pairs +await post.write({ author_id: newUserId }); ``` -## Querying and active records +### One-to-Many (Has Many) -- `Model.query()` returns a query builder proxy. Chain any Knex method (e.g., `where`, `join`, `limit`, `orderBy`). -- `Model.where(...)` is a shorthand for `Model.query().where(...)`. -- `await Model.findById(id)` resolves an active record by id (uses in-memory identity map and cache when enabled). -- `await Model.firstWhere(cond)` returns the first matching record. +Virtual field that queries the inverse many-to-one relation. -Results are wrapped into active record instances. With cache enabled, read queries initially select only `id` for performance; accessing other fields triggers batched fetching behind the scenes. +```js +// ✅ Correct: Define one-to-many relation +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: 'string', + posts: { type: 'one-to-many', foreign: 'Posts.author_id' }, // Virtual field + }; +} -## Creating and flushing +// Usage +const user = await Users.findById(1); +await user.posts.load(); // Load all posts where author_id = user.id +console.log(`User has ${user.posts.items.length} posts`); -- `await Model.create(data)` inserts a new record and returns an active record instance. Many-to-many collections can be pre-filled by setting the relation field to an array of ids (they are written after the main row is inserted). -- `await repo.flush()` persists pending changes across all models. `await model.flush()` flushes one model. +// Query with filters +const publishedPosts = await user.posts.where({ published: true }); +``` -## Indexes and Unique Constraints +### Many-to-Many -You can define indexes and unique constraints at the model level using the `static indexes` property. This is useful for composite indexes, partial indexes, and fine-grained control over index behavior. +Automatically creates a join table. -### Simple syntax (array) +```js +// ✅ Correct: Define many-to-many relation +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + tags: { type: 'many-to-many', model: 'Tags' }, // Auto-creates rel_posts_tags + }; +} + +class Tags { + static _name = 'Tags'; + static fields = { + id: 'primary', + name: 'string', + posts: { type: 'many-to-many', model: 'Posts' }, // Same join table + }; +} + +// Usage +const post = await Posts.findById(1); + +// Load relations +await post.tags.load(); +post.tags.items.forEach(tag => console.log(tag.name)); + +// Manage relations +await post.tags.add(tagId); // Add a tag +await post.tags.remove(tagId); // Remove a tag +await post.tags.set([id1, id2]); // Replace all tags + +// Create with relations +const post = await Posts.create({ + title: 'Hello World', + tags: [tagId1, tagId2] // Automatically creates relations +}); +``` + +### Common Relation Patterns + +```js +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + content: 'text', + + // Many-to-one relations (FK columns) + author_id: { type: 'many-to-one', model: 'Users' }, + category_id: { type: 'many-to-one', model: 'Categories', cascade: true }, + + // One-to-many relations (virtual) + comments: { type: 'one-to-many', foreign: 'Comments.post_id' }, + + // Many-to-many relations (join table) + tags: { type: 'many-to-many', model: 'Tags', joinTable: 'posts_tags' } + }; +} +``` + +## Querying Models + +### Basic Queries + +```js +const Users = repo.get('Users'); + +// ✅ Find by ID (uses cache) +const user = await Users.findById(1); + +// ✅ Find one by condition +const user = await Users.where({ email: 'john@example.com' }).first(); + +// ❌ DON'T: Forget to check for null +const user = await Users.where({ email }).first(); +console.log(user.name); // May throw if user is null! + +// ✅ DO: Always check for null +const user = await Users.where({ email }).first(); +if (!user) { + throw new Error('User not found'); +} + +// ✅ Find many with filters +const users = await Users + .where({ active: true }) + .orderBy('created_at', 'desc') + .limit(10) + .find(); + +// ✅ Count records +const count = await Users.where({ active: true }).count(); + +// ✅ Check existence +const exists = await Users.where({ email }).count() > 0; +``` + +### Complex Filters + +```js +const Posts = repo.get('Posts'); + +// ✅ Multiple conditions (AND) +const posts = await Posts + .where({ published: true }) + .where('views', '>', 1000) + .where('created_at', '>=', lastMonth) + .find(); + +// ✅ JSON criteria with OR +const posts = await Posts.where({ + and: [ + ['published', '=', true], + { + or: [ + ['featured', '=', true], + ['views', '>', 1000] + ] + } + ] +}).find(); + +// ✅ LIKE queries +const posts = await Posts + .where('title', 'like', '%tutorial%') + .find(); + +// ✅ IN queries +const posts = await Posts + .whereIn('category_id', [1, 2, 3]) + .find(); +``` + +### Eager Loading Relations + +```js +const Posts = repo.get('Posts'); + +// ✅ Load single relation +const posts = await Posts + .where({ published: true }) + .include('author') + .find(); + +// ✅ Load multiple relations +const post = await Posts + .where({ id: 1 }) + .include('author', 'tags', 'comments') + .first(); + +// ❌ DON'T: Access unloaded relations +const post = await Posts.findById(1); +console.log(post.author.name); // May be undefined! + +// ✅ DO: Load relations first +const post = await Posts + .where({ id: 1 }) + .include('author') + .first(); +console.log(post.author.name); + +// ✅ OR: Lazy load +const post = await Posts.findById(1); +await post.author.load(); +console.log(post.author.name); +``` + +### Custom Query Scopes + +```js +// ✅ Add static methods for reusable queries +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + published: 'boolean', + featured: 'boolean', + views: 'integer' + }; + + // Query scope for published posts + static published() { + return this.where({ published: true }); + } + + // Query scope for popular posts + static popular(minViews = 1000) { + return this.where('views', '>=', minViews); + } + + // Combine scopes + static trending() { + return this.published() + .where({ featured: true }) + .orderBy('views', 'desc'); + } +} + +// Usage +const Posts = repo.get('Posts'); +const trendingPosts = await Posts.trending().limit(10).find(); +const popularPublished = await Posts.published().popular(5000).find(); +``` + +## Creating and Updating Records + +### Create Records + +```js +const Users = repo.get('Users'); + +// ✅ Simple create +const user = await Users.create({ + email: 'john@example.com', + name: 'John Doe' +}); + +// ❌ DON'T: Create without transaction for multi-step ops +const user = await Users.create({ email }); +const profile = await Profiles.create({ user_id: user.id }); // Not atomic! + +// ✅ DO: Use transactions +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Profiles = tx.get('Profiles'); + + const user = await Users.create({ email }); + await Profiles.create({ user_id: user.id }); +}); + +// ✅ Create with relations +const post = await Posts.create({ + title: 'Hello World', + author_id: userId, + tags: [tagId1, tagId2] // Creates many-to-many relations +}); +``` + +### Update Records + +```js +const Users = repo.get('Users'); + +// ✅ Method 1: Direct modification (optimal for auto-persist) +const user = await Users.findById(1); +user.name = 'Jane Smith'; +user.updated_at = new Date(); +// Changes persist automatically on next query or transaction flush + +// ✅ Method 2: write() with key/value pairs (immediate flush) +const user = await Users.findById(1); +await user.write({ + name: 'Jane Smith', + updated_at: new Date() +}); + +// ❌ DON'T: Use update() method +const user = await Users.findById(1); +await user.update({ email: 'new@example.com' }); // Method doesn't exist! + +// ❌ DON'T: Modify then call write() without arguments +user.email = 'new@example.com'; +await user.write(); // Anti-pattern! + +// ✅ DO: Choose one of the two correct methods +user.email = 'new@example.com'; // Direct (auto-persists) +// OR +await user.write({ email: 'new@example.com' }); // Immediate flush + +// ❌ DON'T: Bulk update using Knex directly (bypasses hooks!) +await Users.query() + .where('last_login', '<', sixMonthsAgo) + .update({ active: false }); // Bypasses all hooks and validation! + +// ✅ DO: Load records and update individually in transaction +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const users = await Users.where('last_login', '<', sixMonthsAgo).find(); + for (const user of users) { + await user.write({ active: false }); + } +}); +``` + +### Delete Records + +```js +const Users = repo.get('Users'); + +// ✅ Delete a record +const user = await Users.findById(1); +await user.unlink(); + +// ❌ DON'T: Use delete() +await user.delete(); // Wrong method! + +// ✅ DO: Use unlink() +await user.unlink(); + +// ❌ DON'T: Bulk delete using Knex directly (bypasses hooks!) +await Users.query() + .where('created_at', '<', oneYearAgo) + .delete(); // Bypasses cascade logic and hooks! + +// ✅ DO: Load records and delete individually in transaction +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const users = await Users.where('created_at', '<', oneYearAgo).find(); + for (const user of users) { + await user.unlink(); + } +}); +``` + +## Instance Methods and Getters + +### Adding Computed Properties + +```js +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: 'string', + first_name: 'string', + last_name: 'string' + }; + + // ✅ Instance getter for computed values + get fullName() { + return `${this.first_name} ${this.last_name}`.trim(); + } + + // ✅ Instance getter with logic + get domain() { + return this.email?.split('@')[1]; + } + + // ❌ DON'T: Use async getters + async get postCount() { // Invalid! + return await this.posts.count(); + } + + // ✅ DO: Use async methods instead + async getPostCount() { + return await this.posts.count(); + } +} + +// Usage +const user = await Users.findById(1); +console.log(user.fullName); // Synchronous getter +const count = await user.getPostCount(); // Async method +``` + +### Lifecycle Hooks + +```js +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + slug: 'string', + updated_at: 'datetime' + }; + + // ✅ Pre-create hook (before insert) + async pre_create() { + if (!this.slug) { + this.slug = this.title.toLowerCase().replace(/\s+/g, '-'); + } + } + + // ✅ Post-create hook (after insert) + async post_create() { + console.log(`Post ${this.id} created`); + // Send notification, update cache, etc. + } + + // ✅ Pre-update hook (before update) + async pre_update() { + this.updated_at = new Date(); + } + + // ✅ Post-update hook (after update) + async post_update() { + console.log(`Post ${this.id} updated`); + } + + // ✅ Pre-validate hook (for validation) + async pre_validate() { + if (this.title && this.title.length < 3) { + throw new Error('Title must be at least 3 characters'); + } + } +} +``` + +## Indexes and Unique Constraints -For basic single-field or composite indexes, use an array: +### Simple Indexes ```js class Articles { @@ -89,12 +570,15 @@ class Articles { static fields = { id: 'primary', title: { type: 'string', required: true }, - slug: { type: 'string', required: true }, - published: { type: 'boolean', default: false }, + slug: { type: 'string', required: true, unique: true }, // Unique index + published: { type: 'boolean', default: false, index: true }, // Simple index }; - // Create indexes on 'slug' and ['title', 'published'] - static indexes = ['slug', ['title', 'published']]; + // ✅ Additional composite indexes + static indexes = [ + ['title', 'published'], // Composite index + 'slug' // Single-field index (if not already defined in fields) + ]; } ``` diff --git a/docs/relational-filters.md b/docs/relational-filters.md index 66bcfd4..f6718a5 100644 --- a/docs/relational-filters.md +++ b/docs/relational-filters.md @@ -251,6 +251,6 @@ const posts = await Posts.where({ ## See Also -- [Query API Documentation](./queries.md) -- [Model Relationships](./relationships.md) -- [Filtering Criteria](./criteria.md) +- [Query API Documentation](./requests.md) +- [Model Relationships](./models.md) +- [Filtering Criteria](./filtering.md) diff --git a/docusaurus.config.js b/docusaurus.config.js index 062769a..4431c88 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -17,7 +17,7 @@ const config = { docs: { routeBasePath: '/', sidebarPath: require.resolve('./docs/assets/sidebars.js'), - editUrl: 'https://github.com/ichiriac/normal/edit/master/', + editUrl: 'https://github.com/ichiriac/normaljs/edit/master/', }, blog: false, theme: { customCss: require.resolve('./docs/assets/custom.css') },