diff --git a/AGENTS.md b/AGENTS.md index 4b12edebf..4348f9f6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,9 +168,9 @@ not wrap untyped builders. Each method returns a copy holding an immutable `Post that describes the query as structured values (operation, columns, filter, orderings, payload, and `PostgrestRequestOptions` for headers, retry, timeout and abort signal). Awaiting the builder hands the request to a `PostgrestTableExecutor` and converts the `PostgrestTableResult` into the table's -`Row` type. `PostgrestHttpTableExecutor` renders the request through the untyped builders at -execution time, so both APIs send the same wire request. These types are internal to the postgrest -package and not exported. +`Row` type. The default `PostgrestHttpTableExecutor` renders the request through the untyped +builders at execution time, so both APIs send the same wire request; another executor (a +`SupabaseClientInterceptor` can wrap it) may cache, queue or answer from a local store instead. ### Realtime Architecture diff --git a/MIGRATION.md b/MIGRATION.md index b6dccbf61..099d76e8e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2342,6 +2342,10 @@ are checked against the column types at compile time, and writes only accept the and update types. `from()` keeps working, and both can be used on one client, so a codebase can move over one table at a time. +Requests made through `table()` run through the executor that `SupabaseClientInterceptor`s wrap, so +features built as interceptors, such as offline support, only apply to them. Requests made through +`from()` bypass every interceptor. + The typed API is marked `@experimental` while it settles. Referencing an experimental type such as `PostgrestFilter` directly reports `experimental_member_use`, which an `// ignore_for_file: experimental_member_use` silences; the generated file already carries it. @@ -2716,7 +2720,7 @@ await supabase ``` The generated table definition also lists `primaryKey` and `relations`. Neither changes how a -request is sent; they describe the table to code that handles requests itself, such as a plugin +request is sent; they describe the table to code that handles requests itself, such as an interceptor that caches rows and needs to tell them apart, or one that resolves an embed from local data. ### Realtime streams diff --git a/packages/postgrest/lib/postgrest.dart b/packages/postgrest/lib/postgrest.dart index ff8f6bf77..211ed0a11 100644 --- a/packages/postgrest/lib/postgrest.dart +++ b/packages/postgrest/lib/postgrest.dart @@ -14,16 +14,6 @@ export 'package:yet_another_json_isolate/yet_another_json_isolate.dart' export 'src/postgrest.dart'; export 'src/postgrest_builder.dart'; -export 'src/postgrest_typed_builder.dart' - hide - PostgrestExplainOptions, - PostgrestHttpTableExecutor, - PostgrestRequestOptions, - PostgrestResultShape, - PostgrestTableExecutor, - PostgrestTableOperation, - PostgrestTableRequest, - PostgrestTableResult, - renderPostgrestQueryValue; +export 'src/postgrest_typed_builder.dart' hide renderPostgrestQueryValue; export 'src/types.dart'; export 'package:http/http.dart' show RequestAbortedException; diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index 011a612ce..accc8ab0d 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -165,12 +165,21 @@ class PostgrestClient { /// .select() /// .where(Books.id.gt(10)); /// ``` + /// + /// The builder collects a [PostgrestTableRequest] and hands it to + /// [executor] when awaited; without one the request is sent to PostgREST + /// through this client by a [PostgrestHttpTableExecutor]. @experimental PostgrestTypedQueryBuilder table( - PostgrestTable table, - ) { + PostgrestTable table, { + PostgrestTableExecutor? executor, + }) { final schema = _schemaIsExplicit ? _schema : (table.schema ?? _schema); - return PostgrestTypedQueryBuilder(table, client: this, schema: schema); + return PostgrestTypedQueryBuilder( + table, + executor: executor ?? PostgrestHttpTableExecutor(this), + schema: schema, + ); } /// Select a schema to query or perform an function (rpc) call. diff --git a/packages/postgrest/lib/src/postgrest_embedded_scope.dart b/packages/postgrest/lib/src/postgrest_embedded_scope.dart index c050c23f2..036ef0c8b 100644 --- a/packages/postgrest/lib/src/postgrest_embedded_scope.dart +++ b/packages/postgrest/lib/src/postgrest_embedded_scope.dart @@ -22,72 +22,80 @@ part of 'postgrest_typed_builder.dart'; @experimental final class PostgrestEmbeddedScope { PostgrestEmbeddedScope._( - this._relation, { + this.relation, { List> orderings = const [], - int? limit, - int? offset, + this.rowLimit, + this.rowOffset, List> scopes = const [], - }) : _orderings = List.unmodifiable(orderings), - _limit = limit, - _offset = offset, - _scopes = List.unmodifiable(scopes); + }) : orderings = List.unmodifiable(orderings), + scopes = List.unmodifiable(scopes); - final PostgrestToManyRelation _relation; - final List> _orderings; - final int? _limit; - final int? _offset; - final List> _scopes; + /// The embed whose rows the scope applies to. + final PostgrestToManyRelation relation; + + /// The sort keys of the embedded rows, first key first. + final List> orderings; + + /// The maximum number of embedded rows per parent row, set by [limit] and + /// [range]. + final int? rowLimit; + + /// The number of embedded rows to skip per parent row, set by [range]. + final int? rowOffset; + + /// The scopes of embeds nested in this one. + final List> scopes; PostgrestEmbeddedScope _copyWith({ - List>? orderings, - int? limit, - int? offset, - List>? scopes, + List>? sortKeys, + int? limitCount, + int? offsetCount, + List>? nested, }) => PostgrestEmbeddedScope._( - _relation, - orderings: orderings ?? _orderings, - limit: limit ?? _limit, - offset: offset ?? _offset, - scopes: scopes ?? _scopes, + relation, + orderings: sortKeys ?? orderings, + rowLimit: limitCount ?? rowLimit, + rowOffset: offsetCount ?? rowOffset, + scopes: nested ?? scopes, ); /// Sorts the embedded rows by [ordering]. /// /// Repeated calls append, so the second key breaks ties in the first. PostgrestEmbeddedScope order(PostgrestOrdering ordering) => - _copyWith(orderings: [..._orderings, ordering]); + _copyWith(sortKeys: [...orderings, ordering]); /// Limits the embedded rows of each parent row to [count]. - PostgrestEmbeddedScope limit(int count) => _copyWith(limit: count); + PostgrestEmbeddedScope limit(int count) => _copyWith(limitCount: count); /// Limits the embedded rows of each parent row to the ones within the /// specified range, inclusive. PostgrestEmbeddedScope range(int from, int to) => - _copyWith(offset: from, limit: to - from + 1); + _copyWith(offsetCount: from, limitCount: to - from + 1); - /// Applies [scope] to the rows of [relation], an embed nested in this one, + /// Applies [scope] to the rows of [embed], an embed nested in this one, /// under a dotted path such as `books.reviews.limit=3`. PostgrestEmbeddedScope embedded( - PostgrestToManyRelation relation, + PostgrestToManyRelation embed, PostgrestEmbeddedScope Function( PostgrestEmbeddedScope scope, ) scope, ) => _copyWith( - scopes: [..._scopes, scope(PostgrestEmbeddedScope._(relation))], + nested: [...scopes, scope(PostgrestEmbeddedScope._(embed))], ); /// The order keys, limit and offset of this scope and every nested one, /// each under the dotted path of its embed, with [prefix] in front. Iterable<_ScopedTransforms> _flattened([String prefix = '']) sync* { - final path = '$prefix${_relation._reference}'; + final path = '$prefix${relation._reference}'; yield ( path: path, - orderKeys: [for (final ordering in _orderings) ordering.orderKey], - limit: _limit, - offset: _offset, + orderKeys: [for (final ordering in orderings) ordering.orderKey], + limit: rowLimit, + offset: rowOffset, ); - for (final scope in _scopes) { + for (final scope in scopes) { yield* scope._flattened('$path.'); } } diff --git a/packages/postgrest/lib/src/postgrest_table_request.dart b/packages/postgrest/lib/src/postgrest_table_request.dart index 2a51675c6..01ec1be83 100644 --- a/packages/postgrest/lib/src/postgrest_table_request.dart +++ b/packages/postgrest/lib/src/postgrest_table_request.dart @@ -162,7 +162,6 @@ final class PostgrestRequestOptions { /// else, against a local store for example, because the filters, orderings /// and payload are still structured values rather than URL text. @experimental -@internal final class PostgrestTableRequest { PostgrestTableRequest({ required this.table, diff --git a/packages/postgrest/lib/src/postgrest_typed_builder.dart b/packages/postgrest/lib/src/postgrest_typed_builder.dart index 964080428..17e4e307b 100644 --- a/packages/postgrest/lib/src/postgrest_typed_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_builder.dart @@ -231,22 +231,24 @@ extension PostgrestTypedRowsBuilder /// A typed PostgREST request that can be awaited. /// -/// Holds the request the builder methods have collected so far. Awaiting the -/// builder executes the request and converts the result into [T], so -/// awaiting it never exposes raw `Map` data. +/// Holds the [request] the builder methods have collected so far and the +/// [PostgrestTableExecutor] that runs it. Awaiting the builder executes the +/// request and converts the result into [T], so awaiting it never exposes raw +/// `Map` data. @experimental class PostgrestTypedBuilder implements Future { - const PostgrestTypedBuilder._(this._request, this._executor, this._convert); + const PostgrestTypedBuilder._(this.request, this._executor, this._convert); - final PostgrestTableRequest _request; + /// The request awaiting this builder executes. + final PostgrestTableRequest request; final PostgrestTableExecutor _executor; final _ResultConverter _convert; - /// Runs [_request] on the executor, routing a synchronous throw into the + /// Runs [request] on the executor, routing a synchronous throw into the /// returned future so the [Future] contract holds for every executor. Future _execute() => - Future.sync(() => _executor.execute(_request)).then(_convert); + Future.sync(() => _executor.execute(request)).then(_convert); PostgrestTypedBuilder _with(PostgrestTableRequest changed) => PostgrestTypedBuilder._(changed, _executor, _convert); @@ -255,7 +257,7 @@ class PostgrestTypedBuilder implements Future { /// /// See [PostgrestBuilder.retry] for [enabled] and [count]. PostgrestTypedBuilder retry({bool enabled = true, int? count}) => - _with(_request._retry(enabled, count)); + _with(request._retry(enabled, count)); /// Bounds how long a single attempt of this request may go without /// progress, overriding the timeout configured on [PostgrestClient]. @@ -263,7 +265,7 @@ class PostgrestTypedBuilder implements Future { /// Unlike [timeout], which only stops waiting for the result, this cancels /// the attempt. See [PostgrestBuilder.requestTimeout]. PostgrestTypedBuilder requestTimeout(Duration timeout) => - _with(_request._requestTimeout(timeout)); + _with(request._requestTimeout(timeout)); /// Cancels the request when [abortSignal] completes, throwing a /// [RequestAbortedException] and stopping any retries. @@ -278,11 +280,11 @@ class PostgrestTypedBuilder implements Future { /// /// See [PostgrestBuilder.abortSignal]. PostgrestTypedBuilder abortSignal(Future abortSignal) => - _with(_request._abortSignal(abortSignal)); + _with(request._abortSignal(abortSignal)); /// Sets [key] to [value] in the headers of this request. PostgrestTypedBuilder setHeader(String key, String value) => - _with(_request._header(key, value)); + _with(request._header(key, value)); /// A broadcast stream of the one result. The request runs when the first /// listener subscribes, so a listener added later still receives it. diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart index 43a8baab3..00ea71de6 100644 --- a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -8,16 +8,16 @@ part of 'postgrest_typed_builder.dart'; class PostgrestTypedFilterBuilder extends PostgrestTypedTransformBuilder { const PostgrestTypedFilterBuilder._( - super._request, + super.request, super.executor, super.convert, super.rowFromJson, ) : super._(); PostgrestTypedFilterBuilder _filtered( - PostgrestTableRequest changed, + PostgrestTableRequest request, ) => PostgrestTypedFilterBuilder._( - changed, + request, _executor, _convert, _rowFromJson, @@ -27,19 +27,19 @@ class PostgrestTypedFilterBuilder PostgrestTypedFilterBuilder retry({ bool enabled = true, int? count, - }) => _filtered(_request._retry(enabled, count)); + }) => _filtered(request._retry(enabled, count)); @override PostgrestTypedFilterBuilder requestTimeout(Duration timeout) => - _filtered(_request._requestTimeout(timeout)); + _filtered(request._requestTimeout(timeout)); @override PostgrestTypedFilterBuilder abortSignal(Future abortSignal) => - _filtered(_request._abortSignal(abortSignal)); + _filtered(request._abortSignal(abortSignal)); @override PostgrestTypedFilterBuilder setHeader(String key, String value) => - _filtered(_request._header(key, value)); + _filtered(request._header(key, value)); /// Only rows satisfying [filter]. /// @@ -65,9 +65,9 @@ class PostgrestTypedFilterBuilder /// /// Repeated [where] calls combine with logical AND. PostgrestTypedFilterBuilder where(PostgrestFilter filter) { - final current = _request.filter; + final current = request.filter; return _filtered( - _request.copyWith(filter: current == null ? filter : current & filter), + request.copyWith(filter: current == null ? filter : current & filter), ); } } diff --git a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart index 2a80408fc..e3516ed1e 100644 --- a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart @@ -11,11 +11,11 @@ part of 'postgrest_typed_builder.dart'; @experimental class PostgrestTypedQueryBuilder { /// {@macro postgrest_typed_query_builder} - PostgrestTypedQueryBuilder( + const PostgrestTypedQueryBuilder( this.table, { - required PostgrestClient client, + required PostgrestTableExecutor executor, String? schema, - }) : _executor = PostgrestHttpTableExecutor(client), + }) : _executor = executor, _schema = schema, _options = null; diff --git a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart index 5d5f501ad..7a5437053 100644 --- a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart @@ -7,7 +7,7 @@ part of 'postgrest_typed_builder.dart'; @experimental class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { const PostgrestTypedTransformBuilder._( - super._request, + super.request, super.executor, super.convert, this._rowFromJson, @@ -28,7 +28,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestResultShape shape, _ResultConverter convert, ) => PostgrestTypedTransformBuilder._( - _request.copyWith(shape: shape), + request.copyWith(shape: shape), _executor, convert, _rowFromJson, @@ -38,20 +38,20 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestTypedTransformBuilder retry({ bool enabled = true, int? count, - }) => _with(_request._retry(enabled, count)); + }) => _with(request._retry(enabled, count)); @override PostgrestTypedTransformBuilder requestTimeout(Duration timeout) => - _with(_request._requestTimeout(timeout)); + _with(request._requestTimeout(timeout)); @override PostgrestTypedTransformBuilder abortSignal( Future abortSignal, - ) => _with(_request._abortSignal(abortSignal)); + ) => _with(request._abortSignal(abortSignal)); @override PostgrestTypedTransformBuilder setHeader(String key, String value) => - _with(_request._header(key, value)); + _with(request._header(key, value)); /// Performs horizontal filtering with SELECT, returning the affected rows /// typed as [Row]. @@ -67,7 +67,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { /// See [selectOnly] to return some columns only. PostgrestTypedTransformBuilder> select() => PostgrestTypedTransformBuilder._( - _request.copyWith( + request.copyWith( columns: const [], shape: PostgrestResultShape.rows, returning: true, @@ -92,7 +92,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestTypedTransformBuilder>> selectOnly(List> columns) => PostgrestTypedTransformBuilder._( - _request.copyWith( + request.copyWith( columns: _checkedSelections(columns, 'columns'), shape: PostgrestResultShape.rows, returning: true, @@ -116,19 +116,19 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { /// Repeated calls append, so the second key breaks ties in the first. PostgrestTypedTransformBuilder order( PostgrestOrdering ordering, - ) => _with(_request.copyWith(orderings: [..._request.orderings, ordering])); + ) => _with(request.copyWith(orderings: [...request.orderings, ordering])); /// Limits the result with the specified [count]. /// /// See [embedded] to limit the rows of an embed instead. PostgrestTypedTransformBuilder limit(int count) => - _with(_request.copyWith(limit: count)); + _with(request.copyWith(limit: count)); /// Limits the result to rows within the specified range, inclusive. /// /// See [embedded] to limit the rows of an embed instead. PostgrestTypedTransformBuilder range(int from, int to) => - _with(_request.copyWith(offset: from, limit: to - from + 1)); + _with(request.copyWith(offset: from, limit: to - from + 1)); /// Sorts and limits the rows of the to-many embed [relation] with [scope], /// leaving the parent rows as they are. @@ -155,9 +155,9 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { ) scope, ) => _with( - _request.copyWith( + request.copyWith( embeddedScopes: [ - ..._request.embeddedScopes, + ...request.embeddedScopes, scope(PostgrestEmbeddedScope._(relation)), ], ), @@ -169,7 +169,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { /// requires PostgREST 11.2 or higher. It applies to row and [single] /// responses, in either call order. PostgrestTypedTransformBuilder stripNulls() => - _with(_request.copyWith(stripNulls: true)); + _with(request.copyWith(stripNulls: true)); /// Runs the query but rolls back the transaction, so no changes are /// persisted. @@ -181,7 +181,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { /// await client.table(Books.table).insert(BookInsert(title: 'foo')).dryRun(); /// ``` PostgrestTypedTransformBuilder dryRun() => - _with(_request.copyWith(dryRun: true)); + _with(request.copyWith(dryRun: true)); /// Sets the maximum number of rows that can be affected by the query. /// @@ -196,7 +196,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { /// .maxAffected(10); /// ``` PostgrestTypedTransformBuilder maxAffected(int value) => - _with(_request.copyWith(maxAffected: value)); + _with(request.copyWith(maxAffected: value)); /// Retrieves the response as CSV. /// @@ -216,7 +216,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { /// await client.table(Books.table).select().head(); /// ``` PostgrestTypedBuilder head() => PostgrestTypedBuilder._( - _request.copyWith(shape: PostgrestResultShape.head), + request.copyWith(shape: PostgrestResultShape.head), _executor, _noResult, ); @@ -229,7 +229,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { /// https://supabase.com/docs/guides/database/extensions/postgis PostgrestTypedBuilder> geojson() => PostgrestTypedBuilder._( - _request.copyWith(shape: PostgrestResultShape.geojson), + request.copyWith(shape: PostgrestResultShape.geojson), _executor, (result) => result.data! as Map, ); @@ -251,7 +251,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { bool wal = false, ExplainFormat format = ExplainFormat.text, }) => PostgrestTypedBuilder._( - _request.copyWith( + request.copyWith( shape: PostgrestResultShape.explain, explainOptions: PostgrestExplainOptions( analyze: analyze, @@ -280,7 +280,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestTypedBuilder> count([ CountOption option = CountOption.exact, ]) => PostgrestTypedBuilder._( - _request.copyWith(countOption: option), + request.copyWith(countOption: option), _executor, (result) => PostgrestResponse( data: _convert(result), diff --git a/packages/postgrest/test/table_executor_test.dart b/packages/postgrest/test/table_executor_test.dart new file mode 100644 index 000000000..cf3fb98a3 --- /dev/null +++ b/packages/postgrest/test/table_executor_test.dart @@ -0,0 +1,522 @@ +// The typed table access API under test is annotated @experimental. +// ignore_for_file: experimental_member_use + +import 'dart:async'; + +import 'package:postgrest/postgrest.dart'; +import 'package:supabase_test/supabase_test.dart'; +import 'package:test/test.dart'; + +extension type const Book(Map _json) + implements Map { + int get id => _json['id'] as int; + String get title => _json['title'] as String; +} + +extension type const BookInsert._(Map _json) + implements Object { + BookInsert({required String title}) : this._({'title': title}); +} + +extension type const BookUpdate._(Map _json) + implements Object { + BookUpdate({String? title}) : this._({'title': ?title}); +} + +extension type const Review(Map _json) + implements Map {} + +class Books { + static const table = PostgrestTable( + 'books', + Book.new, + primaryKey: [id], + relations: [reviews], + ); + static const id = PostgrestColumn('id'); + static const title = PostgrestColumn('title'); + static const reviews = PostgrestToManyRelation( + 'reviews', + columns: [id], + referencedTable: 'reviews', + referencedColumns: [Reviews.bookId], + ); +} + +class Reviews { + static const bookId = PostgrestColumn('book_id'); + static const rating = PostgrestColumn('rating'); +} + +const bookRows = [ + {'id': 1, 'title': 'a'}, + {'id': 2, 'title': 'b'}, +]; + +/// Answers every request with [result] and keeps what it was asked. +class RecordingExecutor implements PostgrestTableExecutor { + RecordingExecutor([this.result = const PostgrestTableResult()]); + + final PostgrestTableResult result; + final requests = []; + + PostgrestTableRequest get onlyRequest => requests.single; + + @override + Future execute(PostgrestTableRequest request) async { + requests.add(request); + return result; + } +} + +class _FailingExecutor implements PostgrestTableExecutor { + const _FailingExecutor(); + + @override + Future execute(PostgrestTableRequest request) async => + throw StateError('offline'); +} + +void main() { + late MockSupabaseHttpClient httpClient; + late PostgrestClient client; + + setUp(() { + httpClient = MockSupabaseHttpClient()..stub(bookRows); + client = PostgrestClient( + 'http://localhost/rest/v1', + httpClient: httpClient, + ); + }); + + tearDown(() async { + await client.dispose(); + }); + + group('request value', () { + test( + 'select collects the table, columns, filters, order and page', + () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: bookRows), + ); + + final books = await client + .table(Books.table, executor: executor) + .selectOnly([Books.id, Books.title]) + .where(Books.id.gt(1)) + .where(Books.title.eq('a')) + .order(Books.title.desc()) + .range(10, 19); + + final request = executor.onlyRequest; + expect(request.table.name, 'books'); + expect(request.schema, isNull); + expect(request.operation, PostgrestTableOperation.select); + expect(request.shape, PostgrestResultShape.rows); + expect(request.columns.map((column) => column.expression), [ + 'id', + 'title', + ]); + // ignore: invalid_use_of_internal_member + expect(request.filter!.queryParameters, [ + (key: 'id', value: 'gt.1'), + (key: 'title', value: 'eq.a'), + ]); + expect(request.orderings.single.orderKey, 'title.desc'); + expect(request.offset, 10); + expect(request.limit, 10); + expect(books.map((book) => book.read(Books.title)), ['a', 'b']); + }, + ); + + test('carries the request options from every phase', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: bookRows), + ); + final abortSignal = Completer().future; + + await client + .table(Books.table, executor: executor) + .setHeader('X-Query', 'query') + .retry(enabled: false) + .select() + .requestTimeout(const Duration(seconds: 3)) + .order(Books.title) + .abortSignal(abortSignal) + .setHeader('X-Transform', 'transform'); + + final options = executor.onlyRequest.options; + expect(options.headers, { + 'X-Query': 'query', + 'X-Transform': 'transform', + }); + expect(options.retryEnabled, isFalse); + expect(options.retryCount, isNull); + expect(options.requestTimeout, const Duration(seconds: 3)); + expect(options.abortSignal, same(abortSignal)); + }); + + test('has no request options unless one is set', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: bookRows), + ); + + await client.table(Books.table, executor: executor).select(); + + final options = executor.onlyRequest.options; + expect(options.headers, isEmpty); + expect(options.retryEnabled, isNull); + expect(options.retryCount, isNull); + expect(options.requestTimeout, isNull); + expect(options.abortSignal, isNull); + }); + + test('carries the schema of the client', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: >[]), + ); + + await client + .schema('library') + .table(Books.table, executor: executor) + .select(); + + expect(executor.onlyRequest.schema, 'library'); + }); + + test('an embedded scope is kept apart from the table page', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: >[]), + ); + + await client + .table(Books.table, executor: executor) + .selectOnly([Books.reviews.select()]) + .limit(5) + .embedded( + Books.reviews, + (reviews) => reviews.order(Reviews.rating.desc()).range(2, 3), + ); + + final request = executor.onlyRequest; + expect(request.limit, 5); + final scope = request.embeddedScopes.single; + expect(scope.relation, same(Books.reviews)); + expect(scope.orderings.single.orderKey, 'rating.desc'); + expect(scope.rowOffset, 2); + expect(scope.rowLimit, 2); + expect(scope.scopes, isEmpty); + }); + + test('single converts the one returned row', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: {'id': 1, 'title': 'a'}), + ); + + final Book book = await client + .table(Books.table, executor: executor) + .select() + .single(); + + expect(executor.onlyRequest.shape, PostgrestResultShape.single); + expect(book.title, 'a'); + }); + + test('maybeSingle converts a missing row into null', () async { + final executor = RecordingExecutor(); + + final Book? book = await client + .table(Books.table, executor: executor) + .select() + .maybeSingle(); + + expect(executor.onlyRequest.shape, PostgrestResultShape.maybeSingle); + expect(book == null, isTrue); + }); + + test('a mutation carries its payload and asks nothing back', () async { + final executor = RecordingExecutor(); + + await client + .table(Books.table, executor: executor) + .update(BookUpdate(title: 'b')) + .where(Books.id.eq(1)); + + final request = executor.onlyRequest; + expect(request.operation, PostgrestTableOperation.update); + expect(request.payload, {'title': 'b'}); + expect(request.shape, PostgrestResultShape.none); + expect(request.returning, isFalse); + expect(request.filter!.comparison!.operator, PostgrestFilterOperator.eq); + }); + + test('a trailing select on a mutation asks the rows back', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: bookRows), + ); + + final books = await client + .table(Books.table, executor: executor) + .insertAll([BookInsert(title: 'a'), BookInsert(title: 'b')]) + .selectOnly([Books.id]); + + final request = executor.onlyRequest; + expect(request.operation, PostgrestTableOperation.insert); + expect(request.payload, [ + {'title': 'a'}, + {'title': 'b'}, + ]); + expect(request.shape, PostgrestResultShape.rows); + expect(request.returning, isTrue); + expect(request.columns.single.expression, 'id'); + expect(books.length, 2); + }); + + test('upsert carries the conflict target and flags', () async { + final executor = RecordingExecutor(); + + await client + .table(Books.table, executor: executor) + .upsert( + BookInsert(title: 'a'), + onConflict: [Books.title], + ignoreDuplicates: true, + defaultToNull: false, + ); + + final request = executor.onlyRequest; + expect(request.operation, PostgrestTableOperation.upsert); + expect(request.onConflict!.single.name, 'title'); + expect(request.ignoreDuplicates, isTrue); + expect(request.defaultToNull, isFalse); + }); + + test('count reads the count of the result', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(count: 7), + ); + + final int count = await client + .table(Books.table, executor: executor) + .count(CountOption.estimated); + + expect(executor.onlyRequest.operation, PostgrestTableOperation.count); + expect(executor.onlyRequest.countOption, CountOption.estimated); + expect(count, 7); + }); + + test('a counted select wraps rows and count', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: bookRows, count: 12), + ); + + final response = await client + .table(Books.table, executor: executor) + .select() + .count(); + + expect(executor.onlyRequest.countOption, CountOption.exact); + expect(response.count, 12); + expect(response.data.map((book) => book.id), [1, 2]); + }); + + test('csv, head and explain set their shapes', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: 'id,title'), + ); + final builder = client.table(Books.table, executor: executor).select(); + + await builder.csv(); + await builder.head(); + await builder.explain(analyze: true); + + expect(executor.requests.map((request) => request.shape), [ + PostgrestResultShape.csv, + PostgrestResultShape.head, + PostgrestResultShape.explain, + ]); + expect(executor.requests.last.explainOptions!.analyze, isTrue); + }); + + test('an empty column list is rejected when select is called', () { + expect( + () => client + .table(Books.table, executor: RecordingExecutor()) + .selectOnly([]), + throwsArgumentError, + ); + }); + + test('the request copies the collections it is given', () { + final columns = >[Books.id]; + final request = PostgrestTableRequest( + table: Books.table, + operation: PostgrestTableOperation.select, + columns: columns, + ); + + columns.clear(); + + expect(request.columns, hasLength(1)); + expect(() => request.columns.clear(), throwsUnsupportedError); + expect(() => request.orderings.clear(), throwsUnsupportedError); + }); + + test('asStream delivers the result to a listener added later', () async { + final stream = client + .table( + Books.table, + executor: RecordingExecutor( + const PostgrestTableResult(data: bookRows), + ), + ) + .select() + .asStream(); + + await Future.delayed(Duration.zero); + + expect(await stream.first, hasLength(2)); + }); + + test('asStream forwards an executor failure', () async { + final stream = client + .table(Books.table, executor: const _FailingExecutor()) + .select() + .asStream(); + + await expectLater(stream, emitsError(isA())); + }); + + test('a builder is a value: reuse does not leak steps', () async { + final executor = RecordingExecutor( + const PostgrestTableResult(data: >[]), + ); + final base = client.table(Books.table, executor: executor).select(); + + await base.where(Books.id.eq(1)); + await base.order(Books.title.asc()); + + expect(executor.requests[0].filter, isNotNull); + expect(executor.requests[0].orderings, isEmpty); + expect(executor.requests[1].filter, isNull); + expect(executor.requests[1].orderings, hasLength(1)); + }); + }); + + group('PostgrestHttpTableExecutor', () { + test('sends what the untyped builder sends for the same query', () async { + await client + .table(Books.table) + .selectOnly([Books.id, Books.title]) + .where(Books.id.gt(1) | Books.title.eq('a')) + .order(Books.title.desc().nullsLast()) + .range(10, 19); + await client + .from('books') + .select('id,title') + .or('id.gt.1,title.eq.a') + .order('title', ascending: false, nullsFirst: false) + .range(10, 19); + + final typed = httpClient.requests[0]; + final untyped = httpClient.requests[1]; + expect(typed.url.queryParameters, untyped.url.queryParameters); + expect(typed.method, untyped.method); + }); + + test('renders an offset without a limit', () async { + await PostgrestHttpTableExecutor(client).execute( + PostgrestTableRequest( + table: Books.table, + operation: PostgrestTableOperation.select, + offset: 5, + ), + ); + + final parameters = httpClient.requests.single.queryParameters; + expect(parameters['offset'], '5'); + expect(parameters.containsKey('limit'), isFalse); + }); + + test('renders the embedded scopes of a recorded request', () async { + final recorder = RecordingExecutor( + const PostgrestTableResult(data: >[]), + ); + await client + .table(Books.table, executor: recorder) + .selectOnly([Books.reviews.select()]) + .embedded(Books.reviews, (reviews) => reviews.range(3, 4)); + + await PostgrestHttpTableExecutor(client).execute(recorder.onlyRequest); + + final parameters = httpClient.requests.single.queryParameters; + expect(parameters['reviews.offset'], '3'); + expect(parameters['reviews.limit'], '2'); + }); + + test('a request without a schema keeps the schema of the client', () async { + await PostgrestHttpTableExecutor(client.schema('library')).execute( + PostgrestTableRequest( + table: Books.table, + operation: PostgrestTableOperation.select, + ), + ); + + expect(httpClient.requests.single.headers['Accept-Profile'], 'library'); + }); + + test('rejects a filter on an insert instead of dropping it', () { + final request = PostgrestTableRequest( + table: Books.table, + operation: PostgrestTableOperation.insert, + payload: const {'title': 'c'}, + filter: Books.id.eq(1), + ); + + expect( + () => PostgrestHttpTableExecutor(client).execute(request), + throwsArgumentError, + ); + expect(httpClient.requests, isEmpty); + }); + + test('renders a returning select on a mutation', () async { + httpClient + ..reset() + ..stub([ + {'id': 3, 'title': 'c'}, + ]); + + final books = await client + .table(Books.table) + .insert(BookInsert(title: 'c')) + .selectOnly([Books.id]); + + final request = httpClient.requests.single; + expect(request.method, 'POST'); + expect(request.headers['Prefer'], contains('return=representation')); + expect(request.queryParameters['select'], 'id'); + expect(books.single.read(Books.id), 3); + }); + + test('scopes the request to the schema of the request', () async { + await client.schema('library').table(Books.table).select(); + + expect(httpClient.requests.single.headers['Accept-Profile'], 'library'); + }); + + test('renders a count only request as HEAD', () async { + httpClient + ..reset() + ..stub(null, headers: {'content-range': '0-1/2'}); + + final int count = await client.table(Books.table).count(); + + final request = httpClient.requests.single; + expect(request.method, 'HEAD'); + expect(request.headers['Prefer'], contains('count=exact')); + expect(count, 2); + }); + }); +} diff --git a/packages/postgrest/test/typed_query_test.dart b/packages/postgrest/test/typed_query_test.dart index 2ee39790a..fe578f294 100644 --- a/packages/postgrest/test/typed_query_test.dart +++ b/packages/postgrest/test/typed_query_test.dart @@ -1122,7 +1122,7 @@ void main() { expect(requestParameters()['id'], 'eq.1'); }); - test('update and delete start unscoped and keep the row type', () async { + test('update and delete start unscoped and keep the row type', () { final PostgrestTypedUnscopedBuilder update = client .table(Books.table) .update(BookUpdate(title: 'b')); @@ -1135,14 +1135,9 @@ void main() { ); final PostgrestTypedFilterBuilder scopedDelete = delete.all(); + expect(scopedUpdate.request.filter, isNotNull); + expect(scopedDelete.request.filter, isNull); expect(httpClient.requests, isEmpty); - - httpClient.stub(null); - await scopedUpdate; - await scopedDelete; - - expect(httpClient.requests.first.url.queryParameters['id'], 'eq.1'); - expect(httpClient.requests.last.url.queryParameters, isEmpty); }); test('all() updates every row without a filter', () async { diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 9fcd14965..c8d92acaf 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -1,3 +1,6 @@ +// The typed table executor this client hands its interceptors is @experimental. +// ignore_for_file: experimental_member_use + import 'dart:async'; import 'package:http/http.dart'; @@ -61,6 +64,11 @@ import 'trace_http_client.dart'; /// parser. A codec passed here is owned by the caller, so [dispose] leaves it /// alone, and it can be shared with other clients. /// +/// [interceptors] extend the client from the outside, see +/// [SupabaseClientInterceptor]. They wrap the executor the typed table API +/// runs through, in the order given, and are disposed together with the +/// client. +/// /// The pkce flow is used by default and keeps its code verifiers in the /// `AuthAsyncStorage` passed to the `asyncStorage` field of [authOptions]. /// Pass a persistent implementation whenever the flow can leave the process @@ -85,7 +93,11 @@ class SupabaseClient { Client? httpClient, AsyncJsonCodec? jsonCodec, Duration? requestTimeout, + List interceptors = const [], }) : _supabaseKey = supabaseKey, + // Disposed one by one from dispose, which the rule cannot see. + // ignore: dispose-class-fields + interceptors = List.unmodifiable(interceptors), _functionsOptions = functionsOptions, _restUrl = '$supabaseUrl/rest/v1', _realtimeUrl = '$supabaseUrl/realtime/v1'.replaceAll('http', 'ws'), @@ -137,6 +149,24 @@ class SupabaseClient { 'Initialize SupabaseClient v$version with custom access token', ); } + final attached = []; + try { + _tableExecutor = interceptors.fold( + _RestTableExecutor(this), + (executor, interceptor) => interceptor.wrapTableExecutor(executor), + ); + for (final interceptor in interceptors) { + interceptor.attach(this); + attached.add(interceptor); + } + } catch (_) { + // The caller never receives the client, so nothing else can free what + // the constructor and the attached interceptors already opened. + unawaited( + _disposeInterceptors(attached).then((_) => _disposeOwnResources()), + ); + rethrow; + } } final String _supabaseKey; final PostgrestClientOptions _postgrestOptions; @@ -167,6 +197,13 @@ class SupabaseClient { /// broadcast messages between clients. late final RealtimeClient realtime; late PostgrestClient _rest; + late final PostgrestTableExecutor _tableExecutor; + + /// The interceptors given to the constructor, in the order they wrap the + /// typed table executor. + /// + /// Each interceptor is disposed from [dispose]. + final List interceptors; StreamSubscription? _authStateSubscription; final AsyncJsonCodec _jsonCodec; final bool _ownsJsonCodec; @@ -251,6 +288,7 @@ class SupabaseClient { authHttpClient: _authHttpClient, realtime: realtime, rest: rest, + tableExecutor: _tableExecutor, ); /// Perform a table operation. @@ -344,6 +382,29 @@ class SupabaseClient { /// the functions, PostgREST, and its internally owned JSON codec. Future dispose() async { clientLogger.fine('Dispose SupabaseClient'); + await _disposeInterceptors(interceptors); + await _disposeOwnResources(); + } + + /// Disposes [attached] in reverse order, logging rather than throwing an + /// error one of them fails with. + Future _disposeInterceptors( + List attached, + ) async { + for (final interceptor in attached.reversed) { + try { + await interceptor.dispose(); + } catch (error, stackTrace) { + clientLogger.warning( + 'Interceptor ${interceptor.runtimeType} failed to dispose', + error, + stackTrace, + ); + } + } + } + + Future _disposeOwnResources() async { await realtime.disconnect(); await _authStateSubscription?.cancel(); await functions.dispose(); @@ -485,3 +546,16 @@ class SupabaseClient { } } } + +/// Sends typed table requests through whichever rest client the +/// [SupabaseClient] currently holds, so a header change that replaces the +/// rest client does not require the interceptor chain to be rebuilt. +class _RestTableExecutor implements PostgrestTableExecutor { + const _RestTableExecutor(this._client); + + final SupabaseClient _client; + + @override + Future execute(PostgrestTableRequest request) => + PostgrestHttpTableExecutor(_client._rest).execute(request); +} diff --git a/packages/supabase/lib/src/supabase_client_interceptor.dart b/packages/supabase/lib/src/supabase_client_interceptor.dart new file mode 100644 index 000000000..f896e82f8 --- /dev/null +++ b/packages/supabase/lib/src/supabase_client_interceptor.dart @@ -0,0 +1,51 @@ +import 'package:meta/meta.dart'; +import 'package:postgrest/postgrest.dart'; + +import 'supabase_client.dart'; + +/// Extends a [SupabaseClient] from the outside, without changing how the +/// application calls it. +/// +/// An interceptor can wrap the [PostgrestTableExecutor] that runs the +/// requests of the typed table API, [SupabaseClient.table], and receives the +/// client once it is fully constructed. The untyped [SupabaseClient.from], +/// `rpc`, storage, functions and realtime traffic never passes through an +/// interceptor. +/// +/// Interceptors are given to the [SupabaseClient] constructor, or to +/// `Supabase.initialize` in Flutter: +/// +/// ```dart +/// final supabase = SupabaseClient(url, key, interceptors: [offline]); +/// ``` +@experimental +abstract class SupabaseClientInterceptor { + const SupabaseClientInterceptor(); + + /// Returns the executor [SupabaseClient.table] runs its requests through. + /// + /// [inner] sends a request to PostgREST with the current access token, so + /// an executor that holds a request back and runs it through [inner] later + /// still authenticates as the current user. Interceptors wrap in the order + /// they were given, so the last interceptor sees a request first. Return + /// [inner] to leave execution untouched. + PostgrestTableExecutor wrapTableExecutor(PostgrestTableExecutor inner) => + inner; + + /// Called once with the fully constructed [client], after every service + /// client exists. + void attach(SupabaseClient client) {} + + /// Called when the host application returns to the foreground, for + /// example on `AppLifecycleState.resumed` in Flutter. + /// + /// The host does not wait for the returned future, so a long running + /// reaction, a synchronization for example, does not delay anything else + /// the host does on resume. An error the future completes with is logged. + Future resume() async {} + + /// Called from [SupabaseClient.dispose] before the service clients are + /// disposed. An error thrown here is logged and does not stop the other + /// interceptors or the client from being disposed. + Future dispose() async {} +} diff --git a/packages/supabase/lib/src/supabase_query_schema.dart b/packages/supabase/lib/src/supabase_query_schema.dart index 404e122dc..00430852c 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -1,3 +1,6 @@ +// The typed table executor is @experimental. +// ignore_for_file: experimental_member_use + import 'package:http/http.dart'; import 'package:meta/meta.dart'; import 'package:supabase/supabase.dart'; @@ -7,7 +10,7 @@ import 'counter.dart'; /// Used to perform [rpc] and [from] operations with a different schema than in /// [SupabaseClient]. class SupabaseQuerySchema { - const SupabaseQuerySchema({ + SupabaseQuerySchema({ required Counter counter, required String restUrl, required String schema, @@ -15,13 +18,15 @@ class SupabaseQuerySchema { required Client? authHttpClient, required RealtimeClient realtime, required PostgrestClient rest, + PostgrestTableExecutor? tableExecutor, }) : _counter = counter, _restUrl = restUrl, _schema = schema, _jsonCodec = jsonCodec, _authHttpClient = authHttpClient, _realtime = realtime, - _rest = rest; + _rest = rest, + _tableExecutor = tableExecutor ?? PostgrestHttpTableExecutor(rest); final Counter _counter; final String _restUrl; final String _schema; @@ -29,6 +34,7 @@ class SupabaseQuerySchema { final Client? _authHttpClient; final RealtimeClient _realtime; final PostgrestClient _rest; + final PostgrestTableExecutor _tableExecutor; /// Perform a table operation. SupabaseQueryBuilder from(String table) { @@ -57,7 +63,7 @@ class SupabaseQuerySchema { return SupabaseTypedQueryBuilder( from(table.name), table, - client: _rest, + executor: _tableExecutor, schema: _schema, ); } @@ -79,6 +85,7 @@ class SupabaseQuerySchema { authHttpClient: _authHttpClient, realtime: _realtime, rest: newRest, + tableExecutor: _tableExecutor, ); } } diff --git a/packages/supabase/lib/src/supabase_typed_query_builder.dart b/packages/supabase/lib/src/supabase_typed_query_builder.dart index 7a529c260..13031df8d 100644 --- a/packages/supabase/lib/src/supabase_typed_query_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_query_builder.dart @@ -13,13 +13,13 @@ class SupabaseTypedQueryBuilder // The query builder is also kept as a field to expose [stream], so it // cannot become a super parameter. // ignore: use_super_parameters - SupabaseTypedQueryBuilder( + const SupabaseTypedQueryBuilder( SupabaseQueryBuilder queryBuilder, PostgrestTable table, { - required PostgrestClient client, + required PostgrestTableExecutor executor, String? schema, }) : _queryBuilder = queryBuilder, - super(table, client: client, schema: schema); + super(table, executor: executor, schema: schema); final SupabaseQueryBuilder _queryBuilder; diff --git a/packages/supabase/lib/supabase.dart b/packages/supabase/lib/supabase.dart index 7969e8539..49f1b65af 100644 --- a/packages/supabase/lib/supabase.dart +++ b/packages/supabase/lib/supabase.dart @@ -12,6 +12,7 @@ export 'package:supabase_storage/supabase_storage.dart'; export 'src/realtime_client_options.dart'; export 'src/supabase_client.dart'; export 'src/supabase_client_options.dart'; +export 'src/supabase_client_interceptor.dart'; export 'src/supabase_query_builder.dart'; export 'src/supabase_query_schema.dart'; export 'src/supabase_stream_builder.dart'; diff --git a/packages/supabase/test/client_interceptor_test.dart b/packages/supabase/test/client_interceptor_test.dart new file mode 100644 index 000000000..7c8fd965d --- /dev/null +++ b/packages/supabase/test/client_interceptor_test.dart @@ -0,0 +1,224 @@ +// The typed table access API and the interceptor seam are @experimental. +// ignore_for_file: experimental_member_use + +import 'package:supabase/supabase.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +extension type const Todo(Map _json) + implements Map { + int get id => _json['id'] as int; + String get task => _json['task'] as String; +} + +extension type const TodoInsert._(Map _json) + implements Object { + TodoInsert({required String task}) : this._({'task': task}); +} + +extension type const TodoUpdate._(Map _json) + implements Object { + TodoUpdate({String? task}) : this._({'task': ?task}); +} + +class Todos { + static const table = PostgrestTable( + 'todos', + Todo.new, + primaryKey: [id], + ); + static const id = PostgrestColumn('id'); + static const task = PostgrestColumn('task'); +} + +const todoRows = [ + {'id': 1, 'task': 'Ship it'}, +]; + +class LoggingExecutor implements PostgrestTableExecutor { + const LoggingExecutor(this.name, this.inner, this.log); + + final String name; + final PostgrestTableExecutor inner; + final List log; + + @override + Future execute(PostgrestTableRequest request) { + log.add('$name ${request.operation.name} ${request.table.name}'); + return inner.execute(request); + } +} + +class LoggingInterceptor extends SupabaseClientInterceptor { + LoggingInterceptor(this.name, this.log); + + final String name; + final List log; + SupabaseClient? attachedClient; + int disposals = 0; + + @override + PostgrestTableExecutor wrapTableExecutor(PostgrestTableExecutor inner) => + LoggingExecutor(name, inner, log); + + @override + void attach(SupabaseClient client) { + attachedClient = client; + log.add('$name attached'); + } + + @override + Future dispose() async { + disposals++; + log.add('$name disposed'); + } +} + +class FailingAttachInterceptor extends SupabaseClientInterceptor { + const FailingAttachInterceptor(); + + @override + void attach(SupabaseClient client) => throw StateError('no attach'); +} + +void main() { + late MockSupabaseHttpClient httpClient; + late List log; + late LoggingInterceptor interceptor; + late SupabaseClient supabase; + + setUp(() { + httpClient = MockSupabaseHttpClient()..stubTable('todos', rows: todoRows); + log = []; + interceptor = LoggingInterceptor('a', log); + supabase = testSupabaseClient( + httpClient: httpClient, + interceptors: [interceptor], + ); + }); + + tearDown(() async { + await supabase.dispose(); + }); + + test('table() runs through the interceptor, from() does not', () async { + final List typed = await supabase.table(Todos.table).select(); + await supabase.from('todos').select(); + + expect(typed.single.task, 'Ship it'); + expect(log, ['a attached', 'a select todos']); + expect(httpClient.requestsTo('/rest/v1/todos'), hasLength(2)); + }); + + test('rpc, storage and functions bypass the interceptor', () async { + httpClient + ..stubRpc('ping', body: 'pong') + ..stubStorageList('avatars', objects: []) + ..stubEdgeFunction('hello', body: {'ok': true}); + + await supabase.rpc('ping'); + await supabase.storage.from('avatars').list(); + await supabase.functions.invoke('hello'); + + expect(log, ['a attached']); + }); + + test( + 'the wire carries the session while the request value does not', + () async { + final session = await signInTestUser(supabase.auth); + + await supabase.table(Todos.table).select(); + + final sent = httpClient.requestsTo('/rest/v1/todos').single; + expect(sent.headers['Authorization'], 'Bearer ${session.accessToken}'); + expect(sent.headers['apikey'], isNotNull); + }, + ); + + test('attach receives the constructed client', () { + expect(interceptor.attachedClient, same(supabase)); + expect(interceptor.attachedClient!.rest.url, endsWith('/rest/v1')); + }); + + test('interceptors wrap in order and dispose in reverse', () async { + await supabase.dispose(); + log.clear(); + final first = LoggingInterceptor('first', log); + final second = LoggingInterceptor('second', log); + supabase = testSupabaseClient( + httpClient: httpClient, + interceptors: [first, second], + ); + + await supabase.table(Todos.table).select(); + await supabase.dispose(); + + expect(log, [ + 'first attached', + 'second attached', + 'second select todos', + 'first select todos', + 'second disposed', + 'first disposed', + ]); + expect(supabase.interceptors, [first, second]); + }); + + test('a header change keeps the interceptor chain in place', () async { + supabase.headers = {'x-tenant': 'acme'}; + + await supabase.table(Todos.table).select(); + + expect(log, ['a attached', 'a select todos']); + final sent = httpClient.requestsTo('/rest/v1/todos').single; + expect(sent.headers['x-tenant'], 'acme'); + }); + + test('a schema scoped table request carries its schema', () async { + await supabase.schema('library').table(Todos.table).select(); + + final sent = httpClient.requestsTo('/rest/v1/todos').single; + expect(sent.headers['Accept-Profile'], 'library'); + expect(log, ['a attached', 'a select todos']); + }); + + test('an interceptor that fails to attach fails the constructor', () { + expect( + () => testSupabaseClient( + httpClient: httpClient, + interceptors: const [FailingAttachInterceptor()], + ), + throwsStateError, + ); + }); + + test('a failed attach disposes the interceptors already attached', () async { + final first = LoggingInterceptor('first', log); + final second = LoggingInterceptor('second', log); + + expect( + () => testSupabaseClient( + httpClient: httpClient, + interceptors: [first, second, const FailingAttachInterceptor()], + ), + throwsStateError, + ); + await pumpEventQueue(); + + expect(log, [ + 'a attached', + 'first attached', + 'second attached', + 'second disposed', + 'first disposed', + ]); + }); + + test('dispose runs each interceptor once', () async { + await supabase.dispose(); + + expect(interceptor.disposals, 1); + }); +} diff --git a/packages/supabase_flutter/lib/src/supabase.dart b/packages/supabase_flutter/lib/src/supabase.dart index 2e9c132f8..ee7755dd3 100644 --- a/packages/supabase_flutter/lib/src/supabase.dart +++ b/packages/supabase_flutter/lib/src/supabase.dart @@ -1,3 +1,6 @@ +// The interceptor seam is @experimental. +// ignore_for_file: experimental_member_use + import 'dart:async'; import 'package:clock/clock.dart'; @@ -97,6 +100,10 @@ class Supabase { /// [AuthFlowType.implicit] to use the old implicit flow for authentication /// involving deep links. /// + /// [interceptors] extend the client from the outside, see + /// [SupabaseClientInterceptor]. Each interceptor is resumed when the app + /// returns to the foreground and disposed together with the client. + /// /// All Supabase packages log through `package:logging` using loggers under /// the `supabase` hierarchy (for example `supabase.auth` or /// `supabase.realtime`). Nothing is printed by default; attach a listener @@ -117,6 +124,7 @@ class Supabase { Future Function()? accessToken, AsyncJsonCodec? jsonCodec, Duration? requestTimeout, + List interceptors = const [], }) async { if (_instance._isInitialized) { flutterLogger.info( @@ -147,6 +155,7 @@ class Supabase { accessToken: accessToken, jsonCodec: jsonCodec, requestTimeout: requestTimeout, + interceptors: interceptors, ); if (accessToken == null) { @@ -224,6 +233,11 @@ class Supabase { /// appends via `.then()` so operations never overlap. Future _pendingLifecycleOperation = Future.value(); + /// The resume work of each interceptor, one chain per interceptor so a + /// resume never overlaps the previous one of the same interceptor. [dispose] + /// waits for the chains before disposing the client. + final _interceptorResumes = >{}; + /// The most recently requested lifecycle state. Checked inside /// [_processLifecycle] after each `await` to skip stale operations /// (e.g. abort a reconnect if the app went back to background). @@ -240,6 +254,8 @@ class Supabase { final supabaseAuth = _supabaseAuth; final lifecycleListener = _lifecycleListener; final pendingLifecycleOperation = _pendingLifecycleOperation; + final interceptorResumes = _interceptorResumes.values.toList(); + _interceptorResumes.clear(); _client = null; _supabaseAuth = null; @@ -256,6 +272,7 @@ class Supabase { await _disposeAll([ () => supabaseAuth?.dispose(), () => pendingLifecycleOperation, + () => Future.wait(interceptorResumes), currentClient.dispose, ]); } @@ -298,6 +315,7 @@ class Supabase { required Future Function()? accessToken, required AsyncJsonCodec? jsonCodec, required Duration? requestTimeout, + required List interceptors, }) { _realtimeLifecycleOptions = realtimeLifecycleOptions; final headers = { @@ -317,6 +335,7 @@ class Supabase { accessToken: accessToken, jsonCodec: jsonCodec, requestTimeout: requestTimeout, + interceptors: interceptors, ); // Close any previous realtime client that may still be connected due to @@ -407,6 +426,20 @@ class Supabase { final realtime = currentClient.realtime; if (captured == AppLifecycleState.resumed) { + for (final interceptor in currentClient.interceptors) { + final previous = + _interceptorResumes[interceptor] ?? Future.value(); + _interceptorResumes[interceptor] = previous + .then((_) => Future.sync(interceptor.resume)) + .catchError((Object error, StackTrace stackTrace) { + flutterLogger.warning( + 'Interceptor ${interceptor.runtimeType} failed to resume', + error, + stackTrace, + ); + }); + } + if (!_realtimeLifecycleOptions.managed) return; // No channels subscribed — nothing to reconnect. diff --git a/packages/supabase_flutter/test/interceptor_test.dart b/packages/supabase_flutter/test/interceptor_test.dart new file mode 100644 index 000000000..3f7870033 --- /dev/null +++ b/packages/supabase_flutter/test/interceptor_test.dart @@ -0,0 +1,143 @@ +// The interceptor seam is @experimental. +// ignore_for_file: experimental_member_use + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'utils.dart'; +import 'widget_test_stubs.dart'; + +class CountingInterceptor extends SupabaseClientInterceptor { + SupabaseClient? attachedClient; + int resumes = 0; + int disposals = 0; + bool failNextResume = false; + Completer? gate; + int finishedResumes = 0; + + @override + void attach(SupabaseClient client) { + attachedClient = client; + } + + @override + Future resume() async { + resumes++; + if (failNextResume) { + failNextResume = false; + throw StateError('resume failed'); + } + await gate?.future; + finishedResumes++; + } + + @override + Future dispose() async { + disposals++; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late CountingInterceptor interceptor; + + setUp(() async { + mockSharedPreferences(); + mockAppLink(); + interceptor = CountingInterceptor(); + await Supabase.initialize( + url: '', + publishableKey: '', + authOptions: FlutterAuthClientOptions(asyncStorage: MockAsyncStorage()), + interceptors: [interceptor], + ); + }); + + tearDown(() async { + try { + await Supabase.instance.dispose(); + } catch (_) {} + }); + + /// Sends the app to the background and brings it back. + Future cycleToResumed() async { + final binding = TestWidgetsFlutterBinding.instance; + binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + binding.handleAppLifecycleStateChanged(AppLifecycleState.detached); + binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await pumpEventQueue(); + } + + test('interceptors reach the client and are attached', () { + expect(Supabase.instance.client.interceptors, [interceptor]); + expect(interceptor.attachedClient, same(Supabase.instance.client)); + }); + + test('resume runs on resumed even without realtime channels', () async { + expect(Supabase.instance.client.realtime.channels, isEmpty); + + await cycleToResumed(); + + expect(interceptor.resumes, 1); + }); + + test('a failing resume is logged and later resumes still run', () async { + interceptor.failNextResume = true; + + await cycleToResumed(); + await cycleToResumed(); + + expect(interceptor.resumes, 2); + }); + + test('resumes of one interceptor run one at a time', () async { + final gate = Completer(); + interceptor.gate = gate; + + await cycleToResumed(); + await cycleToResumed(); + + expect(interceptor.resumes, 1); + + gate.complete(); + interceptor.gate = null; + await pumpEventQueue(); + + expect(interceptor.resumes, 2); + expect(interceptor.finishedResumes, 2); + }); + + test('dispose waits for a running resume', () async { + final gate = Completer(); + interceptor.gate = gate; + await cycleToResumed(); + + var disposed = false; + final disposing = Supabase.instance.dispose().then((_) => disposed = true); + await pumpEventQueue(); + + expect(disposed, isFalse); + expect(interceptor.disposals, 0); + + gate.complete(); + await disposing; + + expect(interceptor.finishedResumes, 1); + expect(interceptor.disposals, 1); + }); + + test('dispose runs the interceptor and stops resume events', () async { + await Supabase.instance.dispose(); + + await cycleToResumed(); + + expect(interceptor.disposals, 1); + expect(interceptor.resumes, 0); + }); +} diff --git a/packages/supabase_test/lib/src/test_supabase_client.dart b/packages/supabase_test/lib/src/test_supabase_client.dart index 829c21711..f0bce378f 100644 --- a/packages/supabase_test/lib/src/test_supabase_client.dart +++ b/packages/supabase_test/lib/src/test_supabase_client.dart @@ -41,6 +41,8 @@ SupabaseClient testSupabaseClient({ String url = 'http://localhost:54321', String? apiKey, bool autoRefreshToken = false, + // ignore: experimental_member_use + List interceptors = const [], }) { return SupabaseClient( url, @@ -51,6 +53,7 @@ SupabaseClient testSupabaseClient({ asyncStorage: MemoryAuthAsyncStorage(), ), realtimeClientOptions: RealtimeClientOptions(transport: realtime?.call), + interceptors: interceptors, ); } diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 78e91a44b..b3da58ac5 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -813,6 +813,60 @@ features: - PostgrestTable.relations - PostgrestTable.computedFields - RowConverter + - PostgrestTypedBuilder.request + - PostgrestTableRequest + - PostgrestTableRequest.PostgrestTableRequest + - PostgrestTableRequest.table + - PostgrestTableRequest.operation + - PostgrestTableRequest.schema + - PostgrestTableRequest.columns + - PostgrestTableRequest.filter + - PostgrestTableRequest.orderings + - PostgrestTableRequest.limit + - PostgrestTableRequest.offset + - PostgrestTableRequest.embeddedScopes + - PostgrestTableRequest.shape + - PostgrestTableRequest.countOption + - PostgrestTableRequest.payload + - PostgrestTableRequest.onConflict + - PostgrestTableRequest.ignoreDuplicates + - PostgrestTableRequest.defaultToNull + - PostgrestTableRequest.maxAffected + - PostgrestTableRequest.stripNulls + - PostgrestTableRequest.dryRun + - PostgrestTableRequest.explainOptions + - PostgrestTableRequest.returning + - PostgrestTableRequest.copyWith + - PostgrestTableRequest.options + - PostgrestTableOperation + - PostgrestTableOperation.isMutation + - PostgrestResultShape + - PostgrestExplainOptions + - PostgrestExplainOptions.PostgrestExplainOptions + - PostgrestExplainOptions.analyze + - PostgrestExplainOptions.verbose + - PostgrestExplainOptions.settings + - PostgrestExplainOptions.buffers + - PostgrestExplainOptions.wal + - PostgrestExplainOptions.format + - PostgrestRequestOptions + - PostgrestRequestOptions.PostgrestRequestOptions + - PostgrestRequestOptions.headers + - PostgrestRequestOptions.retryEnabled + - PostgrestRequestOptions.retryCount + - PostgrestRequestOptions.requestTimeout + - PostgrestRequestOptions.abortSignal + - PostgrestRequestOptions.copyWith + - PostgrestTableResult + - PostgrestTableResult.PostgrestTableResult + - PostgrestTableResult.data + - PostgrestTableResult.count + - PostgrestTableExecutor + - PostgrestTableExecutor.execute + - PostgrestHttpTableExecutor + - PostgrestHttpTableExecutor.PostgrestHttpTableExecutor + - PostgrestHttpTableExecutor.client + - PostgrestHttpTableExecutor.execute - PostgrestSelectable - PostgrestSelectable.expression - PostgrestSelectable.responseKey @@ -1327,6 +1381,11 @@ features: - PostgrestEmbeddedScope.limit - PostgrestEmbeddedScope.range - PostgrestEmbeddedScope.embedded + - PostgrestEmbeddedScope.relation + - PostgrestEmbeddedScope.orderings + - PostgrestEmbeddedScope.rowLimit + - PostgrestEmbeddedScope.rowOffset + - PostgrestEmbeddedScope.scopes - PostgrestEmbed - PostgrestEmbed.relation - PostgrestEmbed.selections @@ -2633,6 +2692,14 @@ features: symbols: - PostgrestClient.httpClient - RealtimeClient.httpClient + supporting_symbols: + - SupabaseClient.interceptors + - SupabaseClientInterceptor + - SupabaseClientInterceptor.SupabaseClientInterceptor + - SupabaseClientInterceptor.wrapTableExecutor + - SupabaseClientInterceptor.attach + - SupabaseClientInterceptor.resume + - SupabaseClientInterceptor.dispose client.request_configuration.global_headers: status: implemented symbols: