Runtime schema introspection
Thanks to the type-safe table classes generated by drift, writing SQL queries in Dart is simple and safe. However, these queries are usually written against a specific table. And while drift supports inheritance for tables, sometimes it is easier to access tables reflectively. Luckily, code generated by drift implements interfaces which can be used to do just that.
Since this is a topic that most drift users will not need, this page mostly gives motivating examples and links to the documentation for relevant
drift classes.
For instance, you might have multiple independent tables that have an id
column. And you might want to filter rows by their id column.
When writing this query against a single table, like the Todos table as seen in the
getting started page,
that's pretty straightforward:
Selectable<Todo> findTodoEntryById(int id) {
return select(todos)..where((row) => row.id.equals(id));
}
But let's say we want to generalize this query to every database table, how could that look like? This following snippet shows how this can be done (note that the links in the snippet point directly towards the relevant documentation):
extension FindById<Table extends HasResultSet, Row>
on ResultSetImplementation<Table, Row> {
Selectable<Row> findById(int id) {
return select()..where((row) {
final idColumn = columnsByName['id'];
if (idColumn == null) {
throw ArgumentError.value(
this,
'this',
'Must be a table with an id column',
);
}
if (idColumn.type != DriftSqlType.int) {
throw ArgumentError('Column `id` is not an integer');
}
return idColumn.equals(id);
});
}
}
Since that is much more complicated than the query that only works for a single table, let's take a look at each interesting line in detail:
-
FindByIdis an extension on ResultSetImplementation. This class is the superclass for every table or view generated by drift. It defines useful methods to inspect the schema, or to translate a rawMaprepresenting a database row into the generated data class.ResultSetImplementationis instantiated with two type arguments: The original table class and the generated row class. For instance, if you define a tableclass Todos extends Table, drift would generate a class that extendsTodoswhile also implementing.ResultSetImplementation<Todos, Todo>(withTodobeing the generated data class).ResultSetImplementationhas two subclasses: TableInfo and ViewInfo which are mixed in to generated table and view classes, respectively.HasResultSetis the superclass forTableandView, the two classes used to declare tables and views in drift.
-
Selectable<Row>represents a query, you can use methods likeget(),watch(),getSingle()andwatchSingle()on it to run the query. -
The
select()extension used infindByIdcan be used to start a select statement without a reference to a database class - all you need is the table instance. -
We can use
columnsByNameto find a column by its name in SQL. Here, we expect anintcolumn to exist. -
The GeneratedColumn class represents a column in a database. Things like column constraints, the type or default values can be read from the
column instance.
- In particular, we use this to assert that the table indeed has an
IntColumnnamedid.
- In particular, we use this to assert that the table indeed has an
To call this extension, await myDatabase.todos.findById(3).getSingle() could be used.
A nice thing about defining the method as an extension is that type inference works really well - calling
findById on todos
returns a Todo instance, the generated data class for this table.
Updates and inserts#
The same approach also works to construct update, delete and insert statements (although those require a
TableInfo instead of a ResultSetImplementation
as views are read-only).
Also, updates and inserts use an Insertable object which represents a partial row of updated or
inserted columns, respectively.
With a known table, one would use the generated typed Companion objects for that.
But this can also be done with schema introspection thanks to the RawValuesInsertable, which
can be used as a generic Insertable backed by a map of column names to values.
This example builds on the previous one to update the title column of a generic table based on a filter
of the id column:
extension UpdateTitle on DatabaseConnectionUser {
Future<Row?> updateTitle<T extends TableInfo<Table, Row>, Row>(
T table,
int id,
String newTitle,
) async {
final columnsByName = table.columnsByName;
final stmt = update(table)
..where((tbl) {
final idColumn = columnsByName['id'];
if (idColumn == null) {
throw ArgumentError.value(
this,
'this',
'Must be a table with an id column',
);
}
if (idColumn.type != DriftSqlType.int) {
throw ArgumentError('Column `id` is not an integer');
}
return idColumn.equals(id);
});
final rows = await stmt.writeReturning(
RawValuesInsertable({'title': Variable<String>(newTitle)}),
);
return rows.singleOrNull;
}
}
In a database or database accessor class, the method can then be called like this:
Future<Todo?> updateTodoTitle(int id, String newTitle) {
return updateTitle(todos, id, newTitle);
}
Hopefully, this page gives you some pointers to start reflectively inspecting your drift databases. The linked Dart documentation also expains the concepts in more detail. If you have questions about this, or have a suggestion for more examples to include on this page, feel free to start a discussion about this.