From 278aa99186f53fe43670bfbe78e7fd31815a337f Mon Sep 17 00:00:00 2001 From: Jens Becker Date: Wed, 17 Jul 2024 17:20:01 +0200 Subject: [PATCH] eng(supabase): add supabase example (#392) --- example_supabase/README.md | 38 ++++++ .../brick/adapters/customer_adapter.g.dart | 126 ++++++++++++++++++ example_supabase/lib/brick/brick.g.dart | 36 +++++ .../brick/db/20240714103011.migration.dart | 44 ++++++ example_supabase/lib/brick/db/schema.g.dart | 20 +++ .../lib/brick/models/customer.model.dart | 23 ++++ .../brick/models/customer.model.request.dart | 20 +++ example_supabase/lib/brick/repository.dart | 39 ++++++ .../lib/brick/supabase_brick_client.dart | 51 +++++++ example_supabase/lib/env.dart | 4 + example_supabase/lib/main.dart | 104 +++++++++++++++ example_supabase/pubspec.yaml | 29 ++++ 12 files changed, 534 insertions(+) create mode 100644 example_supabase/README.md create mode 100644 example_supabase/lib/brick/adapters/customer_adapter.g.dart create mode 100644 example_supabase/lib/brick/brick.g.dart create mode 100644 example_supabase/lib/brick/db/20240714103011.migration.dart create mode 100644 example_supabase/lib/brick/db/schema.g.dart create mode 100644 example_supabase/lib/brick/models/customer.model.dart create mode 100644 example_supabase/lib/brick/models/customer.model.request.dart create mode 100644 example_supabase/lib/brick/repository.dart create mode 100644 example_supabase/lib/brick/supabase_brick_client.dart create mode 100644 example_supabase/lib/env.dart create mode 100644 example_supabase/lib/main.dart create mode 100644 example_supabase/pubspec.yaml diff --git a/example_supabase/README.md b/example_supabase/README.md new file mode 100644 index 00000000..60396e8e --- /dev/null +++ b/example_supabase/README.md @@ -0,0 +1,38 @@ +# Brick with Supabase Example + +This minimal example demonstrates how to use Brick with [Supabase](https://supabase.com/). Follow the instructions below to get started. + +Every Supabase project comes with a [ready-to-use REST API](https://supabase.com/docs/guides/api) using [PostgREST](https://postgrest.org/) which Brick can use to interact with the database. + +## Setting up the Supabase project + +1. **Create the table**: Run the following SQL command in your Supabase SQL editor to create the customers table: + +```sql +CREATE TABLE customers ( + id UUID PRIMARY KEY, + first_name text NOT NULL, + last_name text NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +2. **Insert Dummy Data**: Insert some dummy data into the customers table by running the following SQL command + +```sql +INSERT INTO customers (id, first_name, last_name, created_at) VALUES + ('a8098c1a-f86e-11da-bd1a-00112444be1e', 'Bruce', 'Fortner', NOW()), + ('b8098c1a-f86e-11da-bd1a-00112444be1e', 'Jane', 'Smith', NOW()), + ('c8098c1a-f86e-11da-bd1a-00112444be1e', 'Alice', 'Johnson', NOW()); +``` + + +3. **Enable Anonymous Sign-Ins**: Go to your Supabase dashboard, navigate to Settings > Authentication > User Signups, and enable anonymous sign-ins. + +## Setting up the Flutter example project + +4. **Update Environment Variables**: Open the `lib/env.dart` file and update it with your Supabase project URL and anonymous key. You can find these values in the Supabase dashboard under Settings > API. + +## Running the Flutter app + +5. **Run the Flutter Project**: This example supports iOS and Android. Make sure run `flutter create .` first. \ No newline at end of file diff --git a/example_supabase/lib/brick/adapters/customer_adapter.g.dart b/example_supabase/lib/brick/adapters/customer_adapter.g.dart new file mode 100644 index 00000000..a2a54a2f --- /dev/null +++ b/example_supabase/lib/brick/adapters/customer_adapter.g.dart @@ -0,0 +1,126 @@ +// GENERATED CODE DO NOT EDIT +part of '../brick.g.dart'; + +Future _$CustomerFromRest(Map data, + {required RestProvider provider, + OfflineFirstWithRestRepository? repository}) async { + return Customer( + id: data['id'] as String, + firstName: data['first_name'] as String, + lastName: data['last_name'] as String, + createdAt: DateTime.parse(data['created_at'] as String)); +} + +Future> _$CustomerToRest(Customer instance, + {required RestProvider provider, + OfflineFirstWithRestRepository? repository}) async { + return { + 'id': instance.id, + 'first_name': instance.firstName, + 'last_name': instance.lastName, + 'created_at': instance.createdAt.toIso8601String() + }; +} + +Future _$CustomerFromSqlite(Map data, + {required SqliteProvider provider, + OfflineFirstWithRestRepository? repository}) async { + return Customer( + id: data['id'] as String, + firstName: data['first_name'] as String, + lastName: data['last_name'] as String, + createdAt: DateTime.parse(data['created_at'] as String)) + ..primaryKey = data['_brick_id'] as int; +} + +Future> _$CustomerToSqlite(Customer instance, + {required SqliteProvider provider, + OfflineFirstWithRestRepository? repository}) async { + return { + 'id': instance.id, + 'first_name': instance.firstName, + 'last_name': instance.lastName, + 'created_at': instance.createdAt.toIso8601String() + }; +} + +/// Construct a [Customer] +class CustomerAdapter extends OfflineFirstWithRestAdapter { + CustomerAdapter(); + + @override + final restRequest = CustomerRequestTransformer.new; + @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: String, + ), + 'firstName': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'first_name', + iterable: false, + type: String, + ), + 'lastName': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'last_name', + iterable: false, + type: String, + ), + 'createdAt': const RuntimeSqliteColumnDefinition( + association: false, + columnName: 'created_at', + iterable: false, + type: DateTime, + ) + }; + @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 fromRest(Map input, + {required provider, + covariant OfflineFirstWithRestRepository? repository}) async => + await _$CustomerFromRest(input, + provider: provider, repository: repository); + @override + Future> toRest(Customer input, + {required provider, + covariant OfflineFirstWithRestRepository? repository}) async => + await _$CustomerToRest(input, provider: provider, repository: repository); + @override + Future fromSqlite(Map input, + {required provider, + covariant OfflineFirstWithRestRepository? repository}) async => + await _$CustomerFromSqlite(input, + provider: provider, repository: repository); + @override + Future> toSqlite(Customer input, + {required provider, + covariant OfflineFirstWithRestRepository? repository}) async => + await _$CustomerToSqlite(input, + provider: provider, repository: repository); +} diff --git a/example_supabase/lib/brick/brick.g.dart b/example_supabase/lib/brick/brick.g.dart new file mode 100644 index 00000000..844696dd --- /dev/null +++ b/example_supabase/lib/brick/brick.g.dart @@ -0,0 +1,36 @@ +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_core/query.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_offline_first_with_rest/brick_offline_first_with_rest.dart'; +// ignore: unused_import, unused_shown_name, unnecessary_import +import 'package:brick_rest/brick_rest.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_supabase/brick/models/customer.model.request.dart';// GENERATED CODE DO NOT EDIT +// ignore: unused_import +import 'dart:convert'; +import 'package:brick_sqlite/brick_sqlite.dart' show SqliteModel, SqliteAdapter, SqliteModelDictionary, RuntimeSqliteColumnDefinition, SqliteProvider; +import 'package:brick_rest/brick_rest.dart' show RestProvider, RestModel, RestAdapter, RestModelDictionary; +// ignore: unused_import, unused_shown_name +import 'package:brick_offline_first/brick_offline_first.dart' show RuntimeOfflineFirstDefinition; +// ignore: unused_import, unused_shown_name +import 'package:sqflite_common/sqlite_api.dart' show DatabaseExecutor; + +import '../brick/models/customer.model.dart'; + +part 'adapters/customer_adapter.g.dart'; + +/// Rest mappings should only be used when initializing a [RestProvider] +final Map> restMappings = { + Customer: CustomerAdapter() +}; +final restModelDictionary = RestModelDictionary(restMappings); + +/// Sqlite mappings should only be used when initializing a [SqliteProvider] +final Map> sqliteMappings = { + Customer: CustomerAdapter() +}; +final sqliteModelDictionary = SqliteModelDictionary(sqliteMappings); diff --git a/example_supabase/lib/brick/db/20240714103011.migration.dart b/example_supabase/lib/brick/db/20240714103011.migration.dart new file mode 100644 index 00000000..17230c9a --- /dev/null +++ b/example_supabase/lib/brick/db/20240714103011.migration.dart @@ -0,0 +1,44 @@ +// 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_20240714103011_up = [ + InsertTable('Customer'), + InsertColumn('id', Column.varchar, onTable: 'Customer', unique: true), + InsertColumn('first_name', Column.varchar, onTable: 'Customer'), + InsertColumn('last_name', Column.varchar, onTable: 'Customer'), + InsertColumn('created_at', Column.datetime, onTable: 'Customer'), +]; + +const List _migration_20240714103011_down = [ + DropTable('Customer'), + DropColumn('id', onTable: 'Customer'), + DropColumn('first_name', onTable: 'Customer'), + DropColumn('last_name', onTable: 'Customer'), + DropColumn('created_at', onTable: 'Customer'), +]; + +// +// DO NOT EDIT BELOW THIS LINE +// + +@Migratable( + version: '20240714103011', + up: _migration_20240714103011_up, + down: _migration_20240714103011_down, +) +class Migration20240714103011 extends Migration { + const Migration20240714103011() + : super( + version: 20240714103011, + up: _migration_20240714103011_up, + down: _migration_20240714103011_down, + ); +} diff --git a/example_supabase/lib/brick/db/schema.g.dart b/example_supabase/lib/brick/db/schema.g.dart new file mode 100644 index 00000000..a63955c0 --- /dev/null +++ b/example_supabase/lib/brick/db/schema.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE DO NOT EDIT +// This file should be version controlled +import 'package:brick_sqlite/db.dart'; +part '20240714103011.migration.dart'; + +/// All intelligently-generated migrations from all `@Migratable` classes on disk +final migrations = { + const Migration20240714103011(),}; + +/// A consumable database structure including the latest generated migration. +final schema = Schema(20240714103011, generatorVersion: 1, tables: { + SchemaTable('Customer', columns: { + SchemaColumn('_brick_id', Column.integer, + autoincrement: true, nullable: false, isPrimaryKey: true), + SchemaColumn('id', Column.varchar, unique: true), + SchemaColumn('first_name', Column.varchar), + SchemaColumn('last_name', Column.varchar), + SchemaColumn('created_at', Column.datetime) + }, indices: {}) +}); diff --git a/example_supabase/lib/brick/models/customer.model.dart b/example_supabase/lib/brick/models/customer.model.dart new file mode 100644 index 00000000..632aff7a --- /dev/null +++ b/example_supabase/lib/brick/models/customer.model.dart @@ -0,0 +1,23 @@ +import 'package:brick_offline_first_with_rest/brick_offline_first_with_rest.dart'; +import 'package:brick_rest/brick_rest.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick/models/customer.model.request.dart'; + +@ConnectOfflineFirstWithRest( + restConfig: + RestSerializable(requestTransformer: CustomerRequestTransformer.new), +) +class Customer extends OfflineFirstWithRestModel { + @Sqlite(unique: true) + final String id; + final String firstName; + final String lastName; + final DateTime createdAt; + + Customer({ + required this.id, + required this.firstName, + required this.lastName, + required this.createdAt, + }); +} diff --git a/example_supabase/lib/brick/models/customer.model.request.dart b/example_supabase/lib/brick/models/customer.model.request.dart new file mode 100644 index 00000000..c402dbd5 --- /dev/null +++ b/example_supabase/lib/brick/models/customer.model.request.dart @@ -0,0 +1,20 @@ +import 'package:brick_rest/brick_rest.dart'; + +// NOTE: This is just a minimal example, you may need to adjust the transformer +// to support querying/filtering. See the linked PostgREST docs below. + +class CustomerRequestTransformer extends RestRequestTransformer { + @override + // see https://postgrest.org/en/v12/references/api/tables_views.html#read + RestRequest get get => RestRequest(url: '/customers'); + + @override + // see: https://postgrest.org/en/v12/references/api/tables_views.html#upsert + RestRequest get upsert => RestRequest(url: '/customers'); + + @override + // see https://postgrest.org/en/v12/references/api/tables_views.html#delete + RestRequest get delete => throw UnimplementedError(); + + const CustomerRequestTransformer(super.query, super.instance); +} diff --git a/example_supabase/lib/brick/repository.dart b/example_supabase/lib/brick/repository.dart new file mode 100644 index 00000000..17b0281d --- /dev/null +++ b/example_supabase/lib/brick/repository.dart @@ -0,0 +1,39 @@ +import 'package:brick_offline_first_with_rest/brick_offline_first_with_rest.dart'; +import 'package:brick_rest/brick_rest.dart'; +import 'package:brick_sqlite/brick_sqlite.dart'; +import 'package:brick_supabase/brick/brick.g.dart'; +import 'package:brick_supabase/brick/db/schema.g.dart'; +import 'package:brick_supabase/brick/supabase_brick_client.dart'; +import 'package:brick_supabase/env.dart'; +import 'package:sqflite/sqflite.dart' show databaseFactory; + +class Repository extends OfflineFirstWithRestRepository { + Repository._() + : super( + restProvider: RestProvider( + '${SUPABASE_PROJECT_URL}/rest/v1', + modelDictionary: restModelDictionary, + client: SupabaseBrickClient( + anonKey: SUPABASE_ANON_KEY, + ), + ), + sqliteProvider: SqliteProvider( + 'brick_db.sqlite', + databaseFactory: databaseFactory, + modelDictionary: sqliteModelDictionary, + ), + offlineQueueManager: RestRequestSqliteCacheManager( + 'brick_offline_queue.sqlite', + databaseFactory: databaseFactory, + ), + migrations: migrations, + ); + + factory Repository() => _singleton!; + + static Repository? _singleton; + + static void configure() { + _singleton = Repository._(); + } +} diff --git a/example_supabase/lib/brick/supabase_brick_client.dart b/example_supabase/lib/brick/supabase_brick_client.dart new file mode 100644 index 00000000..87f827bf --- /dev/null +++ b/example_supabase/lib/brick/supabase_brick_client.dart @@ -0,0 +1,51 @@ +import 'package:http/http.dart' as http; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// A HTTP Client that adds all necessary headers for requests to the +/// Supabase REST-API. +class SupabaseBrickClient extends http.BaseClient { + SupabaseBrickClient({ + required this.anonKey, + http.Client? innerClient, + this.resourceName = 'dart.http', + }) : _innerClient = innerClient ?? http.Client(); + + /// The anon key of the supabase project. + /// + /// This is sent in the request headers as the `apikey` field. + final String anonKey; + + /// Populates APM's "RESOURCE" column. Defaults to `dart.http`. + final String resourceName; + + /// A normal HTTP client, treated like a manual `super` + /// as detailed by [the Dart team](https://github.com/dart-lang/http/blob/378179845420caafbf7a34d47b9c22104753182a/README.md#using) + /// + /// By default, a new [http.Client] will be instantiated and used. + final http.Client _innerClient; + + @override + Future send(http.BaseRequest request) async { + // The access token is automatically refreshed by the supabase client + final accessToken = + Supabase.instance.client.auth.currentSession?.accessToken; + + request.headers.addAll({ + if (accessToken != null) 'Authorization': 'Bearer $accessToken', + 'apikey': anonKey, + 'Content-Type': 'application/json; charset=utf-8', + // In order to use the upsert method for updates, the following header + // is needed for the REST API to work correctly. + // see // https://postgrest.org/en/v12/references/api/tables_views.html#upsert + 'Prefer': 'resolution=merge-duplicates', + }); + + return _innerClient.send(request); + } + + @override + void close() { + _innerClient.close(); + super.close(); + } +} diff --git a/example_supabase/lib/env.dart b/example_supabase/lib/env.dart new file mode 100644 index 00000000..796d5d3b --- /dev/null +++ b/example_supabase/lib/env.dart @@ -0,0 +1,4 @@ +// TODO: uncomment these and add your own values: + +// const SUPABASE_PROJECT_URL = ''; +// const SUPABASE_ANON_KEY = ''; diff --git a/example_supabase/lib/main.dart b/example_supabase/lib/main.dart new file mode 100644 index 00000000..6163e5bf --- /dev/null +++ b/example_supabase/lib/main.dart @@ -0,0 +1,104 @@ +import 'package:brick_offline_first/brick_offline_first.dart'; +import 'package:brick_supabase/brick/models/customer.model.dart'; +import 'package:brick_supabase/brick/repository.dart'; +import 'package:brick_supabase/env.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; + +Future main() async { + await Supabase.initialize( + url: SUPABASE_PROJECT_URL, + anonKey: SUPABASE_ANON_KEY, + ); + + Repository.configure(); + await Repository().initialize(); + + await Supabase.instance.client.auth.signInAnonymously(); + + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Brick Supabase Example', + theme: ThemeData(primarySwatch: Colors.blue), + home: MyHomePage(title: 'Brick Supabase Example'), + ); + } +} + +class MyHomePage extends StatelessWidget { + MyHomePage({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + floatingActionButton: FloatingActionButton.extended( + icon: Icon(Icons.add), + label: Text('Add Customer'), + onPressed: () async { + final customer = Customer( + id: Uuid().v4(), + createdAt: DateTime.now(), + firstName: 'John', + lastName: 'Doe', + ); + + await Repository().upsert(customer); + }, + ), + body: Padding( + padding: const EdgeInsets.all(20.0), + child: StreamBuilder( + stream: Repository().subscribe( + policy: OfflineFirstGetPolicy.awaitRemoteWhenNoneExist, + ), + builder: (context, AsyncSnapshot> snapshot) { + print(snapshot); + if (snapshot.hasData) { + final customers = snapshot.data ?? []; + + return customers.isEmpty + ? Center(child: Text('No customers found.')) + : ListView.builder( + itemCount: customers.length, + itemBuilder: (context, index) => + CustomerListTile(customers[index]), + ); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else { + return Center(child: CircularProgressIndicator.adaptive()); + } + }, + ), + ), + ); + } +} + +class CustomerListTile extends StatelessWidget { + final Customer customer; + + CustomerListTile(this.customer); + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + title: Text('${customer.firstName} ${customer.lastName}'), + subtitle: Text('ID: ${customer.id}'), + trailing: Text('Created at: ${customer.createdAt}'), + ), + ); + } +} diff --git a/example_supabase/pubspec.yaml b/example_supabase/pubspec.yaml new file mode 100644 index 00000000..af9ba8a4 --- /dev/null +++ b/example_supabase/pubspec.yaml @@ -0,0 +1,29 @@ +name: brick_supabase +description: A Flutter example project that uses Brick with Supabase + +version: 0.1.0 + +publish_to: none + +environment: + sdk: ">=2.18.0 <4.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + sqflite: any + supabase_flutter: ^2.5.8 + uuid: ^4.4.2 + +dependency_overrides: + brick_sqlite_generators: + path: ../packages/brick_sqlite_generators + +dev_dependencies: + brick_offline_first_with_rest_build: + path: ../packages/brick_offline_first_with_rest_build + build_runner: any + +flutter: + uses-material-design: true