Inserting rows topic
This document aims to demonstrate how rows can be inserting into a database
using package:typed_sql.
In the examples below, assume a database schema defined as follows:
abstract final class Bookstore extends Schema {
Table<Author> get authors;
Table<Book> get books;
}
@PrimaryKey(['authorId'])
abstract final class Author extends Row {
@AutoIncrement()
int get authorId;
@Unique.field()
String get name;
}
@PrimaryKey(['bookId'])
abstract final class Book extends Row {
@AutoIncrement()
int get bookId;
@Unique.field()
String? get title;
@References(table: 'authors', field: 'authorId', name: 'author', as: 'books')
int get authorId;
@DefaultValue(0)
int get stock;
}
And assume that db is an instance of
Database<Bookstore>.
Insert a row in the authors table
If we want to insert an author into our database we can do that as follows:
await db.authors
.insert(
name: toExpr('Roger Rabbit'),
)
.execute();
This works because the authorId is annotated @AutoIncrement(), which means
that by default it'll get an integer value higher than the previous one
(the exact semantics may depend on the database).
It is also possible to explicitly insert an author with a specific authorId,
as illustrated below:
await db.authors
.insert(
authorId: toExpr(42),
name: toExpr('Roger Rabbit'),
)
.execute();
For maximum flexibility we do not simply provide name or id as
String or int respectively. Instead we have to give an Expr<String> or
Expr<int>. We can create such expressions using the toExpr function
(or .asExpr extension method).
The toExpr<T>(T value) function works for the following types:
bool(e.g.toExpr(true)),int(e.g.toExpr(42)),double(e.g.toExpr(3.14)),String(e.g.toExpr('hello world')),DateTime(e.g.toExpr(DateTime.now()), will normalize to UTC),Uint8List(e.g.toExpr(Uint8List.from([1, 2, 3]))), and,Null(e.g.toExpr(null)).
By wrapping values in Expr<T> it possible for package:typed_sql to
distinguish between an omitted value (default value), and intentional decision
to insert NULL. Because NULL is always represented as toExpr(null).
As we will explore later, this also enables us to insert values directly
from a subquery.
For convinience, package:typed_sql will generate a .insertValue method,
which wraps arguments with toExpr. This is strictly less powerful than using
.insert as you cannot insert value from a subquery.
await db.authors
.insertValue(
authorId: 42,
name: 'Roger Rabbit',
)
.execute();
Caution
If a field is nullable in the database, then providing null to
insertValue will insert NULL, even if the field has a default value.
It is possible to omit a field that has a default value by providing null,
but for nullable fields, this does not insert the default value.
Insert with RETURNING clause
Using @AutoIncrement() for primary key fields is very convinient, but it makes it
hard to know what the authorId of the newly inserted row is. In SQL we can
access the inserted row using a RETURNING clause, with package:typed_sql we
also add a .returning clause.
In the simplest form, we can add a .returnInserted() clause, this
will return an Author object representing the inserted row, as illustrated
below:
final author = await db.authors
.insert(
name: toExpr('Roger Rabbit'),
)
.returnInserted()
.executeAndFetch();
// We can now access properties on author, like:
// author.authorId
check(author.authorId).isA<int>();
While simple and easy to type, if we only want access to a subset of the
properties of the row that was inserted, we can return a projection with
.returning(projectionBuilder). In the example below the projectionBuilder
gets an Expr<Author> representing the row that was inserted, and it returns
a record where values are Expr objects.
final authorId = await db.authors
.insert(
name: toExpr('Roger Rabbit'),
)
.returning((author) => (author.authorId,))
.executeAndFetch();
// We now have the authorId available as authorId
check(authorId).isA<int>();
In the example above the we create a record with one value author.authorId,
which means that the result from .executeAndFetch() will be the authorId
of the newly inserted row. This is useful if we want to insert books referencing
the newly inserted author.
It's worth noting that it is possible to write complex expressions and return
multiple values in the .returning clause.
For more information on projections, see Writing queries.
Insert from subquery
If we want to insert a row in the books table, but we don't know the
authorId of the author then we can obviously use a query to lookup the author
first. The following example shows how to lookup the author first, and then
insert a book referencing said author.
final authorId = await db.authors
.where((author) => author.name.equals(toExpr('Easter Bunny')))
.select((author) => (author.authorId,))
.first
.fetch();
if (authorId == null) {
throw Exception('Could not find the author');
}
await db.books
.insert(
title: toExpr('How to hide eggs'),
authorId: toExpr(authorId),
)
.execute();
Notice that when inserting a row in the books table, we are not required to
specify stock, the field has an @DefaultValue(0) annotation. Hence, when
we don't specify stock a default value of 0 is assigned to the newly
inserted row.
The approach above gives us a lot of control over failure scenarios, such as
when an author with name = 'Easter Bunny' cannot be found in the database.
The downside is that requires two queries, which also means two round-trips to
the database. If we know for fact that the Easter Bunny exists, we can
make a single insert where we lookup the authorId of the Easter Bunny in a
subquery.
await db.books
.insert(
title: toExpr('How to hide eggs'),
authorId: db.authors.asSubQuery
.where((author) => author.name.equals(toExpr('Easter Bunny')))
.first
.authorId
.asNotNull(),
)
.execute();
The .asSubQuery converts the Query<(Expr<Author>,)> to a
SubQuery<(Expr<Author>,)> such that .first returns an Expr<Author?>.
The .asNotNull() is only safe because we know that the Easter Bunny
exists, if the subquery turns out to be empty, then .authorId may indeed by
NULL and the insertion will fail because authorId has a NOT NULL
constraint.
Warning
The .asNotNull() is a no-op that simply casts the value to a
non-nullable Expr<T> in Dart. If the expression actually turns out to be
NULL at runtime, .asNotNull() will not necessarily cause an error.
Instead it'll allow the NULL value propagate, so if the Book.authorId
property was nullable such insertion would have been allowed.
For more information on subqueries see Writing queries.
Upsert with ON CONFLICT clause
Suppose we wanted to ensure that an author exists in our database, we could use a transaction to first check if the author exists, and insert a new row if it doesn't exist. This is time proven and for some complex operations it is the only way to do it. But for many common cases we could just use an upsert-clause.
The following example will skip inserting the row, if the insertion would have
failed with a primary key conflict. Creating a new row in authors if
authorId: 42 does not exist, and otherwise, skip insertion.
await db.authors
.insertValue(
authorId: 42,
name: 'Roger Rabbit',
)
.onConflict(.primaryKey)
.doNothing()
.execute();
The .onConflict(target) extension method is used to specify a
conflict target. The conflict target is either a PRIMARY KEY or UNIQUE
constraint. If the insert statement violates a constraint other than the one
specified as conflict target the operation fails. package:typed_sql will
generate an enum consisting of conflict targets ensuring that you can only
pass valid conflict targets to .onConflict(target).
Once you have specified a conflict target using .onConflict, you must
specify a conflict action. This can be .doNothing() or .update(...)
depending on whether you wish to skip inserting the row, or update the existing
row. The .update((row, excluded, set) => set(...)) method takes a builder that
is given 3 parameters:
row, the conflicting row as it exists in the database.excluded, the row that should have been inserted, but conflicted with row.set, a builder for defining the updates to be made onrow.
The following example demonstrates how to insert a book with stock: 5 or
update the existing row, if a book with matching title already exists.
await db.books
.insert(
title: toExpr('Vegan Dining'),
authorId: db.authors
.byName('Bucks Bunny')
.asExpr
.authorId
.asNotNull(),
stock: toExpr(5),
)
.onConflict(.title)
.update(
(book, excluded, set) => set(
stock: book.stock + excluded.stock,
),
)
.execute();
Finally, if we only wanted the stock to be updated if the current stock is
below 50, we can make the .update clause conditional using the .where
extension method as illustrated below:
await db.books
.insert(
title: toExpr('Vegan Dining'),
authorId: db.authors
.byName('Bucks Bunny')
.asExpr
.authorId
.asNotNull(),
stock: toExpr(5),
)
.onConflict(.title)
.update(
(book, excluded, set) => set(
stock: book.stock + excluded.stock,
),
)
.where((book, excluded) => book.stock < toExpr(50))
.execute();
This will insert a new book, or if there is an existing row with a conflicting
title, it will update stock on the existing row, if the existing stock is
less than 50. If existing stock is more than 50, then it'll skip inserting and
updating the row altogether (similar to .doNothing()).
This may be further combined with .returning() or .returnUpserted() to add
on a RETURNING clause. Though it should be observed that only the inserted or
updated rows will be returned, and if you're using .doNothing() or .where()
rows may be skipped.
final currentStock = await db.books
.insert(
title: toExpr('Vegan Dining'),
authorId: db.authors
.byName('Bucks Bunny')
.asExpr
.authorId
.asNotNull(),
stock: toExpr(5),
)
.onConflict(.title)
.update(
(book, excluded, set) => set(
stock: book.stock + excluded.stock,
),
)
.where((book, excluded) => book.stock < toExpr(50))
.returning((book) => (book.stock,))
.executeAndFetch();
// We now have the stock, whether inserted or updated,
// but not if skipped!
check(currentStock).equals(8);
The equivalent SQL depends on the database, but it looks something like:
INSERT INTO books (title, authorId, stock)
VALUES ('Vegan Dining', (SELECT authorId FROM authors WHERE name = 'Bucks Bunny'), 5)
ON CONFLICT (title)
UPDATE SET stock = stock + excluded.stock
WHERE stock < 50
RETURNING stock
Bulk insertion with .insertValuesMapped
If we wanted to insert multiple rows we could call .insert many times,
possibly inside a transaction, but for databases like postgres many sequantial
operations can incur significant overhead from network latency. For this reason
it is often preferable to use bulk insertion.
To do bulk insertion with .insertValuesMapped<T> we must provide a list of
T objects representing rows to be inserted, and then for each column that we
want to insert a value for we must provide a function mapping from T to value
for the column. In the example below newBookTitles is a list of objects, that
represent the rows we want to insert, and title: (b) => b.title simply maps
from one such object to String that should be inserted in the title column.
The example below does not specify a mapping function for bookId, instead
this column will get a default value from database, because it is annotated
with @AutoIncrement() in the schema.
final newBookTitles = [
(title: 'Vegetarian Dining', stock: 2),
(title: 'Vegan Dining', stock: 1),
(title: 'Carrot casserole', stock: 1),
(title: 'Forloren hare', stock: null),
];
await db.books
.insertValuesMapped(
// List of objects representing rows to be inserted
newBookTitles,
// closure mapping from object (given above) to column value
title: (b) => b.title,
authorId: (b) => 2,
stock: (b) => b.stock ?? 0,
// The bookId column is omitted, allowed because it is auto-increment
)
.onConflict(.title)
.update(
(book, excluded, set) => set(
stock: book.stock + excluded.stock,
),
)
.execute();
The .onConflict and .update methods specify how to handle conflicting rows,
this can be quite useful when inserting many rows. Refer to earlier section on
upsert for more details.
Note
When using bulk insertion we must insert the same expressions for all rows,
we cannot insert a value for stock in one row, and fallback on the
default defined in the database schema in another row. This is what makes
.insertValuesMapped require a mapping function for each column, but also
what keeps this extension method type-safe.
Classes
-
Expr<
T extends Object?> Inserting rows Writing queries Update and delete -
A representation of an SQL expression with type
T. -
Table<
T extends Row> Inserting rows Writing queries Update and delete -
A table of rows of type
T.
Functions
-
toExpr<
T extends Object?> (T value) → Expr< Inserting rows Writing queries Update and deleteT> -
Create an Expr<T> wrapping
value.