From 19dfcbeb675909e69143541f180b215881dcebcd Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Wed, 14 Feb 2024 14:38:08 +0200 Subject: [PATCH 1/7] Add POC of using Drift with sqlite_async. --- .gitattributes | 1 + demos/supabase-todolist/build.yaml | 6 + .../lib/attachments/photo_capture_widget.dart | 8 +- .../lib/attachments/photo_widget.dart | 3 +- demos/supabase-todolist/lib/database.dart | 124 +++ demos/supabase-todolist/lib/database.g.dart | 761 ++++++++++++++++++ .../lib/drift_sqlite_async.dart | 177 ++++ .../lib/models/todo_item.dart | 50 -- .../lib/models/todo_list.dart | 103 --- demos/supabase-todolist/lib/powersync.dart | 3 + demos/supabase-todolist/lib/queries.drift | 9 + .../lib/widgets/fts_search_delegate.dart | 10 +- .../lib/widgets/list_item.dart | 13 +- .../lib/widgets/list_item_dialog.dart | 5 +- .../lib/widgets/lists_page.dart | 9 +- .../lib/widgets/todo_item_dialog.dart | 8 +- .../lib/widgets/todo_item_widget.dart | 12 +- .../lib/widgets/todo_list_page.dart | 12 +- demos/supabase-todolist/pubspec.lock | 371 +++++++-- demos/supabase-todolist/pubspec.yaml | 4 + 20 files changed, 1451 insertions(+), 238 deletions(-) create mode 100644 .gitattributes create mode 100644 demos/supabase-todolist/build.yaml create mode 100644 demos/supabase-todolist/lib/database.dart create mode 100644 demos/supabase-todolist/lib/database.g.dart create mode 100644 demos/supabase-todolist/lib/drift_sqlite_async.dart delete mode 100644 demos/supabase-todolist/lib/models/todo_item.dart delete mode 100644 demos/supabase-todolist/lib/models/todo_list.dart create mode 100644 demos/supabase-todolist/lib/queries.drift diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7886fa6b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +**/*.g.dart linguist-generated=true diff --git a/demos/supabase-todolist/build.yaml b/demos/supabase-todolist/build.yaml new file mode 100644 index 00000000..4ea5a80c --- /dev/null +++ b/demos/supabase-todolist/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + builders: + drift_dev: + options: + store_date_time_values_as_text: true diff --git a/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart b/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart index a4bad5a9..bd866164 100644 --- a/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart +++ b/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart @@ -4,7 +4,6 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:powersync/powersync.dart' as powersync; import 'package:powersync_flutter_demo/attachments/queue.dart'; -import 'package:powersync_flutter_demo/models/todo_item.dart'; import 'package:powersync_flutter_demo/powersync.dart'; class TakePhotoWidget extends StatefulWidget { @@ -43,7 +42,7 @@ class _TakePhotoWidgetState extends State { super.dispose(); } - Future _takePhoto(context) async { + Future _takePhoto(BuildContext context) async { try { // Ensure the camera is initialized before taking a photo await _initializeControllerFuture; @@ -57,13 +56,14 @@ class _TakePhotoWidgetState extends State { int photoSize = await photo.length(); - TodoItem.addPhoto(photoId, widget.todoId); - attachmentQueue.savePhoto(photoId, photoSize); + await appDb.addTodoPhoto(widget.todoId, photoId); + await attachmentQueue.savePhoto(photoId, photoSize); } catch (e) { log.info('Error taking photo: $e'); } // After taking the photo, navigate back to the previous screen + if (!context.mounted) return; Navigator.pop(context); } diff --git a/demos/supabase-todolist/lib/attachments/photo_widget.dart b/demos/supabase-todolist/lib/attachments/photo_widget.dart index afa523ef..03937db8 100644 --- a/demos/supabase-todolist/lib/attachments/photo_widget.dart +++ b/demos/supabase-todolist/lib/attachments/photo_widget.dart @@ -4,8 +4,7 @@ import 'package:flutter/material.dart'; import 'package:powersync_flutter_demo/attachments/camera_helpers.dart'; import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart'; import 'package:powersync_flutter_demo/attachments/queue.dart'; - -import '../models/todo_item.dart'; +import 'package:powersync_flutter_demo/database.dart'; class PhotoWidget extends StatefulWidget { final TodoItem todo; diff --git a/demos/supabase-todolist/lib/database.dart b/demos/supabase-todolist/lib/database.dart new file mode 100644 index 00000000..186ffe93 --- /dev/null +++ b/demos/supabase-todolist/lib/database.dart @@ -0,0 +1,124 @@ +import 'package:drift/drift.dart'; +import 'package:powersync/powersync.dart' show uuid, PowerSyncDatabase; +import 'package:powersync_flutter_demo/drift_sqlite_async.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; + +part 'database.g.dart'; + +class TodoItems extends Table { + @override + String get tableName => 'todos'; + + TextColumn get id => text().clientDefault(() => uuid.v4())(); + TextColumn get listId => text().named('list_id').references(ListItems, #id)(); + TextColumn get photoId => text().nullable().named('photo_id')(); + DateTimeColumn get createdAt => dateTime().nullable().named('created_at')(); + DateTimeColumn get completedAt => + dateTime().nullable().named('completed_at')(); + BoolColumn get completed => boolean().nullable()(); + TextColumn get description => text()(); + TextColumn get createdBy => text().nullable().named('created_by')(); + TextColumn get completedBy => text().nullable().named('completed_by')(); +} + +class ListItems extends Table { + @override + String get tableName => 'lists'; + + TextColumn get id => text().clientDefault(() => uuid.v4())(); + DateTimeColumn get createdAt => + dateTime().named('created_at').clientDefault(() => DateTime.now())(); + TextColumn get name => text()(); + TextColumn get ownerId => text().nullable().named('owner_id')(); +} + +class ListItemWithStats { + late ListItem self; + int completedCount; + int pendingCount; + + ListItemWithStats( + this.self, + this.completedCount, + this.pendingCount, + ); +} + +@DriftDatabase(tables: [TodoItems, ListItems], include: {'queries.drift'}) +class AppDatabase extends _$AppDatabase { + AppDatabase(PowerSyncDatabase db) : super(SqliteAsyncQueryExecutor(db)) { + db.updates.listen((event) { + var setUpdates = {}; + for (var tableName in event.tables) { + setUpdates.add(TableUpdate(tableName)); + } + super.streamQueries.handleTableUpdates(setUpdates); + }); + } + + @override + int get schemaVersion => 1; + + Stream> watchLists() { + return (select(listItems) + ..orderBy([(l) => OrderingTerm(expression: l.createdAt)])) + .watch(); + } + + Stream> watchListsWithStats() { + return listsWithStats().watch(); + } + + Future createList(String name) async { + return into(listItems).insertReturning( + ListItemsCompanion.insert(name: name, ownerId: Value(getUserId()))); + } + + Future deleteList(ListItem list) async { + await (delete(listItems)..where((t) => t.id.equals(list.id))).go(); + } + + Stream> watchTodoItems(ListItem list) { + return (select(todoItems) + ..where((t) => t.listId.equals(list.id)) + ..orderBy([(t) => OrderingTerm(expression: t.createdAt)])) + .watch(); + } + + Future deleteTodo(TodoItem todo) async { + await (delete(todoItems)..where((t) => t.id.equals(todo.id))).go(); + } + + Future addTodo(ListItem list, String description) async { + return into(todoItems).insertReturning(TodoItemsCompanion.insert( + listId: list.id, + description: description, + completed: const Value(false), + createdBy: Value(getUserId()))); + } + + Future toggleTodo(TodoItem todo) async { + if (todo.completed != true) { + await (update(todoItems)..where((t) => t.id.equals(todo.id))).write( + TodoItemsCompanion( + completed: const Value(true), + completedAt: Value(DateTime.now()), + completedBy: Value(getUserId()))); + } else { + await (update(todoItems)..where((t) => t.id.equals(todo.id))).write( + const TodoItemsCompanion( + completed: Value(false), + completedAt: Value.absent(), + completedBy: Value.absent())); + } + } + + Future addTodoPhoto(String todoId, String photoId) async { + await (update(todoItems)..where((t) => t.id.equals(todoId))) + .write(TodoItemsCompanion(photoId: Value(photoId))); + } + + Future findList(String id) { + return (select(listItems)..where((t) => t.id.equals(id))).getSingle(); + } +} diff --git a/demos/supabase-todolist/lib/database.g.dart b/demos/supabase-todolist/lib/database.g.dart new file mode 100644 index 00000000..e042fb58 --- /dev/null +++ b/demos/supabase-todolist/lib/database.g.dart @@ -0,0 +1,761 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $ListItemsTable extends ListItems + with TableInfo<$ListItemsTable, ListItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ListItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + clientDefault: () => uuid.v4()); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now()); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _ownerIdMeta = + const VerificationMeta('ownerId'); + @override + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [id, createdAt, name, ownerId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lists'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('owner_id')) { + context.handle(_ownerIdMeta, + ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta)); + } + return context; + } + + @override + Set get $primaryKey => const {}; + @override + ListItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ListItem( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id']), + ); + } + + @override + $ListItemsTable createAlias(String alias) { + return $ListItemsTable(attachedDatabase, alias); + } +} + +class ListItem extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final String name; + final String? ownerId; + const ListItem( + {required this.id, + required this.createdAt, + required this.name, + this.ownerId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['name'] = Variable(name); + if (!nullToAbsent || ownerId != null) { + map['owner_id'] = Variable(ownerId); + } + return map; + } + + ListItemsCompanion toCompanion(bool nullToAbsent) { + return ListItemsCompanion( + id: Value(id), + createdAt: Value(createdAt), + name: Value(name), + ownerId: ownerId == null && nullToAbsent + ? const Value.absent() + : Value(ownerId), + ); + } + + factory ListItem.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ListItem( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + name: serializer.fromJson(json['name']), + ownerId: serializer.fromJson(json['ownerId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'name': serializer.toJson(name), + 'ownerId': serializer.toJson(ownerId), + }; + } + + ListItem copyWith( + {String? id, + DateTime? createdAt, + String? name, + Value ownerId = const Value.absent()}) => + ListItem( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + name: name ?? this.name, + ownerId: ownerId.present ? ownerId.value : this.ownerId, + ); + @override + String toString() { + return (StringBuffer('ListItem(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('name: $name, ') + ..write('ownerId: $ownerId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, name, ownerId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ListItem && + other.id == this.id && + other.createdAt == this.createdAt && + other.name == this.name && + other.ownerId == this.ownerId); +} + +class ListItemsCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value name; + final Value ownerId; + final Value rowid; + const ListItemsCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.name = const Value.absent(), + this.ownerId = const Value.absent(), + this.rowid = const Value.absent(), + }); + ListItemsCompanion.insert({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + required String name, + this.ownerId = const Value.absent(), + this.rowid = const Value.absent(), + }) : name = Value(name); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? name, + Expression? ownerId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (name != null) 'name': name, + if (ownerId != null) 'owner_id': ownerId, + if (rowid != null) 'rowid': rowid, + }); + } + + ListItemsCompanion copyWith( + {Value? id, + Value? createdAt, + Value? name, + Value? ownerId, + Value? rowid}) { + return ListItemsCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + name: name ?? this.name, + ownerId: ownerId ?? this.ownerId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ListItemsCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('name: $name, ') + ..write('ownerId: $ownerId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $TodoItemsTable extends TodoItems + with TableInfo<$TodoItemsTable, TodoItem> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TodoItemsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + clientDefault: () => uuid.v4()); + static const VerificationMeta _listIdMeta = const VerificationMeta('listId'); + @override + late final GeneratedColumn listId = GeneratedColumn( + 'list_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('REFERENCES lists (id)')); + static const VerificationMeta _photoIdMeta = + const VerificationMeta('photoId'); + @override + late final GeneratedColumn photoId = GeneratedColumn( + 'photo_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _completedAtMeta = + const VerificationMeta('completedAt'); + @override + late final GeneratedColumn completedAt = GeneratedColumn( + 'completed_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _completedMeta = + const VerificationMeta('completed'); + @override + late final GeneratedColumn completed = GeneratedColumn( + 'completed', aliasedName, true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("completed" IN (0, 1))')); + static const VerificationMeta _descriptionMeta = + const VerificationMeta('description'); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdByMeta = + const VerificationMeta('createdBy'); + @override + late final GeneratedColumn createdBy = GeneratedColumn( + 'created_by', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _completedByMeta = + const VerificationMeta('completedBy'); + @override + late final GeneratedColumn completedBy = GeneratedColumn( + 'completed_by', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + id, + listId, + photoId, + createdAt, + completedAt, + completed, + description, + createdBy, + completedBy + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'todos'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('list_id')) { + context.handle(_listIdMeta, + listId.isAcceptableOrUnknown(data['list_id']!, _listIdMeta)); + } else if (isInserting) { + context.missing(_listIdMeta); + } + if (data.containsKey('photo_id')) { + context.handle(_photoIdMeta, + photoId.isAcceptableOrUnknown(data['photo_id']!, _photoIdMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('completed_at')) { + context.handle( + _completedAtMeta, + completedAt.isAcceptableOrUnknown( + data['completed_at']!, _completedAtMeta)); + } + if (data.containsKey('completed')) { + context.handle(_completedMeta, + completed.isAcceptableOrUnknown(data['completed']!, _completedMeta)); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, _descriptionMeta)); + } else if (isInserting) { + context.missing(_descriptionMeta); + } + if (data.containsKey('created_by')) { + context.handle(_createdByMeta, + createdBy.isAcceptableOrUnknown(data['created_by']!, _createdByMeta)); + } + if (data.containsKey('completed_by')) { + context.handle( + _completedByMeta, + completedBy.isAcceptableOrUnknown( + data['completed_by']!, _completedByMeta)); + } + return context; + } + + @override + Set get $primaryKey => const {}; + @override + TodoItem map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TodoItem( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + listId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}list_id'])!, + photoId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}photo_id']), + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at']), + completedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}completed_at']), + completed: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}completed']), + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description'])!, + createdBy: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}created_by']), + completedBy: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}completed_by']), + ); + } + + @override + $TodoItemsTable createAlias(String alias) { + return $TodoItemsTable(attachedDatabase, alias); + } +} + +class TodoItem extends DataClass implements Insertable { + final String id; + final String listId; + final String? photoId; + final DateTime? createdAt; + final DateTime? completedAt; + final bool? completed; + final String description; + final String? createdBy; + final String? completedBy; + const TodoItem( + {required this.id, + required this.listId, + this.photoId, + this.createdAt, + this.completedAt, + this.completed, + required this.description, + this.createdBy, + this.completedBy}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['list_id'] = Variable(listId); + if (!nullToAbsent || photoId != null) { + map['photo_id'] = Variable(photoId); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || completedAt != null) { + map['completed_at'] = Variable(completedAt); + } + if (!nullToAbsent || completed != null) { + map['completed'] = Variable(completed); + } + map['description'] = Variable(description); + if (!nullToAbsent || createdBy != null) { + map['created_by'] = Variable(createdBy); + } + if (!nullToAbsent || completedBy != null) { + map['completed_by'] = Variable(completedBy); + } + return map; + } + + TodoItemsCompanion toCompanion(bool nullToAbsent) { + return TodoItemsCompanion( + id: Value(id), + listId: Value(listId), + photoId: photoId == null && nullToAbsent + ? const Value.absent() + : Value(photoId), + createdAt: createdAt == null && nullToAbsent + ? const Value.absent() + : Value(createdAt), + completedAt: completedAt == null && nullToAbsent + ? const Value.absent() + : Value(completedAt), + completed: completed == null && nullToAbsent + ? const Value.absent() + : Value(completed), + description: Value(description), + createdBy: createdBy == null && nullToAbsent + ? const Value.absent() + : Value(createdBy), + completedBy: completedBy == null && nullToAbsent + ? const Value.absent() + : Value(completedBy), + ); + } + + factory TodoItem.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TodoItem( + id: serializer.fromJson(json['id']), + listId: serializer.fromJson(json['listId']), + photoId: serializer.fromJson(json['photoId']), + createdAt: serializer.fromJson(json['createdAt']), + completedAt: serializer.fromJson(json['completedAt']), + completed: serializer.fromJson(json['completed']), + description: serializer.fromJson(json['description']), + createdBy: serializer.fromJson(json['createdBy']), + completedBy: serializer.fromJson(json['completedBy']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'listId': serializer.toJson(listId), + 'photoId': serializer.toJson(photoId), + 'createdAt': serializer.toJson(createdAt), + 'completedAt': serializer.toJson(completedAt), + 'completed': serializer.toJson(completed), + 'description': serializer.toJson(description), + 'createdBy': serializer.toJson(createdBy), + 'completedBy': serializer.toJson(completedBy), + }; + } + + TodoItem copyWith( + {String? id, + String? listId, + Value photoId = const Value.absent(), + Value createdAt = const Value.absent(), + Value completedAt = const Value.absent(), + Value completed = const Value.absent(), + String? description, + Value createdBy = const Value.absent(), + Value completedBy = const Value.absent()}) => + TodoItem( + id: id ?? this.id, + listId: listId ?? this.listId, + photoId: photoId.present ? photoId.value : this.photoId, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + completedAt: completedAt.present ? completedAt.value : this.completedAt, + completed: completed.present ? completed.value : this.completed, + description: description ?? this.description, + createdBy: createdBy.present ? createdBy.value : this.createdBy, + completedBy: completedBy.present ? completedBy.value : this.completedBy, + ); + @override + String toString() { + return (StringBuffer('TodoItem(') + ..write('id: $id, ') + ..write('listId: $listId, ') + ..write('photoId: $photoId, ') + ..write('createdAt: $createdAt, ') + ..write('completedAt: $completedAt, ') + ..write('completed: $completed, ') + ..write('description: $description, ') + ..write('createdBy: $createdBy, ') + ..write('completedBy: $completedBy') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, listId, photoId, createdAt, completedAt, + completed, description, createdBy, completedBy); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TodoItem && + other.id == this.id && + other.listId == this.listId && + other.photoId == this.photoId && + other.createdAt == this.createdAt && + other.completedAt == this.completedAt && + other.completed == this.completed && + other.description == this.description && + other.createdBy == this.createdBy && + other.completedBy == this.completedBy); +} + +class TodoItemsCompanion extends UpdateCompanion { + final Value id; + final Value listId; + final Value photoId; + final Value createdAt; + final Value completedAt; + final Value completed; + final Value description; + final Value createdBy; + final Value completedBy; + final Value rowid; + const TodoItemsCompanion({ + this.id = const Value.absent(), + this.listId = const Value.absent(), + this.photoId = const Value.absent(), + this.createdAt = const Value.absent(), + this.completedAt = const Value.absent(), + this.completed = const Value.absent(), + this.description = const Value.absent(), + this.createdBy = const Value.absent(), + this.completedBy = const Value.absent(), + this.rowid = const Value.absent(), + }); + TodoItemsCompanion.insert({ + this.id = const Value.absent(), + required String listId, + this.photoId = const Value.absent(), + this.createdAt = const Value.absent(), + this.completedAt = const Value.absent(), + this.completed = const Value.absent(), + required String description, + this.createdBy = const Value.absent(), + this.completedBy = const Value.absent(), + this.rowid = const Value.absent(), + }) : listId = Value(listId), + description = Value(description); + static Insertable custom({ + Expression? id, + Expression? listId, + Expression? photoId, + Expression? createdAt, + Expression? completedAt, + Expression? completed, + Expression? description, + Expression? createdBy, + Expression? completedBy, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (listId != null) 'list_id': listId, + if (photoId != null) 'photo_id': photoId, + if (createdAt != null) 'created_at': createdAt, + if (completedAt != null) 'completed_at': completedAt, + if (completed != null) 'completed': completed, + if (description != null) 'description': description, + if (createdBy != null) 'created_by': createdBy, + if (completedBy != null) 'completed_by': completedBy, + if (rowid != null) 'rowid': rowid, + }); + } + + TodoItemsCompanion copyWith( + {Value? id, + Value? listId, + Value? photoId, + Value? createdAt, + Value? completedAt, + Value? completed, + Value? description, + Value? createdBy, + Value? completedBy, + Value? rowid}) { + return TodoItemsCompanion( + id: id ?? this.id, + listId: listId ?? this.listId, + photoId: photoId ?? this.photoId, + createdAt: createdAt ?? this.createdAt, + completedAt: completedAt ?? this.completedAt, + completed: completed ?? this.completed, + description: description ?? this.description, + createdBy: createdBy ?? this.createdBy, + completedBy: completedBy ?? this.completedBy, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (listId.present) { + map['list_id'] = Variable(listId.value); + } + if (photoId.present) { + map['photo_id'] = Variable(photoId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (completedAt.present) { + map['completed_at'] = Variable(completedAt.value); + } + if (completed.present) { + map['completed'] = Variable(completed.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdBy.present) { + map['created_by'] = Variable(createdBy.value); + } + if (completedBy.present) { + map['completed_by'] = Variable(completedBy.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TodoItemsCompanion(') + ..write('id: $id, ') + ..write('listId: $listId, ') + ..write('photoId: $photoId, ') + ..write('createdAt: $createdAt, ') + ..write('completedAt: $completedAt, ') + ..write('completed: $completed, ') + ..write('description: $description, ') + ..write('createdBy: $createdBy, ') + ..write('completedBy: $completedBy, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + late final $ListItemsTable listItems = $ListItemsTable(this); + late final $TodoItemsTable todoItems = $TodoItemsTable(this); + Selectable listsWithStats() { + return customSelect( + 'SELECT"self"."id" AS "nested_0.id", "self"."created_at" AS "nested_0.created_at", "self"."name" AS "nested_0.name", "self"."owner_id" AS "nested_0.owner_id", (SELECT count() FROM todos WHERE list_id = self.id AND completed = TRUE) AS completed_count, (SELECT count() FROM todos WHERE list_id = self.id AND completed = FALSE) AS pending_count FROM lists AS self ORDER BY created_at', + variables: [], + readsFrom: { + todoItems, + listItems, + }).asyncMap((QueryRow row) async => ListItemWithStats( + await listItems.mapFromRow(row, tablePrefix: 'nested_0'), + row.read('completed_count'), + row.read('pending_count'), + )); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [listItems, todoItems]; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/demos/supabase-todolist/lib/drift_sqlite_async.dart b/demos/supabase-todolist/lib/drift_sqlite_async.dart new file mode 100644 index 00000000..b4dd8c2b --- /dev/null +++ b/demos/supabase-todolist/lib/drift_sqlite_async.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:drift/backends.dart'; +import 'package:drift/drift.dart'; +import 'package:sqlite_async/sqlite_async.dart' as s; + +class _SqliteAsyncDelegate extends DatabaseDelegate { + final s.SqliteConnection db; + + _SqliteAsyncDelegate(this.db); + + @override + late final DbVersionDelegate versionDelegate = + _SqliteAsyncVersionDelegate(db); + + @override + late final TransactionDelegate transactionDelegate = + _SqliteAsyncTransactionDelegate(db); + + @override + bool get isOpen => !db.closed; + + @override + Future open(QueryExecutorUser user) async { + // Workaround - this ensures the db is open + await db.get('SELECT 1'); + } + + @override + Future close() { + return db.close(); + } + + @override + Future runBatched(BatchedStatements statements) async { + return db.writeLock((tx) async { + // sqlite_async's batch functionality doesn't have enough flexibility to support + // this with prepared statements yet. + for (final arg in statements.arguments) { + await tx.execute( + statements.statements[arg.statementIndex], arg.arguments); + } + }); + } + + @override + Future runCustom(String statement, List args) { + return db.execute(statement, args); + } + + @override + Future runInsert(String statement, List args) async { + return db.writeLock((tx) async { + await tx.execute(statement, args); + final row = await tx.get('SELECT last_insert_rowid() as row_id'); + return row['row_id']; + }); + } + + @override + Future runSelect(String statement, List args) async { + // Could be "INSERT INTO ... RETURNING *", so we need to use execute() instead of getAll() + final result = await db.execute(statement, args); + return QueryResult(result.columnNames, result.rows); + } + + @override + Future runUpdate(String statement, List args) { + return db.writeLock((tx) async { + await tx.execute(statement, args); + final row = await tx.get('SELECT changes() as changes'); + return row['changes']; + }); + } +} + +class _SqliteAsyncQueryDelegate extends QueryDelegate { + final s.SqliteWriteContext ctx; + + _SqliteAsyncQueryDelegate(this.ctx); + + @override + Future runBatched(BatchedStatements statements) async { + // sqlite_async's batch functionality doesn't have enough flexibility to support + // this with prepared statements yet. + for (final arg in statements.arguments) { + await ctx.execute( + statements.statements[arg.statementIndex], arg.arguments); + } + } + + @override + Future runCustom(String statement, List args) { + return ctx.execute(statement, args); + } + + @override + Future runInsert(String statement, List args) async { + await ctx.execute(statement, args); + final row = await ctx.get('SELECT last_insert_rowid() as row_id'); + return row['row_id']; + } + + @override + Future runSelect(String statement, List args) async { + final result = await ctx.execute(statement, args); + return QueryResult(result.columnNames, result.rows); + } + + @override + Future runUpdate(String statement, List args) async { + await ctx.execute(statement, args); + final row = await ctx.get('SELECT changes() as changes'); + return row['changes']; + } +} + +class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { + final s.SqliteConnection _db; + + _SqliteAsyncVersionDelegate(this._db); + + @override + Future get schemaVersion async { + final result = await _db.get('PRAGMA user_version;'); + return result['user_version']; + } + + @override + Future setSchemaVersion(int version) async { + await _db.execute('PRAGMA user_version = $version;'); + } +} + +/// A query executor that uses sqflite internally. +class SqliteAsyncQueryExecutor extends DelegatedDatabase { + /// A query executor that will store the database in the file declared by + /// [path]. If [logStatements] is true, statements sent to the database will + /// be [print]ed, which can be handy for debugging. The [singleInstance] + /// parameter sets the corresponding parameter on [s.openDatabase]. + /// The [creator] will be called when the database file doesn't exist. It can + /// be used to, for instance, populate default data from an asset. Note that + /// migrations might behave differently when populating the database this way. + /// For instance, a database created by an [creator] will not receive the + /// [MigrationStrategy.onCreate] callback because it hasn't been created by + /// drift. + SqliteAsyncQueryExecutor(s.SqliteConnection db) + : super( + _SqliteAsyncDelegate(db), + ); + + /// The underlying SqliteDatabase used by drift to send queries. + s.SqliteConnection? get db { + final sqfliteDelegate = delegate as _SqliteAsyncDelegate; + return sqfliteDelegate.isOpen ? sqfliteDelegate.db : null; + } + + @override + // We're not really required to be sequential since sqflite has an internal + // lock to bring statements into a sequential order. + // Setting isSequential here helps with cancellations in stream queries + // though. + bool get isSequential => true; +} + +class _SqliteAsyncTransactionDelegate extends SupportedTransactionDelegate { + final s.SqliteConnection _db; + + _SqliteAsyncTransactionDelegate(this._db); + + @override + FutureOr startTransaction(Future Function(QueryDelegate p1) run) { + return _db.writeTransaction((tx) async { + await run(_SqliteAsyncQueryDelegate(tx)); + }); + } +} diff --git a/demos/supabase-todolist/lib/models/todo_item.dart b/demos/supabase-todolist/lib/models/todo_item.dart deleted file mode 100644 index 5e15f3f8..00000000 --- a/demos/supabase-todolist/lib/models/todo_item.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:powersync_flutter_demo/models/schema.dart'; - -import '../powersync.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; - -/// TodoItem represents a result row of a query on "todos". -/// -/// This class is immutable - methods on this class do not modify the instance -/// directly. Instead, watch or re-query the data to get the updated item. -class TodoItem { - final String id; - final String description; - final String? photoId; - final bool completed; - - TodoItem( - {required this.id, - required this.description, - required this.completed, - required this.photoId}); - - factory TodoItem.fromRow(sqlite.Row row) { - return TodoItem( - id: row['id'], - description: row['description'], - photoId: row['photo_id'], - completed: row['completed'] == 1); - } - - Future toggle() async { - if (completed) { - await db.execute( - 'UPDATE $todosTable SET completed = FALSE, completed_by = NULL, completed_at = NULL WHERE id = ?', - [id]); - } else { - await db.execute( - 'UPDATE $todosTable SET completed = TRUE, completed_by = ?, completed_at = datetime() WHERE id = ?', - [getUserId(), id]); - } - } - - Future delete() async { - await db.execute('DELETE FROM $todosTable WHERE id = ?', [id]); - } - - static Future addPhoto(String photoId, String id) async { - await db.execute( - 'UPDATE $todosTable SET photo_id = ? WHERE id = ?', [photoId, id]); - } -} diff --git a/demos/supabase-todolist/lib/models/todo_list.dart b/demos/supabase-todolist/lib/models/todo_list.dart deleted file mode 100644 index 489d1631..00000000 --- a/demos/supabase-todolist/lib/models/todo_list.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:powersync/sqlite3.dart' as sqlite; - -import './todo_item.dart'; -import '../powersync.dart'; - -/// TodoList represents a result row of a query on "lists". -/// -/// This class is immutable - methods on this class do not modify the instance -/// directly. Instead, watch or re-query the data to get the updated list. -class TodoList { - /// List id (UUID). - final String id; - - /// Descriptive name. - final String name; - - /// Number of completed todos in this list. - final int? completedCount; - - /// Number of pending todos in this list. - final int? pendingCount; - - TodoList( - {required this.id, - required this.name, - this.completedCount, - this.pendingCount}); - - factory TodoList.fromRow(sqlite.Row row) { - return TodoList( - id: row['id'], - name: row['name'], - completedCount: row['completed_count'], - pendingCount: row['pending_count']); - } - - /// Watch all lists. - static Stream> watchLists() { - // This query is automatically re-run when data in "lists" or "todos" is modified. - return db - .watch('SELECT * FROM lists ORDER BY created_at, id') - .map((results) { - return results.map(TodoList.fromRow).toList(growable: false); - }); - } - - /// Watch all lists, with [completedCount] and [pendingCount] populated. - static Stream> watchListsWithStats() { - // This query is automatically re-run when data in "lists" or "todos" is modified. - return db.watch(''' - SELECT - *, - (SELECT count() FROM todos WHERE list_id = lists.id AND completed = TRUE) as completed_count, - (SELECT count() FROM todos WHERE list_id = lists.id AND completed = FALSE) as pending_count - FROM lists - ORDER BY created_at - ''').map((results) { - return results.map(TodoList.fromRow).toList(growable: false); - }); - } - - /// Create a new list - static Future create(String name) async { - final results = await db.execute(''' - INSERT INTO - lists(id, created_at, name, owner_id) - VALUES(uuid(), datetime(), ?, ?) - RETURNING * - ''', [name, getUserId()]); - return TodoList.fromRow(results.first); - } - - /// Watch items within this list. - Stream> watchItems() { - return db.watch( - 'SELECT * FROM todos WHERE list_id = ? ORDER BY created_at DESC, id', - parameters: [id]).map((event) { - return event.map(TodoItem.fromRow).toList(growable: false); - }); - } - - /// Delete this list. - Future delete() async { - await db.execute('DELETE FROM lists WHERE id = ?', [id]); - } - - /// Find list item. - static Future find(id) async { - final results = await db.get('SELECT * FROM lists WHERE id = ?', [id]); - return TodoList.fromRow(results); - } - - /// Add a new todo item to this list. - Future add(String description) async { - final results = await db.execute(''' - INSERT INTO - todos(id, created_at, completed, list_id, description, created_by) - VALUES(uuid(), datetime(), FALSE, ?, ?, ?) - RETURNING * - ''', [id, description, getUserId()]); - return TodoItem.fromRow(results.first); - } -} diff --git a/demos/supabase-todolist/lib/powersync.dart b/demos/supabase-todolist/lib/powersync.dart index aaf952b4..85838e01 100644 --- a/demos/supabase-todolist/lib/powersync.dart +++ b/demos/supabase-todolist/lib/powersync.dart @@ -3,6 +3,7 @@ import 'package:logging/logging.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:powersync/powersync.dart'; +import 'package:powersync_flutter_demo/database.dart'; import 'package:powersync_flutter_demo/migrations/fts_setup.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -134,6 +135,7 @@ class SupabaseConnector extends PowerSyncBackendConnector { /// Global reference to the database late final PowerSyncDatabase db; +late final AppDatabase appDb; bool isLoggedIn() { return Supabase.instance.client.auth.currentSession?.accessToken != null; @@ -154,6 +156,7 @@ Future openDatabase() async { db = PowerSyncDatabase( schema: schema, path: await getDatabasePath(), logger: attachedLogger); await db.initialize(); + appDb = AppDatabase(db); await loadSupabase(); diff --git a/demos/supabase-todolist/lib/queries.drift b/demos/supabase-todolist/lib/queries.drift new file mode 100644 index 00000000..cd0d24ce --- /dev/null +++ b/demos/supabase-todolist/lib/queries.drift @@ -0,0 +1,9 @@ +import 'database.dart'; + +listsWithStats WITH ListItemWithStats: + SELECT + self.**, + (SELECT count() FROM todos WHERE list_id = self.id AND completed = TRUE) as completed_count, + (SELECT count() FROM todos WHERE list_id = self.id AND completed = FALSE) as pending_count + FROM lists as self + ORDER BY created_at; diff --git a/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart b/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart index a4ef1dce..80aafa0d 100644 --- a/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart +++ b/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:powersync_flutter_demo/database.dart'; import 'package:powersync_flutter_demo/fts_helpers.dart' as fts_helpers; -import 'package:powersync_flutter_demo/models/todo_list.dart'; - -import './todo_list_page.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; +import 'package:powersync_flutter_demo/widgets/todo_list_page.dart'; final log = Logger('powersync-supabase'); @@ -69,8 +69,8 @@ class FtsSearchDelegate extends SearchDelegate { return ListTile( title: Text(snapshot.data?[index]['name'] ?? ''), onTap: () async { - TodoList list = - await TodoList.find(snapshot.data![index]['id']); + ListItem list = + await appDb.findList(snapshot.data![index]['id']); navigator.push(MaterialPageRoute( builder: (context) => TodoListPage(list: list), )); diff --git a/demos/supabase-todolist/lib/widgets/list_item.dart b/demos/supabase-todolist/lib/widgets/list_item.dart index 981b382a..6c4650b4 100644 --- a/demos/supabase-todolist/lib/widgets/list_item.dart +++ b/demos/supabase-todolist/lib/widgets/list_item.dart @@ -1,18 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:powersync_flutter_demo/database.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; import './todo_list_page.dart'; -import '../models/todo_list.dart'; class ListItemWidget extends StatelessWidget { ListItemWidget({ required this.list, }) : super(key: ObjectKey(list)); - final TodoList list; + final ListItemWithStats list; Future delete() async { // Server will take care of deleting related todos - await list.delete(); + await appDb.deleteList(list.self); } @override @@ -20,8 +21,8 @@ class ListItemWidget extends StatelessWidget { viewList() { var navigator = Navigator.of(context); - navigator.push( - MaterialPageRoute(builder: (context) => TodoListPage(list: list))); + navigator.push(MaterialPageRoute( + builder: (context) => TodoListPage(list: list.self))); } final subtext = @@ -34,7 +35,7 @@ class ListItemWidget extends StatelessWidget { ListTile( onTap: viewList, leading: const Icon(Icons.list), - title: Text(list.name), + title: Text(list.self.name), subtitle: Text(subtext)), Row( mainAxisAlignment: MainAxisAlignment.end, diff --git a/demos/supabase-todolist/lib/widgets/list_item_dialog.dart b/demos/supabase-todolist/lib/widgets/list_item_dialog.dart index 3fb8c133..3c5e12f8 100644 --- a/demos/supabase-todolist/lib/widgets/list_item_dialog.dart +++ b/demos/supabase-todolist/lib/widgets/list_item_dialog.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; - -import '../models/todo_list.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; class ListItemDialog extends StatefulWidget { const ListItemDialog({super.key}); @@ -23,7 +22,7 @@ class _ListItemDialogState extends State { } Future add() async { - await TodoList.create(_textFieldController.text); + await appDb.createList(_textFieldController.text); } @override diff --git a/demos/supabase-todolist/lib/widgets/lists_page.dart b/demos/supabase-todolist/lib/widgets/lists_page.dart index 933fabc1..ffbbd647 100644 --- a/demos/supabase-todolist/lib/widgets/lists_page.dart +++ b/demos/supabase-todolist/lib/widgets/lists_page.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:powersync_flutter_demo/database.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; +import '../main.dart'; import './list_item.dart'; import './list_item_dialog.dart'; -import '../main.dart'; -import '../models/todo_list.dart'; void _showAddDialog(BuildContext context) async { return showDialog( @@ -51,7 +52,7 @@ class ListsWidget extends StatefulWidget { } class _ListsWidgetState extends State { - List _data = []; + List _data = []; StreamSubscription? _subscription; _ListsWidgetState(); @@ -59,7 +60,7 @@ class _ListsWidgetState extends State { @override void initState() { super.initState(); - final stream = TodoList.watchListsWithStats(); + final stream = appDb.watchListsWithStats(); _subscription = stream.listen((data) { if (!mounted) { return; diff --git a/demos/supabase-todolist/lib/widgets/todo_item_dialog.dart b/demos/supabase-todolist/lib/widgets/todo_item_dialog.dart index 641abd7f..b624c022 100644 --- a/demos/supabase-todolist/lib/widgets/todo_item_dialog.dart +++ b/demos/supabase-todolist/lib/widgets/todo_item_dialog.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; - -import '../models/todo_list.dart'; +import 'package:powersync_flutter_demo/database.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; class TodoItemDialog extends StatefulWidget { - final TodoList list; + final ListItem list; const TodoItemDialog({super.key, required this.list}); @@ -32,7 +32,7 @@ class _TodoItemDialogState extends State { Future add() async { Navigator.of(context).pop(); - await widget.list.add(_textFieldController.text); + await appDb.addTodo(widget.list, _textFieldController.text); } @override diff --git a/demos/supabase-todolist/lib/widgets/todo_item_widget.dart b/demos/supabase-todolist/lib/widgets/todo_item_widget.dart index efe64fcc..aad890ae 100644 --- a/demos/supabase-todolist/lib/widgets/todo_item_widget.dart +++ b/demos/supabase-todolist/lib/widgets/todo_item_widget.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:powersync_flutter_demo/app_config.dart'; import 'package:powersync_flutter_demo/attachments/photo_widget.dart'; import 'package:powersync_flutter_demo/attachments/queue.dart'; - -import '../models/todo_item.dart'; +import 'package:powersync_flutter_demo/database.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; class TodoItemWidget extends StatelessWidget { TodoItemWidget({ @@ -25,24 +25,24 @@ class TodoItemWidget extends StatelessWidget { if (todo.photoId != null) { attachmentQueue.deletePhoto(todo.photoId!); } - await todo.delete(); + await appDb.deleteTodo(todo); } @override Widget build(BuildContext context) { return ListTile( - onTap: todo.toggle, + onTap: () => appDb.toggleTodo(todo), leading: Checkbox( value: todo.completed, onChanged: (_) { - todo.toggle(); + appDb.toggleTodo(todo); }, ), title: Row( children: [ Expanded( child: Text(todo.description, - style: _getTextStyle(todo.completed))), + style: _getTextStyle(todo.completed == true))), IconButton( iconSize: 30, icon: const Icon( diff --git a/demos/supabase-todolist/lib/widgets/todo_list_page.dart b/demos/supabase-todolist/lib/widgets/todo_list_page.dart index e8cebba5..e88c3dbb 100644 --- a/demos/supabase-todolist/lib/widgets/todo_list_page.dart +++ b/demos/supabase-todolist/lib/widgets/todo_list_page.dart @@ -1,14 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:powersync_flutter_demo/models/todo_item.dart'; +import 'package:powersync_flutter_demo/database.dart'; +import 'package:powersync_flutter_demo/powersync.dart'; import './status_app_bar.dart'; import './todo_item_dialog.dart'; import './todo_item_widget.dart'; -import '../models/todo_list.dart'; -void _showAddDialog(BuildContext context, TodoList list) async { +void _showAddDialog(BuildContext context, ListItem list) async { return showDialog( context: context, barrierDismissible: false, // user must tap button! @@ -19,7 +19,7 @@ void _showAddDialog(BuildContext context, TodoList list) async { } class TodoListPage extends StatelessWidget { - final TodoList list; + final ListItem list; const TodoListPage({super.key, required this.list}); @@ -41,7 +41,7 @@ class TodoListPage extends StatelessWidget { } class TodoListWidget extends StatefulWidget { - final TodoList list; + final ListItem list; const TodoListWidget({super.key, required this.list}); @@ -60,7 +60,7 @@ class TodoListWidgetState extends State { @override void initState() { super.initState(); - final stream = widget.list.watchItems(); + final stream = appDb.watchTodoItems(widget.list); _subscription = stream.listen((data) { if (!mounted) { return; diff --git a/demos/supabase-todolist/pubspec.lock b/demos/supabase-todolist/pubspec.lock index cd570c1b..3ef67e52 100644 --- a/demos/supabase-todolist/pubspec.lock +++ b/demos/supabase-todolist/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + url: "https://pub.dev" + source: hosted + version: "64.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" app_links: dependency: transitive description: @@ -17,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.10" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" async: dependency: transitive description: @@ -33,6 +65,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + url: "https://pub.dev" + source: hosted + version: "2.4.8" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 + url: "https://pub.dev" + source: hosted + version: "8.9.0" camera: dependency: "direct main" description: @@ -53,18 +149,18 @@ packages: dependency: transitive description: name: camera_avfoundation - sha256: "608b56b0880722f703871329c4d7d4c2f379c8e2936940851df7fc041abc6f51" + sha256: "7d0763dfcbf060f56aa254a68c103210280bee9e97bbe4fdef23e257a4f70ab9" url: "https://pub.dev" source: hosted - version: "0.9.13+10" + version: "0.9.14" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: e971ebca970f7cfee396f76ef02070b5e441b4aa04942da9c108d725f57bbd32 + sha256: fceb2c36038b6392317b1d5790c6ba9e6ca9f1da3031181b8bea03882bf9387a url: "https://pub.dev" source: hosted - version: "2.7.2" + version: "2.7.3" camera_web: dependency: transitive description: @@ -81,6 +177,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" clock: dependency: transitive description: @@ -89,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + url: "https://pub.dev" + source: hosted + version: "4.10.0" collection: dependency: transitive description: @@ -121,6 +249,31 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + drift: + dependency: "direct main" + description: + path: drift + ref: test + resolved-ref: e37557b92e371748865e42468c3273b76fda89c8 + url: "https://github.com/powersync-ja/drift.git" + source: git + version: "2.15.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: c037d9431b6f8dc633652b1469e5f53aaec6e4eb405ed29dd232fa888ef10d88 + url: "https://pub.dev" + source: hosted + version: "2.15.0" fake_async: dependency: transitive description: @@ -184,6 +337,14 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" functions_client: dependency: transitive description: @@ -192,46 +353,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - gotrue: + glob: dependency: transitive description: - name: gotrue - sha256: "0af635a935d4ec78e8885f71d71d1994460b1a855faee9b5b520e9b5417ceb02" + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.3.0" - gtk: + version: "2.1.2" + gotrue: dependency: transitive description: - name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + name: gotrue + sha256: f40610bacf1074723354b0856a4f586508ffb075b799f72466f34e843133deb9 url: "https://pub.dev" source: hosted - version: "2.1.0" - hive: + version: "2.5.0" + graphs: dependency: transitive description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 url: "https://pub.dev" source: hosted - version: "2.2.3" - hive_flutter: + version: "2.3.1" + gtk: dependency: transitive description: - name: hive_flutter - sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "2.1.0" http: dependency: transitive description: name: http - sha256: d4872660c46d929f6b8a9ef4e7a7eff7e49bbf0c4ec3f385ee32df5119175139 + sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" http_parser: dependency: transitive description: @@ -244,10 +413,18 @@ packages: dependency: "direct main" description: name: image - sha256: "004a2e90ce080f8627b5a04aecb4cdfac87d2c3f3b520aa291260be5a32c033d" + sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" url: "https://pub.dev" source: hosted - version: "4.1.4" + version: "4.1.7" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" js: dependency: transitive description: @@ -256,6 +433,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" jwt_decode: dependency: transitive description: @@ -308,10 +493,18 @@ packages: dependency: transitive description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: "direct main" description: @@ -396,10 +589,18 @@ packages: dependency: transitive description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "3.7.4" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" postgrest: dependency: transitive description: @@ -414,7 +615,7 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.2.0" + version: "1.2.1" powersync_attachments_helper: dependency: "direct main" description: @@ -422,6 +623,22 @@ packages: relative: true source: path version: "0.2.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + url: "https://pub.dev" + source: hosted + version: "1.2.3" realtime_client: dependency: transitive description: @@ -430,6 +647,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" retry: dependency: transitive description: @@ -502,11 +727,35 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" source_span: dependency: transitive description: @@ -527,18 +776,18 @@ packages: dependency: transitive description: name: sqlite3 - sha256: c4a4c5a4b2a32e2d0f6837b33d7c91a67903891a5b7dbe706cf4b1f6b0c798c5 + sha256: "072128763f1547e3e9b4735ce846bfd226d68019ccda54db4cd427b12dfdedc9" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" sqlite3_flutter_libs: dependency: transitive description: name: sqlite3_flutter_libs - sha256: "3e3583b77cf888a68eae2e49ee4f025f66b86623ef0d83c297c8d903daa14871" + sha256: d6c31c8511c441d1f12f20b607343df1afe4eddf24a1cf85021677c8eea26060 url: "https://pub.dev" source: hosted - version: "0.5.18" + version: "0.5.20" sqlite_async: dependency: "direct main" description: @@ -547,6 +796,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: dc384bb1f56d1384ce078edb5ff8247976abdab79d0c83e437210c85f06ecb61 + url: "https://pub.dev" + source: hosted + version: "0.34.0" stack_trace: dependency: transitive description: @@ -559,10 +816,10 @@ packages: dependency: transitive description: name: storage_client - sha256: b49ff2e1e6738c0ef445546d6ec77040829947f0c7ef0b115acb125656127c83 + sha256: bf5589d5de61a2451edb1b8960a0e673d4bb5c42ecc4dddf7c051a93789ced34 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" stream_channel: dependency: transitive description: @@ -591,18 +848,18 @@ packages: dependency: transitive description: name: supabase - sha256: cee9fd3fe6465a81a2536e2e2e0d9ec0b48d5f195015e060f70e2bd1b619bb26 + sha256: "4bce9c49f264f4cd44b4ffc895647af2dca0c40125c169045be9f708fd2a2a40" url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.0.7" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: "118070e5d827e4dfd7feaf5a1679e0ecdca548b16a1b6ec15c2a507cf2520bb2" + sha256: "5ef71289c380b6429216e941c69971c75eaab50d67fd7b540f6c1f6ebfc00ed7" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.3" term_glyph: dependency: transitive description: @@ -619,6 +876,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" typed_data: dependency: transitive description: @@ -631,18 +896,18 @@ packages: dependency: transitive description: name: url_launcher - sha256: d25bb0ca00432a5e1ee40e69c36c85863addf7cc45e433769d61bed3fe81fd96 + sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c url: "https://pub.dev" source: hosted - version: "6.2.3" + version: "6.2.4" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "507dc655b1d9cb5ebc756032eb785f114e415f91557b73bf60b7e201dfedeb2f" + sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 url: "https://pub.dev" source: hosted - version: "6.2.2" + version: "6.3.0" url_launcher_ios: dependency: transitive description: @@ -707,6 +972,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" web: dependency: transitive description: @@ -747,6 +1020,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" yet_another_json_isolate: dependency: transitive description: @@ -756,5 +1037,5 @@ packages: source: hosted version: "2.0.0" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.16.0" + dart: ">=3.2.3 <4.0.0" + flutter: ">=3.16.6" diff --git a/demos/supabase-todolist/pubspec.yaml b/demos/supabase-todolist/pubspec.yaml index f263e09f..3894e392 100644 --- a/demos/supabase-todolist/pubspec.yaml +++ b/demos/supabase-todolist/pubspec.yaml @@ -20,8 +20,12 @@ dependencies: sqlite_async: ^0.6.0 camera: ^0.10.5+7 image: ^4.1.3 + drift: ^2.15.0 dev_dependencies: + drift_dev: ^2.15.0 + build_runner: ^2.4.8 + flutter_test: sdk: flutter From 070dc1e08df64ebe5795561db6611b75672e3524 Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Wed, 14 Feb 2024 14:53:18 +0200 Subject: [PATCH 2/7] Cleanup. --- .gitattributes | 2 +- .../lib/drift_sqlite_async.dart | 26 ++++--------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/.gitattributes b/.gitattributes index 7886fa6b..5a73a231 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -**/*.g.dart linguist-generated=true +*.g.dart linguist-generated diff --git a/demos/supabase-todolist/lib/drift_sqlite_async.dart b/demos/supabase-todolist/lib/drift_sqlite_async.dart index b4dd8c2b..b94d2283 100644 --- a/demos/supabase-todolist/lib/drift_sqlite_async.dart +++ b/demos/supabase-todolist/lib/drift_sqlite_async.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:drift/backends.dart'; -import 'package:drift/drift.dart'; import 'package:sqlite_async/sqlite_async.dart' as s; class _SqliteAsyncDelegate extends DatabaseDelegate { @@ -132,35 +131,20 @@ class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { } } -/// A query executor that uses sqflite internally. +/// A query executor that uses sqlite_async internally. class SqliteAsyncQueryExecutor extends DelegatedDatabase { - /// A query executor that will store the database in the file declared by - /// [path]. If [logStatements] is true, statements sent to the database will - /// be [print]ed, which can be handy for debugging. The [singleInstance] - /// parameter sets the corresponding parameter on [s.openDatabase]. - /// The [creator] will be called when the database file doesn't exist. It can - /// be used to, for instance, populate default data from an asset. Note that - /// migrations might behave differently when populating the database this way. - /// For instance, a database created by an [creator] will not receive the - /// [MigrationStrategy.onCreate] callback because it hasn't been created by - /// drift. SqliteAsyncQueryExecutor(s.SqliteConnection db) : super( _SqliteAsyncDelegate(db), ); - /// The underlying SqliteDatabase used by drift to send queries. - s.SqliteConnection? get db { - final sqfliteDelegate = delegate as _SqliteAsyncDelegate; - return sqfliteDelegate.isOpen ? sqfliteDelegate.db : null; + /// The underlying SqliteConnection used by drift to send queries. + s.SqliteConnection get db { + return (delegate as _SqliteAsyncDelegate).db; } @override - // We're not really required to be sequential since sqflite has an internal - // lock to bring statements into a sequential order. - // Setting isSequential here helps with cancellations in stream queries - // though. - bool get isSequential => true; + bool get isSequential => false; } class _SqliteAsyncTransactionDelegate extends SupportedTransactionDelegate { From 5c3b243334c0d45f9f83090e1492474308c45a0e Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 15 Feb 2024 10:13:38 +0200 Subject: [PATCH 3/7] Hack to support some concurrency. --- .../lib/drift_sqlite_async.dart | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/demos/supabase-todolist/lib/drift_sqlite_async.dart b/demos/supabase-todolist/lib/drift_sqlite_async.dart index b94d2283..595ea622 100644 --- a/demos/supabase-todolist/lib/drift_sqlite_async.dart +++ b/demos/supabase-todolist/lib/drift_sqlite_async.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:drift/backends.dart'; +import 'package:sqlite_async/sqlite3.dart'; import 'package:sqlite_async/sqlite_async.dart' as s; class _SqliteAsyncDelegate extends DatabaseDelegate { @@ -19,6 +20,13 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { @override bool get isOpen => !db.closed; + // Ends with " RETURNING *", or starts with insert/update/delete. + // Drift-generated queries will always have the RETURNING *. + // The INSERT/UPDATE/DELETE check is for custom queries, and is not exhaustive. + final _returningCheck = RegExp( + r'( RETURNING \*;?$)|(^(INSERT|UPDATE|DELETE))', + caseSensitive: false); + @override Future open(QueryExecutorUser user) async { // Workaround - this ensures the db is open @@ -58,8 +66,18 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { @override Future runSelect(String statement, List args) async { - // Could be "INSERT INTO ... RETURNING *", so we need to use execute() instead of getAll() - final result = await db.execute(statement, args); + ResultSet result; + if (_returningCheck.hasMatch(statement)) { + // Could be "INSERT INTO ... RETURNING *" (or update or delete), + // so we need to use execute() instead of getAll(). + // This takes write lock, so we want to avoid it for plain select statements. + // This is not an exhaustive check, but should cover all Drift-generated queries using + // `runSelect()`. + result = await db.execute(statement, args); + } else { + // Plain SELECT statement - use getAll() to avoid using a write lock. + result = await db.getAll(statement, args); + } return QueryResult(result.columnNames, result.rows); } From 92cb2029329921fbae0ede81704770e851a3f9dc Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 15 Feb 2024 15:05:31 +0200 Subject: [PATCH 4/7] Support nested transactions. --- .../lib/drift_sqlite_async.dart | 202 +++++++++++++++--- 1 file changed, 169 insertions(+), 33 deletions(-) diff --git a/demos/supabase-todolist/lib/drift_sqlite_async.dart b/demos/supabase-todolist/lib/drift_sqlite_async.dart index 595ea622..568b0a7e 100644 --- a/demos/supabase-todolist/lib/drift_sqlite_async.dart +++ b/demos/supabase-todolist/lib/drift_sqlite_async.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:drift/backends.dart'; +import 'package:powersync/sqlite_async.dart'; import 'package:sqlite_async/sqlite3.dart'; import 'package:sqlite_async/sqlite_async.dart' as s; @@ -15,7 +16,7 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { @override late final TransactionDelegate transactionDelegate = - _SqliteAsyncTransactionDelegate(db); + const NoTransactionDelegate(); @override bool get isOpen => !db.closed; @@ -91,11 +92,49 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { } } -class _SqliteAsyncQueryDelegate extends QueryDelegate { - final s.SqliteWriteContext ctx; +class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { + final s.SqliteConnection _db; + + _SqliteAsyncVersionDelegate(this._db); - _SqliteAsyncQueryDelegate(this.ctx); + @override + Future get schemaVersion async { + final result = await _db.get('PRAGMA user_version;'); + return result['user_version']; + } + @override + Future setSchemaVersion(int version) async { + await _db.execute('PRAGMA user_version = $version;'); + } +} + +/// A query executor that uses sqlite_async internally. +class SqliteAsyncQueryExecutor extends DelegatedDatabase { + SqliteAsyncQueryExecutor(s.SqliteConnection db) + : super( + _SqliteAsyncDelegate(db), + ); + + /// The underlying SqliteConnection used by drift to send queries. + s.SqliteConnection get db { + return (delegate as _SqliteAsyncDelegate).db; + } + + @override + bool get isSequential => false; + + @override + TransactionExecutor beginTransaction() { + return _SqliteAsyncTransactionExecutor(db); + } +} + +abstract class _QueryDelegate { + SqliteWriteContext get ctx; +} + +mixin _QueryMixin implements QueryExecutor, _QueryDelegate { @override Future runBatched(BatchedStatements statements) async { // sqlite_async's batch functionality doesn't have enough flexibility to support @@ -107,8 +146,8 @@ class _SqliteAsyncQueryDelegate extends QueryDelegate { } @override - Future runCustom(String statement, List args) { - return ctx.execute(statement, args); + Future runCustom(String statement, [List? args]) { + return ctx.execute(statement, args ?? const []); } @override @@ -119,9 +158,10 @@ class _SqliteAsyncQueryDelegate extends QueryDelegate { } @override - Future runSelect(String statement, List args) async { + Future>> runSelect( + String statement, List args) async { final result = await ctx.execute(statement, args); - return QueryResult(result.columnNames, result.rows); + return QueryResult(result.columnNames, result.rows).asMap.toList(); } @override @@ -130,50 +170,146 @@ class _SqliteAsyncQueryDelegate extends QueryDelegate { final row = await ctx.get('SELECT changes() as changes'); return row['changes']; } + + @override + Future runDelete(String statement, List args) { + return runUpdate(statement, args); + } } -class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { +/// Based on _WrappingTransactionExecutor, which is private. +/// Extended to support nested transactions. +class _SqliteAsyncTransactionExecutor extends TransactionExecutor + with _QueryMixin { final s.SqliteConnection _db; + static final _artificialRollback = + Exception('artificial exception to rollback the transaction'); + final Zone _createdIn = Zone.current; + final Completer _completerForCallback = Completer(); + Completer? _opened, _finished; - _SqliteAsyncVersionDelegate(this._db); + /// Whether this executor has explicitly been closed. + bool _closed = false; @override - Future get schemaVersion async { - final result = await _db.get('PRAGMA user_version;'); - return result['user_version']; + late SqliteWriteContext ctx; + + _SqliteAsyncTransactionExecutor(this._db); + + void _checkCanOpen() { + if (_closed) { + throw StateError( + "A tranaction was used after being closed. Please check that you're " + 'awaiting all database operations inside a `transaction` block.'); + } } @override - Future setSchemaVersion(int version) async { - await _db.execute('PRAGMA user_version = $version;'); + Future ensureOpen(QueryExecutorUser user) { + _checkCanOpen(); + var opened = _opened; + + if (opened == null) { + _opened = opened = Completer(); + _createdIn.run(() async { + final result = _db.writeTransaction((innerCtx) async { + opened!.complete(); + ctx = innerCtx; + await _completerForCallback.future; + }); + + _finished = Completer() + ..complete( + // ignore: void_checks + result + // Ignore the exception caused by [rollback] which may be + // rethrown by startTransaction + .onError((error, stackTrace) => null, + test: (e) => e == _artificialRollback) + // Consider this transaction closed after the call completes + // This may happen without send/rollback being called in + // case there's an exception when opening the transaction. + .whenComplete(() => _closed = true), + ); + }); + } + + // The opened completer is never completed if `startTransaction` throws + // before our callback is invoked (probably becaue `BEGIN` threw an + // exception). In that case, _finished will complete with that error though. + return Future.any([opened.future, if (_finished != null) _finished!.future]) + .then((value) => true); } -} -/// A query executor that uses sqlite_async internally. -class SqliteAsyncQueryExecutor extends DelegatedDatabase { - SqliteAsyncQueryExecutor(s.SqliteConnection db) - : super( - _SqliteAsyncDelegate(db), - ); + @override + Future send() async { + // don't do anything if the transaction completes before it was opened + if (_opened == null || _closed) return; - /// The underlying SqliteConnection used by drift to send queries. - s.SqliteConnection get db { - return (delegate as _SqliteAsyncDelegate).db; + _completerForCallback.complete(); + _closed = true; + await _finished?.future; } @override - bool get isSequential => false; + Future rollback() async { + // Note: This may be called after send() if send() throws (that is, the + // transaction can't be completed). But if completing fails, we assume that + // the transaction will implicitly be rolled back the underlying connection + // (it's not like we could explicitly roll it back, we only have one + // callback to implement). + if (_opened == null || _closed) return; + + _completerForCallback.completeError(_artificialRollback); + _closed = true; + await _finished?.future; + } + + @override + TransactionExecutor beginTransaction() { + return _SqliteAsyncNestedTransactionExecutor(ctx, 1); + } + + @override + SqlDialect get dialect => SqlDialect.sqlite; + + @override + bool get supportsNestedTransactions => true; } -class _SqliteAsyncTransactionDelegate extends SupportedTransactionDelegate { - final s.SqliteConnection _db; +class _SqliteAsyncNestedTransactionExecutor extends TransactionExecutor + with _QueryMixin { + @override + final SqliteWriteContext ctx; + + int depth; - _SqliteAsyncTransactionDelegate(this._db); + _SqliteAsyncNestedTransactionExecutor(this.ctx, this.depth); @override - FutureOr startTransaction(Future Function(QueryDelegate p1) run) { - return _db.writeTransaction((tx) async { - await run(_SqliteAsyncQueryDelegate(tx)); - }); + Future ensureOpen(QueryExecutorUser user) async { + await ctx.execute('SAVEPOINT tx$depth'); + return true; } + + @override + Future send() async { + await ctx.execute('RELEASE SAVEPOINT tx$depth'); + } + + @override + Future rollback() async { + await ctx.execute('ROLLBACK TO SAVEPOINT tx$depth'); + } + + @override + TransactionExecutor beginTransaction() { + return _SqliteAsyncNestedTransactionExecutor(ctx, depth + 1); + } + + @override + SqlDialect get dialect => SqlDialect.sqlite; + + @override + bool get supportsNestedTransactions => true; } From 36da0bb1fd89a8a2303f3f162f573b22914f7620 Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 15 Feb 2024 15:19:17 +0200 Subject: [PATCH 5/7] Refactor update notifications. --- demos/supabase-todolist/lib/database.dart | 9 ++----- .../lib/drift_sqlite_async.dart | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/demos/supabase-todolist/lib/database.dart b/demos/supabase-todolist/lib/database.dart index 186ffe93..1c64a9ec 100644 --- a/demos/supabase-todolist/lib/database.dart +++ b/demos/supabase-todolist/lib/database.dart @@ -47,13 +47,8 @@ class ListItemWithStats { @DriftDatabase(tables: [TodoItems, ListItems], include: {'queries.drift'}) class AppDatabase extends _$AppDatabase { AppDatabase(PowerSyncDatabase db) : super(SqliteAsyncQueryExecutor(db)) { - db.updates.listen((event) { - var setUpdates = {}; - for (var tableName in event.tables) { - setUpdates.add(TableUpdate(tableName)); - } - super.streamQueries.handleTableUpdates(setUpdates); - }); + // Important for watch() to work + SqliteAsyncQueryExecutor.forwardUpdates(db, super.connection); } @override diff --git a/demos/supabase-todolist/lib/drift_sqlite_async.dart b/demos/supabase-todolist/lib/drift_sqlite_async.dart index 568b0a7e..9c92f0cb 100644 --- a/demos/supabase-todolist/lib/drift_sqlite_async.dart +++ b/demos/supabase-todolist/lib/drift_sqlite_async.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:drift/backends.dart'; -import 'package:powersync/sqlite_async.dart'; +import 'package:drift/drift.dart'; import 'package:sqlite_async/sqlite3.dart'; -import 'package:sqlite_async/sqlite_async.dart' as s; +import 'package:sqlite_async/sqlite_async.dart'; class _SqliteAsyncDelegate extends DatabaseDelegate { - final s.SqliteConnection db; + final SqliteConnection db; _SqliteAsyncDelegate(this.db); @@ -93,7 +93,7 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { } class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { - final s.SqliteConnection _db; + final SqliteConnection _db; _SqliteAsyncVersionDelegate(this._db); @@ -111,13 +111,24 @@ class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { /// A query executor that uses sqlite_async internally. class SqliteAsyncQueryExecutor extends DelegatedDatabase { - SqliteAsyncQueryExecutor(s.SqliteConnection db) + static StreamSubscription forwardUpdates( + SqliteQueries db, DatabaseConnection connection) { + return db.updates!.listen((event) { + var setUpdates = {}; + for (var tableName in event.tables) { + setUpdates.add(TableUpdate(tableName)); + } + connection.streamQueries.handleTableUpdates(setUpdates); + }); + } + + SqliteAsyncQueryExecutor(SqliteConnection db) : super( _SqliteAsyncDelegate(db), ); /// The underlying SqliteConnection used by drift to send queries. - s.SqliteConnection get db { + SqliteConnection get db { return (delegate as _SqliteAsyncDelegate).db; } @@ -181,7 +192,7 @@ mixin _QueryMixin implements QueryExecutor, _QueryDelegate { /// Extended to support nested transactions. class _SqliteAsyncTransactionExecutor extends TransactionExecutor with _QueryMixin { - final s.SqliteConnection _db; + final SqliteConnection _db; static final _artificialRollback = Exception('artificial exception to rollback the transaction'); final Zone _createdIn = Zone.current; From 3eb1a38f62fcae0b6a102d175f69a499ba1544b8 Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Thu, 15 Feb 2024 15:31:28 +0200 Subject: [PATCH 6/7] Expose SqliteAsyncConnection instead. --- demos/supabase-todolist/lib/database.dart | 5 +--- .../lib/drift_sqlite_async.dart | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/demos/supabase-todolist/lib/database.dart b/demos/supabase-todolist/lib/database.dart index 1c64a9ec..92159eca 100644 --- a/demos/supabase-todolist/lib/database.dart +++ b/demos/supabase-todolist/lib/database.dart @@ -46,10 +46,7 @@ class ListItemWithStats { @DriftDatabase(tables: [TodoItems, ListItems], include: {'queries.drift'}) class AppDatabase extends _$AppDatabase { - AppDatabase(PowerSyncDatabase db) : super(SqliteAsyncQueryExecutor(db)) { - // Important for watch() to work - SqliteAsyncQueryExecutor.forwardUpdates(db, super.connection); - } + AppDatabase(PowerSyncDatabase db) : super(SqliteAsyncConnection(db)); @override int get schemaVersion => 1; diff --git a/demos/supabase-todolist/lib/drift_sqlite_async.dart b/demos/supabase-todolist/lib/drift_sqlite_async.dart index 9c92f0cb..945ebcc7 100644 --- a/demos/supabase-todolist/lib/drift_sqlite_async.dart +++ b/demos/supabase-todolist/lib/drift_sqlite_async.dart @@ -109,7 +109,30 @@ class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { } } +class SqliteAsyncConnection extends DatabaseConnection { + late StreamSubscription _updateSubscription; + + SqliteAsyncConnection(SqliteConnection db) + : super(SqliteAsyncQueryExecutor(db)) { + _updateSubscription = (db as SqliteQueries).updates!.listen((event) { + var setUpdates = {}; + for (var tableName in event.tables) { + setUpdates.add(TableUpdate(tableName)); + } + super.streamQueries.handleTableUpdates(setUpdates); + }); + } + + @override + Future close() async { + await _updateSubscription.cancel(); + await super.close(); + } +} + /// A query executor that uses sqlite_async internally. +/// In most cases, SqliteAsyncConnection should be used instead, as it handles +/// stream queries automatically. class SqliteAsyncQueryExecutor extends DelegatedDatabase { static StreamSubscription forwardUpdates( SqliteQueries db, DatabaseConnection connection) { From 5d5cd402f672884f560e34027bce929c2a7ab9d9 Mon Sep 17 00:00:00 2001 From: Ralf Kistner Date: Tue, 20 Feb 2024 17:03:56 +0200 Subject: [PATCH 7/7] Update to use drift_sqlite_async package. --- demos/supabase-todolist/lib/database.dart | 4 +- .../lib/drift_sqlite_async.dart | 349 ------------------ demos/supabase-todolist/pubspec.lock | 25 +- demos/supabase-todolist/pubspec.yaml | 1 + 4 files changed, 19 insertions(+), 360 deletions(-) delete mode 100644 demos/supabase-todolist/lib/drift_sqlite_async.dart diff --git a/demos/supabase-todolist/lib/database.dart b/demos/supabase-todolist/lib/database.dart index 92159eca..1913e599 100644 --- a/demos/supabase-todolist/lib/database.dart +++ b/demos/supabase-todolist/lib/database.dart @@ -1,6 +1,6 @@ import 'package:drift/drift.dart'; import 'package:powersync/powersync.dart' show uuid, PowerSyncDatabase; -import 'package:powersync_flutter_demo/drift_sqlite_async.dart'; +import 'package:drift_sqlite_async/drift_sqlite_async.dart'; import 'package:powersync_flutter_demo/powersync.dart'; part 'database.g.dart'; @@ -46,7 +46,7 @@ class ListItemWithStats { @DriftDatabase(tables: [TodoItems, ListItems], include: {'queries.drift'}) class AppDatabase extends _$AppDatabase { - AppDatabase(PowerSyncDatabase db) : super(SqliteAsyncConnection(db)); + AppDatabase(PowerSyncDatabase db) : super(SqliteAsyncDriftConnection(db)); @override int get schemaVersion => 1; diff --git a/demos/supabase-todolist/lib/drift_sqlite_async.dart b/demos/supabase-todolist/lib/drift_sqlite_async.dart deleted file mode 100644 index 945ebcc7..00000000 --- a/demos/supabase-todolist/lib/drift_sqlite_async.dart +++ /dev/null @@ -1,349 +0,0 @@ -import 'dart:async'; - -import 'package:drift/backends.dart'; -import 'package:drift/drift.dart'; -import 'package:sqlite_async/sqlite3.dart'; -import 'package:sqlite_async/sqlite_async.dart'; - -class _SqliteAsyncDelegate extends DatabaseDelegate { - final SqliteConnection db; - - _SqliteAsyncDelegate(this.db); - - @override - late final DbVersionDelegate versionDelegate = - _SqliteAsyncVersionDelegate(db); - - @override - late final TransactionDelegate transactionDelegate = - const NoTransactionDelegate(); - - @override - bool get isOpen => !db.closed; - - // Ends with " RETURNING *", or starts with insert/update/delete. - // Drift-generated queries will always have the RETURNING *. - // The INSERT/UPDATE/DELETE check is for custom queries, and is not exhaustive. - final _returningCheck = RegExp( - r'( RETURNING \*;?$)|(^(INSERT|UPDATE|DELETE))', - caseSensitive: false); - - @override - Future open(QueryExecutorUser user) async { - // Workaround - this ensures the db is open - await db.get('SELECT 1'); - } - - @override - Future close() { - return db.close(); - } - - @override - Future runBatched(BatchedStatements statements) async { - return db.writeLock((tx) async { - // sqlite_async's batch functionality doesn't have enough flexibility to support - // this with prepared statements yet. - for (final arg in statements.arguments) { - await tx.execute( - statements.statements[arg.statementIndex], arg.arguments); - } - }); - } - - @override - Future runCustom(String statement, List args) { - return db.execute(statement, args); - } - - @override - Future runInsert(String statement, List args) async { - return db.writeLock((tx) async { - await tx.execute(statement, args); - final row = await tx.get('SELECT last_insert_rowid() as row_id'); - return row['row_id']; - }); - } - - @override - Future runSelect(String statement, List args) async { - ResultSet result; - if (_returningCheck.hasMatch(statement)) { - // Could be "INSERT INTO ... RETURNING *" (or update or delete), - // so we need to use execute() instead of getAll(). - // This takes write lock, so we want to avoid it for plain select statements. - // This is not an exhaustive check, but should cover all Drift-generated queries using - // `runSelect()`. - result = await db.execute(statement, args); - } else { - // Plain SELECT statement - use getAll() to avoid using a write lock. - result = await db.getAll(statement, args); - } - return QueryResult(result.columnNames, result.rows); - } - - @override - Future runUpdate(String statement, List args) { - return db.writeLock((tx) async { - await tx.execute(statement, args); - final row = await tx.get('SELECT changes() as changes'); - return row['changes']; - }); - } -} - -class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { - final SqliteConnection _db; - - _SqliteAsyncVersionDelegate(this._db); - - @override - Future get schemaVersion async { - final result = await _db.get('PRAGMA user_version;'); - return result['user_version']; - } - - @override - Future setSchemaVersion(int version) async { - await _db.execute('PRAGMA user_version = $version;'); - } -} - -class SqliteAsyncConnection extends DatabaseConnection { - late StreamSubscription _updateSubscription; - - SqliteAsyncConnection(SqliteConnection db) - : super(SqliteAsyncQueryExecutor(db)) { - _updateSubscription = (db as SqliteQueries).updates!.listen((event) { - var setUpdates = {}; - for (var tableName in event.tables) { - setUpdates.add(TableUpdate(tableName)); - } - super.streamQueries.handleTableUpdates(setUpdates); - }); - } - - @override - Future close() async { - await _updateSubscription.cancel(); - await super.close(); - } -} - -/// A query executor that uses sqlite_async internally. -/// In most cases, SqliteAsyncConnection should be used instead, as it handles -/// stream queries automatically. -class SqliteAsyncQueryExecutor extends DelegatedDatabase { - static StreamSubscription forwardUpdates( - SqliteQueries db, DatabaseConnection connection) { - return db.updates!.listen((event) { - var setUpdates = {}; - for (var tableName in event.tables) { - setUpdates.add(TableUpdate(tableName)); - } - connection.streamQueries.handleTableUpdates(setUpdates); - }); - } - - SqliteAsyncQueryExecutor(SqliteConnection db) - : super( - _SqliteAsyncDelegate(db), - ); - - /// The underlying SqliteConnection used by drift to send queries. - SqliteConnection get db { - return (delegate as _SqliteAsyncDelegate).db; - } - - @override - bool get isSequential => false; - - @override - TransactionExecutor beginTransaction() { - return _SqliteAsyncTransactionExecutor(db); - } -} - -abstract class _QueryDelegate { - SqliteWriteContext get ctx; -} - -mixin _QueryMixin implements QueryExecutor, _QueryDelegate { - @override - Future runBatched(BatchedStatements statements) async { - // sqlite_async's batch functionality doesn't have enough flexibility to support - // this with prepared statements yet. - for (final arg in statements.arguments) { - await ctx.execute( - statements.statements[arg.statementIndex], arg.arguments); - } - } - - @override - Future runCustom(String statement, [List? args]) { - return ctx.execute(statement, args ?? const []); - } - - @override - Future runInsert(String statement, List args) async { - await ctx.execute(statement, args); - final row = await ctx.get('SELECT last_insert_rowid() as row_id'); - return row['row_id']; - } - - @override - Future>> runSelect( - String statement, List args) async { - final result = await ctx.execute(statement, args); - return QueryResult(result.columnNames, result.rows).asMap.toList(); - } - - @override - Future runUpdate(String statement, List args) async { - await ctx.execute(statement, args); - final row = await ctx.get('SELECT changes() as changes'); - return row['changes']; - } - - @override - Future runDelete(String statement, List args) { - return runUpdate(statement, args); - } -} - -/// Based on _WrappingTransactionExecutor, which is private. -/// Extended to support nested transactions. -class _SqliteAsyncTransactionExecutor extends TransactionExecutor - with _QueryMixin { - final SqliteConnection _db; - static final _artificialRollback = - Exception('artificial exception to rollback the transaction'); - final Zone _createdIn = Zone.current; - final Completer _completerForCallback = Completer(); - Completer? _opened, _finished; - - /// Whether this executor has explicitly been closed. - bool _closed = false; - - @override - late SqliteWriteContext ctx; - - _SqliteAsyncTransactionExecutor(this._db); - - void _checkCanOpen() { - if (_closed) { - throw StateError( - "A tranaction was used after being closed. Please check that you're " - 'awaiting all database operations inside a `transaction` block.'); - } - } - - @override - Future ensureOpen(QueryExecutorUser user) { - _checkCanOpen(); - var opened = _opened; - - if (opened == null) { - _opened = opened = Completer(); - _createdIn.run(() async { - final result = _db.writeTransaction((innerCtx) async { - opened!.complete(); - ctx = innerCtx; - await _completerForCallback.future; - }); - - _finished = Completer() - ..complete( - // ignore: void_checks - result - // Ignore the exception caused by [rollback] which may be - // rethrown by startTransaction - .onError((error, stackTrace) => null, - test: (e) => e == _artificialRollback) - // Consider this transaction closed after the call completes - // This may happen without send/rollback being called in - // case there's an exception when opening the transaction. - .whenComplete(() => _closed = true), - ); - }); - } - - // The opened completer is never completed if `startTransaction` throws - // before our callback is invoked (probably becaue `BEGIN` threw an - // exception). In that case, _finished will complete with that error though. - return Future.any([opened.future, if (_finished != null) _finished!.future]) - .then((value) => true); - } - - @override - Future send() async { - // don't do anything if the transaction completes before it was opened - if (_opened == null || _closed) return; - - _completerForCallback.complete(); - _closed = true; - await _finished?.future; - } - - @override - Future rollback() async { - // Note: This may be called after send() if send() throws (that is, the - // transaction can't be completed). But if completing fails, we assume that - // the transaction will implicitly be rolled back the underlying connection - // (it's not like we could explicitly roll it back, we only have one - // callback to implement). - if (_opened == null || _closed) return; - - _completerForCallback.completeError(_artificialRollback); - _closed = true; - await _finished?.future; - } - - @override - TransactionExecutor beginTransaction() { - return _SqliteAsyncNestedTransactionExecutor(ctx, 1); - } - - @override - SqlDialect get dialect => SqlDialect.sqlite; - - @override - bool get supportsNestedTransactions => true; -} - -class _SqliteAsyncNestedTransactionExecutor extends TransactionExecutor - with _QueryMixin { - @override - final SqliteWriteContext ctx; - - int depth; - - _SqliteAsyncNestedTransactionExecutor(this.ctx, this.depth); - - @override - Future ensureOpen(QueryExecutorUser user) async { - await ctx.execute('SAVEPOINT tx$depth'); - return true; - } - - @override - Future send() async { - await ctx.execute('RELEASE SAVEPOINT tx$depth'); - } - - @override - Future rollback() async { - await ctx.execute('ROLLBACK TO SAVEPOINT tx$depth'); - } - - @override - TransactionExecutor beginTransaction() { - return _SqliteAsyncNestedTransactionExecutor(ctx, depth + 1); - } - - @override - SqlDialect get dialect => SqlDialect.sqlite; - - @override - bool get supportsNestedTransactions => true; -} diff --git a/demos/supabase-todolist/pubspec.lock b/demos/supabase-todolist/pubspec.lock index 9410becd..7ee1eaef 100644 --- a/demos/supabase-todolist/pubspec.lock +++ b/demos/supabase-todolist/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: camera_avfoundation - sha256: "7d0763dfcbf060f56aa254a68c103210280bee9e97bbe4fdef23e257a4f70ab9" + sha256: "608b56b0880722f703871329c4d7d4c2f379c8e2936940851df7fc041abc6f51" url: "https://pub.dev" source: hosted - version: "0.9.14" + version: "0.9.13+10" camera_platform_interface: dependency: transitive description: @@ -260,11 +260,10 @@ packages: drift: dependency: "direct main" description: - path: drift - ref: test - resolved-ref: e37557b92e371748865e42468c3273b76fda89c8 - url: "https://github.com/powersync-ja/drift.git" - source: git + name: drift + sha256: b50a8342c6ddf05be53bda1d246404cbad101b64dc73e8d6d1ac1090d119b4e2 + url: "https://pub.dev" + source: hosted version: "2.15.0" drift_dev: dependency: "direct dev" @@ -274,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.15.0" + drift_sqlite_async: + dependency: "direct main" + description: + name: drift_sqlite_async + sha256: fb0f7681e1f314326874193d2b0c998def00f97d44c29d66239623e7a32b79fd + url: "https://pub.dev" + source: hosted + version: "0.1.0-alpha.1" fake_async: dependency: transitive description: @@ -1037,5 +1044,5 @@ packages: source: hosted version: "2.0.0" sdks: - dart: ">=3.2.3 <4.0.0" - flutter: ">=3.16.6" + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0" diff --git a/demos/supabase-todolist/pubspec.yaml b/demos/supabase-todolist/pubspec.yaml index 3894e392..4ecb7785 100644 --- a/demos/supabase-todolist/pubspec.yaml +++ b/demos/supabase-todolist/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: camera: ^0.10.5+7 image: ^4.1.3 drift: ^2.15.0 + drift_sqlite_async: ^0.1.0-alpha.1 dev_dependencies: drift_dev: ^2.15.0