b012_data is a Flutter package focused on data manipulation, inspired by
the Spring Boot framework: declare a plain Dart class as an entity and the
package automatically generates every CRUD operation (create, read, update,
delete) on your local SQLite database — no hand-written SQL, no boilerplate
repository.
- Object persistence on a local SQLite database, via
DataAccess.instance. - File system helpers (read, write, append, check, delete, load binary files as images, audios, videos and so on), via
DiscData.instance.
Supported platforms: Android, iOS, macOS, Linux, Windows and web. Optional encryption of the database and of the files (2.1).
On mobile platforms (Android, iOS, macOS) the package uses the native
sqfliteimplementation. On desktop platforms (Linux, Windows) it transparently falls back tosqflite_common_ffi. In a browser it runs SQLite as WebAssembly withsqlite3(see Web).
More details and a longer walk-through can be found at https://birane012.github.io.
Add the package to your pubspec.yaml:
dependencies:
b012_data: ^2.1.0Then run:
flutter pub get2.1 requires Dart 3.12 and Flutter 3.44 or later (the current sqflite
packages do); an application on an older Flutter keeps resolving 2.0.3.
Important note: The first access creates a default database named sqlf_easy.db in the application's databases folder (see Where your data lives). You can use the changeDB method if you don't want to use this one.
Before calling any method that needs the application documents directory
(databasesPath, filesPath, etc.), make sure the Flutter bindings are
initialized:
import 'disc_example.dart' as disc;
import 'sqlite_example.dart' as sqlite;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// ... your app here, like below.
// SQLite CRUD walk-through (Person entity).
await sqlite.runSqliteDemo();
// File system / binary file walk-through (Images entity).
await disc.runDiscDemo();
}An entity is a plain Dart class that exposes a few well-known members used
by DataAccess to generate SQL statements and deserialize rows.
// -----------------------------------------------------------------------------
// b012_data — SQLite CRUD example
//
// Spring Boot-inspired walk-through of `DataAccess.instance`: you declare a
// plain Dart class (`Person`) as an entity, and b012_data automatically
// generates every CRUD statement (CREATE TABLE, INSERT, SELECT, UPDATE,
// DELETE) — no hand-written SQL, no repository boilerplate.
//
// This file is runnable on its own via `flutter run -t example/sqlite_example.dart`.
// -----------------------------------------------------------------------------
import 'package:b012_data/b012_sqlflite_easy.dart';
import 'package:flutter/foundation.dart';
//Person entity
class Person {
String? idPers;
String? firstName;
String? lastName;
bool? sex;
DateTime? dateOfBirth;
// String? email;
// String? profession;
// 1. Required: primary key declaration.
// MapEntry key is the column name, value is true to use AUTOINCREMENT.
MapEntry<String, bool> get pKeyAuto => const MapEntry('idPers', false);
// 2. Optional: non-nullable columns, unique columns, check constraints,
// default values and foreign keys.
List<String> get notNulls =>
<String>['firstName', 'lastName', 'sex', 'dateOfBirth'];
// List<String> get uniques => <String>['email'];
// Map<String, String> get checks => {'email': 'length(email) > 4'};
// Map<String, String> get defaults => {'profession': 'NULL'};
// Map<String, List<String>> get fKeys =>
// {'profession': ['Profession', 'idProf']};
// 3. Required: an unnamed constructor used by the package to instantiate
// an entity from the `fromMap` method below.
Person([this.idPers, this.firstName, this.lastName, this.sex, this.dateOfBirth]);
// 4. Required: serialization to a Map.
// For non-nullable fields the `ColumnType` fallback can be omitted —
// the value will never be null at insertion time.
Map<String, dynamic> toMap() => {
'idPers': idPers ?? ColumnType.String,
'firstName': firstName ?? ColumnType.String,
'lastName': lastName ?? ColumnType.String,
'sex': sex ?? ColumnType.bool,
'dateOfBirth': dateOfBirth ?? ColumnType.DateTime,
};
// 5. Required: deserialization from a row.
Person.fromMap(Map<String, Object?> json, {bool isInt = true}) {
idPers = json['idPers'] as String?;
firstName = json['firstName'] as String?;
lastName = json['lastName'] as String?;
sex = boolean(json['sex'], isInt: isInt);
dateOfBirth = dateTime(json['dateOfBirth'] as String?);
}
// 6. Required: instance-side fromMap used by the generic getters.
Person fromMap(Map<String, Object?> json) => Person.fromMap(json);
}
/* WORKING WITH DATABASE */
/// Full CRUD walk-through driven exclusively through `DataAccess.instance`.
/// Every call below is the high-level equivalent of a Spring Data JPA
/// repository method — no SQL string is ever written by hand.
Future<void> runSqliteDemo() async {
// Inspect the CREATE TABLE statement generated for the Person entity.
debugPrint('${DataAccess.instance.showCreateTable(Person())}\n\n');
// Does the Person table already exist in the database?
final bool personTableExists =
await DataAccess.instance.checkIfEntityTableExists<Person>();
debugPrint('Person table exists? $personTableExists\n');
// CREATE — insert a single Person row.
final bool inserted = await DataAccess.instance.insertObjet(
Person(newKey, 'KEBE', 'Birane', true, DateTime(2000, 8, 5)),
);
debugPrint('Single insert ok? $inserted\n');
// CREATE (bulk) — insert several Persons inside a single transaction.
final bool personsListInserted =
await DataAccess.instance.insertObjetList(<Person>[
Person(newKey, 'Mbaye', 'Aliou', true, DateTime(1999, 5, 1)),
Person(newKey, 'Cisse', 'Fatou', false, DateTime(2000, 7, 9)),
]);
debugPrint('Bulk insert ok? $personsListInserted\n');
// READ — fetch a single row matching an SQL predicate.
final Person? birane = await DataAccess.instance.get<Person>(
Person(),
"firstName = 'Birane' AND lastName = 'KEBE'",
);
debugPrint('Found Birane? ${birane != null}\n');
// READ — fetch every row.
final List<Person>? everyone = await DataAccess.instance.getAll<Person>(
Person(),
);
debugPrint('Total persons: ${everyone?.length ?? 0}\n');
// READ — fetch rows matching a predicate. Booleans are automatically
// converted to 0/1 for SQLite, so `sex = true` is equivalent to `sex = 1`.
final List<Person>? men = await DataAccess.instance.getAllSorted<Person>(
Person(),
'sex = true',
);
debugPrint('Men: ${men?.length ?? 0}\n');
// READ (projection) — pull a single column across the whole table.
final List<String> firstNames =
await DataAccess.instance.getAColumnFrom<String, Person>('firstName');
debugPrint('First names: $firstNames\n');
final List<String> womenFirstNames = await DataAccess.instance
.getAColumnFrom<String, Person>('firstName', afterWhere: 'sex = false');
debugPrint('Women first names: $womenFirstNames\n');
// READ (projection) — pull several columns at once.
final List<Map<String, Object?>> nameRows = await DataAccess.instance
.getSomeColumnsFrom<Person>('firstName, lastName');
debugPrint('First + last names: $nameRows\n');
final List<Map<String, Object?>> womenNameRows =
await DataAccess.instance.getSomeColumnsFrom<Person>(
'firstName, lastName',
afterWhere: 'sex = 0',
);
debugPrint('Women first + last names: $womenNameRows\n');
// UPDATE — values follow the order `columnsToUpadate` + `whereColumns`.
// Here: set `firstName = 'developer'` and `lastName = '2022'` where
// `firstName = 'Birane' AND lastName = 'KEBE'`.
final bool updated = await DataAccess.instance.updateSomeColumnsOf<Person>(
<String>['firstName', 'lastName'],
<String>['firstName', 'lastName'],
<Object>['developer', '2022', 'Birane', 'KEBE'],
);
debugPrint('Update ok? $updated\n');
// DELETE — remove every row matching the predicate.
final bool deletedFatou = await DataAccess.instance.deleteObjet<Person>(
"firstName = 'Fatou'",
);
debugPrint('Fatou deleted? $deletedFatou\n');
// COUNT — total rows and filtered rows.
final int total = await DataAccess.instance.countElementsOf<Person>();
final int males = await DataAccess.instance.countElementsOf<Person>(
afterWhere: 'sex = true',
);
debugPrint('Total rows: $total, male rows: $males\n');
// Attention: The following instruction drops every table in the sqlf_easy.db database (tables stay, rows are removed).
// await DataAccess.instance.cleanAllTablesData();
}- Most entity-oriented methods throw
DatabaseException('no such table:...')when the underlying table does not exist. The exceptions areupdateWholeObject,updateSomeColumnsOf,getAColumnFromWithTableNameandgetAColumnFrom, which catch the error and only log it viadebugPrint(andonError) — unlessstrictis set. Wrap the other methods intry/catchif you are not sure the table already exists. afterWheretexts are inserted as is: never build them from user input, use?andwhereArgs(see Security).insertObjetandinsertObjetListinternally convert your entity map usingmapToUse(entity.toMap()). CallmapToUse(entity.toMap(), forDB: false)yourself when you want a plain Dart map (e.g. for JSON serialization or display) instead of an SQLite-ready one.
// -----------------------------------------------------------------------------
// b012_data — File system (binary file) manipulation example
//
// Walk-through of `DiscData.instance`: read, write, append, check, delete
// and load binary files as text, Base64, raw bytes or Flutter `Image`
// wimageIDgets (images, audio, vimageIDeo, any blob). The example also shows how
// `getEntityFileOnDisc` resolves a file name stored in an SQLite column
// back to raw bytes, so you can keep metadata in the database and the
// actual payload on disk.
//
// This file is runnable on its own via `flutter run -t example/disc_example.dart`.
// -----------------------------------------------------------------------------
import 'package:b012_data/b012_disc_data.dart';
import 'package:b012_data/b012_sqlflite_easy.dart';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'static_and_variables.dart';
/// Metadata entity used by `getEntityFileOnDisc` to resolve an image file
/// name stored in the database and return its raw bytes. Follows the same
/// six-step convention as any b012_data entity.
class Images {
String? imageID;
String? imageName;
DateTime? dateSave;
DateTime? dateLastUpdate;
// 1. Required: primary key declaration.
MapEntry<String, bool> get pKeyAuto => const MapEntry('imageID', false);
// 2. Optional: non-nullable columns, unique columns, check constraints,
// default values and foreign keys.
List<String> get notNulls =>
const <String>['imageName', 'dateSave', 'dateLastUpdate'];
// 3. Required: unnamed constructor.
Images([this.imageID, this.imageName, this.dateSave, this.dateLastUpdate]);
// 4. Required: serialization to a Map.
Map<String, dynamic> toMap() => <String, dynamic>{
'imageID': imageID ?? ColumnType.String,
'imageName': imageName ?? ColumnType.String,
'dateSave': dateSave ?? ColumnType.DateTime,
'dateLastUpdate': dateLastUpdate ?? ColumnType.DateTime,
};
// 5. Required: deserialization from a row.
Images.fromMap(Map<String, Object?> json, {bool isInt = true}) {
imageID = json['imageID'] as String?;
imageName = json['imageName'] as String?;
dateSave = dateTime(json['dateSave'] as String?);
dateLastUpdate = dateTime(json['dateLastUpdate'] as String?);
}
// 6. Required: instance-simageIDe fromMap used by the generic getters.
Images fromMap(Map<String, Object?> json) => Images.fromMap(json);
}
/* WORKING WITH FLIES ON FILESYSTEM */
/// Full walk-through driven exclusively through `DiscData.instance`.
/// Every path, every byte buffer and every decoded `Image` comes from the
/// application documents directory — nothing is loaded from assets.
Future<void> runDiscDemo() async {
// -------- System paths exposed by the package --------
// Directory that stores SQLite database files.
final String databasesPath = await DiscData.instance.databasesPath;
debugPrint('Databases path: $databasesPath\n');
// Default directory where user files are saved.
final String filesPath = await DiscData.instance.filesPath;
debugPrint('Files path: $filesPath\n');
// Application documents directory (the root used above).
final String rootPath = await DiscData.instance.rootPath;
debugPrint('Root path: $rootPath\n');
// -------- WRITE --------
//1. Save a text file into the default `files` directory.
final String? savedName = await DiscData.instance.saveDataToDisc(
'contents of test.txt',
DataType.text,
takeThisName: 'test.txt',
);
debugPrint('Saved file name: $savedName\n\n');
// Inspect the CREATE TABLE statement generated for the Images entity.
debugPrint('${DataAccess.instance.showCreateTable(Images())}\n\n');
//2. CREATE — insert a single Image record into Images Table.
final bool imageRecordCreated = await DataAccess.instance.insertObjet(
Images('img0001', 'testImage.png', DateTime.now(), DateTime.now()),
);
debugPrint(
'Image record saved in Images table on sqlf_easy.db database ? $imageRecordCreated\n');
//1. Save an Image in base64 format to the default `files` directory.
final String? testImage = await DiscData.instance.saveDataToDisc(
testImageAsBase64,
DataType.base64,
takeThisName: 'testImage.png',
);
debugPrint('Image saved on disk with name: $testImage\n');
// -------- EXISTS --------
final bool exists = await DiscData.instance.checkFileExists(
fileName: 'test.txt',
);
debugPrint('test.txt exists? $exists\n');
// -------- READ --------
// Read as a UTF-8 string.
final String? asString = await DiscData.instance.readFileAsString(
fileName: 'test.txt',
);
debugPrint('test.txt content: $asString\n');
// Read as a Base64-encoded string (handy to ship blobs over JSON).
final String? asBase64 = await DiscData.instance.readFileAsBase64(
fileName: 'testImage.png',
);
debugPrint('testImage.png Base64 length: ${asBase64?.length ?? 0}\n');
// Read as raw bytes (Uint8List) —
// use this for any binary payload:
// images, audio clips, vimageIDeo chunks, PDFs, etc.
final Uint8List? asBytes = await DiscData.instance.readFileAsBytes(
fileName: 'testImage.png',
);
debugPrint('testImage.png Bytes length: ${asBytes?.length ?? 0}\n');
// Read an image file straight into a Flutter `Image` wimageIDget.
final Image? asImage = await DiscData.instance.getImageFromDisc(
imageName: 'testImage.png',
);
debugPrint('Image testImage.png loaded? ${asImage != null}\n');
// -------- DATABASE-BACKED FILE RESOLUTION --------
// Given a table `Images` with a column `imageName` that holds a file
// name on disk, fetch the raw bytes of the row where `imageimageID = 1`.
// Perfect pattern for user-uploaded media: keep metadata in SQLite,
// keep the binary blob on disk, resolve one from the other with a
// single call.
final Uint8List? entityBytes = await DiscData.instance
.getEntityFileOnDisc<Uint8List, Images>(
'imageName', 'imageimageID', 'img0001');
debugPrint('Image img0001 Entity file bytes: ${entityBytes?.length ?? 0}');
}2.1 hardens the package without changing how you use it: every method of
2.0.3 keeps its name, its parameters and its default behaviour (including
returning false, null or [] on failure). Everything new is opt-in.
afterWhere is inserted as is after WHERE (that is what lets you write
ORDER BY and LIMIT in it): never build it from user input. Put ?
placeholders in it and pass the values in whereArgs instead — booleans and
dates are bound exactly as b012_data stores them:
final Person? p = await DataAccess.instance.get<Person>(
Person(),
'lastName = ? AND sex = ?',
[userInput, true],
);
final int n = await DataAccess.instance.countElementsOf<Person>(
afterWhere: 'dateOfBirth >= ?',
whereArgs: [DateTime(2000)],
);Every afterWhere method accepts whereArgs (positional after
afterWhere for get, getAllSorted and deleteObjet, named otherwise).
The queries the package builds itself (getLogin, checkIfTableExists,
getEntityFileOnDisc, ...) always bind their values. Table and column names
that are SQL keywords (order, group, ...) are quoted for you; in your own
afterWhere text, quote them yourself ('"group" = ?'). whereMcop accepts
only AND and OR.
DataAccess.instance.strict = true; // throw the real error
DataAccess.instance.onError = (e, s) => log(e); // or just be toldWith strict, a failing insert or update throws the real DatabaseException
instead of returning false; "nothing found" is still null or [].
Without it, every error turned into false or [] is still printed with
debugPrint and also passed to onError.
await DataAccess.instance.transaction(() async {
await DataAccess.instance.insertObjet(order);
await DataAccess.instance.updateSomeColumnsOf<Stock>(['qty'], ['id'], [qty - 1, id]);
}); // everything or nothingEvery DataAccess method called inside takes part in the transaction; a
transaction started inside a transaction joins it. Inside a transaction a
failing method throws (even outside strict mode) instead of returning
false, so that everything is rolled back. Outside a transaction,
insertObjetList keeps its 2.0.3 behaviour (rows before a failing one are
kept).
await DataAccess.instance.insertObjet(person, orReplace: true); // INSERT OR REPLACENew optional entity getters:
List<String> get pKeys => ['maintenanceId', 'technicianId']; // composite key
Map<String, List<String>> get indexes => {'assignment_trade': ['trade']};
Map<String, List<String>> get uniqueIndexes => {'one_trade': ['maintenanceId', 'trade']};Indexes are created when missing, also on an existing table.
A table is named after its class. Obfuscated builds (and web release builds) change class names, so register the entities whose name must not move:
DataAccess.instance.registerEntity<Person>('person');Every method follows the registration, including the ones that only receive
the type (countElementsOf<Person>()). In a browser, registration is
required.
DataAccess.instance.autoAddColumns = true; // new entity columns are added
DataAccess.instance.migrations = {
2: () => DataAccess.instance.rawExecute('UPDATE person SET sex = 0 WHERE sex IS NULL'),
3: () async { /* ... */ },
};With autoAddColumns (off by default), a column added to an entity is added
to its table (ALTER TABLE ... ADD COLUMN); columns are never dropped or
renamed. Migrations run once each when the database opens, in order, each in
its own transaction that sets PRAGMA user_version; a failing step is rolled
back and makes the opening fail. Set both before the first access.
final List<Person> page = await DataAccess.instance.query<Person>(
Person(),
where: 'sex = ?',
whereArgs: [true],
orderBy: 'lastName',
limit: 20,
offset: 40,
); // [] when nothing matches
final rows = await DataAccess.instance.rawQuery('SELECT ... WHERE x = ?', [x]);
final people = await DataAccess.instance.rawQueryAs<Person>(Person(), 'SELECT ...');
final changed = await DataAccess.instance.rawExecute('UPDATE ... WHERE id = ?', [id]);fKeys were declared but never enforced. Set
DataAccess.instance.enforceForeignKeys = true before the first access to
enforce them (PRAGMA foreign_keys = ON). It is off by default because
enforcing them could break an application that inserts a child before its
parent.
- Android, iOS, macOS: exactly as in 2.0.3.
- Windows, Linux: 2.0.3 created the database under the current
directory (
.dart_tool/sqflite_common_ffi/databases), so launching the application from another folder opened another database, and thedbNamefile and the files folder were shared by every application of the computer. 2.1 keeps a new installation's databases,dbNameand files in the application support folder. A database left by 2.0.3 is found and kept where it is (its absolute path is recorded), so nothing disappears; an application in that case keeps its 2.0.3 files folder (and its 2.0.3databasesPath) too. For a new installation, files saved by 2.0.3 in the shared folder stay readable by name, but are never modified or deleted through it. The decision is taken once and does not change afterwards. changeDBcreates the folder it needs.
Files are read and written asynchronously. A file is written to a temporary
file, flushed, then renamed over the target: a reader never sees half of it.
A file name can no longer point outside filesPath ('../x' throws an
ArgumentError); explicit absolute paths work as before.
The same API runs in a browser:
dart run b012_data:setup_web # puts sqlite3mc.wasm and sqlite3.wasm in web/- SQLite runs as WebAssembly (
sqlite3mc.wasmby default; setDataAccess.instance.webSqliteWasmUrito use another module). Databases andDiscDatafiles live in IndexedDB; persistent storage is requested. - Every write is stored in IndexedDB before its future completes.
- One tab at a time: a second tab opening the same database gets a clear
StateErrorinstead of a copy that would overwrite the first; the storage is read only once the tab holds the database, so it always sees the other tab's last writes. This needs the Web Locks API, available onhttpsandlocalhost. - Entities must be registered (
registerEntity). DataAccess.dbandopenDB(sqflite objects) are not available;DiscData.getFilereturnsnull(there is no file system).- A browser can still erase site data when the user clears it.
Against a lost or stolen device, the database and the files saved by name
can be encrypted with SQLite3MultipleCiphers
(MIT licence). Choose this engine in your application's pubspec.yaml:
hooks:
user_defines:
sqlite3:
source: sqlite3mc(in a browser, setup_web already provides sqlite3mc.wasm). Then:
await DataAccess.instance.useEncryptionKey(key); // before the first access
await DataAccess.instance.encryptDatabase(key); // encrypt an existing plaintext database in place
await DataAccess.instance.changeEncryptionKey(newKey);- A new database is created encrypted; a wrong key throws a
StateError. - The engine is checked first: if it does not encrypt, a
StateErrorsays what to add topubspec.yaml. A database is never created in plaintext by mistake. - A key given for an existing plaintext database is refused (use
encryptDatabase); an encrypted database is never opened without its key (on Android the system SQLite would take it for a corrupt file and erase it). - Files saved by name with
DiscDataare encrypted (AES-256-GCM) with a random key kept inside the encrypted database, so changing the database key rewrites no file. Files written at an explicit path (exports) stay plain unlesssaveDataToDisc(..., encrypt: true). Older plaintext files remain readable.encryptDatabaseencrypts the filesDiscDatasaved by name since 2.1 (it keeps their list) and the names you pass inalsoFiles; nothing else in the files folder is touched (on Android it is shared with other plugins). A file is encrypted with the key of the database open when it is saved: an application that switches between encrypted databases withchangeDBreads each file with the database that saved it. - On Android and iOS an encrypted database goes through the engine bundled
by
package:sqlite3, not the system SQLite. - b012_data never stores the key: your application provides it at each start. On a device, keep a random key in the system vault (Keystore, Keychain, Windows data protection) and unlock it however your application authenticates its user. In a browser there is no vault: the key must come from something the user types, or the encryption protects nothing.
2.1 was tested on Windows (unit tests, real SQLite3MultipleCiphers), in
Chrome (a check application in tool/web_check) and on an Android emulator.
iOS, macOS and Linux share the same code paths but were not run.
The example/ folder splits the two concerns into self-contained,
runnable files so each API can be explored in isolation:
example/sqlite_example.dart— SQLite CRUD demo built around thePersonentity andDataAccess.instance. Run it alone withflutter run -t example/sqlite_example.dart.example/disc_example.dart— File system / binary file demo (text, Base64, raw bytes,Image) built aroundDiscData.instanceand theImagesentity. Run it alone withflutter run -t example/disc_example.dart.example/whats_new_example.dart— the 2.1 additions: registration, bound values, transactions, queries, replace. Run it alone withflutter run -t example/whats_new_example.dart.example/main.dart— Entry point that runs the demos in sequence.
Published under the terms of the included LICENSE file.