Model
A model is a Dart class for one database table, used to read, create, change and delete its rows.
A model has all the query builder methods too.
Create a model
dox make:model Blog # only the model
dox make:model Blog -m # the model and a migration for the table `blogs`
This makes lib/app/models/blog/blog.model.dart.
create:model is another name for the same command.
The model code uses a generated class (BlogGenerator).
Run the generator after you make or change a model:
dart run build_runner watch runs the generator again each time you save a file.
A model
import 'package:dox_orm/dox_orm.dart';
part 'blog.model.g.dart'; // (1)!
@DoxModel() // (2)!
class Blog extends BlogGenerator { // (3)!
@Column() // (4)!
String? title;
@Column(beforeSave: makeSlug) // (5)!
String? slug;
@Column()
String? status;
@Column(name: 'body') // (6)!
String? description;
@Column(name: 'user_id')
int? userId;
static String? makeSlug(Map<String, dynamic> map) {
return map['title']?.toString().toLowerCase().replaceAll(' ', '-');
}
}
build_runnerwrites this file. It holds the generatedBlogGeneratorclass.- Marks the class as a model. The options, like
tableandsoftDelete, go here. The table isblogby default: the class name in snake_case. - The generated class gives the model
id,createdAtandupdatedAt, and the query builder methods. - One
@Column()field is one table column. The column name is the field name in snake_case. Digits stay as they are:address2isaddress2. beforeSaveruns this static function before a save. It gets all values and returns the value for this column.namesets the column name when it is not the snake_case of the field.descriptionreads and writesbody. The name is used exactly as written:name: 'userID'isuserID.
Make every column field nullable (String?, int?). A new model has no values yet.
Model options
All options go in @DoxModel(...).
| Option | Default | What it does |
|---|---|---|
table |
class name in snake_case | The table name. |
primaryKey |
'id' |
The primary key column. |
primaryKeyType |
int |
The Dart type of the primary key, like String for a UUID. |
createdAt |
'created_at' |
The column that keeps the create time. |
updatedAt |
'updated_at' |
The column that keeps the last update time. |
timestamps |
true |
false turns off createdAt and updatedAt. |
softDelete |
false |
delete() sets deleted_at and keeps the row. |
table
The default table name is the class name in snake_case, and singular.
Blog uses blog, and BlogPost uses blog_post.
Info
dox make:model Blog always sets table: 'blogs' for you, with or without -m.
The migration that -m makes uses the same plural table blogs.
primaryKey
The field name is the key in camelCase. With primaryKey: 'blog_id', the field is blogId.
primaryKeyType
The primary key field is int? by default.
Use primaryKeyType for another type, like a UUID string.
Device device = Device();
device.uuid = '0b6f2c1e-1a2b-4c3d-8e9f-001122334455';
await device.save(); // insert
save() inserts a new model, also when you set the key yourself.
It updates a model that was read from the table or saved before.
model.exists tells which one it is.
createdAt and updatedAt
save() sets these columns to the current UTC time.
Use the options when your columns have other names.
Read the values with blog.createdAt and blog.updatedAt.
timestamps
Set timestamps: false when the table has no created_at and updated_at columns.
To turn off only one of them, set it to null, like @DoxModel(updatedAt: null).
softDelete
The table needs a nullable deleted_at column. In a migration, add table.softDeletes();.
delete()setsdeleted_atto the current time. The row stays in the table. When the model has timestamps, it setsupdated_attoo.- Queries do not return soft deleted rows. Use
withTrash()to get them too, oronlyTrashed()to get only them. forceDelete()really deletes the row.restore()setsdeleted_atback toNULL. When the model has timestamps, it setsupdated_attoo.
Columns
Field types
The field type tells Dox how to read the value.
| Field type | Column |
|---|---|
String? |
text, varchar |
int? |
integer types |
double? |
float, double. MySQL also gives DECIMAL as double. Postgres gives numeric as String. |
bool? |
boolean, or a number like tinyint (0 is false) |
DateTime? |
date, datetime, timestamp |
Map<String, dynamic>? or List<dynamic>? |
JSON |
an enum, like Status? |
text with the enum name |
For other conversions, use a cast, or beforeSave and beforeGet.
beforeSave and beforeGet
These options take a static function of the model class. The function gets a map and returns the value.
beforeSaveruns before a save. It gets the model values (column name => value). It returns the value to save in this column.beforeGetruns when a row is read. It gets the row from the database. It returns the value for this field.
@DoxModel()
class Blog extends BlogGenerator {
@Column()
String? title;
// beforeSave: make the slug from the title
@Column(beforeSave: makeSlug)
String? slug;
// beforeGet: old rows have no status, show them as draft
@Column(beforeGet: readStatus)
String? status;
static String? makeSlug(Map<String, dynamic> map) {
return map['title']?.toString().toLowerCase().replaceAll(' ', '-');
}
static String? readStatus(Map<String, dynamic> map) {
return map['status'] as String? ?? 'draft';
}
}
Save
save() inserts a new model, or updates a model that has an id.
// insert
Blog blog = Blog();
blog.title = 'My first blog';
await blog.save();
print(blog.id); // the new id
// update
Blog? found = await Blog().find(1);
found?.title = 'New title';
await found?.save();
// set a field to null
found?.status = null;
await found?.save(); // status is now NULL in the database
// create from a map: returns the new model as it is in the table.
// Blog needs `fillable`, see mass assignment below.
Blog created = await Blog().create(<String, dynamic>{
'title': 'My first blog',
'status': 'draft',
});
- On insert,
nullfields are not sent, so the database default is used. - On update, only the fields that changed are sent.
A field is compared to its value from the last load or save.
A
MaporListfield is always sent, because it can change in place. - When nothing changed,
save()runs no query, andupdated_atstays as it is. - When you set
updatedAtyourself,save()keeps your value. Else it setsupdated_atto now. - On update, a field is set to
NULLonly when it had a value before (loaded from the database, or saved). Fields that were not loaded, for example withselect(), are not changed. save()returnstruewhen it saved, andfalsewhen a model event cancels it.blog.update(<String, dynamic>{...})on a loaded model changes only that row and setsupdated_attoo. It fires no model events.
create() and save() are not the same
On a model without events, create() inserts the map as it is.
It does not run beforeSave, and it does not set created_at and updated_at.
Use save() when you need them, or put the values in the map.
Mass assignment
create(), insert(), update(), upsert() and the find or create methods take keys from a map.
If they took every key, User().create(req.body) with {"is_admin": 1} would make an admin.
So a model is guarded by default, like Laravel.
Set fillable to the keys that may be taken from a map.
@DoxModel()
class User extends UserGenerator {
// only these keys are taken from a map
@override
List<String> get fillable => <String>['name', 'email'];
// or: every key but these is taken from a map
@override
List<String> get guarded => <String>['is_admin'];
}
- A model with no
fillable(and the defaultguarded) throws aMassAssignmentExceptionwhen it gets a map with keys. It does not save an empty row. - With
fillable, keys that are not in it are dropped. They do not throw. -
To take every key (not safe with user input), set
guardedto an empty list: -
save()is not changed. Fields you set in code are always saved.
Skip the fillable check
Sometimes your own code must set a guarded column, like is_admin.
These methods ignore fillable and guarded.
Never pass user input, like req.body, to them.
// create a row with every key of the map
User admin = await User().forceCreate(<String, dynamic>{'name': 'Ann', 'is_admin': true});
// update the rows of a query with every key of the map
await User().where('id', 1).forceUpdate(<String, dynamic>{'is_admin': true});
// set fields on a model, then save it
User? user = await User().find(1);
await user!.forceFill(<String, dynamic>{'is_admin': true}).save();
// turn the check off for everything inside the callback
await Model.unguarded(() async {
await User().create(<String, dynamic>{'name': 'Bob', 'is_admin': true});
await User().where('id', 2).update(<String, dynamic>{'is_admin': true});
});
forceFill()does not save. Callsave()after it. It skips a key that is not a column. A value of the wrong type throws anArgumentError.Model.unguarded()covers everyawaitinside the callback. Other requests that run at the same time still usefillable.- Work that starts inside the callback and finishes later also runs with the check off. This means timers, futures that are not awaited, and stream listeners. So await everything inside the callback.
- In Laravel, a query
update()ignoresfillable. In Dox, a queryupdate()keeps the check, soupdate(req.body)stays safe. UseforceUpdate()when you need the Laravel behaviour. - Model events that fire inside
forceCreate()andforceUpdate()(likecreated) also run with the check off, like Laravel. Acreate()orupdate()inside such a hook skipsfillabletoo.
Find or create
These methods find a row by the first map. When there is no row, they use both maps.
// find the user, or create it
User user = await User().firstOrCreate(
<String, dynamic>{'email': 'ann@example.com'},
<String, dynamic>{'name': 'Ann'},
);
// find the user, or make a new model that is not saved yet
User other = await User().firstOrNew(<String, dynamic>{'email': 'bob@example.com'});
if (!other.exists) {
await other.save();
}
// update the user, or create it
User updated = await User().updateOrCreate(
<String, dynamic>{'email': 'ann@example.com'},
<String, dynamic>{'name': 'Ann Lee'},
);
firstOrCreate() and updateOrCreate() use create() and save(), so the model events run.
firstOrNew() does not save, so no event runs.
See Query builder.
Increment
increment() and decrement() change a number column in one query.
On a model from the table, they change only the row of that model.
The field of the model gets the new value too.
This example has an int? views column field on Blog.
Blog? blog = await Blog().find(1);
await blog!.increment('views'); // views + 1
await blog.increment('views', 5); // views + 5
await blog.decrement('views'); // views - 1
print(blog.views);
- They return the number of changed rows: 1, or 0 when the row is not there.
- They fire no model events and do not set
updated_at. - A third argument sets other columns in the same query:
increment('views', 1, <String, dynamic>{'status': 'hot'}). Keys thatfillableandguardeddo not allow are dropped.
Touch and replicate
touch() sets updated_at to now. Give a column name to set another column.
On a model from the table it changes only that row, and the field gets the new time too.
Blog? blog = await Blog().find(1);
await blog!.touch(); // updated_at = now
await blog.touch('published_at'); // published_at = now
- It returns the number of changed rows: 1, or 0 when the row is not there.
It also returns 0 when the model has no
updated_atcolumn and you give no column. - It fires the save events:
saving,updating,updatedandsaved, likesave(). A hook that throwsCancelModelEventstops it, and it returns 0. touchQuietly()does the same with no events.- A model with no primary key value (never saved, or read without the key)
throws a
StateError. Sotouch()can not change every row by mistake.
replicate() makes a new model with the same column values. It is not saved yet.
It has no primary key, created_at or updated_at, so save() inserts a new row.
Blog? blog = await Blog().find(1);
Blog copy = blog!.replicate()..title = 'Copy of ${blog.title}';
await copy.save();
// leave out some columns, they are set to null
Blog other = blog.replicate(<String>['slug']);
- Relations are not copied.
- Like Laravel, it copies
deleted_at. A copy of a soft deleted model is soft deleted too. - A column in the list that can not be
null, or that is not a column, throws anArgumentError.
Delete
// delete a record
Blog? blog = await Blog().find(1);
int count = await blog!.delete(); // deletes only this record
// force delete
Blog? trashed = await Blog().withTrash().find(1);
await trashed?.forceDelete(); // deletes the row, also with softDelete
// restore
Blog? deleted = await Blog().withTrash().find(1);
await deleted?.restore(); // sets deleted_at back to NULL, and updated_at
delete()andforceDelete()return the number of deleted rows.- They return 1 when the row was deleted.
- They return 0 when the row was not there, or when a model event cancels.
- A model with no primary key value (never saved, or read without the key)
throws a
StateErrorand runs no query. SoBlog().delete()can not delete every row by mistake.Blog().where('status', 'draft').delete()is a query delete, and it works.update(),increment(),decrement()andtouch()on a model with no key and nowherethrow too. A global scope counts as awhere, see global scopes. To delete every row on purpose, useBlog().whereRaw('1 = 1').delete(). See write every row.
save(), touch(), delete(), forceDelete() and restore() fire model events.
Convert to a map or JSON
Blog? blog = await Blog().find(1);
Map<String, dynamic>? map = blog?.toMap(); // all columns and computed values
Map<String, dynamic>? json = blog?.toJson(); // the same, without hidden fields
Map<String, dynamic>? row = blog?.toMap(original: true); // the raw database row
jsonEncode(blog) uses toJson().
So you can return a model, or a list of models, from a controller.
Hide fields
Fields in hidden are left out of toJson().
Use the column names.
@DoxModel()
class User extends UserGenerator {
@override
List<String> get hidden => <String>['password', 'remember_token'];
}
Computed values
A computed value is a getter that is added to toJson() and toMap().
Add @Computed() to the getter, then run dart run build_runner build.
@DoxModel()
class User extends UserGenerator {
@Column(name: 'first_name')
String? firstName;
@Column(name: 'last_name')
String? lastName;
@Computed() // json key: full_name
String get fullName => '$firstName $lastName';
@Computed(name: 'initials') // custom json key
String get shortName => '${firstName?[0]}${lastName?[0]}';
}
User? user = await User().find(1);
print(user?.toJson());
// {id: 1, first_name: John, last_name: Wick, ..., full_name: John Wick, initials: JW}
- The default key is the getter name in snake_case (
fullNamebecomesfull_name). Aname:is used exactly as written. - A computed value is never a column. It is not read from the database,
and it is not sent in
INSERTorUPDATE. hiddenalso hides computed values. Use the json key, for example['full_name'].- Computed values of loaded relations are in the nested JSON too.
toMap(original: true)returns the raw database row, so it has no computed values.- A getter runs each time
toJson()ortoMap()is called, so keep it fast.
New query
A model object keeps the parts of its query (where, orderBy, ...).
query() gives a new, empty model, so you can start another query.
Blog blog = Blog();
List<Blog> active = await blog.where('status', 'active').get();
// a new query, without the where above
List<Blog> deleted = await blog.query().where('status', 'deleted').get();
Debug
debug(true) prints the SQL of this model in the terminal.
Relationships
A model can load related models. See HasOne, HasMany, BelongsTo, ManyToMany and Polymorphic.
After reload() or a preload
await user.reload() and a preload set the relation fields again, also when there is no related row.
A to-one field that can be null becomes null. A to-many field becomes []. A to-one field that can not be null keeps its old value.
The relationship pages use these tables:
erDiagram
user ||--o| user_info : "HasOne"
user ||--o{ post : "HasMany"
user ||--o{ comment : "BelongsTo, from comment"
artist ||--o{ artist_song : "ManyToMany"
song ||--o{ artist_song : "ManyToMany"
user {
int id PK
string name
}
user_info {
int id PK
int user_id FK
string address
}
post {
int id PK
int user_id FK
string title
}
comment {
int id PK
int user_id FK
string content
}
artist_song {
int id PK
int artist_id FK
int song_id FK
}