Models

Models#

Models in Duxt ORM follow the ActiveRecord pattern. Each model maps to a database table.

Defining a Model#

Extend the Entity class and implement required methods:

import 'package:duxt_orm/duxt_orm.dart';

class Post extends Entity {
  int? _id;
  String title;
  String slug;
  String? content;
  int userId;
  bool published;
  DateTime? createdAt;

  Post({
    int? id,
    required this.title,
    required this.slug,
    this.content,
    required this.userId,
    this.published = false,
    this.createdAt,
  }) : _id = id;

  // Required: ID getter and setter
  @override
  dynamic get id => _id;

  @override
  set id(dynamic value) => _id = value as int?;

  // Required: Convert to map for INSERT/UPDATE
  @override
  Map<String, dynamic> toMap() => {
    'title': title,
    'slug': slug,
    'content': content,
    'user_id': userId,
    'published': published ? 1 : 0,
  };

  // Required: Factory constructor from database row
  factory Post.fromRow(Map<String, dynamic> row) => Post(
    id: row['id'] as int?,
    title: row['title'] as String,
    slug: row['slug'] as String,
    content: row['content'] as String?,
    userId: row['user_id'] as int,
    published: (row['published'] as int?) == 1,
    createdAt: DateTime.tryParse(row['created_at'] as String? ?? ''),
  );

  // Required: Register with schema
  static void register() {
    Entity.registerModel<Post>(
      Post.fromRow,
      schema: {
        'id': Column.integer().primaryKey().autoIncrement(),
        'title': Column.string(200).notNull(),
        'slug': Column.string(200).unique().notNull(),
        'content': Column.text().nullable(),
        'user_id': Column.integer().notNull().references('users'),
        'published': Column.boolean().defaultValue(false),
        'created_at': Column.dateTime().nullable(),
        'updated_at': Column.dateTime().nullable(),
      },
    );
  }
}

Registering Models#

Call register() on each model before initializing the ORM:

void main() async {
  // Register all models
  User.register();
  Post.register();
  Comment.register();

  // Initialize ORM
  await DuxtOrm.init(DuxtConfig.database);

  // Run migrations (creates/updates tables)
  await DuxtOrm.migrate();
}

CRUD Operations#

Use Model<T>() for clean Rails-like queries:

Create#

final posts = Model<Post>();

// Create and save
final post = Post(
  title: 'Hello World',
  slug: 'hello-world',
  content: 'My first post!',
  userId: 1,
);
await post.save();

print(post.id); // Auto-assigned ID

// Or create in one step
final newPost = await posts.create({
  'title': 'Hello',
  'slug': 'hello',
  'user_id': 1,
});

Read#

final posts = Model<Post>();

// Find by ID
final post = await posts.find(1);

// Find or throw
final post = await posts.findOrFail(1);

// Get all
final allPosts = await posts.all();

// Get first
final first = await posts.first();

// Count
final count = await posts.count();

Update#

final posts = Model<Post>();
final post = await posts.find(1);

if (post != null) {
  post.title = 'Updated Title';
  post.published = true;
  await post.save();
}

Delete#

final posts = Model<Post>();
final post = await posts.find(1);

if (post != null) {
  await post.destroy();
}

Custom Table Names#

Override the auto-inferred table name:

Entity.registerModel<User>(
  User.fromRow,
  tableName: 'app_users',  // Instead of 'users'
  schema: { ... },
);

Model Query Interface#

The Model<T> class provides a clean API for queries:

final posts = Model<Post>();

// All queries flow from this interface
await posts.all();
await posts.find(1);
await posts.where('published', true).get();
await posts.create({'title': 'New Post', ...});
await posts.count();

This gives you Rails-like syntax without the complexity.

Soft Deletes#

Soft deletes are enabled by default for all models. When you call destroy(), the record gets a deleted_at timestamp instead of being permanently removed. The deleted_at column is auto-injected into your schema during registration.

Deleting Records#

final post = await Model<Post>().find(1);

// Soft delete (sets deleted_at)
await post!.destroy();

// Check if soft-deleted
print(post.trashed); // true

// Restore
await post.restore();

// Permanently delete (bypasses soft deletes)
await post.forceDelete();

Querying Soft-Deleted Records#

By default, queries automatically exclude soft-deleted records:

final posts = Model<Post>();

// Only returns non-deleted posts
final active = await posts.all();

// Include soft-deleted posts
final all = await posts.withTrashed().get();

// Only soft-deleted posts
final trashed = await posts.onlyTrashed().get();

Opting Out of Soft Deletes#

To disable soft deletes for a specific model, pass softDeletes: false during registration:

static void register() {
  Entity.registerModel<LogEntry>(
    LogEntry.fromRow,
    softDeletes: false,  // Hard deletes only
    schema: {
      'id': Column.integer().primaryKey().autoIncrement(),
      'message': Column.text().notNull(),
    },
  );
}

Lifecycle Hooks#

Override lifecycle hooks to run logic before or after persistence operations:

class Post extends Entity {
  String? title;
  String? slug;

  @override
  Future<void> beforeSave() async {
    // Auto-generate slug from title
    slug ??= title?.toLowerCase().replaceAll(' ', '-');
  }

  @override
  Future<void> afterSave() async {
    // Log or trigger side effects
    print('Post saved: $id');
  }

  @override
  Future<void> beforeDelete() async {
    // Validate or clean up before deletion
  }

  @override
  Future<void> afterDelete() async {
    // Clean up related resources
  }
}

Available Hooks#

HookWhen it runs
beforeSave()Before INSERT or UPDATE
afterSave()After INSERT or UPDATE
beforeDelete()Before DELETE (including soft delete)
afterDelete()After DELETE (including soft delete)

All hooks are async and return Future<void>. The default implementation does nothing.