ManyToMany
Use ManyToMany when rows on both sides can have many rows on the other side.
For example, an artist has many songs, and a song has many artists.
The link is kept in a third table, the pivot table. It has one foreign key to each side.
Tables
| Table | Columns |
|---|---|
artist |
id, name |
song |
id, title |
artist_song (pivot) |
id, artist_id, song_id |
A migration for the pivot table:
await schema.create('artist_song', (Table table) {
table.id();
table.foreignId('artist_id').constrained('artist').cascadeOnDelete();
table.foreignId('song_id').constrained('song').cascadeOnDelete();
});
Define it
Add a field with @ManyToMany(...) to each model that needs it.
The field type is a list of the related model. Start it as an empty list.
import 'package:dox_orm/dox_orm.dart';
import '../song/song.model.dart';
part 'artist.model.g.dart';
@DoxModel()
class Artist extends ArtistGenerator {
@Column()
String? name;
@ManyToMany(Song)
List<Song> songs = <Song>[];
}
import 'package:dox_orm/dox_orm.dart';
import '../artist/artist.model.dart';
part 'song.model.g.dart';
@DoxModel()
class Song extends SongGenerator {
@Column()
String? title;
@ManyToMany(Artist)
List<Artist> artists = <Artist>[];
}
Run dart run build_runner build after you add a relationship.
Load it
The relation is not loaded by default. Load it in one of these ways.
Use the field name ('songs') as the relation name.
preload
Use it when you load the models with a query.
Artist? artist = await Artist().preload('songs').find(1);
print(artist?.songs.length);
List<Song> songs = await Song().preload('artists').get();
preload works with find, get, all and getFirst.
For a list of models, it runs one query for all of them.
A model without links gets an empty list.
$getRelation
Use it when you already have the model.
Artist? artist = await Artist().find(1);
await artist?.$getRelation('songs');
print(artist?.songs.length);
related
Use it when you need more conditions on the related rows.
Artist? artist = await Artist().find(1);
List<Song> songs = await artist
?.related<Song>('songs')
?.orderBy('song.title')
.get() ??
<Song>[];
It does not set artist.songs.
The query joins the pivot table, so write table.column for column names.
eager
Use it when every query of Artist should load the relation.
You do not call preload.
Do not set eager: true on both sides. Each side would load the other side again and again.
The load runs this SQL:
SELECT "song".*, "artist_song"."artist_id" as "_owner_id" FROM "song"
LEFT JOIN "artist_song" on "artist_song"."song_id" = "song"."id"
WHERE "artist_song"."artist_id" IN (@artist_songartist_id_p0, ...)
With withPivot or withTimestamps, it also selects those pivot columns, like "artist_song"."role" as "_pivot_role".
reload() and a preload set the relation fields again, also when there is no related row. See Relationships.
Read pivot columns
The pivot table can have more columns, like a role.
List them in withPivot.
Read them with pivot on each related model.
Artist? artist = await Artist().preload('songs').find(1);
for (Song song in artist!.songs) {
print(song.pivot['role']);
}
pivothas the two pivot keys, likeartist_idandsong_id, when the rows are loaded by the relation: withpreload,$getRelation,eagerorrelated('songs')!.get(). They are read from the loaded rows, so a relation withoutwithPivotruns no extra SQL.withTimestamps: trueaddscreated_atandupdated_attopivot.attach(),sync()andtoggle()then set them.updateExistingPivot()setsupdated_at. The pivot table needs both columns (table.timestamps()).- A model that was not loaded by a ManyToMany relation has an empty
pivot. pivotis not intoJson(). Read it in Dart and add it to your response yourself.
Change the links
These methods add and remove rows in the pivot table.
The first argument is the relation name, like 'songs'.
Save the model first. A model without an id throws a StateError.
The ids can be:
- one id:
1 - a model:
song - a list of ids or models:
[1, 2, song] - a map of
id => pivot values:{1: {'role': 'singer'}, 2: {}}
null means no ids. So attach('songs', null) and detach('songs', null) change nothing, and sync('songs', null) deletes every link, like sync('songs', []).
Ids match with one rule:
- Two texts only match when the text is the same.
So on a text key column,
'007'and'7'are two ids. The ORM does not know the type of the key column, soattach('songs', ['1', '01'])adds two rows. - When one id is a number (
intordouble), Dart reads the other one as a number. It trims the text, drops a.0end, and triesint.tryParse(' 1','+1','01','1.0'), thendouble.tryParse('1.','1e0'). They match when the two numbers are the same. Rows of a number key column come back as numbers, sosync('songs', ['01'])keeps the row of song1. - This is only how Dart matches ids. The databases read some texts in other ways.
For example,
'0x1'is1in Dart and on Postgres,0on MariaDB, and an error on MySQL. Postgres throws for'1.0','1.'and'1e0'on an integer column. - A given text that only reads as a number at or past ±2^53, like
'1e20'or'9007199254740993.', is not exact. It throws anArgumentErrorwhen it is matched to a number.syncchecks every id before its first write. - A stored value never throws. A value in the table that can not be read exactly does not match a number.
So a long digit text in a text key column does not stop
sync. - A bool id is
1or0, like the databases read it. Sosync('songs', true)keeps the row of song1. - A
BigIntid is anintwhen it fits, else it throws anArgumentError. Any other id type that is not anint, aString, abool, adoubleor a model throws anArgumentError. - Each id finds its own stored row when it can. A row with the same text wins, so on a text key
1finds'1', not'01'. A stored row is matched once, so on a number keytoggle('songs', ['1', '01'])detaches song1once. detachandupdateExistingPivotsend every given id. So on a text key,detach('songs', [1, '01'])deletes both rows.syncdeletes a stored row only when none of the given ids matches it. So on a text key,sync('songs', [1, '01'])keeps the rows'1'and'01'.- A new row is added once for ids that are the same by this rule, and the first id wins.
So on a number key
attach('songs', [1, '01'])adds one row. On a text key it adds only the row1. - A whole double within ±2^53, like
2.0, is sent as the int2. Any other double, like1.5, NaN, infinity or1e20, throws anArgumentError. - Text ids are sent to the database as they are.
A null id or a model that is not saved is skipped by attach, detach, toggle, syncWithoutDetaching and updateExistingPivot.
sync throws an ArgumentError for one, before it changes anything, because it would delete every other link.
Each id is used once: [1, 1, '1'] adds one row.
A text key column can compare text without case.
This is the default in MySQL and MariaDB, and in SQLite with COLLATE NOCASE.
COLLATE NOCASE ignores case only. MariaDB can also ignore end spaces.
Dart does not know that. So sync and toggle also ask the database about each text id that Dart did not match.
Dox asks the database about all of these ids in one query (one query per 100 ids), with WHERE key = id for each id.
When it finds a stored row, that row counts as a match. With a stored 'ABC':
sync('songs', ['abc'])keeps the row'ABC'. It adds no row and deletes nothing.toggle('songs', ['abc'])detaches the row'ABC'. It adds no row.- The delete never removes a row that the database sees as a kept id.
detachedhas only the ids whose row is really gone.
attach
Adds a pivot row for each id. The third argument has the pivot values for every new row.
await artist.attach('songs', 1);
await artist.attach('songs', <int>[2, 3], <String, dynamic>{'role': 'singer'});
await artist.attach('songs', <int, Map<String, dynamic>>{
4: <String, dynamic>{'role': 'writer'},
});
attach does not check for a row that is already there.
Use sync or syncWithoutDetaching when the row may be there.
detach
Deletes the pivot rows of the ids.
With no ids argument, it deletes every pivot row of the model.
detach('songs', null) deletes nothing.
It returns how many rows it deleted.
The songs stay in the song table.
sync
Makes the pivot rows match the ids.
- It adds a row for each new id.
- It deletes the rows of the ids that are not in the list.
- It sets the pivot values of the rows that are already there, when a value changed.
updatedhas those ids.
It returns a PivotSyncResult with the ids of each change.
A number id is an int in the result, also when you gave '3'.
A text that is not the plain text of an int, like '03' or '007', stays text.
PivotSyncResult result = await artist.sync('songs', <int>[1, 2, 3]);
print(result.attached); // ids that got a new row
print(result.detached); // ids whose row was deleted
print(result.updated); // ids whose pivot values changed
syncWithoutDetaching does the same, but it does not delete rows.
toggle
Adds a row for each id that has none.
Deletes the row of each id that has one.
It returns a PivotSyncResult with attached and detached.
updateExistingPivot
Sets pivot values on the rows of one id or a list of ids. It returns how many rows changed.
Filter by it
whereHas() keeps the artists that have a song.
It checks the pivot table artist_song for you.
It does not load the songs.
List<Artist> artists = await Artist().whereHas('songs', (QueryBuilder<dynamic> q) {
q.where('title', 'Yesterday');
}).get();
List<Artist> noSongs = await Artist().whereDoesntHave('songs').get();
See Query by relations.
Options
The examples are for @ManyToMany(Song) in the Artist model.
| Option | Default | Example value | What it is |
|---|---|---|---|
pivotTable |
the two model names in A-Z order, joined with _ |
artist_song |
The pivot table. |
pivotForeignKey |
{this model}_id |
artist_id |
The pivot column that points to this model. |
pivotRelatedForeignKey |
{related model}_id |
song_id |
The pivot column that points to the related model. |
localKey |
the primary key of this model | id |
The column in artist that pivotForeignKey points to. |
relatedKey |
the primary key of the related model | id |
The column in song that pivotRelatedForeignKey points to. |
withPivot |
none | ['role'] |
Extra pivot columns to read. See Read pivot columns. |
withTimestamps |
false |
true |
Read and set created_at and updated_at in the pivot table. |
onQuery |
none | A static function that changes the query. | |
eager |
false |
Load the relation on every query. |
The defaults use the class names in snake_case, not the table names.
So @DoxModel(table: 'artists') does not change them.
ArtistandSonggiveartist_song.BlogandCategorygiveblog_category.BlogPostandTaggiveblog_post_tag.
Set all keys
@ManyToMany(
Song,
pivotTable: 'artist_song',
pivotForeignKey: 'artist_id',
pivotRelatedForeignKey: 'song_id',
localKey: 'id',
relatedKey: 'id',
)
List<Song> songs = <Song>[];
onQuery
onQuery is a static function in the same model class.
It gets the query of the related model and returns it.
@DoxModel()
class Artist extends ArtistGenerator {
@ManyToMany(Song, onQuery: onlyPublished)
List<Song> songs = <Song>[];
static QueryBuilder<Song> onlyPublished(Song q) {
return q.where('song.status', 'published');
}
}
In JSON
- A relation with
eager: trueis always intoJson(). - A relation loaded with
preloadis intoJson()of that query. - The JSON key is the field name in snake_case:
songs.