diff --git a/example_supabase/lib/brick/adapters/customer_adapter.g.dart b/example_supabase/lib/brick/adapters/customer_adapter.g.dart index 693d65e8..d5141cd0 100644 --- a/example_supabase/lib/brick/adapters/customer_adapter.g.dart +++ b/example_supabase/lib/brick/adapters/customer_adapter.g.dart @@ -17,7 +17,14 @@ Future _$CustomerFromSupabase(Map data, Future> _$CustomerToSupabase(Customer instance, {required SupabaseProvider provider, OfflineFirstWithSupabaseRepository? repository}) async { - return {'id': instance.id, 'first_name': instance.firstName, 'last_name': instance.lastName}; + return { + 'id': instance.id, + 'first_name': instance.firstName, + 'last_name': instance.lastName, + 'pizzas': await Future.wait>(instance.pizzas + .map((s) => PizzaAdapter().toSupabase(s, provider: provider, repository: repository)) + .toList()) + }; } Future _$CustomerFromSqlite(Map data, @@ -51,7 +58,7 @@ class CustomerAdapter extends OfflineFirstWithSupabaseAdapter { CustomerAdapter(); @override - final tableName = 'customers'; + final supabaseTableName = 'customers'; @override final defaultToNull = true; @override @@ -71,6 +78,8 @@ class CustomerAdapter extends OfflineFirstWithSupabaseAdapter { 'pizzas': const RuntimeSupabaseColumnDefinition( association: true, columnName: 'pizzas', + associationType: Pizza, + associationIsNullable: false, ) }; @override @@ -132,7 +141,7 @@ class CustomerAdapter extends OfflineFirstWithSupabaseAdapter { 'SELECT `f_Pizza_brick_id` FROM `_brick_Customer_pizzas` WHERE `l_Customer_brick_id` = ?', [instance.primaryKey]); final pizzasOldIds = pizzasOldColumns.map((a) => a['f_Pizza_brick_id']); - final pizzasNewIds = instance.pizzas?.map((s) => s.primaryKey).whereType() ?? []; + final pizzasNewIds = instance.pizzas.map((s) => s.primaryKey).whereType(); final pizzasIdsToDelete = pizzasOldIds.where((id) => !pizzasNewIds.contains(id)); await Future.wait(pizzasIdsToDelete.map((id) async { @@ -141,13 +150,12 @@ class CustomerAdapter extends OfflineFirstWithSupabaseAdapter { [instance.primaryKey, id]).catchError((e) => null); })); - await Future.wait(instance.pizzas?.map((s) async { - final id = s.primaryKey ?? await provider.upsert(s, repository: repository); - return await provider.rawInsert( - 'INSERT OR IGNORE INTO `_brick_Customer_pizzas` (`l_Customer_brick_id`, `f_Pizza_brick_id`) VALUES (?, ?)', - [instance.primaryKey, id]); - }) ?? - []); + await Future.wait(instance.pizzas.map((s) async { + final id = s.primaryKey ?? await provider.upsert(s, repository: repository); + return await provider.rawInsert( + 'INSERT OR IGNORE INTO `_brick_Customer_pizzas` (`l_Customer_brick_id`, `f_Pizza_brick_id`) VALUES (?, ?)', + [instance.primaryKey, id]); + })); } } diff --git a/example_supabase/lib/brick/adapters/pizza_adapter.g.dart b/example_supabase/lib/brick/adapters/pizza_adapter.g.dart index ec7cd931..6746a7d3 100644 --- a/example_supabase/lib/brick/adapters/pizza_adapter.g.dart +++ b/example_supabase/lib/brick/adapters/pizza_adapter.g.dart @@ -5,7 +5,8 @@ Future _$PizzaFromSupabase(Map data, {required SupabaseProvider provider, OfflineFirstWithSupabaseRepository? repository}) async { return Pizza( id: data['id'] as int, - toppings: data['toppings'].map(Topping.values.byName).toList().cast(), + toppings: + data['toppings'].whereType().map(Topping.values.byName).toList().cast(), frozen: data['frozen'] as bool); } @@ -45,7 +46,7 @@ class PizzaAdapter extends OfflineFirstWithSupabaseAdapter { PizzaAdapter(); @override - final tableName = 'pizzas'; + final supabaseTableName = 'pizzas'; @override final defaultToNull = true; @override diff --git a/example_supabase/lib/brick/db/20200121222037.migration.dart b/example_supabase/lib/brick/db/20200121222037.migration.dart deleted file mode 100644 index b5241618..00000000 --- a/example_supabase/lib/brick/db/20200121222037.migration.dart +++ /dev/null @@ -1,52 +0,0 @@ -// GENERATED CODE EDIT WITH CAUTION -// THIS FILE **WILL NOT** BE REGENERATED -// This file should be version controlled and can be manually edited. -part of 'schema.g.dart'; - -// While migrations are intelligently created, the difference between some commands, such as -// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after -// they are created to ensure the correct inference was made. - -// The migration version must **always** mirror the file name - -const List _migration_20200121222037_up = [ - InsertTable("Customer"), - InsertTable("Pizza"), - InsertColumn("id", Column.integer, onTable: "Customer", unique: true), - InsertColumn("first_name", Column.varchar, onTable: "Customer"), - InsertColumn("last_name", Column.varchar, onTable: "Customer"), - InsertColumn("pizzas", Column.varchar, onTable: "Customer"), - InsertColumn("id", Column.integer, onTable: "Pizza", unique: true), - InsertColumn("toppings", Column.varchar, onTable: "Pizza"), - InsertColumn("frozen", Column.boolean, onTable: "Pizza"), -]; - -const List _migration_20200121222037_down = [ - DropTable("Customer"), - DropTable("Pizza"), - DropColumn("id", onTable: "Customer"), - DropColumn("first_name", onTable: "Customer"), - DropColumn("last_name", onTable: "Customer"), - DropColumn("pizzas", onTable: "Customer"), - DropColumn("id", onTable: "Pizza"), - DropColumn("toppings", onTable: "Pizza"), - DropColumn("frozen", onTable: "Pizza"), -]; - -// -// DO NOT EDIT BELOW THIS LINE -// - -@Migratable( - version: '20200121222037', - up: _migration_20200121222037_up, - down: _migration_20200121222037_down, -) -class Migration20200121222037 extends Migration { - const Migration20200121222037() - : super( - version: 20200121222037, - up: _migration_20200121222037_up, - down: _migration_20200121222037_down, - ); -} diff --git a/example_supabase/lib/brick/db/20210111042657.migration.dart b/example_supabase/lib/brick/db/20210111042657.migration.dart deleted file mode 100644 index 0d6f3f5e..00000000 --- a/example_supabase/lib/brick/db/20210111042657.migration.dart +++ /dev/null @@ -1,48 +0,0 @@ -// GENERATED CODE EDIT WITH CAUTION -// THIS FILE **WILL NOT** BE REGENERATED -// This file should be version controlled and can be manually edited. -part of 'schema.g.dart'; - -// While migrations are intelligently created, the difference between some commands, such as -// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after -// they are created to ensure the correct inference was made. - -// The migration version must **always** mirror the file name - -const List _migration_20210111042657_up = [ - DropColumn('pizzas', onTable: 'Customer'), - InsertTable('_brick_Customer_pizzas'), - InsertForeignKey('_brick_Customer_pizzas', 'Customer', - foreignKeyColumn: 'l_Customer_brick_id', onDeleteCascade: true, onDeleteSetDefault: false), - InsertForeignKey('_brick_Customer_pizzas', 'Pizza', - foreignKeyColumn: 'f_Pizza_brick_id', onDeleteCascade: true, onDeleteSetDefault: false), - CreateIndex( - columns: ['l_Customer_brick_id', 'f_Pizza_brick_id'], - onTable: '_brick_Customer_pizzas', - unique: true) -]; - -const List _migration_20210111042657_down = [ - DropTable('_brick_Customer_pizzas'), - DropColumn('l_Customer_brick_id', onTable: '_brick_Customer_pizzas'), - DropColumn('f_Pizza_brick_id', onTable: '_brick_Customer_pizzas'), - DropIndex('index__brick_Customer_pizzas_on_l_Customer_brick_id_f_Pizza_brick_id') -]; - -// -// DO NOT EDIT BELOW THIS LINE -// - -@Migratable( - version: '20210111042657', - up: _migration_20210111042657_up, - down: _migration_20210111042657_down, -) -class Migration20210111042657 extends Migration { - const Migration20210111042657() - : super( - version: 20210111042657, - up: _migration_20210111042657_up, - down: _migration_20210111042657_down, - ); -} diff --git a/example_supabase/lib/brick/db/20240906052847.migration.dart b/example_supabase/lib/brick/db/20240906052847.migration.dart new file mode 100644 index 00000000..0e8a9c0c --- /dev/null +++ b/example_supabase/lib/brick/db/20240906052847.migration.dart @@ -0,0 +1,74 @@ +// GENERATED CODE EDIT WITH CAUTION +// THIS FILE **WILL NOT** BE REGENERATED +// This file should be version controlled and can be manually edited. +part of 'schema.g.dart'; + +// While migrations are intelligently created, the difference between some commands, such as +// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after +// they are created to ensure the correct inference was made. + +// The migration version must **always** mirror the file name + +const List _migration_20240906052847_up = [ + InsertTable('_brick_Customer_pizzas'), + InsertTable('Customer'), + InsertTable('Pizza'), + InsertForeignKey( + '_brick_Customer_pizzas', + 'Customer', + foreignKeyColumn: 'l_Customer_brick_id', + onDeleteCascade: true, + onDeleteSetDefault: false, + ), + InsertForeignKey( + '_brick_Customer_pizzas', + 'Pizza', + foreignKeyColumn: 'f_Pizza_brick_id', + onDeleteCascade: true, + onDeleteSetDefault: false, + ), + InsertColumn('id', Column.integer, onTable: 'Customer', unique: true), + InsertColumn('first_name', Column.varchar, onTable: 'Customer'), + InsertColumn('last_name', Column.varchar, onTable: 'Customer'), + InsertColumn('id', Column.integer, onTable: 'Pizza', unique: true), + InsertColumn('toppings', Column.varchar, onTable: 'Pizza'), + InsertColumn('frozen', Column.boolean, onTable: 'Pizza'), + CreateIndex( + columns: ['l_Customer_brick_id', 'f_Pizza_brick_id'], + onTable: '_brick_Customer_pizzas', + unique: true, + ), +]; + +const List _migration_20240906052847_down = [ + DropTable('_brick_Customer_pizzas'), + DropTable('Customer'), + DropTable('Pizza'), + DropColumn('l_Customer_brick_id', onTable: '_brick_Customer_pizzas'), + DropColumn('f_Pizza_brick_id', onTable: '_brick_Customer_pizzas'), + DropColumn('id', onTable: 'Customer'), + DropColumn('first_name', onTable: 'Customer'), + DropColumn('last_name', onTable: 'Customer'), + DropColumn('id', onTable: 'Pizza'), + DropColumn('toppings', onTable: 'Pizza'), + DropColumn('frozen', onTable: 'Pizza'), + DropIndex('index__brick_Customer_pizzas_on_l_Customer_brick_id_f_Pizza_brick_id'), +]; + +// +// DO NOT EDIT BELOW THIS LINE +// + +@Migratable( + version: '20240906052847', + up: _migration_20240906052847_up, + down: _migration_20240906052847_down, +) +class Migration20240906052847 extends Migration { + const Migration20240906052847() + : super( + version: 20240906052847, + up: _migration_20240906052847_up, + down: _migration_20240906052847_down, + ); +} diff --git a/example_supabase/lib/brick/db/schema.g.dart b/example_supabase/lib/brick/db/schema.g.dart index 0029cd2a..0509e747 100644 --- a/example_supabase/lib/brick/db/schema.g.dart +++ b/example_supabase/lib/brick/db/schema.g.dart @@ -1,14 +1,15 @@ // GENERATED CODE DO NOT EDIT // This file should be version controlled import 'package:brick_sqlite/db.dart'; -part '20210111042657.migration.dart'; -part '20200121222037.migration.dart'; +part '20240906052847.migration.dart'; /// All intelligently-generated migrations from all `@Migratable` classes on disk -final migrations = {const Migration20210111042657(), const Migration20200121222037()}; +final migrations = { + const Migration20240906052847(), +}; /// A consumable database structure including the latest generated migration. -final schema = Schema(20210111042657, generatorVersion: 1, tables: { +final schema = Schema(20240906052847, generatorVersion: 1, tables: { SchemaTable('_brick_Customer_pizzas', columns: { SchemaColumn('_brick_id', Column.integer, autoincrement: true, nullable: false, isPrimaryKey: true), diff --git a/example_supabase/lib/brick/models/customer.model.dart b/example_supabase/lib/brick/models/customer.model.dart index e41dc7a0..1e6ad2db 100644 --- a/example_supabase/lib/brick/models/customer.model.dart +++ b/example_supabase/lib/brick/models/customer.model.dart @@ -14,12 +14,12 @@ class Customer extends OfflineFirstWithSupabaseModel { final String? lastName; - final List? pizzas; + final List pizzas; Customer({ this.id, this.firstName, this.lastName, - this.pizzas, + required this.pizzas, }); } diff --git a/example_supabase/pubspec.yaml b/example_supabase/pubspec.yaml index fab6b11b..d6bf9775 100644 --- a/example_supabase/pubspec.yaml +++ b/example_supabase/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: cupertino_icons: ^1.0.2 flutter: sdk: flutter - brick_supabase: brick_offline_first_with_supabase: sqflite: supabase_flutter: @@ -27,10 +26,19 @@ dependency_overrides: path: ../packages/brick_sqlite_generators brick_offline_first_with_supabase_build: path: ../packages/brick_offline_first_with_supabase_build + brick_build: + path: ../packages/brick_build + brick_supabase_generators: + path: ../packages/brick_supabase_generators + brick_offline_first_build: + path: ../packages/brick_offline_first_build + brick_offline_first_with_rest: + path: ../packages/brick_offline_first_with_rest + brick_json_generators: + path: ../packages/brick_json_generators dev_dependencies: brick_offline_first_with_supabase_build: - build_runner: any shelf: ^1.1.0 diff --git a/packages/brick_build/CHANGELOG.md b/packages/brick_build/CHANGELOG.md index dadd1163..2ddbe282 100644 --- a/packages/brick_build/CHANGELOG.md +++ b/packages/brick_build/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +## 3.2.1 + +- Revert `.getDisplayString()` change due to Flutter 3.22 being restricted to analyzer <6.4.1. `meta` is pinned to `1.12` in this version of Flutter, and `analyzer >=6.5.0`, where the change was made, requires `meta >= 1.15`. This change will eventually be re-reverted. + ## 3.2.0 - Add convenience mixin `AnnotationFinderWithFieldRename` for field renames in generators diff --git a/packages/brick_build/lib/src/utils/shared_checker.dart b/packages/brick_build/lib/src/utils/shared_checker.dart index b8da7e75..a54c744f 100644 --- a/packages/brick_build/lib/src/utils/shared_checker.dart +++ b/packages/brick_build/lib/src/utils/shared_checker.dart @@ -181,7 +181,7 @@ class SharedChecker<_SiblingModel extends Model> { if (classElement.supertype?.typeArguments == null || classElement.supertype!.typeArguments.isEmpty) { throw InvalidGenerationSourceError( - 'Type argument for ${targetType.getDisplayString()} is undefined.', + 'Type argument for ${targetType.getDisplayString(withNullability: true)} is undefined.', todo: 'Define the type on class ${targetType.element}, e.g. `extends ${withoutNullability(classElement.supertype!)}`', element: targetType.element, @@ -243,7 +243,8 @@ class SharedChecker<_SiblingModel extends Model> { } /// Print the `DartType` without nullability - static String withoutNullability(DartType type) => type.getDisplayString().replaceAll('?', ''); + static String withoutNullability(DartType type) => + type.getDisplayString(withNullability: true).replaceAll('?', ''); /// Destructs a type to determine the bottom type after going through Futures and Iterables. /// diff --git a/packages/brick_build/pubspec.yaml b/packages/brick_build/pubspec.yaml index 96dd9aa2..e168d521 100644 --- a/packages/brick_build/pubspec.yaml +++ b/packages/brick_build/pubspec.yaml @@ -4,7 +4,7 @@ homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_build issue_tracker: https://github.com/GetDutchie/brick/issues repository: https://github.com/GetDutchie/brick -version: 3.2.0 +version: 3.2.1 environment: sdk: ">=2.18.0 <4.0.0" diff --git a/packages/brick_json_generators/CHANGELOG.md b/packages/brick_json_generators/CHANGELOG.md index def8bb2a..51e5f28a 100644 --- a/packages/brick_json_generators/CHANGELOG.md +++ b/packages/brick_json_generators/CHANGELOG.md @@ -1,8 +1,9 @@ ## Unreleased - (test) remove analysis options override for non-standard library prefixes +- Revert `.getDisplayString()` change due to Flutter 3.22 being restricted to analyzer <6.4.1. `meta` is pinned to `1.12` in this version of Flutter, and `analyzer >=6.5.0`, where the change was made, requires `meta >= 1.15`. This change will eventually be re-reverted. -## 3.1.0 +## 3.1.1 - Apply standardized lints - Update `analyzer` constraints to `>=6.0.0 <7.0.0` diff --git a/packages/brick_json_generators/lib/json_deserialize.dart b/packages/brick_json_generators/lib/json_deserialize.dart index f52b835c..4bd95aab 100644 --- a/packages/brick_json_generators/lib/json_deserialize.dart +++ b/packages/brick_json_generators/lib/json_deserialize.dart @@ -94,7 +94,7 @@ mixin JsonDeserialize ${klass.displayName}.fromJson(d as ${parameterType.getDisplayString()}) + (d) => ${klass.displayName}.fromJson(d as ${parameterType.getDisplayString(withNullability: true)}) )$castIterable$defaultValue'''; } @@ -138,7 +138,7 @@ mixin JsonDeserialize=2.18.0 <4.0.0" diff --git a/packages/brick_offline_first_build/CHANGELOG.md b/packages/brick_offline_first_build/CHANGELOG.md index 9377a796..39aeabaa 100644 --- a/packages/brick_offline_first_build/CHANGELOG.md +++ b/packages/brick_offline_first_build/CHANGELOG.md @@ -1,6 +1,9 @@ ## Unreleased +## 3.2.1 + - (test) remove analysis options override for non-standard library prefixes +- Revert `.getDisplayString()` change due to Flutter 3.22 being restricted to analyzer <6.4.1. `meta` is pinned to `1.12` in this version of Flutter, and `analyzer >=6.5.0`, where the change was made, requires `meta >= 1.15`. This change will eventually be re-reverted. ## 3.2.0 diff --git a/packages/brick_offline_first_build/lib/src/offline_first_json_generators.dart b/packages/brick_offline_first_build/lib/src/offline_first_json_generators.dart index 49b575d3..922f3844 100644 --- a/packages/brick_offline_first_build/lib/src/offline_first_json_generators.dart +++ b/packages/brick_offline_first_build/lib/src/offline_first_json_generators.dart @@ -198,7 +198,8 @@ mixin OfflineFirstJsonDeserialize ${SharedChecker.withoutNullability(checker.argType)}.$constructorName(c as $serializableType))$castIterable$defaultValue'; } diff --git a/packages/brick_offline_first_build/lib/src/offline_first_sqlite_generators.dart b/packages/brick_offline_first_build/lib/src/offline_first_sqlite_generators.dart index 17f4e257..a3b7528c 100644 --- a/packages/brick_offline_first_build/lib/src/offline_first_sqlite_generators.dart +++ b/packages/brick_offline_first_build/lib/src/offline_first_sqlite_generators.dart @@ -91,7 +91,8 @@ class OfflineFirstSqliteDeserialize extends SqliteDeserialize { if (argTypeChecker.hasSerdes) { final doesHaveConstructor = hasConstructor(checker.argType); if (doesHaveConstructor) { - final serializableType = argTypeChecker.superClassTypeArgs.last.getDisplayString(); + final serializableType = + argTypeChecker.superClassTypeArgs.last.getDisplayString(withNullability: true); return ''' jsonDecode($fieldValue).map( (c) => $argType.$constructorName(c as $serializableType) @@ -105,7 +106,8 @@ class OfflineFirstSqliteDeserialize extends SqliteDeserialize { if ((checker as OfflineFirstChecker).hasSerdes) { final doesHaveConstructor = hasConstructor(field.type); if (doesHaveConstructor) { - final serializableType = checker.superClassTypeArgs.last.getDisplayString(); + final serializableType = + checker.superClassTypeArgs.last.getDisplayString(withNullability: true); return '${SharedChecker.withoutNullability(field.type)}.$constructorName($fieldValue as $serializableType)'; } } diff --git a/packages/brick_offline_first_build/pubspec.yaml b/packages/brick_offline_first_build/pubspec.yaml index 73445db4..dfbe4e40 100644 --- a/packages/brick_offline_first_build/pubspec.yaml +++ b/packages/brick_offline_first_build/pubspec.yaml @@ -4,7 +4,7 @@ homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_offline_f issue_tracker: https://github.com/GetDutchie/brick/issues repository: https://github.com/GetDutchie/brick -version: 3.2.0 +version: 3.2.1 environment: sdk: ">=2.18.0 <4.0.0" diff --git a/packages/brick_offline_first_with_supabase/test/__mocks__.dart b/packages/brick_offline_first_with_supabase/test/__mocks__.dart new file mode 100644 index 00000000..4d61e7f6 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/test/__mocks__.dart @@ -0,0 +1,518 @@ +// ignore: unused_import +// ignore_for_file: constant_identifier_names + +import 'dart:convert'; + +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_core/query.dart'; +// ignore: unused_import, unused_shown_name +import 'package:brick_offline_first/brick_offline_first.dart' show RuntimeOfflineFirstDefinition; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_sqlite/brick_sqlite.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_sqlite/db.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_supabase/brick_supabase.dart'; +// ignore: unused_import, unused_shown_name +import 'package:sqflite_common/sqlite_api.dart' show DatabaseExecutor; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(), +) +class Customer extends OfflineFirstWithSupabaseModel { + @Sqlite(unique: true) + final int? id; + + final String? firstName; + + final String? lastName; + + final List pizzas; + + Customer({ + this.id, + this.firstName, + this.lastName, + required this.pizzas, + }); +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(), +) +class Pizza extends OfflineFirstWithSupabaseModel { + /// Read more about `@Sqlite`: https://github.com/GetDutchie/brick/tree/main/packages/brick_sqlite#fields + @Sqlite(unique: true) + final int id; + + /// Read more about `@Supabase`: https://github.com/GetDutchie/brick/tree/main/packages/brick_supabase#fields + @Supabase(enumAsString: true) + final List toppings; + + final bool frozen; + + Pizza({ + required this.id, + required this.toppings, + required this.frozen, + }); +} + +enum Topping { olive, pepperoni } + +/// Supabase mappings should only be used when initializing a [SupabaseProvider] +final Map> supabaseMappings = { + Customer: CustomerAdapter(), + Pizza: PizzaAdapter(), +}; +final supabaseModelDictionary = SupabaseModelDictionary(supabaseMappings); + +/// Sqlite mappings should only be used when initializing a [SqliteProvider] +final Map> sqliteMappings = { + Customer: CustomerAdapter(), + Pizza: PizzaAdapter(), +}; +final sqliteModelDictionary = SqliteModelDictionary(sqliteMappings); + +Future _$PizzaFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Pizza( + id: data['id'] as int, + toppings: + data['toppings'].whereType().map(Topping.values.byName).toList().cast(), + frozen: data['frozen'] as bool, + ); +} + +Future> _$PizzaToSupabase( + Pizza instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'toppings': instance.toppings.map((e) => e.name).toList(), + 'frozen': instance.frozen, + }; +} + +Future _$PizzaFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Pizza( + id: data['id'] as int, + toppings: jsonDecode(data['toppings']) + .map((d) => d as int > -1 ? Topping.values[d] : null) + .whereType() + .toList() + .cast(), + frozen: data['frozen'] == 1, + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$PizzaToSqlite( + Pizza instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'toppings': jsonEncode(instance.toppings.map((s) => Topping.values.indexOf(s)).toList()), + 'frozen': instance.frozen ? 1 : 0, + }; +} + +/// Construct a [Pizza] +class PizzaAdapter extends OfflineFirstWithSupabaseAdapter { + PizzaAdapter(); + + @override + final supabaseTableName = 'pizzas'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'toppings': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'toppings', + ), + 'frozen': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'frozen', + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: int, + ), + 'toppings': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'toppings', + iterable: true, + type: Topping, + ), + 'frozen': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'frozen', + iterable: false, + type: bool, + ), + }; + @override + Future primaryKeyByUniqueColumns(Pizza instance, DatabaseExecutor executor) async { + final results = await executor.rawQuery( + ''' + SELECT * FROM `Pizza` WHERE id = ? LIMIT 1''', + [instance.id], + ); + + // SQFlite returns [{}] when no results are found + if (results.isEmpty || (results.length == 1 && results.first.isEmpty)) { + return null; + } + + return results.first['_brick_id'] as int; + } + + @override + final String tableName = 'Pizza'; + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$PizzaFromSupabase(input, provider: provider, repository: repository); + @override + Future> toSupabase( + Pizza input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$PizzaToSupabase(input, provider: provider, repository: repository); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$PizzaFromSqlite(input, provider: provider, repository: repository); + @override + Future> toSqlite( + Pizza input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$PizzaToSqlite(input, provider: provider, repository: repository); +} + +Future _$CustomerFromSupabase( + Map data, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Customer( + id: data['id'] as int?, + firstName: data['first_name'] as String?, + lastName: data['last_name'] as String?, + pizzas: await Future.wait( + data['pizzas'] + ?.map( + (d) => PizzaAdapter().fromSupabase(d, provider: provider, repository: repository), + ) + .toList() + .cast>() ?? + [], + ), + ); +} + +Future> _$CustomerToSupabase( + Customer instance, { + required SupabaseProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return { + 'id': instance.id, + 'first_name': instance.firstName, + 'last_name': instance.lastName, + 'pizzas': await Future.wait>( + instance.pizzas + .map((s) => PizzaAdapter().toSupabase(s, provider: provider, repository: repository)) + .toList(), + ), + }; +} + +Future _$CustomerFromSqlite( + Map data, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return Customer( + id: data['id'] == null ? null : data['id'] as int?, + firstName: data['first_name'] == null ? null : data['first_name'] as String?, + lastName: data['last_name'] == null ? null : data['last_name'] as String?, + pizzas: (await provider.rawQuery( + 'SELECT DISTINCT `f_Pizza_brick_id` FROM `_brick_Customer_pizzas` WHERE l_Customer_brick_id = ?', + [data['_brick_id'] as int], + ).then((results) { + final ids = results.map((r) => r['f_Pizza_brick_id']); + return Future.wait( + ids.map( + (primaryKey) => repository! + .getAssociation( + Query.where('primaryKey', primaryKey, limit1: true), + ) + .then((r) => r!.first), + ), + ); + })) + .toList() + .cast(), + )..primaryKey = data['_brick_id'] as int; +} + +Future> _$CustomerToSqlite( + Customer instance, { + required SqliteProvider provider, + OfflineFirstWithSupabaseRepository? repository, +}) async { + return {'id': instance.id, 'first_name': instance.firstName, 'last_name': instance.lastName}; +} + +/// Construct a [Customer] +class CustomerAdapter extends OfflineFirstWithSupabaseAdapter { + CustomerAdapter(); + + @override + final supabaseTableName = 'customers'; + @override + final defaultToNull = true; + @override + final fieldsToSupabaseColumns = { + 'id': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'id', + ), + 'firstName': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'first_name', + ), + 'lastName': const RuntimeSupabaseColumnDefinition( + association: false, + columnName: 'last_name', + ), + 'pizzas': const RuntimeSupabaseColumnDefinition( + association: true, + columnName: 'pizzas', + associationType: Pizza, + associationIsNullable: false, + ), + }; + @override + final ignoreDuplicates = false; + @override + final uniqueFields = {}; + @override + final Map fieldsToSqliteColumns = { + 'primaryKey': const RuntimeSqliteColumnDefinition( + association: false, + columnName: '_brick_id', + iterable: false, + type: int, + ), + 'id': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'id', + iterable: false, + type: int, + ), + 'firstName': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'first_name', + iterable: false, + type: String, + ), + 'lastName': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'last_name', + iterable: false, + type: String, + ), + 'pizzas': const RuntimeSqliteColumnDefinition( + association: true, + columnName: 'pizzas', + iterable: true, + type: Pizza, + ), + }; + @override + Future primaryKeyByUniqueColumns(Customer instance, DatabaseExecutor executor) async { + final results = await executor.rawQuery( + ''' + SELECT * FROM `Customer` WHERE id = ? LIMIT 1''', + [instance.id], + ); + + // SQFlite returns [{}] when no results are found + if (results.isEmpty || (results.length == 1 && results.first.isEmpty)) { + return null; + } + + return results.first['_brick_id'] as int; + } + + @override + final String tableName = 'Customer'; + @override + Future afterSave(instance, {required provider, repository}) async { + if (instance.primaryKey != null) { + final pizzasOldColumns = await provider.rawQuery( + 'SELECT `f_Pizza_brick_id` FROM `_brick_Customer_pizzas` WHERE `l_Customer_brick_id` = ?', + [instance.primaryKey], + ); + final pizzasOldIds = pizzasOldColumns.map((a) => a['f_Pizza_brick_id']); + final pizzasNewIds = instance.pizzas.map((s) => s.primaryKey).whereType(); + final pizzasIdsToDelete = pizzasOldIds.where((id) => !pizzasNewIds.contains(id)); + + await Future.wait( + pizzasIdsToDelete.map((id) async { + return await provider.rawExecute( + 'DELETE FROM `_brick_Customer_pizzas` WHERE `l_Customer_brick_id` = ? AND `f_Pizza_brick_id` = ?', + [instance.primaryKey, id], + ).catchError((e) => null); + }), + ); + + await Future.wait( + instance.pizzas.map((s) async { + final id = s.primaryKey ?? await provider.upsert(s, repository: repository); + return await provider.rawInsert( + 'INSERT OR IGNORE INTO `_brick_Customer_pizzas` (`l_Customer_brick_id`, `f_Pizza_brick_id`) VALUES (?, ?)', + [instance.primaryKey, id], + ); + }), + ); + } + } + + @override + Future fromSupabase( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$CustomerFromSupabase(input, provider: provider, repository: repository); + @override + Future> toSupabase( + Customer input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$CustomerToSupabase(input, provider: provider, repository: repository); + @override + Future fromSqlite( + Map input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$CustomerFromSqlite(input, provider: provider, repository: repository); + @override + Future> toSqlite( + Customer input, { + required provider, + covariant OfflineFirstWithSupabaseRepository? repository, + }) async => + await _$CustomerToSqlite(input, provider: provider, repository: repository); +} + +const List _migration_20240906052847_up = [ + InsertTable('_brick_Customer_pizzas'), + InsertTable('Customer'), + InsertTable('Pizza'), + InsertForeignKey( + '_brick_Customer_pizzas', + 'Customer', + foreignKeyColumn: 'l_Customer_brick_id', + onDeleteCascade: true, + onDeleteSetDefault: false, + ), + InsertForeignKey( + '_brick_Customer_pizzas', + 'Pizza', + foreignKeyColumn: 'f_Pizza_brick_id', + onDeleteCascade: true, + onDeleteSetDefault: false, + ), + InsertColumn('id', Column.integer, onTable: 'Customer', unique: true), + InsertColumn('first_name', Column.varchar, onTable: 'Customer'), + InsertColumn('last_name', Column.varchar, onTable: 'Customer'), + InsertColumn('id', Column.integer, onTable: 'Pizza', unique: true), + InsertColumn('toppings', Column.varchar, onTable: 'Pizza'), + InsertColumn('frozen', Column.boolean, onTable: 'Pizza'), + CreateIndex( + columns: ['l_Customer_brick_id', 'f_Pizza_brick_id'], + onTable: '_brick_Customer_pizzas', + unique: true, + ), +]; + +const List _migration_20240906052847_down = [ + DropTable('_brick_Customer_pizzas'), + DropTable('Customer'), + DropTable('Pizza'), + DropColumn('l_Customer_brick_id', onTable: '_brick_Customer_pizzas'), + DropColumn('f_Pizza_brick_id', onTable: '_brick_Customer_pizzas'), + DropColumn('id', onTable: 'Customer'), + DropColumn('first_name', onTable: 'Customer'), + DropColumn('last_name', onTable: 'Customer'), + DropColumn('id', onTable: 'Pizza'), + DropColumn('toppings', onTable: 'Pizza'), + DropColumn('frozen', onTable: 'Pizza'), + DropIndex('index__brick_Customer_pizzas_on_l_Customer_brick_id_f_Pizza_brick_id'), +]; + +// +// DO NOT EDIT BELOW THIS LINE +// + +@Migratable( + version: '20240906052847', + up: _migration_20240906052847_up, + down: _migration_20240906052847_down, +) +class Migration20240906052847 extends Migration { + const Migration20240906052847() + : super( + version: 20240906052847, + up: _migration_20240906052847_up, + down: _migration_20240906052847_down, + ); +} diff --git a/packages/brick_offline_first_with_supabase/test/offline_first_with_supabase_repository_test.dart b/packages/brick_offline_first_with_supabase/test/offline_first_with_supabase_repository_test.dart new file mode 100644 index 00000000..69986c59 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/test/offline_first_with_supabase_repository_test.dart @@ -0,0 +1,93 @@ +// ignore_for_file: unawaited_futures + +import 'package:brick_offline_first_with_supabase/src/offline_first_with_supabase_repository.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_sqlite/memory_cache_provider.dart'; +import 'package:brick_supabase/brick_supabase.dart'; +import 'package:brick_supabase/testing.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:supabase/supabase.dart'; +import 'package:test/test.dart'; + +import '__mocks__.dart'; + +class TestRepository extends OfflineFirstWithSupabaseRepository { + TestRepository._({ + required super.supabaseProvider, + required super.sqliteProvider, + required super.offlineRequestQueue, + super.memoryCacheProvider, + }) : super( + migrations: { + const Migration20240906052847(), + }, + ); + + static TestRepository configure(SupabaseMockServer mock) { + final (client, queue) = OfflineFirstWithSupabaseRepository.clientQueue( + databaseFactory: databaseFactoryFfi, + reattemptForStatusCodes: [], + ); + + final provider = SupabaseProvider( + SupabaseClient(mock.serverUrl, mock.apiKey, httpClient: client), + modelDictionary: supabaseModelDictionary, + ); + + return TestRepository._( + offlineRequestQueue: queue, + memoryCacheProvider: MemoryCacheProvider(), + supabaseProvider: provider, + sqliteProvider: SqliteProvider( + 'my_repository.sqlite', + databaseFactory: databaseFactoryFfi, + modelDictionary: sqliteModelDictionary, + ), + ); + } +} + +void main() async { + sqfliteFfiInit(); + + final mock = SupabaseMockServer(modelDictionary: supabaseModelDictionary); + + group('OfflineFirstWithSupabaseRepository', () { + late TestRepository repository; + + setUp(() async { + await mock.setUp(); + repository = TestRepository.configure(mock); + await repository.initialize(); + }); + + tearDown(() async { + await mock.tearDown(); + await repository.reset(); + }); + + group('#get', () { + test('stores locally', () async { + final req = SupabaseRequest(); + final resp = SupabaseResponse([ + await mock.serialize( + Customer( + id: 1, + firstName: 'Thomas', + lastName: 'Guy', + pizzas: [ + Pizza(id: 2, toppings: [Topping.pepperoni], frozen: false), + ], + ), + ), + ]); + mock.handle({req: resp}); + + final customers = await repository.get(); + expect(customers, hasLength(1)); + final localPizzas = await repository.sqliteProvider.get(); + expect(localPizzas, hasLength(1)); + }); + }); + }); +} diff --git a/packages/brick_offline_first_with_supabase_build/test/offline_first_generator/test_offline_first_where.dart b/packages/brick_offline_first_with_supabase_build/test/offline_first_generator/test_offline_first_where.dart index 11fffa3c..3b931bd5 100644 --- a/packages/brick_offline_first_with_supabase_build/test/offline_first_generator/test_offline_first_where.dart +++ b/packages/brick_offline_first_with_supabase_build/test/offline_first_generator/test_offline_first_where.dart @@ -3,7 +3,7 @@ import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supab import 'package:brick_supabase/brick_supabase.dart'; final output = r''' -// GENERATED CODE DO NOT EDIT +/// GENERATED CODE DO NOT EDIT part of '../brick.g.dart'; Future _$SupabaseOfflineFirstWhereFromSupabase( @@ -15,14 +15,35 @@ Future _$SupabaseOfflineFirstWhereFromSupabase( .getAssociation(Query( where: [Where.exact('id', data["association"]["id"])], providerArgs: {'limit': 1})) - .then((r) => r!.first)); + .then((r) => r!.first), + associations: await Future.wait(data['associations'] + ?.map((d) => AssocAdapter() + .fromSupabase(d, provider: provider, repository: repository)) + .toList() + .cast>() ?? + []), + nullableAssociations: await Future.wait( + data['nullable_associations']?.map((d) => AssocAdapter().fromSupabase(d, provider: provider, repository: repository)).toList().cast>() ?? [])); } Future> _$SupabaseOfflineFirstWhereToSupabase( SupabaseOfflineFirstWhere instance, {required SupabaseProvider provider, OfflineFirstRepository? repository}) async { - return {'association': instance.association.id}; + return { + 'association': instance.association.id, + 'associations': await Future.wait>(instance + .associations + .map((s) => AssocAdapter() + .toSupabase(s, provider: provider, repository: repository)) + .toList()), + 'nullable_associations': await Future.wait>(instance + .nullableAssociations + ?.map((s) => AssocAdapter() + .toSupabase(s, provider: provider, repository: repository)) + .toList() ?? + []) + }; } Future _$SupabaseOfflineFirstWhereFromSqlite( @@ -31,10 +52,36 @@ Future _$SupabaseOfflineFirstWhereFromSqlite( OfflineFirstRepository? repository}) async { return SupabaseOfflineFirstWhere( association: (await repository!.getAssociation( - Query.where('primaryKey', data['association_Assoc_brick_id'] as int, - limit1: true), - ))! - .first) + Query.where('primaryKey', data['association_Assoc_brick_id'] as int, + limit1: true), + ))! + .first, + associations: (await provider.rawQuery( + 'SELECT DISTINCT `f_Assoc_brick_id` FROM `_brick_SupabaseOfflineFirstWhere_associations` WHERE l_SupabaseOfflineFirstWhere_brick_id = ?', + [ + data['_brick_id'] as int + ]).then((results) { + final ids = results.map((r) => r['f_Assoc_brick_id']); + return Future.wait(ids.map((primaryKey) => repository + .getAssociation( + Query.where('primaryKey', primaryKey, limit1: true), + ) + .then((r) => r!.first))); + })) + .toList() + .cast(), + nullableAssociations: (await provider.rawQuery( + 'SELECT DISTINCT `f_Assoc_brick_id` FROM `_brick_SupabaseOfflineFirstWhere_nullable_associations` WHERE l_SupabaseOfflineFirstWhere_brick_id = ?', + [data['_brick_id'] as int]).then((results) { + final ids = results.map((r) => r['f_Assoc_brick_id']); + return Future.wait(ids.map((primaryKey) => repository + .getAssociation( + Query.where('primaryKey', primaryKey, limit1: true), + ) + .then((r) => r!.first))); + })) + .toList() + .cast()) ..primaryKey = data['_brick_id'] as int; } @@ -72,6 +119,18 @@ class SupabaseOfflineFirstWhereAdapter columnName: 'association', associationType: Assoc, associationIsNullable: false, + ), + 'associations': const RuntimeSupabaseColumnDefinition( + association: true, + columnName: 'associations', + associationType: Assoc, + associationIsNullable: false, + ), + 'nullableAssociations': const RuntimeSupabaseColumnDefinition( + association: true, + columnName: 'nullable_associations', + associationType: Assoc, + associationIsNullable: true, ) }; @override @@ -91,6 +150,18 @@ class SupabaseOfflineFirstWhereAdapter columnName: 'association_Assoc_brick_id', iterable: false, type: Assoc, + ), + 'associations': const RuntimeSqliteColumnDefinition( + association: true, + columnName: 'associations', + iterable: true, + type: Assoc, + ), + 'nullableAssociations': const RuntimeSqliteColumnDefinition( + association: true, + columnName: 'nullable_associations', + iterable: true, + type: Assoc, ) }; @override @@ -99,6 +170,63 @@ class SupabaseOfflineFirstWhereAdapter instance.primaryKey; @override final String tableName = 'SupabaseOfflineFirstWhere'; + @override + Future afterSave(instance, {required provider, repository}) async { + if (instance.primaryKey != null) { + final associationsOldColumns = await provider.rawQuery( + 'SELECT `f_Assoc_brick_id` FROM `_brick_SupabaseOfflineFirstWhere_associations` WHERE `l_SupabaseOfflineFirstWhere_brick_id` = ?', + [instance.primaryKey]); + final associationsOldIds = + associationsOldColumns.map((a) => a['f_Assoc_brick_id']); + final associationsNewIds = + instance.associations.map((s) => s.primaryKey).whereType(); + final associationsIdsToDelete = + associationsOldIds.where((id) => !associationsNewIds.contains(id)); + + await Future.wait(associationsIdsToDelete.map((id) async { + return await provider.rawExecute( + 'DELETE FROM `_brick_SupabaseOfflineFirstWhere_associations` WHERE `l_SupabaseOfflineFirstWhere_brick_id` = ? AND `f_Assoc_brick_id` = ?', + [instance.primaryKey, id]).catchError((e) => null); + })); + + await Future.wait(instance.associations.map((s) async { + final id = s.primaryKey ?? + await provider.upsert(s, repository: repository); + return await provider.rawInsert( + 'INSERT OR IGNORE INTO `_brick_SupabaseOfflineFirstWhere_associations` (`l_SupabaseOfflineFirstWhere_brick_id`, `f_Assoc_brick_id`) VALUES (?, ?)', + [instance.primaryKey, id]); + })); + } + + if (instance.primaryKey != null) { + final nullableAssociationsOldColumns = await provider.rawQuery( + 'SELECT `f_Assoc_brick_id` FROM `_brick_SupabaseOfflineFirstWhere_nullable_associations` WHERE `l_SupabaseOfflineFirstWhere_brick_id` = ?', + [instance.primaryKey]); + final nullableAssociationsOldIds = + nullableAssociationsOldColumns.map((a) => a['f_Assoc_brick_id']); + final nullableAssociationsNewIds = instance.nullableAssociations + ?.map((s) => s.primaryKey) + .whereType() ?? + []; + final nullableAssociationsIdsToDelete = nullableAssociationsOldIds + .where((id) => !nullableAssociationsNewIds.contains(id)); + + await Future.wait(nullableAssociationsIdsToDelete.map((id) async { + return await provider.rawExecute( + 'DELETE FROM `_brick_SupabaseOfflineFirstWhere_nullable_associations` WHERE `l_SupabaseOfflineFirstWhere_brick_id` = ? AND `f_Assoc_brick_id` = ?', + [instance.primaryKey, id]).catchError((e) => null); + })); + + await Future.wait(instance.nullableAssociations?.map((s) async { + final id = s.primaryKey ?? + await provider.upsert(s, repository: repository); + return await provider.rawInsert( + 'INSERT OR IGNORE INTO `_brick_SupabaseOfflineFirstWhere_nullable_associations` (`l_SupabaseOfflineFirstWhere_brick_id`, `f_Assoc_brick_id`) VALUES (?, ?)', + [instance.primaryKey, id]); + }) ?? + []); + } + } @override Future fromSupabase(Map input, @@ -134,7 +262,10 @@ class SupabaseOfflineFirstWhere extends OfflineFirstModel { @OfflineFirst(where: {'id': 'data["association"]["id"]'}) final Assoc association; - SupabaseOfflineFirstWhere(this.association); + final List associations; + final List? nullableAssociations; + + SupabaseOfflineFirstWhere(this.association, this.associations, {this.nullableAssociations}); } class Assoc extends OfflineFirstModel { diff --git a/packages/brick_offline_first_with_supabase_build/test/offline_first_generator_test.dart b/packages/brick_offline_first_with_supabase_build/test/offline_first_generator_test.dart index 70fdb01b..a1946473 100644 --- a/packages/brick_offline_first_with_supabase_build/test/offline_first_generator_test.dart +++ b/packages/brick_offline_first_with_supabase_build/test/offline_first_generator_test.dart @@ -81,6 +81,5 @@ Future generateAdapterExpectation( annotation.annotation, MockBuildStep(), ); - print(generated); expect(generated.trim(), output.trim()); } diff --git a/packages/brick_sqlite_generators/CHANGELOG.md b/packages/brick_sqlite_generators/CHANGELOG.md index bc21f647..6c70b964 100644 --- a/packages/brick_sqlite_generators/CHANGELOG.md +++ b/packages/brick_sqlite_generators/CHANGELOG.md @@ -2,6 +2,10 @@ ## 3.2.1 +- Revert `.getDisplayString()` change due to Flutter 3.22 being restricted to analyzer <6.4.1. `meta` is pinned to `1.12` in this version of Flutter, and `analyzer >=6.5.0`, where the change was made, requires `meta >= 1.15`. This change will eventually be re-reverted. + +## 3.2.1 + - Use `SharedChecker.withoutNullability` instead of stripping null suffixes manually - Standardize `_finalTypeForField` to `SharedChecker#withoutNullResultType` - (test) remove analysis options override for non-standard library prefixes diff --git a/packages/brick_sqlite_generators/lib/src/sqlite_deserialize.dart b/packages/brick_sqlite_generators/lib/src/sqlite_deserialize.dart index 6d66c48f..4d310a50 100644 --- a/packages/brick_sqlite_generators/lib/src/sqlite_deserialize.dart +++ b/packages/brick_sqlite_generators/lib/src/sqlite_deserialize.dart @@ -137,7 +137,7 @@ class SqliteDeserialize<_Model extends SqliteModel> extends SqliteSerdesGenerato final discoveredByIndex = 'jsonDecode($fieldValue).map((d) => d as int > -1 ? ${SharedChecker.withoutNullability(argType)}.values[d] : null)'; final nullableSuffix = checker.isNullable ? '?' : ''; - return '$discoveredByIndex$nullableSuffix.whereType<${argType.getDisplayString()}>()$castIterable'; + return '$discoveredByIndex$nullableSuffix.whereType<${argType.getDisplayString(withNullability: true)}>()$castIterable'; } // Iterable @@ -152,7 +152,7 @@ class SqliteDeserialize<_Model extends SqliteModel> extends SqliteSerdesGenerato final nullableSuffix = checker.isNullable ? " ?? '[]'" : ''; return '''jsonDecode($fieldValue$nullableSuffix).map( - (d) => ${klass.displayName}.fromJson(d as ${parameterType.getDisplayString()}) + (d) => ${klass.displayName}.fromJson(d as ${parameterType.getDisplayString(withNullability: true)}) )$castIterable$defaultValue'''; } @@ -210,7 +210,7 @@ class SqliteDeserialize<_Model extends SqliteModel> extends SqliteSerdesGenerato } else if (checker.fromJsonConstructor != null) { final klass = checker.targetType.element as ClassElement; final parameterType = checker.fromJsonConstructor!.parameters.first.type; - return '${klass.displayName}.fromJson(jsonDecode($fieldValue as String) as ${parameterType.getDisplayString()})'; + return '${klass.displayName}.fromJson(jsonDecode($fieldValue as String) as ${parameterType.getDisplayString(withNullability: true)})'; } return null; diff --git a/packages/brick_sqlite_generators/pubspec.yaml b/packages/brick_sqlite_generators/pubspec.yaml index 1518c59a..cd047fd7 100644 --- a/packages/brick_sqlite_generators/pubspec.yaml +++ b/packages/brick_sqlite_generators/pubspec.yaml @@ -4,7 +4,7 @@ homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_sqlite_ge issue_tracker: https://github.com/GetDutchie/brick/issues repository: https://github.com/GetDutchie/brick -version: 3.2.1 +version: 3.2.2 environment: sdk: ">=2.18.0 <4.0.0" diff --git a/packages/brick_supabase/lib/testing.dart b/packages/brick_supabase/lib/testing.dart new file mode 100644 index 00000000..a34b9633 --- /dev/null +++ b/packages/brick_supabase/lib/testing.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:brick_core/query.dart'; +import 'package:brick_supabase/brick_supabase.dart'; +import 'package:brick_supabase/src/query_supabase_transformer.dart'; +import 'package:collection/collection.dart'; +import 'package:supabase/supabase.dart'; + +class SupabaseRequest { + /// If `fields` are not provided, they will try to be inferred using the + /// [SupabaseMockServer]'s `modelDictionary`. + final String? fields; + + final String? filter; + + final int? limit; + + final String? requestMethod; + + /// If a `tableName` is not provided, it will try to be inferred using the + /// [SupabaseMockServer]'s `modelDictionary` based on the + /// `SupabaseAdapter`'s `supabaseTableName`. + final String? tableName; + + SupabaseRequest({ + this.tableName, + this.fields, + this.filter, + this.limit, + this.requestMethod = 'GET', + }); + + Uri toUri(SupabaseModelDictionary? modelDictionary) { + final generatedFields = modelDictionary != null + ? SupabaseRequest.fieldsFromDictionary(modelDictionary) + : fields; + final generatedTableName = + modelDictionary != null ? modelDictionary.adapterFor[TModel]?.supabaseTableName : tableName; + final url = + '/rest/v1/$generatedTableName${filter != null ? '?$filter&' : '?'}select=${Uri.encodeComponent(generatedFields ?? '')}${limit != null ? '&limit=$limit' : ''}'; + return Uri.parse(url); + } + + /// This provides a convenience method to generate [fields] as the + /// [SupabaseProvider] would generate them. + static String fieldsFromDictionary( + SupabaseModelDictionary modelDictionary, { + Query? query, + }) { + final transformer = + QuerySupabaseTransformer(modelDictionary: modelDictionary, query: query); + return transformer.selectFields; + } +} + +class SupabaseResponse { + final dynamic data; + + final Map? headers; + + SupabaseResponse(this.data, {this.headers}); +} + +class SupabaseMockServer { + final String apiKey; + + late SupabaseClient client; + + final SupabaseModelDictionary modelDictionary; + + late HttpServer server; + + String get serverUrl => 'http://${server.address.host}:${server.port}'; + + SupabaseMockServer({this.apiKey = 'supabaseKey', required this.modelDictionary}); + + /// Invoke within a group as `tearDown(mock.tearDown)` + Future tearDown() async { + await client.dispose(); + await client.removeAllChannels(); + await server.close(force: true); + } + + /// Invoke within a test block before any calls are made to a Supabase server + // https://github.com/supabase/supabase-flutter/blob/main/packages/supabase/test/mock_test.dart#L21 + Future handle(Map responses) async { + await for (final request in server) { + final matchingRequest = responses.entries.firstWhereOrNull((r) { + final url = request.uri.toString(); + final matchesRequestMethod = + r.key.requestMethod == request.method || r.key.requestMethod == null; + final matchesPath = request.uri.path == r.key.toUri(modelDictionary).path; + var matchesQuery = true; + for (final param in r.key.toUri(modelDictionary).queryParameters.entries) { + if (!request.uri.queryParameters.containsKey(param.key) || + param.value != request.uri.queryParameters[param.key]) { + matchesQuery = false; + break; + } + } + return r.key.toUri(modelDictionary).toString() == url || + (matchesRequestMethod && matchesPath && matchesQuery); + }); + + if (matchingRequest != null) { + final resp = request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json; + if (matchingRequest.value.headers != null) { + matchingRequest.value.headers!.forEach(resp.headers.set); + } + + resp.write(jsonEncode(matchingRequest.value.data)); + await resp.close(); + } else { + final resp = request.response..statusCode = HttpStatus.notImplemented; + await resp.close(); + } + } + } + + Future> serialize( + TModel instance, + ) async { + final adapter = modelDictionary.adapterFor[TModel]!; + return await adapter.toSupabase( + instance, + provider: SupabaseProvider(client, modelDictionary: modelDictionary), + repository: null, + ); + } + + /// Invoke within a group as `setUp(mock.setUp)`. + /// + /// It is critical to recreate the server for each test to ensure + /// that there are no collisions from responses that were configured + /// in prior tests. + Future setUp() async { + server = await HttpServer.bind('localhost', 0); + client = SupabaseClient(serverUrl, apiKey); + } +} diff --git a/packages/brick_supabase/test/supabase_provider_test.dart b/packages/brick_supabase/test/supabase_provider_test.dart index e2f6382e..cff87713 100644 --- a/packages/brick_supabase/test/supabase_provider_test.dart +++ b/packages/brick_supabase/test/supabase_provider_test.dart @@ -1,151 +1,56 @@ // ignore_for_file: unawaited_futures -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - import 'package:brick_supabase/src/supabase_provider.dart'; -import 'package:collection/collection.dart'; -import 'package:supabase/supabase.dart'; +import 'package:brick_supabase/testing.dart'; import 'package:test/test.dart'; import '__mocks__.dart'; -class _SupabaseRequest { - final String? requestMethod; - final String tableName; - final String fields; - final String? filter; - final int? limit; - - _SupabaseRequest( - this.tableName, { - this.requestMethod = 'GET', - required this.fields, - this.filter, - this.limit, - }); - - @override - String toString() => - '/rest/v1/$tableName${filter != null ? '?$filter&' : '?'}select=${Uri.encodeComponent(fields)}${limit != null ? '&limit=$limit' : ''}'; - - Uri get uri => Uri.parse(toString()); -} - -class _SupabaseResponse { - final dynamic data; - final Map? headers; - - _SupabaseResponse(this.data, [this.headers]); -} - void main() { - late SupabaseClient supabase; - late HttpServer mockServer; - const apiKey = 'supabaseKey'; - - // https://github.com/supabase/supabase-flutter/blob/main/packages/supabase/test/mock_test.dart#L21 - Future handleRequests( - HttpServer server, - Map<_SupabaseRequest, _SupabaseResponse> responses, - ) async { - await for (final HttpRequest request in server) { - final matchingRequest = responses.entries.firstWhereOrNull((r) { - final url = request.uri.toString(); - final matchesRequestMethod = - r.key.requestMethod == request.method || r.key.requestMethod == null; - final matchesPath = request.uri.path == r.key.uri.path; - var matchesQuery = true; - for (final param in r.key.uri.queryParameters.entries) { - if (!request.uri.queryParameters.containsKey(param.key) || - param.value != request.uri.queryParameters[param.key]) { - matchesQuery = false; - break; - } - } - return r.key.toString() == url || (matchesRequestMethod && matchesPath && matchesQuery); - }); - - if (matchingRequest != null) { - final resp = request.response - ..statusCode = HttpStatus.ok - ..headers.contentType = ContentType.json; - if (matchingRequest.value.headers != null) { - matchingRequest.value.headers!.forEach((key, value) { - resp.headers.set(key, value); - }); - } - - resp.write(jsonEncode(matchingRequest.value.data)); - await resp.close(); - } else { - final resp = request.response..statusCode = HttpStatus.notImplemented; - await resp.close(); - } - } - } + final mock = SupabaseMockServer(modelDictionary: supabaseModelDictionary); - setUp(() async { - mockServer = await HttpServer.bind('localhost', 0); - supabase = SupabaseClient( - 'http://${mockServer.address.host}:${mockServer.port}', - apiKey, - ); - }); + group('SupabaseProvider', () { + setUp(mock.setUp); - tearDown(() async { - await supabase.dispose(); - await supabase.removeAllChannels(); - await mockServer.close(force: true); - }); + tearDown(mock.tearDown); - group('SupabaseProvider', () { test('#delete', () async { - final req = _SupabaseRequest( - 'demos', + final req = SupabaseRequest( requestMethod: 'DELETE', - fields: 'id,name,age', filter: 'id=eq.1', limit: 1, ); - final resp = _SupabaseResponse({'id': '1', 'name': 'Demo 1'}); + final instance = DemoModel(age: 1, name: 'Demo 1', id: '1'); + final resp = SupabaseResponse(await mock.serialize(instance)); - handleRequests(mockServer, {req: resp}); - final provider = SupabaseProvider(supabase, modelDictionary: supabaseModelDictionary); - final didDelete = - await provider.delete(DemoModel(age: 1, name: 'Demo 1', id: '1')); + mock.handle({req: resp}); + final provider = SupabaseProvider(mock.client, modelDictionary: supabaseModelDictionary); + final didDelete = await provider.delete(instance); expect(didDelete, true); }); test('#exists', () async { - final req = _SupabaseRequest( - 'demos', - fields: 'id,name,age', + final req = SupabaseRequest(); + final instance = DemoModel(age: 1, name: 'Demo 1', id: '1'); + final resp = SupabaseResponse( + [await mock.serialize(instance)], + headers: {'content-range': '*/1'}, ); - final resp = _SupabaseResponse([ - {'id': '1', 'name': 'Demo 1', 'age': 1}, - ], { - 'content-range': '*/1', - }); - handleRequests(mockServer, {req: resp}); - final provider = SupabaseProvider(supabase, modelDictionary: supabaseModelDictionary); + mock.handle({req: resp}); + final provider = SupabaseProvider(mock.client, modelDictionary: supabaseModelDictionary); final doesExist = await provider.exists(); expect(doesExist, true); }); test('#get', () async { - final req = _SupabaseRequest( - 'demos', - fields: 'id,name,age', - ); - final resp = _SupabaseResponse([ - {'id': '1', 'name': 'Demo 1', 'age': 1}, - {'id': '2', 'name': 'Demo 2', 'age': 2}, + final req = SupabaseRequest(); + final resp = SupabaseResponse([ + await mock.serialize(DemoModel(age: 1, name: 'Demo 1', id: '1')), + await mock.serialize(DemoModel(age: 2, name: 'Demo 2', id: '2')), ]); - handleRequests(mockServer, {req: resp}); - final provider = SupabaseProvider(supabase, modelDictionary: supabaseModelDictionary); + mock.handle({req: resp}); + final provider = SupabaseProvider(mock.client, modelDictionary: supabaseModelDictionary); final retrieved = await provider.get(); expect(retrieved, hasLength(2)); expect(retrieved[0].id, '1'); @@ -158,20 +63,16 @@ void main() { group('#upsert', () { test('no associations', () async { - final req = _SupabaseRequest( - 'demos', + final req = SupabaseRequest( requestMethod: 'POST', - fields: 'id,name,age', filter: 'id=eq.1', limit: 1, ); - final resp = _SupabaseResponse( - {'id': '1', 'name': 'Demo 1', 'age': 1}, - ); - handleRequests(mockServer, {req: resp}); - - final provider = SupabaseProvider(supabase, modelDictionary: supabaseModelDictionary); final instance = DemoModel(age: 1, name: 'Demo 1', id: '1'); + final resp = SupabaseResponse(await mock.serialize(instance)); + mock.handle({req: resp}); + + final provider = SupabaseProvider(mock.client, modelDictionary: supabaseModelDictionary); final inserted = await provider.upsert(instance); expect(inserted.id, instance.id); expect(inserted.age, instance.age); @@ -179,39 +80,28 @@ void main() { }); test('one association', () async { - final demoModelReq = _SupabaseRequest( - 'demos', + final demoModelReq = SupabaseRequest( requestMethod: 'POST', - fields: 'id,name,age', filter: 'id=eq.2', limit: 1, ); - final demoModelResp = _SupabaseResponse( - {'id': '1', 'name': 'Demo 1', 'age': 1}, - ); - final assocReq = _SupabaseRequest( - 'demo_associations', + final demoModelResp = + SupabaseResponse(await mock.serialize(DemoModel(age: 1, name: 'Demo 1', id: '1'))); + final assocReq = SupabaseRequest( requestMethod: 'POST', - fields: 'id,name,assoc:demos!assoc_id(id,name,age),assocs:demos!assocs_id(id,name,age)', filter: 'id=eq.1', limit: 1, ); - final assocResp = _SupabaseResponse( - { - 'id': '1', - 'name': 'Demo 1', - 'age': 1, - 'assoc': {'id': '2', 'name': 'Nested', 'age': 1}, - }, - ); - handleRequests(mockServer, {demoModelReq: demoModelResp, assocReq: assocResp}); - - final provider = SupabaseProvider(supabase, modelDictionary: supabaseModelDictionary); final instance = DemoAssociationModel( assoc: DemoModel(age: 1, name: 'Nested', id: '2'), name: 'Demo 1', id: '1', ); + final assocResp = SupabaseResponse(await mock.serialize(instance)); + mock.handle({demoModelReq: demoModelResp, assocReq: assocResp}); + + final provider = SupabaseProvider(mock.client, modelDictionary: supabaseModelDictionary); + final inserted = await provider.upsert(instance); expect(inserted.id, instance.id); expect(inserted.assoc.age, instance.assoc.age);