This guide shows how to implement a complete Isar local adapter for Datum.
Overview#
Isar is a high-performance database for Flutter built on top of SQLite. This adapter provides a full implementation of the
LocalAdapter interface using Isar for data persistence.
Setup#
First, add Isar to your pubspec.yaml:
dependencies:
isar: ^4.0.0
isar_flutter_libs: ^4.0.0
dev_dependencies:
isar_generator: ^4.0.0
build_runner: ^2.4.6
Entity Definition#
Create an Isar collection for your entity:
import 'package:isar/isar.dart';
part 'task.g.dart';
@collection
class TaskEntity extends DatumEntity {
Id id = Isar.autoIncrement; // Isar ID
@Index(unique: true, replace: true)
late String datumId; // Your Datum entity ID
@Index()
late String userId;
late String title;
late bool isCompleted;
@Index()
late DateTime createdAt;
@Index()
late DateTime modifiedAt;
late bool isDeleted;
@Index()
late int version;
TaskEntity({
required this.datumId,
required this.userId,
required this.title,
this.isCompleted = false,
required this.createdAt,
required this.modifiedAt,
this.isDeleted = false,
this.version = 1,
});
// Convert from/to Datum entity
factory TaskEntity.fromDatum(Task task) => TaskEntity(
datumId: task.id,
userId: task.userId,
title: task.title,
isCompleted: task.isCompleted,
createdAt: task.createdAt,
modifiedAt: task.modifiedAt,
isDeleted: task.isDeleted,
version: task.version,
);
Task toDatum() => Task(
id: datumId,
userId: userId,
title: title,
isCompleted: isCompleted,
createdAt: createdAt,
modifiedAt: modifiedAt,
isDeleted: isDeleted,
version: version,
);
}
Generate the Isar code:
dart run build_runner build
Implementation#
import 'package:datum/datum.dart';
import 'package:isar/isar.dart';
class IsarLocalAdapter<T extends DatumEntityInterface> extends LocalAdapter<T> {
final IsarCollection<T> collection;
final T Function(Map<String, dynamic>) fromMap;
IsarLocalAdapter({
required this.collection,
required this.fromMap,
});
@override
Future<void> initialize() async {
// Isar is initialized globally, no specific initialization needed
}
@override
Future<void> dispose() async {
// Isar is disposed globally
}
@override
Future<AdapterHealthStatus> checkHealth() async {
try {
await collection.count();
return AdapterHealthStatus.healthy;
} catch (e) {
return AdapterHealthStatus.unhealthy;
}
}
@override
Future<T?> read(String id, {String? userId}) async {
final query = collection.filter().idEqualTo(id);
if (userId != null) {
query.userIdEqualTo(userId);
}
return await query.findFirst();
}
@override
Future<List<T>> readAll({String? userId}) async {
var query = collection.filter().isDeletedEqualTo(false);
if (userId != null) {
query = query.userIdEqualTo(userId);
}
return await query.findAll();
}
@override
Future<void> create(T entity) async {
await collection.isar.writeTxn(() async {
await collection.put(entity);
});
}
@override
Future<void> update(T entity) async {
await collection.isar.writeTxn(() async {
await collection.put(entity);
});
}
@override
Future<void> delete(String id, {String? userId}) async {
await collection.isar.writeTxn(() async {
final entity = await read(id, userId: userId);
if (entity != null) {
// Soft delete by updating the entity
final updated = entity.copyWith(isDeleted: true) as T;
await collection.put(updated);
}
});
}
@override
Future<void> patch({
required String id,
required Map<String, dynamic> delta,
String? userId,
}) async {
await collection.isar.writeTxn(() async {
final entity = await read(id, userId: userId);
if (entity != null) {
// Create updated entity from delta
final updatedMap = entity.toDatumMap()..addAll(delta);
final updated = fromMap(updatedMap);
await collection.put(updated);
}
});
}
@override
Future<List<T>> query(DatumQuery query, {String? userId}) async {
var builder = collection.filter().isDeletedEqualTo(false);
if (userId != null) {
builder = builder.userIdEqualTo(userId);
}
// Apply filters
for (final filter in query.filters) {
builder = _applyFilter(builder, filter);
}
var queryBuilder = builder;
// Apply sorting
for (final sort in query.sorting) {
switch (sort.field) {
case 'createdAt':
queryBuilder = sort.direction == SortDirection.descending
? queryBuilder.sortByCreatedAtDesc()
: queryBuilder.sortByCreatedAt();
break;
case 'modifiedAt':
queryBuilder = sort.direction == SortDirection.descending
? queryBuilder.sortByModifiedAtDesc()
: queryBuilder.sortByModifiedAt();
break;
// Add more sort fields as needed
}
}
// Apply pagination
if (query.offset > 0) {
queryBuilder = queryBuilder.offset(query.offset);
}
if (query.limit != null) {
queryBuilder = queryBuilder.limit(query.limit!);
}
return await queryBuilder.findAll();
}
QueryBuilder<T, T, QFilterCondition> _applyFilter(
QueryBuilder<T, T, QFilterCondition> builder,
FilterCondition condition,
) {
if (condition is Filter) {
final field = condition.field;
final value = condition.value;
switch (field) {
case 'id':
return _applyStringFilter(builder, condition);
case 'userId':
return _applyStringFilter(builder, condition);
case 'createdAt':
case 'modifiedAt':
return _applyDateFilter(builder, condition);
case 'version':
return _applyIntFilter(builder, condition);
case 'isDeleted':
return _applyBoolFilter(builder, condition);
default:
// For custom fields, you might need additional logic
return builder;
}
}
return builder;
}
QueryBuilder<T, T, QFilterCondition> _applyStringFilter(
QueryBuilder<T, T, QFilterCondition> builder,
Filter filter,
) {
final value = filter.value as String;
switch (filter.operator) {
case FilterOperator.equals:
return builder.idEqualTo(value);
case FilterOperator.contains:
return builder.idContains(value);
case FilterOperator.startsWith:
return builder.idStartsWith(value);
default:
return builder;
}
}
QueryBuilder<T, T, QFilterCondition> _applyDateFilter(
QueryBuilder<T, T, QFilterCondition> builder,
Filter filter,
) {
final value = filter.value as DateTime;
final field = filter.field;
switch (filter.operator) {
case FilterOperator.equals:
if (field == 'createdAt') {
return builder.createdAtEqualTo(value);
} else {
return builder.modifiedAtEqualTo(value);
}
case FilterOperator.greaterThan:
if (field == 'createdAt') {
return builder.createdAtGreaterThan(value);
} else {
return builder.modifiedAtGreaterThan(value);
}
default:
return builder;
}
}
QueryBuilder<T, T, QFilterCondition> _applyIntFilter(
QueryBuilder<T, T, QFilterCondition> builder,
Filter filter,
) {
final value = filter.value as int;
switch (filter.operator) {
case FilterOperator.equals:
return builder.versionEqualTo(value);
case FilterOperator.greaterThan:
return builder.versionGreaterThan(value);
default:
return builder;
}
}
QueryBuilder<T, T, QFilterCondition> _applyBoolFilter(
QueryBuilder<T, T, QFilterCondition> builder,
Filter filter,
) {
final value = filter.value as bool;
return builder.isDeletedEqualTo(value);
}
@override
Stream<DatumChangeDetail<T>>? get changeStream {
return collection.watchLazy().map((_) {
// This is a simplified implementation
// In a real app, you'd need to track what changed
return DatumChangeDetail<T>(
type: DatumOperationType.update,
entityId: 'unknown',
userId: 'unknown',
timestamp: DateTime.now(),
data: null,
);
});
}
@override
Future<List<T>> readAllPaginated(PaginationConfig config, {String? userId}) async {
var query = collection.filter().isDeletedEqualTo(false);
if (userId != null) {
query = query.userIdEqualTo(userId);
}
final offset = config.page * config.pageSize;
return await query.offset(offset).limit(config.pageSize).findAll();
}
@override
Future<List<T>> readByIds(List<String> ids, {required String userId}) async {
return await collection.filter()
.isDeletedEqualTo(false)
.userIdEqualTo(userId)
.anyOf(ids.map((id) => FilterGroup.and().idEqualTo(id)))
.findAll();
}
@override
Future<int> getStorageSize({String? userId}) async {
// Isar doesn't provide direct size information
// This is an estimate
final count = await collection.count();
return count * 1024; // Rough estimate
}
@override
Future<void> clearUserData(String userId) async {
await collection.isar.writeTxn(() async {
await collection.filter().userIdEqualTo(userId).deleteAll();
});
}
@override
Future<void> clear() async {
await collection.isar.writeTxn(() async {
await collection.clear();
});
}
@override
Future<List<String>> getAllUserIds() async {
final distinct = await collection.where().distinctByUserId().userIdProperty().findAll();
return distinct.whereType<String>().toList();
}
@override
Future<DatumSyncMetadata?> getSyncMetadata(String userId) async {
// You'd need a separate collection for sync metadata
// This is a simplified implementation
return null;
}
@override
Future<void> updateSyncMetadata(DatumSyncMetadata metadata, String userId) async {
// Implementation would depend on your metadata storage strategy
}
@override
Future<List<DatumSyncOperation<T>>> getPendingOperations(String userId) async {
// You'd need a separate collection for pending operations
return [];
}
@override
Future<void> addPendingOperation(String userId, DatumSyncOperation<T> operation) async {
// Implementation would depend on your pending operations storage
}
@override
Future<void> removePendingOperation(String operationId) async {
// Implementation would depend on your pending operations storage
}
@override
Future<void> transaction<R>(Future<R> Function() action) async {
return await collection.isar.writeTxn(action);
}
@override
Future<void> setStoredSchemaVersion(int version) async {
// Store in a separate collection or shared preferences
}
@override
Future<int> getStoredSchemaVersion() async {
// Retrieve from storage
return 0;
}
@override
Future<List<Map<String, dynamic>>> getAllRawData({String? userId}) async {
final entities = await readAll(userId: userId);
return entities.map((e) => e.toDatumMap()).toList();
}
@override
Future<void> overwriteAllRawData(List<Map<String, dynamic>> data, {String? userId}) async {
await collection.isar.writeTxn(() async {
// Clear existing data
if (userId != null) {
await collection.filter().userIdEqualTo(userId).deleteAll();
} else {
await collection.clear();
}
// Insert new data
for (final item in data) {
final entity = fromMap(item);
await collection.put(entity);
}
});
}
}
Usage Example#
// Initialize Isar
final isar = await Isar.open([TaskEntitySchema]);
// Create the adapter
final taskAdapter = IsarLocalAdapter<Task>(
collection: isar.taskEntitys, // Generated collection
fromMap: (map) => Task.fromMap(map),
);
// Register with Datum
final registrations = [
DatumRegistration<Task>(
localAdapter: taskAdapter,
remoteAdapter: SupabaseRemoteAdapter<Task>(
tableName: 'tasks',
fromMap: (map) => Task.fromMap(map),
),
),
];
Features#
- High Performance: Isar provides excellent query performance with indexing
- Type Safety: Compile-time type checking with generated code
- Complex Queries: Rich query API with filtering, sorting, and linking
- Transactions: ACID-compliant transactions
- Change Streams: Built-in reactive change notifications
- Migration Support: Schema versioning and data migration
Performance Considerations#
- Indexing: Proper indexing is crucial for query performance
- Memory Usage: Isar is memory-efficient compared to other databases
- Concurrent Access: Supports concurrent read/write operations
- Change Notifications: Efficient reactive updates with watchers