basalt 0.1.0
basalt: ^0.1.0 copied to clipboard
A type-safe, migration-first ORM for Dart: a compile-time-checked query builder, code-generated row mappers, and pluggable SQL backends.
basalt #
The dialect-agnostic core of basalt_dart — the typed schema, expression/query/write
builders, the SQL serializer, and the Connection / SqlDialect seams. This package has no driver
dependency; concrete backends (basalt_sqlite, basalt_postgres)
plug into it.
Part of the basalt_dart workspace. New here? Start with the root README and the getting-started guide.
Contents #
- What's inside
- Install
- The typed schema
- Building queries
- Reading rows
- Writes
- Serializer & dialect seam
- The Connection interface
- Annotations
- DevTools inspector
- Design notes
- Reference
What's inside #
| Area | Types | Source |
|---|---|---|
| Types & codecs | SqlType<T> — integer, text, real, boolean, blob, dateTime, *OrNull |
types/sql_type.dart |
| Schema | sealed TableColumn<T, Tbl> (ValueColumn / PrimaryKey / Ref), TableRef, QuerySource, TableAlias, Selection, Aggregate |
schema/table.dart |
| Expressions | Expression<T, Tbl> + & / | / .and / .or combinators |
expression/expression.dart |
| Query builder | from(...) → where/filter/order/limit/offset/select/groupBy/having/join → .map(...) → MappedQuery<R>; RowMapper<R> |
query/query.dart |
| Writes | insertInto / update / deleteFrom, TableColumn.set, RETURNING, ON CONFLICT |
query/write.dart |
| AST + serializer | untyped SqlNode tree → QueryBuilder → (sql, params) |
ast/, serialize/ |
| Execution seam | Connection (async-first), SqlDialect, IntrospectedTable |
connection.dart, serialize/sql_dialect.dart |
| Annotations | @Queryable / @Insertable / @AsChangeset / @Column / @Relation |
annotations/ |
Install #
basalt alone builds and serializes queries; add a backend to run them.
dependencies:
basalt:
basalt_sqlite: # or basalt_postgres
The typed schema #
A table is a final class extending TableRef<Self>; static const table is its singleton instance and
columns are static const objects holding a typed reference to it, so the same object serves the query
builder (Users.age.gt(18)) and the codegen annotations (@Column(Users.name)) — annotation arguments
must be constants. The default projection lives in the columns getter override (a getter, not a const
field, which is what keeps table ⇄ columns free of const-initializer cycles). In real projects this file
is generated by basalt generate-schema.
import 'package:basalt/basalt.dart';
final class Users extends TableRef<Users> {
const Users._() : super('users');
static const table = Users._();
static const id = PrimaryKey<int, Users>(table, 'id', IntSqlType());
static const name = ValueColumn<String, Users>(table, 'name', StringSqlType());
static const age = ValueColumn<int, Users>(table, 'age', IntSqlType());
static const managerId = Ref<int?, Users, Users>(table, 'manager_id',
NullableSqlType(IntSqlType()), references: Users.id);
@override
List<TableColumn<Object?, Object?>> get columns => const [id, name, age, managerId];
}
TableColumn<T, Tbl> is sealed — every column is a ValueColumn, a PrimaryKey, or a Ref (foreign
key) — which is what gives the join API and codegen FK-awareness. (Note: Column is the field annotation;
the column type is TableColumn.)
Building queries #
Expression<T, Tbl> carries a phantom Tbl scope, so a predicate can only reference its own table until you
join:
final q = from(Users.table)
.where((Users.age > 18) & Users.name.like('A%')) // & / | combine; chaining .where() REPLACES
.orderBy(Users.name.asc())
.limit(20)
.map((r) => (r.get(Users.id), r.get(Users.name))); // -> MappedQuery<(int, String)>
Predicates: eq ne gt ge lt le (and > < >= <= sugar), isIn/eqAny, between, like
(text columns), isNull/isNotNull, eqColumn (for joins). Aggregates (count/sum/avg/min/max,
countAll()) plug into select + groupBy + having. See
packages/basalt/doc/queries.md.
Gotcha: chained
.where().where()replaces the predicate (last wins). Combine with&/|, or use.filter()— the basalt-style method that ANDs repeated calls.
Reading rows #
A RowReader reads values by selection key (columns by table.name, alias-aware; aggregates by alias) —
never by position — so reads are order-independent and join-safe. RowMapper<R> wraps a reader for reuse
(this is what codegen emits):
const userMapper = RowMapper<User>(_userFromRow);
final users = await db.fetch(from(Users.table).map(userMapper.read));
Writes #
insertInto(Users.table).value(Users.name.set('Bob')); // INSERT
update(Users.table).set(Users.age.set(31)).where(Users.id.eq(1)); // UPDATE ... SET ... WHERE
deleteFrom(Posts.table).where(Posts.views.lt(10)); // DELETE
// RETURNING, batch VALUES, and upsert (ON CONFLICT ... DO UPDATE / DO NOTHING) are supported —
// see packages/basalt/doc/queries.md.
TableColumn.set(value) is type-checked: TableColumn<int>.set('x') is a compile error.
Serializer & dialect seam #
The heart of the package is pure and driver-free: QueryBuilder(dialect).buildSelect(query) /
.buildWrite(stmt) walk the AST into (String sql, List<Object?> params). A SqlDialect supplies the three
things that differ per backend — quoteIdentifier, placeholder, and encodeParam. This is the
unit-testable core (test/serializer_test.dart), and it's why adding a backend is a drop-in.
The Connection interface #
Connection is async-first: every method returns a Future; FutureOr appears only on the
transaction callback. SQLite runs synchronously and returns already-completed futures, which is exactly
what lets an async backend (Postgres) implement the same interface unchanged.
abstract interface class Connection {
Future<List<R>> fetch<R>(SelectQuery<R> statement);
Future<int> execute(WriteStatement statement);
Future<List<R>> executeReturning<R>(ReturningQuery<R> statement);
Future<void> executeSql(String sql, [List<Object?> params]);
Future<List<Map<String, Object?>>> queryRaw(String sql, [List<Object?> params]);
Future<List<IntrospectedTable>> introspect();
Future<T> transaction<T>(FutureOr<T> Function(Connection tx) action);
Future<void> close();
}
Annotations #
Metadata only — no runtime behaviour; basalt_codegen consumes them.
| Annotation | Purpose |
|---|---|
@Queryable(table) |
derive a row reader + mapper + query getter |
@Insertable(table) |
derive toInsert() |
@AsChangeset(table) |
derive toUpdate() |
@Column(col, {readOnly, writeOnly}) |
map a field to a column / tune its read/write direction |
@Relation(fk, {depth}) |
fill a field by joining and nesting a related @Queryable |
DevTools inspector #
package:basalt/devtools.dart is a separate entrypoint (not exported by package:basalt/basalt.dart,
so plain ORM users don't pull it in) that powers a DevTools "basalt" tab: pick the active instance,
browse / filter / sort tables, view and edit rows, and run raw SQL. Backend-agnostic (SQLite + Postgres).
import 'package:basalt/devtools.dart';
BasaltDevTools.register(conn, name: 'main'); // dev-only; the ext.basalt.* hooks are absent from release builds
The Flutter UI lives in packages/basalt_devtools_extension and is compiled
into this package's extension/devtools/. Opening the tab from the CLI needs a Dart Tooling Daemon, so use
the launcher:
dart run example/tool/inspect.dart # starts a DTD + app + DevTools; then enable "basalt" in the Extensions menu
Reference #
- Query builder · Types · Annotations & codegen
- Generate API docs:
cd packages/basalt && dart doc . - Working on the core itself? See CONTRIBUTING.md (architecture, conventions,
the build-vs-execute design, sealed-
partlayout).