From 234c5eb9e95ca041fc798fd0e9f0b63724b57735 Mon Sep 17 00:00:00 2001 From: Tim Shedor Date: Sun, 18 Aug 2024 23:27:48 -0700 Subject: [PATCH] eng(supabase): add supabase and offline first with supabase packages --- .../brick_offline_first_with_supabase.yaml | 18 +++ .github/workflows/brick_supabase.yaml | 18 +++ .../.gitignore | 1 + .../CHANGELOG.md | 5 + .../brick_offline_first_with_supabase/LICENSE | 21 +++ .../README.md | 30 ++++ .../analysis_options.yaml | 1 + .../brick_offline_first_with_supabase.dart | 3 + .../offline_first_with_supabase_adapter.dart | 9 ++ .../offline_first_with_supabase_model.dart | 4 + ...ffline_first_with_supabase_repository.dart | 30 ++++ .../pubspec.yaml | 27 ++++ packages/brick_supabase/CHANGELOG.md | 5 + packages/brick_supabase/LICENSE | 21 +++ packages/brick_supabase/README.md | 90 +++++++++++ packages/brick_supabase/analysis_options.yaml | 1 + .../brick_supabase/lib/brick_supabase.dart | 4 + .../lib/src/query_supabase_transformer.dart | 151 ++++++++++++++++++ .../lib/src/supabase_adapter.dart | 37 +++++ .../lib/src/supabase_model_dictionary.dart | 9 ++ .../lib/src/supabase_provider.dart | 97 +++++++++++ packages/brick_supabase/pubspec.yaml | 21 +++ 22 files changed, 603 insertions(+) create mode 100644 .github/workflows/brick_offline_first_with_supabase.yaml create mode 100644 .github/workflows/brick_supabase.yaml create mode 100644 packages/brick_offline_first_with_supabase/.gitignore create mode 100644 packages/brick_offline_first_with_supabase/CHANGELOG.md create mode 100644 packages/brick_offline_first_with_supabase/LICENSE create mode 100644 packages/brick_offline_first_with_supabase/README.md create mode 100644 packages/brick_offline_first_with_supabase/analysis_options.yaml create mode 100644 packages/brick_offline_first_with_supabase/lib/brick_offline_first_with_supabase.dart create mode 100644 packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_adapter.dart create mode 100644 packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_model.dart create mode 100644 packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_repository.dart create mode 100644 packages/brick_offline_first_with_supabase/pubspec.yaml create mode 100644 packages/brick_supabase/CHANGELOG.md create mode 100644 packages/brick_supabase/LICENSE create mode 100644 packages/brick_supabase/README.md create mode 100644 packages/brick_supabase/analysis_options.yaml create mode 100644 packages/brick_supabase/lib/brick_supabase.dart create mode 100644 packages/brick_supabase/lib/src/query_supabase_transformer.dart create mode 100644 packages/brick_supabase/lib/src/supabase_adapter.dart create mode 100644 packages/brick_supabase/lib/src/supabase_model_dictionary.dart create mode 100644 packages/brick_supabase/lib/src/supabase_provider.dart create mode 100644 packages/brick_supabase/pubspec.yaml diff --git a/.github/workflows/brick_offline_first_with_supabase.yaml b/.github/workflows/brick_offline_first_with_supabase.yaml new file mode 100644 index 00000000..181846ed --- /dev/null +++ b/.github/workflows/brick_offline_first_with_supabase.yaml @@ -0,0 +1,18 @@ +name: Brick Offline First with Supabase +on: + push: + branches: + - main + pull_request: + paths: + - "packages/brick_offline_first_with_supabase/**" + - ".github/workflows/brick_offline_first_with_supabase.yaml" + +env: + PUB_ENVIRONMENT: bot.github + +jobs: + analyze_format_test: + uses: ./.github/workflows/reusable-flutter-analyze-format-test.yaml + with: + package: brick_offline_first_with_supabase diff --git a/.github/workflows/brick_supabase.yaml b/.github/workflows/brick_supabase.yaml new file mode 100644 index 00000000..b6f4f6ff --- /dev/null +++ b/.github/workflows/brick_supabase.yaml @@ -0,0 +1,18 @@ +name: Brick Supabase +on: + push: + branches: + - main + pull_request: + paths: + - "packages/brick_supabase/**" + - ".github/workflows/brick_supabase.yaml" + +env: + PUB_ENVIRONMENT: bot.github + +jobs: + analyze_format_test: + uses: ./.github/workflows/reusable-dart-analyze-format-test.yaml + with: + package: brick_supabase diff --git a/packages/brick_offline_first_with_supabase/.gitignore b/packages/brick_offline_first_with_supabase/.gitignore new file mode 100644 index 00000000..79dcd336 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/.gitignore @@ -0,0 +1 @@ +!example/**/*.g.dart diff --git a/packages/brick_offline_first_with_supabase/CHANGELOG.md b/packages/brick_offline_first_with_supabase/CHANGELOG.md new file mode 100644 index 00000000..b5de7397 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/CHANGELOG.md @@ -0,0 +1,5 @@ +## Unreleased + +### 0.0.1 + +Initial diff --git a/packages/brick_offline_first_with_supabase/LICENSE b/packages/brick_offline_first_with_supabase/LICENSE new file mode 100644 index 00000000..3b662c6b --- /dev/null +++ b/packages/brick_offline_first_with_supabase/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Green Bits, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without supabaseriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/brick_offline_first_with_supabase/README.md b/packages/brick_offline_first_with_supabase/README.md new file mode 100644 index 00000000..626ad5ce --- /dev/null +++ b/packages/brick_offline_first_with_supabase/README.md @@ -0,0 +1,30 @@ +![brick_offline_first_with_supabase workflow](https://github.com/GetDutchie/brick/actions/workflows/brick_offline_first_with_supabase.yaml/badge.svg) + +`OfflineFirstWithSupabaseRepository` streamlines the Supabase integration with an `OfflineFirstRepository`. + +The `OfflineFirstWithSupabase` domain uses all the same configurations and annotations as `OfflineFirst`. + +## Models + +### ConnectOfflineFirstWithSupabase + +`@ConnectOfflineFirstWithSupabase` decorates the model that can be serialized by one or more providers. Offline First does not have configuration at the class level and only extends configuration held by its providers: + +```dart +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(), + sqliteConfig: SqliteSerializable(), +) +class MyModel extends OfflineFirstModel {} +``` + +### FAQ + +#### Why can't I declare a model argument? + +Due to [an open analyzer bug](https://github.com/dart-lang/sdk/issues/38309), a custom model cannot be passed to the repository as a type argument. + +## Unsupported Field Types + +- Any unsupported field types from `SupabaseProvider`, or `SqliteProvider` +- Future iterables of future models (i.e. `Future>>`). diff --git a/packages/brick_offline_first_with_supabase/analysis_options.yaml b/packages/brick_offline_first_with_supabase/analysis_options.yaml new file mode 100644 index 00000000..f04c6cf0 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/brick_offline_first_with_supabase/lib/brick_offline_first_with_supabase.dart b/packages/brick_offline_first_with_supabase/lib/brick_offline_first_with_supabase.dart new file mode 100644 index 00000000..3fce4164 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/lib/brick_offline_first_with_supabase.dart @@ -0,0 +1,3 @@ +export 'package:brick_offline_first_with_supabase/src/offline_first_with_supabase_adapter.dart'; +export 'package:brick_offline_first_with_supabase/src/offline_first_with_supabase_model.dart'; +export 'package:brick_offline_first_with_supabase/src/offline_first_with_supabase_repository.dart'; diff --git a/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_adapter.dart b/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_adapter.dart new file mode 100644 index 00000000..5bc8ab0f --- /dev/null +++ b/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_adapter.dart @@ -0,0 +1,9 @@ +import 'package:brick_offline_first/brick_offline_first.dart'; +import 'package:brick_offline_first_with_supabase/src/offline_first_with_supabase_model.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +/// This adapter fetches first from [SqliteProvider] then hydrates with [SupabaseProvider]. +abstract class OfflineFirstWithSupabaseAdapter<_Model extends OfflineFirstWithSupabaseModel> + extends OfflineFirstAdapter<_Model> with SupabaseAdapter<_Model> { + OfflineFirstWithSupabaseAdapter(); +} diff --git a/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_model.dart b/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_model.dart new file mode 100644 index 00000000..f0c5a8c2 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_model.dart @@ -0,0 +1,4 @@ +import 'package:brick_offline_first/brick_offline_first.dart'; +import 'package:brick_supabase_abstract/brick_supabase_abstract.dart'; + +abstract class OfflineFirstWithSupabaseModel extends OfflineFirstModel with SupabaseModel {} diff --git a/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_repository.dart b/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_repository.dart new file mode 100644 index 00000000..96791a95 --- /dev/null +++ b/packages/brick_offline_first_with_supabase/lib/src/offline_first_with_supabase_repository.dart @@ -0,0 +1,30 @@ +import 'package:brick_offline_first/brick_offline_first.dart'; +import 'package:brick_offline_first_with_supabase/src/offline_first_with_supabase_model.dart'; +import 'package:brick_supabase/brick_supabase.dart' show SupabaseProvider; + +/// Ensures the [remoteProvider] is a [SupabaseProvider]. +/// +/// OfflineFirstWithSupabaseRepository should accept a type argument such as +/// <_RepositoryModel extends OfflineFirstWithSupabaseModel>, however, this causes a type bound +/// error on runtime. The argument should be reintroduced with a future version of the +/// compiler/analyzer. +abstract class OfflineFirstWithSupabaseRepository + extends OfflineFirstRepository { + /// The type declaration is important here for the rare circumstances that + /// require interfacting with [SupabaseProvider]'s client directly. + @override + // ignore: overridden_fields + final SupabaseProvider remoteProvider; + + OfflineFirstWithSupabaseRepository({ + super.autoHydrate, + super.loggerName, + super.memoryCacheProvider, + required super.migrations, + required SupabaseProvider supabaseProvider, + required super.sqliteProvider, + }) : remoteProvider = supabaseProvider, + super( + remoteProvider: supabaseProvider, + ); +} diff --git a/packages/brick_offline_first_with_supabase/pubspec.yaml b/packages/brick_offline_first_with_supabase/pubspec.yaml new file mode 100644 index 00000000..8227c05f --- /dev/null +++ b/packages/brick_offline_first_with_supabase/pubspec.yaml @@ -0,0 +1,27 @@ +name: brick_offline_first_with_supabase +description: A Brick domain that routes data fetching through local providers + before a Supabase provider. +homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_offline_first_with_supabase +issue_tracker: https://github.com/GetDutchie/brick/issues +repository: https://github.com/GetDutchie/brick + +version: 0.0.1 + +environment: + sdk: ">=2.18.0 <4.0.0" + +dependencies: + brick_core: ^1.1.1 + brick_offline_first: ">=3.0.0 <4.0.0" + brick_supabase: ">=0.0.1 <2.0.0" + brick_supabase_abstract: ">=0.0.1 <2.0.0" + brick_sqlite: ">=3.0.0 <4.0.0" + logging: ">=1.0.0 <2.0.0" + meta: ">=1.3.0 <2.0.0" + sqflite_common: ">=2.0.0 <3.0.0" + +dev_dependencies: + lints: ^2.0.1 + mockito: ^5.0.0 + test: ^1.16.5 + sqflite_common_ffi: ^2.0.0 diff --git a/packages/brick_supabase/CHANGELOG.md b/packages/brick_supabase/CHANGELOG.md new file mode 100644 index 00000000..b5de7397 --- /dev/null +++ b/packages/brick_supabase/CHANGELOG.md @@ -0,0 +1,5 @@ +## Unreleased + +### 0.0.1 + +Initial diff --git a/packages/brick_supabase/LICENSE b/packages/brick_supabase/LICENSE new file mode 100644 index 00000000..3b662c6b --- /dev/null +++ b/packages/brick_supabase/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Green Bits, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without supabaseriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/brick_supabase/README.md b/packages/brick_supabase/README.md new file mode 100644 index 00000000..c797e2fc --- /dev/null +++ b/packages/brick_supabase/README.md @@ -0,0 +1,90 @@ +![brick_supabase workflow](https://github.com/GetDutchie/brick/actions/workflows/brick_supabase.yaml/badge.svg) + +# Supabase Provider + +Connecting [Brick](https://github.com/GetDutchie/brick) with Supabase. + +## Models + +### `@SupabaseSerializable(tableName:)` + +The Supabase table name must be specified to connect `from`, `upsert` and `delete` invocations: + +```dart +@SupabaseSerializable(tableName: 'users') +class User +``` + +## Fields + +### `@Supabase(unique:)` + +Connect Supabase's primary key (or any other index) to your application code. This is useful for `upsert` and `delete` logic when mutating instances. + +```dart +@Supabase(unique: true, name: 'id') +final int supabaseId; +``` + +### `@Supabase(foreignKey:)` + +Specify the foreign key to use on the table when fetching for a remote association. + +For example, given the `orders` table has a `customer_id` column that associates +the `customers` table, an `Order` class in Dart may look like: + +```dart +@SupabaseSerializeable(tableName: 'orders') +class Order { + @Supabase(foreignKey: 'customer_id') + final Customer customer; +} + +@SupabaseSerializeable(tableName: 'customers') +class Customer { + final int id; +} +``` + +### `@Supabase(enumAsString:)` + +Brick by default assumes enums from a REST API will be delivered as integers matching the index in the Flutter app. However, if your API delivers strings instead, the field can be easily annotated without writing a custom generator. + +Given the API: + +```json +{ "user": { "hats": ["bowler", "birthday"] } } +``` + +Simply convert `hats` into a Dart enum: + +```dart +enum Hat { baseball, bowler, birthday } + +... + +@Supabase(enumAsString: true) +final List hats; +``` + +### `@Supabase(name:)` + +REST keys can be renamed per field. This will override the default set by `SupabaseSerializable#fieldRename`. + +```dart +@Supabase( + name: "full_name" // "full_name" is used in from and to requests to REST instead of "last_name" +) +final String lastName; +``` + +### `@Supabase(ignoreFrom:)` and `@Supabase(ignoreTo:)` + +When true, the field will be ignored by the (de)serializing function in the adapter. + +## Unsupported Field Types + +The following are not serialized to REST. However, unsupported types can still be accessed in the model as non-final fields. + +- Nested `List<>` e.g. `>>` +- Many-to-many associations diff --git a/packages/brick_supabase/analysis_options.yaml b/packages/brick_supabase/analysis_options.yaml new file mode 100644 index 00000000..f04c6cf0 --- /dev/null +++ b/packages/brick_supabase/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/brick_supabase/lib/brick_supabase.dart b/packages/brick_supabase/lib/brick_supabase.dart new file mode 100644 index 00000000..5747d516 --- /dev/null +++ b/packages/brick_supabase/lib/brick_supabase.dart @@ -0,0 +1,4 @@ +export 'package:brick_supabase/src/supabase_adapter.dart'; +export 'package:brick_supabase/src/supabase_model_dictionary.dart'; +export 'package:brick_supabase/src/supabase_provider.dart'; +export 'package:brick_supabase_abstract/brick_supabase_abstract.dart'; diff --git a/packages/brick_supabase/lib/src/query_supabase_transformer.dart b/packages/brick_supabase/lib/src/query_supabase_transformer.dart new file mode 100644 index 00000000..d6475c27 --- /dev/null +++ b/packages/brick_supabase/lib/src/query_supabase_transformer.dart @@ -0,0 +1,151 @@ +import 'package:brick_core/core.dart'; +import 'package:brick_supabase/src/supabase_adapter.dart'; +import 'package:brick_supabase/src/supabase_model_dictionary.dart'; +import 'package:brick_supabase_abstract/brick_supabase_abstract.dart' hide Supabase; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Create a prepared SQLite statement for eventual execution. Only [statement] and [values] +/// should be accessed. +/// +/// Example (using SQLFlite): +/// ```dart +/// final sqliteQuery = QuerySqlTransformer(modelDictionary: dict, query: query); +/// final results = await (await db).rawQuery(sqliteQuery.statement, sqliteQuery.values); +/// ``` +class QuerySupabaseTransformer<_Model extends SupabaseModel> { + final SupabaseAdapter adapter; + final SupabaseModelDictionary modelDictionary; + + /// Must-haves for the [statement], mainly used to build clauses + final Query? query; + + /// [selectStatement] will output [statement] as a `SELECT FROM`. When false, the [statement] + /// output will be a `SELECT COUNT(*)`. Defaults `true`. + QuerySupabaseTransformer({ + required this.modelDictionary, + this.query, + }) : adapter = modelDictionary.adapterFor[_Model]!; + + String get selectQuery { + return _destructureAssociation(adapter.fieldsToSupabaseColumns.values).join(',\n '); + } + + PostgrestFilterBuilder>> select() { + final builder = Supabase.instance.client.from(adapter.tableName); + + return (query?.where ?? []).fold(builder.select(selectQuery), (acc, condition) { + final whereStatement = _expandCondition(condition); + for (final where in whereStatement) { + for (final entry in where.entries) { + final newUri = acc.appendSearchParams(entry.key, entry.value); + acc = acc.copyWithUrl(newUri); + } + } + return acc; + }); + } + + List _destructureAssociation(Iterable? columns) { + final selectedFields = []; + + if (columns == null) return selectedFields; + + for (final field in columns) { + if (field.association && field.associationType != null) { + var associationOutput = + '${field.columnName}:${modelDictionary.adapterFor[field.associationType!]?.tableName}'; + if (field.associationForeignKey != null) { + associationOutput += '!${field.associationForeignKey}'; + } + associationOutput += '('; + final fields = _destructureAssociation( + modelDictionary.adapterFor[field.associationType!]?.fieldsToSupabaseColumns.values, + ); + associationOutput += fields.join(',\n '); + associationOutput += ')'; + + selectedFields.add(associationOutput); + continue; + } + + selectedFields.add(field.columnName); + } + + return selectedFields; + } + + String _compareToSearchParam(Compare compare) { + switch (compare) { + case Compare.exact: + return 'eq'; + case Compare.contains: + return 'like'; + case Compare.doesNotContain: + return 'not.like'; + case Compare.greaterThan: + return 'gt'; + case Compare.greaterThanOrEqualTo: + return 'gte'; + case Compare.lessThan: + return 'lt'; + case Compare.lessThanOrEqualTo: + return 'lte'; + case Compare.between: + return 'adj'; + case Compare.notEqual: + return 'neq'; + } + } + + /// Recursively step through a `Where` or `WherePhrase` to ouput a condition for `WHERE`. + List> _expandCondition( + WhereCondition condition, [ + SupabaseAdapter? passedAdapter, + ]) { + passedAdapter ??= adapter; + + // Begin a separate where phrase + if (condition is WherePhrase) { + final conditions = condition.conditions + .map((c) => _expandCondition(c, passedAdapter)) + .expand((c) => c) + .toList(); + + if (condition.isRequired) { + return conditions; + } + + return [ + { + 'or': '(${conditions.map((c) => '${c.keys.first}.${c.values.first}').join(', ')})', + } + ]; + } + + if (!passedAdapter.fieldsToSupabaseColumns.containsKey(condition.evaluatedField)) { + throw ArgumentError( + 'Field ${condition.evaluatedField} on $_Model is not serialized by SQLite', + ); + } + + final definition = passedAdapter.fieldsToSupabaseColumns[condition.evaluatedField]!; + + if (definition.association) { + if (condition.value is! WhereCondition) { + throw ArgumentError( + 'Query value for association ${condition.evaluatedField} on $_Model must be a Where or WherePhrase', + ); + } + + final associationAdapter = modelDictionary.adapterFor[definition.associationType]!; + + return _expandCondition(condition.value as WhereCondition, associationAdapter); + } + + return [ + { + definition.columnName: '${_compareToSearchParam(condition.compare)}.${condition.value}', + } + ]; + } +} diff --git a/packages/brick_supabase/lib/src/supabase_adapter.dart b/packages/brick_supabase/lib/src/supabase_adapter.dart new file mode 100644 index 00000000..c75dd7ab --- /dev/null +++ b/packages/brick_supabase/lib/src/supabase_adapter.dart @@ -0,0 +1,37 @@ +import 'package:brick_core/core.dart'; +import 'package:brick_supabase/src/supabase_provider.dart'; +import 'package:brick_supabase_abstract/brick_supabase_abstract.dart'; + +/// Constructors that convert app models to and from Supabase +abstract class SupabaseAdapter implements Adapter { + /// Used for upserts; forwards to Supabase's `defaultToNull` + bool get defaultToNull; + + /// A dictionary that connects field names to Supabase columns. + Map get fieldsToSupabaseColumns; + + /// Used for upserts; forwards to Supabase's `ignoreDuplicates` + bool get ignoreDuplicates; + + /// Used for upserts; forwards to Supabase's `onConflict` + String? get onConflict; + + /// Declared by the [SupabaseSerializable] `tableName` property + String get tableName; + + /// Unique fields that map to Supabase columns (using [fieldsToSupabaseColumns]) + /// used to target upsert and delete operations. + Set get uniqueFields; + + TModel fromSupabase( + Map input, { + required SupabaseProvider provider, + ModelRepository? repository, + }); + + Future> toSupabase( + TModel input, { + required SupabaseProvider provider, + ModelRepository? repository, + }); +} diff --git a/packages/brick_supabase/lib/src/supabase_model_dictionary.dart b/packages/brick_supabase/lib/src/supabase_model_dictionary.dart new file mode 100644 index 00000000..f81f6d1c --- /dev/null +++ b/packages/brick_supabase/lib/src/supabase_model_dictionary.dart @@ -0,0 +1,9 @@ +import 'package:brick_core/core.dart'; +import 'package:brick_supabase/src/supabase_adapter.dart'; +import 'package:brick_supabase_abstract/brick_supabase_abstract.dart'; + +/// Associates app models with their [SupabaseAdapter] +class SupabaseModelDictionary + extends ModelDictionary> { + const SupabaseModelDictionary(super.adapterFor); +} diff --git a/packages/brick_supabase/lib/src/supabase_provider.dart b/packages/brick_supabase/lib/src/supabase_provider.dart new file mode 100644 index 00000000..5fe3fbae --- /dev/null +++ b/packages/brick_supabase/lib/src/supabase_provider.dart @@ -0,0 +1,97 @@ +import 'package:brick_core/core.dart'; +import 'package:brick_supabase/src/query_supabase_transformer.dart'; +import 'package:brick_supabase/src/supabase_model_dictionary.dart'; +import 'package:brick_supabase_abstract/brick_supabase_abstract.dart' hide Supabase; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Retrieves from an HTTP endpoint +class SupabaseProvider implements Provider { + /// A fully-qualified URL + final String baseEndpoint; + + /// The glue between app models and generated adapters. + @override + final SupabaseModelDictionary modelDictionary; + + @protected + final Logger logger; + + SupabaseProvider( + this.baseEndpoint, { + required this.modelDictionary, + }) : logger = Logger('SupabaseProvider'); + + /// Sends a DELETE request method to the endpoint + @override + Future delete(instance, {query, repository}) async { + final adapter = modelDictionary.adapterFor[TModel]!; + final tableBuilder = Supabase.instance.client.from(adapter.tableName); + final output = await adapter.toSupabase(instance, provider: this, repository: repository); + + final queryTransformer = + QuerySupabaseTransformer(modelDictionary: modelDictionary, query: query); + + final builder = adapter.uniqueFields.fold(tableBuilder.delete(), (acc, uniqueFieldName) { + final columnName = adapter.fieldsToSupabaseColumns[uniqueFieldName]!.columnName; + if (output.containsKey(columnName)) { + return acc.eq(columnName, output[columnName]); + } + return acc; + }); + + final resp = await builder.select(queryTransformer.selectQuery).limit(1).maybeSingle(); + return resp == null; + } + + @override + Future exists({query, repository}) async { + final queryTransformer = + QuerySupabaseTransformer(modelDictionary: modelDictionary, query: query); + final builder = queryTransformer.select(); + + final resp = await builder.count(CountOption.exact); + return resp.count > 0; + } + + @override + Future> get({query, repository}) async { + final adapter = modelDictionary.adapterFor[TModel]!; + final queryTransformer = + QuerySupabaseTransformer(modelDictionary: modelDictionary, query: query); + final builder = queryTransformer.select(); + + final resp = await builder; + + return resp + .map((r) => adapter.fromSupabase(r, repository: repository, provider: this)) + .toList() + .cast(); + } + + @override + Future upsert(instance, {query, repository}) async { + final adapter = modelDictionary.adapterFor[TModel]!; + final tableBuilder = Supabase.instance.client.from(adapter.tableName); + final output = await adapter.toSupabase(instance, provider: this, repository: repository); + + final queryTransformer = + QuerySupabaseTransformer(modelDictionary: modelDictionary, query: query); + + final builder = adapter.uniqueFields.fold(tableBuilder.upsert(output), (acc, uniqueFieldName) { + final columnName = adapter.fieldsToSupabaseColumns[uniqueFieldName]!.columnName; + if (output.containsKey(columnName)) { + return acc.eq(columnName, output[columnName]); + } + return acc; + }); + final resp = await builder.select(queryTransformer.selectQuery).limit(1).maybeSingle(); + + if (resp == null) { + throw StateError('Upsert of $instance failed'); + } + + return adapter.fromSupabase(resp, repository: repository, provider: this) as TModel; + } +} diff --git a/packages/brick_supabase/pubspec.yaml b/packages/brick_supabase/pubspec.yaml new file mode 100644 index 00000000..af88000a --- /dev/null +++ b/packages/brick_supabase/pubspec.yaml @@ -0,0 +1,21 @@ +name: brick_supabase +description: Supabase connector for Brick, a data persistence library. Includes annotations, adapter, model, and provider. +homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_supabase +issue_tracker: https://github.com/GetDutchie/brick/issues +repository: https://github.com/GetDutchie/brick + +version: 0.0.1 + +environment: + sdk: ">=2.18.0 <4.0.0" + +dependencies: + brick_core: ^1.1.1 + supabase_flutter: ">=2.6.0 <3.0.0" + logging: ">=1.0.0 <2.0.0" + meta: ">=1.3.0 <2.0.0" + brick_supabase_abstract: ">=0.0.1 <2.0.0" + +dev_dependencies: + lints: ^2.0.1 + test: ^1.16.5