flutter_local_db 2.0.0
flutter_local_db: ^2.0.0 copied to clipboard
Diesel-style local database for Flutter on Rust and LMDB 1.0: typed tables, indexed queries, transactions and streams on Android, iOS, macOS, Linux and Windows.
flutter_local_db #
An embedded database for Flutter with a query API modelled on Diesel: typed tables, secondary indexes, a query planner, transactions with savepoints and reactive queries. The engine is offline_first_core, written in Rust on LMDB 1.0.2 through natdb.
- Tables of JSON rows with a primary key, typed columns and optional auto-increment keys. No code generation.
- Indexes: single, composite and unique, maintained in the same transaction as their rows and built over existing rows when added.
- Queries:
filter,orFilter,order,limit,offset,count,sum,avg,min,max, andexplainto see the chosen index. - Transactions: commit on success, rollback on error, savepoints, read snapshots and atomic batches.
- Reactive:
watchemits a query's rows again after every committed write to its table. - Platforms: Android, iOS, macOS, Linux and Windows. The library ships prebuilt and a build hook bundles it: nothing to configure.
- Off the UI isolate: every call runs on a database isolate.
final db = await LocalDatabase.openNamed('app', tables: [users]);
await users.insert([User(id: 1, name: 'Ana', city: 'Lima', age: 31)]).execute(db);
final adults = await users
.filter(users.city.eq('Lima') & users.age.ge(18))
.order(users.age.desc())
.limit(20)
.load(db);
Install #
dependencies:
flutter_local_db: ^2.0.0
Flutter 3.38 or later (build hooks). There is no platform setup: the build hook adds the native library of each target (Android ABIs, iOS device and simulator, macOS, Linux and Windows on x64 and arm64) to the app.
Define a table #
A table maps a Dart class to rows. Columns name the fields that queries use;
the row itself is whatever toJson returns.
final class User {
const User({this.id, required this.name, required this.city, required this.age});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
city: json['city'] as String,
age: json['age'] as int,
);
final int? id;
final String name;
final String city;
final int age;
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'city': city, 'age': age};
}
final class UsersTable extends Table<User> {
UsersTable() : super('users');
late final id = integer('id');
late final name = text('name');
late final city = text('city');
late final age = integer('age');
@override
Column<Object> get primaryKey => id;
@override
bool get autoIncrement => true; // a null id is generated on insert
@override
List<Index> get indexes => [
Index('by_city_age', [city, age]),
Index.unique('by_name', [name]),
];
@override
User fromJson(Map<String, dynamic> json) => User.fromJson(json);
@override
Map<String, dynamic> toJson(User row) => row.toJson();
}
final users = UsersTable();
Column types: text, integer, real, boolean and dateTime (stored as
UTC microseconds, so it sorts; convert with DateTimeColumn.toStorage and
fromStorage in your toJson/fromJson). A column name can be a dotted path
into nested objects (text('address.city')).
Open #
// In the application support directory:
final db = await LocalDatabase.openNamed('app', tables: [users]);
// Or at a path of your choice (files go to '<path>.lmdb'):
final db = await LocalDatabase.open(path: '${dir.path}/app', tables: [users]);
open defines the tables: a new table is created, and indexes added to or
removed from an existing table are built or dropped. Open each database once
and share the LocalDatabase object: a second object on the same path shares
the files but not the write queue, so its writes can wait behind the other's
transactions.
Query #
| Diesel (Rust) | flutter_local_db |
|---|---|
users.filter(city.eq("Lima")) |
users.filter(users.city.eq('Lima')) |
.and(...) / .or(...) / not(...) |
a & b / a | b / ~a |
.or_filter(...) |
.orFilter(...) |
ne, gt, ge, lt, le |
ne, gt, ge, lt, le |
eq_any, ne_all |
eqAny, neAll |
between, not_between |
between, notBetween |
is_null, is_not_null |
isNull(), isNotNull() |
like, ilike |
like, ilike (text columns) |
.order(age.desc()), .then_order_by(...) |
.order(users.age.desc()), .thenOrderBy(...) |
.limit(n), .offset(n) |
.limit(n), .offset(n) |
.load(conn), .first(conn) |
.load(db), .first(db) |
.count().get_result(conn) |
.count(db) |
users.find(id).first(conn) |
users.find(id).first(db) |
sum, avg, min, max |
.sum(col, db), .avg(col, db), .min(col, db), .max(col, db) |
final page = await users
.filter(users.city.eqAny(['Lima', 'Bogotá']))
.orFilter(users.age.lt(18))
.order(users.name.asc())
.limit(50)
.offset(100)
.load(db);
final ana = await users.find(1).first(db); // null when missing
final average = await users.filter(users.city.eq('Lima')).avg(users.age, db);
Comparisons follow SQL: a value only compares with values of its kind, and
NULL or a missing field matches no comparison (use isNull()). Without
order, the order of the rows is unspecified.
Write #
await users.insert([ana, luis]).execute(db); // returns the count
final saved = await users.insert([nuevo]).getResults(db); // rows with generated ids
await users.insert([ana]).onConflictDoNothing().execute(db);
await users.insert([ana]).onConflictReplace().execute(db);
await users
.update()
.filter(users.city.eq('Lima'))
.set(users.age, 40)
.execute(db);
await users.delete().filter(users.age.lt(18)).execute(db);
// Fails, changing nothing, unless exactly one row matches:
await users.delete().filter(users.id.eq(7)).expectAffectedRows(1).execute(db);
An insert of an existing primary key fails with duplicateKey, and a
duplicate value of a unique index with uniqueViolation, unless
onConflictDoNothing or onConflictReplace says otherwise. Every statement
is atomic.
Transactions #
final total = await db.transaction((tx) async {
await users.insert([ana]).execute(tx);
await tx.savepoint((sp) async {
// A failure here rolls back only the savepoint, and rethrows.
await users.update().filter(users.id.eq(2)).set(users.age, 30).execute(sp);
});
return users.all().count(tx); // reads see the transaction's writes
}); // committed here; an exception rolls everything back
final snapshot = await db.readTransaction((tx) async {
// One consistent view, while other writes go on.
return (await users.all().count(tx), await users.all().max(users.age, tx));
});
await db.atomicBatch([
users.insert([ana]),
users.update().filter(users.id.eq(2)).set(users.age, 30),
]); // all or nothing, in one round trip
- Inside
transaction, run statements ontx. Usingdbthere fails withtransactionReentrancyinstead of deadlocking. - A write that fails inside a transaction makes it rollback-only: the commit
fails with
transactionAbortedeven when the error was caught. Put recoverable work in asavepoint. - Other writes of the database wait until the transaction ends. A transaction
idle for longer than
idleTimeout(30 s by default) is rolled back.
Watch #
StreamBuilder<List<User>>(
stream: users.filter(users.city.eq('Lima')).order(users.name.asc()).watch(db),
builder: (context, snapshot) => UserList(users: snapshot.data ?? const []),
);
watch emits the rows at once and again after every committed write to the
table. db.changes streams the tables each commit wrote.
Indexes and explain #
The planner looks rows up by primary key, or picks the index with the most
leading eq columns plus a range on the next one, or an index that already
follows order (stopping after limit). explain shows the choice:
final plan = await users
.filter(users.city.eq('Lima') & users.age.gt(30))
.order(users.age.desc())
.explain(db);
// {table: users, access: index_scan, index: by_city_age,
// descending: true, presorted: true, exact: true}
exact: true means the index alone decides the filter: rows are not checked
again, and count reads no row at all. Add an index when explain reports
full_scan for a query on a large table.
Durability #
final db = await LocalDatabase.open(
path: path,
options: const LocalDbOptions(durability: Durability.noMetaSync),
);
Durability |
A committed transaction after a power loss |
|---|---|
full (default) |
is kept: data and metadata are flushed on every commit |
noMetaSync |
may be undone (the last one), never corrupted; one flush per commit instead of two |
noSync |
the last ones may be undone; flushing is left to the OS. An app crash loses nothing |
On Apple hardware a flush is F_FULLFSYNC, which takes milliseconds: group
writes in a transaction or atomicBatch rather than committing row by row.
The database file grows as needed: LocalDbOptions.initialSize (64 MiB) and
maxSize (16 GiB) are address space reserved for the memory map, not disk.
Errors #
Failures throw LocalDbException with a code (LocalDbErrorCode):
duplicateKey, uniqueViolation, affectedRowsMismatch, tableNotFound,
invalidSchema, schemaMismatch, missingPrimaryKey, keyTooLarge,
mapFull, transactionAborted, transactionReentrancy,
transactionExpired, closed, legacyFormat, unsupportedPlatform,
nativeLibrary and the rest listed in the API docs.
Platforms #
| Platform | Architectures | Minimum |
|---|---|---|
| Android | arm64-v8a, armeabi-v7a, x86_64, x86 (16 KB pages) | API 21 |
| iOS | device arm64; simulator arm64, x86_64 | iOS 13 |
| macOS | arm64, x86_64 | macOS 10.15 |
| Linux | x86_64, arm64 | glibc 2.35 |
| Windows | x64, arm64 | Windows 10 |
| Web | key-value LocalDB API on IndexedDB |
— |
The query API needs the native engine: on the web, LocalDatabase.open
throws unsupportedPlatform.
Benchmarks #
Same data and operations for every engine: 10 000 rows
(id, name, email, age, city, active) with an index on (city, age). Each
cell is the median of 3 rounds, measured from Dart in a profile (AOT) build
of the benchmark app on an Apple M1 Max, macOS 26.7, Flutter
3.47.5. Rows are grouped by what a commit guarantees.
| Engine | Durability | Runs on | Insert 10k rows, 1 transaction (per row) | Insert, 1 transaction per row | Find by primary key | Indexed query, limit 50 | Indexed count | Update by key |
|---|---|---|---|---|---|---|---|---|
| flutter_local_db 2.0 (full) | data and metadata flushed per commit | database isolate | 6.4 µs | 10.65 ms | 29.7 µs | 101.5 µs | 64.5 µs | 10.63 ms |
| SQLite 3.53.4 (synchronous=FULL) | WAL flushed per commit (fullfsync on Apple) | calling isolate | 2.2 µs | 5.14 ms | 4.1 µs | 42.4 µs | 49.4 µs | 5.30 ms |
| drift 2 (synchronous=FULL) | WAL flushed per commit (fullfsync on Apple) | background isolate | 3.2 µs | 5.09 ms | 43.9 µs | 138.1 µs | 95.6 µs | 5.40 ms |
| flutter_local_db 2.0 (no_meta_sync) | data flushed per commit | database isolate | 6.5 µs | 5.00 ms | 33.6 µs | 108.7 µs | 70.5 µs | 5.35 ms |
| flutter_local_db 2.0 (no_sync) | no flush per commit | database isolate | 5.3 µs | 77.0 µs | 36.6 µs | 109.3 µs | 70.0 µs | 72.9 µs |
| SQLite 3.53.4 (synchronous=OFF) | no flush per commit (WAL) | calling isolate | 1.6 µs | 26.2 µs | 3.5 µs | 41.0 µs | 47.4 µs | 32.8 µs |
| Hive CE 2 | no flush per write | calling isolate | 2.3 µs | 40.6 µs | 0.5 µs | 18.1 µs | 468.5 µs | 48.4 µs |
| Sembast 3 | no flush per write | calling isolate | 37.5 µs | 133.7 µs | 1.5 µs | 70.2 µs | 1.38 ms | 139.2 µs |
How to read it:
- Where the work runs decides the small operations. sqlite3, Hive CE and Sembast run on the calling isolate: a lookup costs microseconds, and a slow query or a flush blocks the UI for as long as it takes. flutter_local_db and drift hand every call to another isolate, which costs a round trip of about 30 µs here and never blocks the UI. Against drift, the same design, flutter_local_db is faster on lookups, indexed queries and counts.
- A durable LMDB commit flushes twice (data, then the metadata page);
SQLite in WAL mode flushes once.
noMetaSyncflushes once and matches it. - Hive CE and Sembast keep every row in memory. Lookups are fast, and queries and counts scan all the rows.
Reproduce with cd benchmark && flutter drive --profile -d macos --driver test_driver/integration_test.dart --target integration_test/benchmark_test.dart
(any device works; the table is saved to benchmark/build/benchmark.md).
Key-value API #
LocalDB is the 1.x API: string keys and JSON values, on every platform
including the web (IndexedDB).
await LocalDB.init();
await LocalDB.Post('user-1', {'name': 'Ana'});
final user = await LocalDB.GetById('user-1'); // LocalDbResult<LocalDbModel?, ErrorLocalDb>
await LocalDB.Put('user-1', {'name': 'Ana María'});
await LocalDB.Delete('user-1');
final all = await LocalDB.GetAll();
Results are LocalDbResult (Ok or Err), handled with when, isOk or
unwrapOr. LocalDbModel.createdAt and updatedAt are not stored: keep your
own timestamps in the data.
Migrating from 1.x #
2.0 stores data with LMDB 1.0, which cannot read the files of 1.x. Export with
LocalDB.exportAll() from a version of your app on 1.6, then import with
LocalDB.importAll() in 2.0: see MIGRATION.md.
Roadmap #
Not in 2.0, planned for later versions:
- Offline-first sync: a change log written in the same commit as each row, acknowledged by revision, with tombstones and conflict policies.
- Joins, associations and
GROUP BYaggregates. - A binary wire format and handles for the C ABI (the protocol is versioned, so the public API stays).
- Schema files with generated table classes.
- The query API on the web.
License #
MIT. See LICENSE.