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..0bd12b3e --- /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-dart-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/.github/workflows/reusable-dart-analyze-format-test.yaml b/.github/workflows/reusable-dart-analyze-format-test.yaml index ad22a27a..81093e4b 100644 --- a/.github/workflows/reusable-dart-analyze-format-test.yaml +++ b/.github/workflows/reusable-dart-analyze-format-test.yaml @@ -29,8 +29,8 @@ jobs: key: ${{ runner.os }}-${{ inputs.package }}-v1-${{ matrix.sdk_version }}-${{ hashFiles(format('packages/{0}/pubspec.yaml', inputs.package)) }} restore-keys: | ${{ runner.os }}-${{ inputs.package }}-v1-${{ matrix.sdk_version }} - - id: checkout - uses: actions/checkout@v3 + + - uses: actions/checkout@v3 - run: dart pub get && dart run melos bootstrap --scope="${{ inputs.package }}" diff --git a/.github/workflows/reusable-flutter-analyze-format-test.yaml b/.github/workflows/reusable-flutter-analyze-format-test.yaml index 8401344d..040834ad 100644 --- a/.github/workflows/reusable-flutter-analyze-format-test.yaml +++ b/.github/workflows/reusable-flutter-analyze-format-test.yaml @@ -15,7 +15,7 @@ jobs: flutter_analyze_format_test: runs-on: ubuntu-latest container: - image: cirrusci/flutter:${{ matrix.flutter_version }} + image: ghcr.io/cirruslabs/flutter:${{ matrix.flutter_version }} strategy: fail-fast: false matrix: @@ -32,17 +32,22 @@ jobs: restore-keys: | ${{ runner.os }}-${{ inputs.package }}-v1-${{ matrix.flutter_version }} - - id: checkout - uses: actions/checkout@v3 + - uses: actions/checkout@v3 - run: flutter pub get working-directory: packages/${{ inputs.package }} - - run: dart analyze --fatal-infos lib test + - run: dart analyze --fatal-infos lib working-directory: packages/${{ inputs.package }} - - run: dart format --output=none --line-length 100 --set-exit-if-changed lib test + - run: if [ -d "test" ]; then dart analyze --fatal-infos test; fi working-directory: packages/${{ inputs.package }} - - run: flutter test + - run: dart format --output=none --line-length 100 --set-exit-if-changed lib + working-directory: packages/${{ inputs.package }} + + - run: if [ -d "test" ]; then dart format --output=none --line-length 100 --set-exit-if-changed test; fi + working-directory: packages/${{ inputs.package }} + + - run: if [ -d "test" ]; then flutter test; fi working-directory: packages/${{ inputs.package }} diff --git a/packages/brick_offline_first_with_supabase_abstract/.gitignore b/packages/brick_offline_first_with_supabase/.gitignore similarity index 100% rename from packages/brick_offline_first_with_supabase_abstract/.gitignore rename to packages/brick_offline_first_with_supabase/.gitignore diff --git a/packages/brick_offline_first_with_supabase_abstract/CHANGELOG.md b/packages/brick_offline_first_with_supabase/CHANGELOG.md similarity index 100% rename from packages/brick_offline_first_with_supabase_abstract/CHANGELOG.md rename to packages/brick_offline_first_with_supabase/CHANGELOG.md diff --git a/packages/brick_supabase_abstract/LICENSE b/packages/brick_offline_first_with_supabase/LICENSE similarity index 100% rename from packages/brick_supabase_abstract/LICENSE rename to packages/brick_offline_first_with_supabase/LICENSE 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_abstract/analysis_options.yaml b/packages/brick_offline_first_with_supabase/analysis_options.yaml similarity index 100% rename from packages/brick_offline_first_with_supabase_abstract/analysis_options.yaml rename to packages/brick_offline_first_with_supabase/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_abstract/lib/src/connect_offline_first_with_supabase.dart b/packages/brick_offline_first_with_supabase/lib/src/annotations/connect_offline_first_with_supabase.dart similarity index 100% rename from packages/brick_offline_first_with_supabase_abstract/lib/src/connect_offline_first_with_supabase.dart rename to packages/brick_offline_first_with_supabase/lib/src/annotations/connect_offline_first_with_supabase.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_abstract/pubspec.yaml b/packages/brick_offline_first_with_supabase/pubspec.yaml similarity index 55% rename from packages/brick_offline_first_with_supabase_abstract/pubspec.yaml rename to packages/brick_offline_first_with_supabase/pubspec.yaml index 6dca6be4..8227c05f 100644 --- a/packages/brick_offline_first_with_supabase_abstract/pubspec.yaml +++ b/packages/brick_offline_first_with_supabase/pubspec.yaml @@ -1,7 +1,7 @@ -name: brick_offline_first_with_supabase_abstract +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_abstract +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 @@ -11,9 +11,17 @@ environment: sdk: ">=2.18.0 <4.0.0" dependencies: - brick_supabase_abstract: ">=0.0.1 <1.0.0" + 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_offline_first_with_supabase_abstract/README.md b/packages/brick_offline_first_with_supabase_abstract/README.md deleted file mode 100644 index 96e4711c..00000000 --- a/packages/brick_offline_first_with_supabase_abstract/README.md +++ /dev/null @@ -1,147 +0,0 @@ -![brick_offline_first_with_rest workflow](https://github.com/GetDutchie/brick/actions/workflows/brick_offline_first_with_rest.yaml/badge.svg) - -`OfflineFirstWithRestRepository` streamlines the REST integration with an `OfflineFirstRepository`. A serial queue is included to track REST requests in a separate SQLite database, only removing requests when a response is returned from the host (i.e. the device has lost internet connectivity). See `OfflineFirstWithRest#reattemptForStatusCodes`. - -The `OfflineFirstWithRest` domain uses all the same configurations and annotations as `OfflineFirst`. - -## Models - -### ConnectOfflineFirstWithRest - -`@ConnectOfflineFirstWithRest` 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 -@ConnectOfflineFirstWithRest( - restConfig: RestSerializable(), - sqliteConfig: SqliteSerializable(), -) -class MyModel extends OfflineFirstModel {} -``` - -## Generating Models from a REST Endpoint - -A utility class is provided to make model generation from a JSON API a snap. Given an endpoint, the converter will infer the type of a field and scaffold a class. For example, the following would be saved to the `lib` directory of your project and run `$ dart lib/converter_script.dart`: - -```dart -// lib/converter_script.dart -import 'package:brick_offline_first/rest_to_offline_first_converter.dart'; - -const BASE = "http://0.0.0.0:3000"; -const endpoint = "$BASE/users"; - -final converter = RestToOfflineFirstConverter(endpoint: endpoint); - -void main() { - converter.saveToFile(); -} - -// => dart lib/converter_script.dart -``` - -After the model is generated, double check for `List` and `null` types. While the converter is smart, it's not smarter than you. - -## Testing - -Responses can be stubbed to and from an `OfflineFirstWithRest` repository. For convenience, file data can be used to stub JSON responses from an API: - -```dart -// test/models/api/user.json -{ - "user": { "name" : "Thomas" } -} - -// test/models/user_test.dart -import 'package:brick_sqlite/testing.dart'; -import 'package:my_app/brick/repository.dart'; - -void main() { - group("MySqliteProvider", () { - late MyRepository repository; - setUpAll(() async { - repository = MyRepository( - restProvider: RestProvider( - client: StubOfflineFirstWithRest.fromFiles('http://0.0.0.0:3000', { - 'users': 'api/user.json' - }).client, - ) - ); - - await repository.initialize() - }); - }); -} -``` - -By default, the same response is returned for both `upsert` and `get` methods, with the only variation being in status code. However, responses can be configured for different methods: - -```dart -StubOfflineFirstWithRest( - baseEndpoint: 'http://0.0.0.0:3000', - responses: [ - StubOfflineFirstRestResponse.fromFile('users', 'api/user.json', StubHttpMethod.get), - StubOfflineFirstRestResponse.fromFile('users', 'api/user-post.json', StubHttpMethod.post), - ], -) -``` - -### Stubbing Without Files - -While storing the responses in a file can be convenient and reduce code clutter, responses can be defined inline: - -```dart -StubOfflineFirstWithRest( - baseEndpoint: 'http://0.0.0.0:3000', - responses: [ - StubOfflineFirstRestResponse('users', '{"name":"Bob"'), - StubOfflineFirstRestResponse('users', '{"name":"Alice"'), - ], -) -``` - -### Handling Endpoint Variations - -Variants in the endpoint must be explicitly declared. For example, `/user`, `/users`, `/users?by_first_name=Guy` are all different. When instantiating, specify any expected variants: - -```dart -StubOfflineFirstRestResponse( - endpoints: ["user", "users", "users?by_first_name=Guy"] -) -``` - -### Stubbing Multiple Models - -Rarely will only one model need to be stubbed. All classes in an app can be stubbed efficiently using `StubOfflineFirstWithRest`: - -```dart -setUpAll() async { - final config = { - User: ['user', 'users'], - // Even individual member endpoints must be declared for association fetching - // REST endpoints are manually configured, so the content may vary - Hat: ['hat/1', 'hat/2', 'hats'], - } - final responses = config.entries.map((modelConfig) { - return modelConfig.value.map((endpoint) { - return StubOfflineFirstRestResponse.fromFile( - 'api/${modelConfig.key.toString().toLowerCase()}.json', - endpoint: endpoint, - ); - }); - }).expand((e) => e); - final client = StubOfflineFirstWithRest( - baseEndpoint: 'http://0.0.0.0:3000', - responses: responses, - ).client; -} -``` - -### 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 `RestProvider`, or `SqliteProvider` -* Future iterables of future models (i.e. `Future>>`. diff --git a/packages/brick_offline_first_with_supabase_abstract/lib/brick_offline_first_with_supabase_abstract.dart b/packages/brick_offline_first_with_supabase_abstract/lib/brick_offline_first_with_supabase_abstract.dart deleted file mode 100644 index ac8637cd..00000000 --- a/packages/brick_offline_first_with_supabase_abstract/lib/brick_offline_first_with_supabase_abstract.dart +++ /dev/null @@ -1 +0,0 @@ -export 'package:brick_offline_first_with_supabase_abstract/src/connect_offline_first_with_supabase.dart'; diff --git a/packages/brick_supabase_abstract/CHANGELOG.md b/packages/brick_supabase/CHANGELOG.md similarity index 100% rename from packages/brick_supabase_abstract/CHANGELOG.md rename to packages/brick_supabase/CHANGELOG.md diff --git a/packages/brick_offline_first_with_supabase_abstract/LICENSE b/packages/brick_supabase/LICENSE similarity index 92% rename from packages/brick_offline_first_with_supabase_abstract/LICENSE rename to packages/brick_supabase/LICENSE index f8548186..3b662c6b 100644 --- a/packages/brick_offline_first_with_supabase_abstract/LICENSE +++ b/packages/brick_supabase/LICENSE @@ -4,7 +4,7 @@ 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 restriction, including without limitation the rights +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: diff --git a/packages/brick_supabase/README.md b/packages/brick_supabase/README.md new file mode 100644 index 00000000..a70ef8f3 --- /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: 'uuid') +final String supabaseUuid; +``` + +### `@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_abstract/analysis_options.yaml b/packages/brick_supabase/analysis_options.yaml similarity index 100% rename from packages/brick_supabase_abstract/analysis_options.yaml rename to packages/brick_supabase/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..b2becb5e --- /dev/null +++ b/packages/brick_supabase/lib/brick_supabase.dart @@ -0,0 +1,7 @@ +export 'package:brick_supabase/src/annotations/supabase.dart'; +export 'package:brick_supabase/src/annotations/supabase_serializable.dart'; +export 'package:brick_supabase/src/runtime_supabase_column_definition.dart'; +export 'package:brick_supabase/src/supabase_adapter.dart'; +export 'package:brick_supabase/src/supabase_model.dart'; +export 'package:brick_supabase/src/supabase_model_dictionary.dart'; +export 'package:brick_supabase/src/supabase_provider.dart'; diff --git a/packages/brick_supabase_abstract/lib/src/supabase.dart b/packages/brick_supabase/lib/src/annotations/supabase.dart similarity index 100% rename from packages/brick_supabase_abstract/lib/src/supabase.dart rename to packages/brick_supabase/lib/src/annotations/supabase.dart diff --git a/packages/brick_supabase_abstract/lib/src/supabase_serializable.dart b/packages/brick_supabase/lib/src/annotations/supabase_serializable.dart similarity index 100% rename from packages/brick_supabase_abstract/lib/src/supabase_serializable.dart rename to packages/brick_supabase/lib/src/annotations/supabase_serializable.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..bc4fe96d --- /dev/null +++ b/packages/brick_supabase/lib/src/query_supabase_transformer.dart @@ -0,0 +1,149 @@ +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/supabase.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(SupabaseQueryBuilder builder) { + 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_abstract/lib/src/runtime_supabase_column_definition.dart b/packages/brick_supabase/lib/src/runtime_supabase_column_definition.dart similarity index 100% rename from packages/brick_supabase_abstract/lib/src/runtime_supabase_column_definition.dart rename to packages/brick_supabase/lib/src/runtime_supabase_column_definition.dart 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..b4c8bad0 --- /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 mixin 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_abstract/lib/src/supabase_model.dart b/packages/brick_supabase/lib/src/supabase_model.dart similarity index 100% rename from packages/brick_supabase_abstract/lib/src/supabase_model.dart rename to packages/brick_supabase/lib/src/supabase_model.dart 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..9923191c --- /dev/null +++ b/packages/brick_supabase/lib/src/supabase_provider.dart @@ -0,0 +1,101 @@ +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/supabase.dart'; + +/// Retrieves from an HTTP endpoint +class SupabaseProvider implements Provider { + /// A fully-qualified URL + final String baseEndpoint; + + final SupabaseClient client; + + /// The glue between app models and generated adapters. + @override + final SupabaseModelDictionary modelDictionary; + + @protected + final Logger logger; + + SupabaseProvider( + this.baseEndpoint, { + required this.client, + 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 = 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 adapter = modelDictionary.adapterFor[TModel]!; + final queryTransformer = + QuerySupabaseTransformer(modelDictionary: modelDictionary, query: query); + final builder = queryTransformer.select(client.from(adapter.tableName)); + + 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(client.from(adapter.tableName)); + + 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 output = await adapter.toSupabase(instance, provider: this, repository: repository); + + final queryTransformer = + QuerySupabaseTransformer(modelDictionary: modelDictionary, query: query); + + final builder = adapter.uniqueFields.fold(client.from(adapter.tableName).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_abstract/pubspec.yaml b/packages/brick_supabase/pubspec.yaml similarity index 52% rename from packages/brick_supabase_abstract/pubspec.yaml rename to packages/brick_supabase/pubspec.yaml index a3bcc432..77e15497 100644 --- a/packages/brick_supabase_abstract/pubspec.yaml +++ b/packages/brick_supabase/pubspec.yaml @@ -1,6 +1,6 @@ -name: brick_supabase_abstract -description: Supabase annotations and model/adapter to remove the Flutter restriction on build packages -homepage: https://github.com/GetDutchie/brick/tree/main/packages/brick_supabase_abstract +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 @@ -11,6 +11,10 @@ environment: dependencies: brick_core: ^1.1.1 + supabase: ">=2.3.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 diff --git a/packages/brick_supabase_abstract/README.md b/packages/brick_supabase_abstract/README.md deleted file mode 100644 index 5e384beb..00000000 --- a/packages/brick_supabase_abstract/README.md +++ /dev/null @@ -1,202 +0,0 @@ -![brick_supabase workflow](https://github.com/GetDutchie/brick/actions/workflows/brick_supabase.yaml/badge.svg) - -# REST Provider - -Connecting [Brick](https://github.com/GetDutchie/brick) with a RESTful API. - -## Supported `Query` Configuration - -### `providerArgs:` - -- `'request'` (`SupabaseRequest`) Specifies configurable information about the request like HTTP method or top level key - -### `where:` - -`SupabaseProvider` does not support any `Query#where` arguments. These should be configured on a model-by-model base by the `SupabaseSerializable#endpoint` argument. - -## Models - -### `@SupabaseSerializable(requestTransformer:)` - -:bulb: `requestTransformer` was added in Brick 3. For upgrading to Brick v3 from v2, please see [the migration guide](https://github.com/GetDutchie/brick/blob/main/MIGRATING.md). - -Every REST API is built differently, and with a fair amount of technical debt. Brick provides flexibility for inconsistent endpoints within any system. Endpoints can also change based on the query. The model adapter will query `endpoint` for `upsert` or `get` or `delete`. - -Since Dart requires annotations to be constants, dynamic functions cannot be used. This is a headache. Instead, the a `const`antized [constructor tearoff](https://medium.com/dartlang/dart-2-15-7e7a598e508a) can be used. The transformers permit dynamically defining the request (method, top level key, url, etc.) at runtime based on query params or if a Dart instance is available (`upsert` and `delete` only) - -```dart -class UserRequestTransformer extends SupabaseRequestTransformer { - final get = const SupabaseRequest(url: '/users'); - const UserRequestTransformer(Query? query, SupabaseModel? instance) : super(query, instance); -} - -@ConnectOfflineFirstWithSupabase( - supabaseConfig: SupabaseSerializable( - requestTransformer: UserRequestTransformer.new; - ) -) -class User extends OfflineFirstModel {} -``` - -Different provider calls will use different transformer fields: - -```dart -class UserRequestTransformer extends SupabaseRequestTransformer { - final get = const SupabaseRequest(url: '/users'); - final delete = SupabaseRequest(url: '/users/${instance.id}'); - - const UserRequestTransformer(Query? query, Model? instance) : super(query, instance); -} - -@ConnectOfflineFirstWithSupabase( - supabaseConfig: SupabaseSerializable( - requestTransformer: UserRequestTransformer.new, - ) -) -class User extends OfflineFirstModel {} -``` - -:warning: If an `SupabaseRequestTransform`'s method field (`get`, `upsert`, `delete`) is `null` or it's `url` is `null`, the request is skipped by the provider. - -#### With Query#providerArgs - -```dart -class UserRequestTransformer extends SupabaseRequestTransformer { - SupabaseRequest? get get { - if (query?.providerArgs.isNotEmpty && query.providerArgs['limit'] != null) { - return SupabaseRequest(url: "/users?limit=${query.providerArgs['limit']}"); - } - const SupabaseRequest(url: '/users'); - } - - final delete = SupabaseRequest(url: '/users/${instance.id}'); - - const UserRequestTransformer(Query? query, SupabaseModel? instance) : super(query, instance); -} - -@ConnectOfflineFirstWithSupabase( - supabaseConfig: SupabaseSerializable( - requestTransformer: UserRequestTransformer.new, - ) -) -class User extends OfflineFirstModel {} -``` - -#### With Query#where - -```dart -class UserRequestTransformer extends SupabaseRequestTransformer { - SupabaseRequest? get get { - if (query?.where != null) { - final id = Where.firstByField('id', query.where)?.value; - if (id != null) return SupabaseRequest(url: "/users/$id"); - } - const SupabaseRequest(url: '/users'); - } - - final delete = SupabaseRequest(url: '/users/${instance.id}'); - - const UserRequestTransformer(Query? query, SupabaseModel? instance) : super(query, instance); -} - -@ConnectOfflineFirstWithSupabase( - supabaseConfig: SupabaseSerializable( - requestTransformer: UserRequestTransformer.new, - ) -) -class User extends OfflineFirstModel {} -``` - -:bulb: For ease of illustration, the code is provided as if the transformer and model logic live in the same file. It's strongly recommended to include the request transformer logic in its own, colocated file (such as `user.model.request.dart`). - -### `@SupabaseRequest(topLevelKey:)` - -Data will most often be nested beneath a top-level key in a JSON response. The key is determined by the following priority: - -1. A `topLevelKey` in `Query#providerArgs['request']` with a non-empty value -1. `topLevelKey` if defined in a `SupabaseRequest` -1. The first discovered key. As a map is effectively an unordered list, relying on this fall through is not recommended. - -```dart -class UserRequestTransformer extends SupabaseRequestTransformer { - final get = const SupabaseRequest(url: '/users', topLevelKey: 'users'); - final upsert = const SupabaseRequest(url: '/users', topLevelKey: 'user'); - const UserRequestTransformer(Query? query, SupabaseModel? instance) : super(query, instance); -} - -@ConnectOfflineFirstWithSupabase( - requestTransformer: UserRequestTransformer.new -) -class User extends OfflineFirstModel {} -``` - -:warning: If the response from REST **is not** a map, the full response is returned instead. - -### `@SupabaseSerializable(fieldRename:)` - -Brick reduces the need to map REST keys to model field names by assuming a standard naming convention. For example: - -```dart -SupabaseSerializable(fieldRename: FieldRename.snake_case) -// on from supabase (get) - "last_name" => final String lastName -// on to supabase (upsert) -final String lastName => "last_name" -``` - -## Fields - -### `@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. - -## GZipping Requests - -All requests to the API endpoint can be compressed with Dart's standard [GZip library](https://api.dart.dev/stable/2.10.4/dart-io/GZipCodec-class.html). All requests will (over)write the `Content-Encoding` header to `{'Content-Encoding': 'gzip'}`. - -```dart -import 'package:brick_supabase/gzip_http_client.dart'; - -final supabaseProvider = SupabaseProvider(client: GZipHttpClient(level: 9)); -``` - -:warning: Your API must be able to accept and decode GZipped requests. - -## 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_abstract/lib/brick_supabase_abstract.dart b/packages/brick_supabase_abstract/lib/brick_supabase_abstract.dart deleted file mode 100644 index a29d8aa0..00000000 --- a/packages/brick_supabase_abstract/lib/brick_supabase_abstract.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'package:brick_supabase_abstract/src/runtime_supabase_column_definition.dart'; -export 'package:brick_supabase_abstract/src/supabase.dart'; -export 'package:brick_supabase_abstract/src/supabase_model.dart'; -export 'package:brick_supabase_abstract/src/supabase_serializable.dart';