From 8939c92c299dd3e50aa118f3b8e5815a699c7150 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 18:02:13 +0200 Subject: [PATCH 1/6] feat: add typed table access with PostgrestTable and TableColumn --- packages/postgrest/lib/postgrest.dart | 1 + packages/postgrest/lib/src/postgrest.dart | 16 + .../postgrest/lib/src/postgrest_table.dart | 313 +++++++++++++++++ .../lib/src/postgrest_typed_builder.dart | 59 ++++ .../src/postgrest_typed_filter_builder.dart | 76 ++++ .../src/postgrest_typed_query_builder.dart | 121 +++++++ .../postgrest_typed_transform_builder.dart | 124 +++++++ packages/postgrest/test/typed_query_test.dart | 330 ++++++++++++++++++ .../supabase/lib/src/supabase_client.dart | 16 + .../lib/src/supabase_query_schema.dart | 5 + .../lib/src/supabase_typed_query_builder.dart | 47 +++ .../src/supabase_typed_stream_builder.dart | 132 +++++++ packages/supabase/lib/supabase.dart | 2 + packages/supabase/test/mock_test.dart | 65 ++++ sdk-compliance.yaml | 83 +++++ 15 files changed, 1390 insertions(+) create mode 100644 packages/postgrest/lib/src/postgrest_table.dart create mode 100644 packages/postgrest/lib/src/postgrest_typed_builder.dart create mode 100644 packages/postgrest/lib/src/postgrest_typed_filter_builder.dart create mode 100644 packages/postgrest/lib/src/postgrest_typed_query_builder.dart create mode 100644 packages/postgrest/lib/src/postgrest_typed_transform_builder.dart create mode 100644 packages/postgrest/test/typed_query_test.dart create mode 100644 packages/supabase/lib/src/supabase_typed_query_builder.dart create mode 100644 packages/supabase/lib/src/supabase_typed_stream_builder.dart diff --git a/packages/postgrest/lib/postgrest.dart b/packages/postgrest/lib/postgrest.dart index da0e86ff9..7c0566818 100644 --- a/packages/postgrest/lib/postgrest.dart +++ b/packages/postgrest/lib/postgrest.dart @@ -3,5 +3,6 @@ library; export 'src/postgrest.dart'; export 'src/postgrest_builder.dart'; +export 'src/postgrest_typed_builder.dart'; 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 7905c331f..3c67ba8f8 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -114,6 +114,22 @@ class PostgrestClient { ); } + /// Perform a typed table operation. + /// + /// Unlike [from], results are converted into the row type of [table] + /// instead of raw `Map` data, and filters are built from + /// [TableColumn]s, which makes them compile-time checked. + /// + /// ```dart + /// final List books = await client + /// .table(Books.table) + /// .select() + /// .where(Books.id.gt(10)); + /// ``` + PostgrestTypedQueryBuilder table(PostgrestTable table) { + return PostgrestTypedQueryBuilder(from(table.name), table); + } + /// Select a schema to query or perform an function (rpc) call. /// /// The schema needs to be on the list of exposed schemas inside Supabase. diff --git a/packages/postgrest/lib/src/postgrest_table.dart b/packages/postgrest/lib/src/postgrest_table.dart new file mode 100644 index 000000000..7f9854fa8 --- /dev/null +++ b/packages/postgrest/lib/src/postgrest_table.dart @@ -0,0 +1,313 @@ +part of 'postgrest_typed_builder.dart'; + +/// Converts a single decoded PostgREST row into [Row]. +typedef RowConverter = Row Function(Map json); + +/// Describes a database table (or view) together with the Dart type its rows +/// are converted into. +/// +/// Passing a [PostgrestTable] to [PostgrestClient.table] gives fully typed +/// query results, so no raw `Map` needs to be handled: +/// +/// ```dart +/// extension type Book(Map json) { +/// int get id => json['id'] as int; +/// String get title => json['title'] as String; +/// } +/// +/// class Books { +/// static const table = PostgrestTable('books', Book.new); +/// static const id = TableColumn('id'); +/// static const title = TableColumn('title'); +/// } +/// +/// final List books = await client +/// .table(Books.table) +/// .select() +/// .where(Books.title.like('%Dart%')); +/// ``` +/// +/// Extension types over the decoded JSON map (as above) are the recommended +/// row representation since they carry no conversion cost and tolerate +/// partial selects, but any converter works, for example `Book.fromJson` on a +/// regular data class. +class PostgrestTable { + const PostgrestTable(this.name, this.rowFromJson); + + /// Name of the table in the database. + final String name; + + /// Converts a decoded row into [Row]. + final RowConverter rowFromJson; +} + +/// A reference to a column of type [Value] on a database table. +/// +/// Used to build compile-time checked filters through methods like +/// [TableColumn.eq], which only accept values matching the column type. +/// +/// [Value] is always the non-nullable value type of the column. Null checks +/// are expressed with [isNull] and [isNotNull] instead of nullable values. +class TableColumn { + const TableColumn(this.name); + + /// Name of the column in the database. + final String name; + + @override + String toString() => name; + + /// Only rows where this column equals [value]. + /// + /// For `null` equality, use [isNull] instead. + ColumnFilter eq(Value value) => + ColumnFilter._(name, 'eq', value, (builder) => builder.eq(name, value)); + + /// Only rows where this column does not equal [value]. + ColumnFilter neq(Value value) => + ColumnFilter._(name, 'neq', value, (builder) => builder.neq(name, value)); + + /// Only rows where this column is greater than [value]. + ColumnFilter gt(Value value) => + ColumnFilter._(name, 'gt', value, (builder) => builder.gt(name, value)); + + /// Only rows where this column is greater than or equal to [value]. + ColumnFilter gte(Value value) => + ColumnFilter._(name, 'gte', value, (builder) => builder.gte(name, value)); + + /// Only rows where this column is less than [value]. + ColumnFilter lt(Value value) => + ColumnFilter._(name, 'lt', value, (builder) => builder.lt(name, value)); + + /// Only rows where this column is less than or equal to [value]. + ColumnFilter lte(Value value) => + ColumnFilter._(name, 'lte', value, (builder) => builder.lte(name, value)); + + /// Only rows where this column is `null`. + ColumnFilter isNull() => ColumnFilter._( + name, + 'is', + null, + (builder) => builder.isFilter(name, null), + ); + + /// Only rows where this column is not `null`. + ColumnFilter isNotNull() => isNull().not(); + + /// Only rows where this column equals one of [values]. + ColumnFilter inFilter(List values) => ColumnFilter._( + name, + 'in', + values, + (builder) => builder.inFilter(name, values), + ); + + /// Only rows where this column is not equal to [value], treating `null` as + /// a comparable value. + ColumnFilter isDistinctFrom(Value? value) => ColumnFilter._( + name, + 'isdistinct', + value, + (builder) => builder.isDistinct(name, value), + ); + + /// Only rows whose json, array, or range value contains [value]. + /// + /// See [PostgrestFilterBuilder.contains] for the accepted value shapes. + ColumnFilter contains(Object value) => ColumnFilter._( + name, + 'cs', + value, + (builder) => builder.contains(name, value), + ); + + /// Only rows whose json, array, or range value is contained by [value]. + /// + /// See [PostgrestFilterBuilder.containedBy] for the accepted value shapes. + ColumnFilter containedBy(Object value) => ColumnFilter._( + name, + 'cd', + value, + (builder) => builder.containedBy(name, value), + ); + + /// Only rows whose array or range value overlaps with [value]. + ColumnFilter overlaps(Object value) => ColumnFilter._( + name, + 'ov', + value, + (builder) => builder.overlaps(name, value), + ); + + /// Only rows whose range value is strictly to the left of [range]. + ColumnFilter rangeLt(String range) => ColumnFilter._( + name, + 'sl', + range, + (builder) => builder.rangeLt(name, range), + ); + + /// Only rows whose range value is strictly to the right of [range]. + ColumnFilter rangeGt(String range) => ColumnFilter._( + name, + 'sr', + range, + (builder) => builder.rangeGt(name, range), + ); + + /// Only rows whose range value does not extend to the left of [range]. + ColumnFilter rangeGte(String range) => ColumnFilter._( + name, + 'nxl', + range, + (builder) => builder.rangeGte(name, range), + ); + + /// Only rows whose range value does not extend to the right of [range]. + ColumnFilter rangeLte(String range) => ColumnFilter._( + name, + 'nxr', + range, + (builder) => builder.rangeLte(name, range), + ); + + /// Only rows whose range value is adjacent to [range]. + ColumnFilter rangeAdjacent(String range) => ColumnFilter._( + name, + 'adj', + range, + (builder) => builder.rangeAdjacent(name, range), + ); +} + +/// Filters that only apply to text columns. +extension TextTableColumnFilters on TableColumn { + /// Only rows whose value matches [pattern] case-sensitively. + ColumnFilter like(String pattern) => ColumnFilter._( + name, + 'like', + pattern, + (builder) => builder.like(name, pattern), + ); + + /// Only rows whose value matches all of [patterns] case-sensitively. + ColumnFilter likeAllOf(List patterns) => ColumnFilter._( + name, + 'like(all)', + patterns, + (builder) => builder.likeAllOf(name, patterns), + ); + + /// Only rows whose value matches any of [patterns] case-sensitively. + ColumnFilter likeAnyOf(List patterns) => ColumnFilter._( + name, + 'like(any)', + patterns, + (builder) => builder.likeAnyOf(name, patterns), + ); + + /// Only rows whose value matches [pattern] case-insensitively. + ColumnFilter ilike(String pattern) => ColumnFilter._( + name, + 'ilike', + pattern, + (builder) => builder.ilike(name, pattern), + ); + + /// Only rows whose value matches all of [patterns] case-insensitively. + ColumnFilter ilikeAllOf(List patterns) => ColumnFilter._( + name, + 'ilike(all)', + patterns, + (builder) => builder.ilikeAllOf(name, patterns), + ); + + /// Only rows whose value matches any of [patterns] case-insensitively. + ColumnFilter ilikeAnyOf(List patterns) => ColumnFilter._( + name, + 'ilike(any)', + patterns, + (builder) => builder.ilikeAnyOf(name, patterns), + ); + + /// Only rows whose value matches [pattern] as a PostgreSQL regular + /// expression, case-sensitively. + ColumnFilter matchRegex(String pattern) => ColumnFilter._( + name, + 'match', + pattern, + (builder) => builder.matchRegex(name, pattern), + ); + + /// Only rows whose value matches [pattern] as a PostgreSQL regular + /// expression, case-insensitively. + ColumnFilter imatchRegex(String pattern) => ColumnFilter._( + name, + 'imatch', + pattern, + (builder) => builder.imatchRegex(name, pattern), + ); + + /// Only rows whose text or tsvector value matches the tsquery in [query]. + /// + /// See [PostgrestFilterBuilder.textSearch] for [config] and [type]. + ColumnFilter textSearch( + String query, { + String? config, + TextSearchType? type, + }) { + final typePart = switch (type) { + TextSearchType.plain => 'pl', + TextSearchType.phrase => 'ph', + TextSearchType.websearch => 'w', + null => '', + }; + final configPart = config == null ? '' : '($config)'; + return ColumnFilter._( + name, + '${typePart}fts$configPart', + query, + (builder) => builder.textSearch(name, query, config: config, type: type), + ); + } +} + +/// A single filter condition on a column, created through the methods on +/// [TableColumn] such as [TableColumn.eq]. +/// +/// Applied to a typed query with [PostgrestTypedFilterBuilder.where]. +class ColumnFilter { + const ColumnFilter._(this.column, this.operator, this.value, this._apply); + + /// Name of the column being filtered on. + final String column; + + /// The PostgREST operator of this filter, for example `eq` or `like(all)`. + final String operator; + + /// The value the filter compares against. + final Object? value; + + final PostgrestFilterBuilder Function( + PostgrestFilterBuilder builder, + ) + _apply; + + /// Negates this filter. + /// + /// ```dart + /// client.table(Books.table).select().where(Books.id.eq(1).not()); + /// ``` + ColumnFilter not() { + if (operator.startsWith('not.')) { + throw StateError('The filter on "$column" is already negated.'); + } + final positiveOperator = operator; + return ColumnFilter._( + column, + 'not.$operator', + value, + (builder) => builder.not(column, positiveOperator, value), + ); + } +} diff --git a/packages/postgrest/lib/src/postgrest_typed_builder.dart b/packages/postgrest/lib/src/postgrest_typed_builder.dart new file mode 100644 index 000000000..865ee8329 --- /dev/null +++ b/packages/postgrest/lib/src/postgrest_typed_builder.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:postgrest/postgrest.dart'; + +part 'postgrest_table.dart'; +part 'postgrest_typed_query_builder.dart'; +part 'postgrest_typed_transform_builder.dart'; +part 'postgrest_typed_filter_builder.dart'; + +List _rowsFromJson(PostgrestTable table, dynamic data) => [ + for (final row in data as List) + table.rowFromJson(row as Map), +]; + +Row _rowFromJson(PostgrestTable table, dynamic data) => + table.rowFromJson(data as Map); + +Row? _maybeRowFromJson(PostgrestTable table, dynamic data) => + data == null ? null : table.rowFromJson(data as Map); + +void _toVoid(dynamic data) {} + +/// A typed PostgREST request that can be awaited. +/// +/// Wraps an untyped [PostgrestBuilder] and converts its result into [T] +/// before it is returned, so awaiting it never exposes raw +/// `Map` data. +class PostgrestTypedBuilder implements Future { + PostgrestTypedBuilder._(this._rawBuilder, this._convert); + + final PostgrestBuilder _rawBuilder; + final T Function(dynamic data) _convert; + + Future _execute() async { + final dynamic data = await _rawBuilder; + return _convert(data); + } + + @override + Stream asStream() => _execute().asStream(); + + @override + Future catchError(Function onError, {bool Function(Object error)? test}) => + _execute().catchError(onError, test: test); + + @override + Future then( + FutureOr Function(T value) onValue, { + Function? onError, + }) => _execute().then(onValue, onError: onError); + + @override + Future timeout(Duration timeLimit, {FutureOr Function()? onTimeout}) => + _execute().timeout(timeLimit, onTimeout: onTimeout); + + @override + Future whenComplete(FutureOr Function() action) => + _execute().whenComplete(action); +} diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart new file mode 100644 index 000000000..005b6f5c6 --- /dev/null +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -0,0 +1,76 @@ +part of 'postgrest_typed_builder.dart'; + +/// The typed counterpart of [PostgrestFilterBuilder]. +/// +/// Filters are built from [TableColumn]s and applied with [where], which +/// checks the value type of each filter against its column at compile time. +class PostgrestTypedFilterBuilder + extends PostgrestTypedTransformBuilder { + PostgrestTypedFilterBuilder._( + PostgrestFilterBuilder super.rawBuilder, + super.table, + super.convert, + ) : super._(); + + PostgrestFilterBuilder get _filterBuilder => + _rawBuilder as PostgrestFilterBuilder; + + /// Only rows satisfying [filter]. + /// + /// Chain multiple [where] calls to combine filters with logical AND. + /// + /// ```dart + /// final List books = await client + /// .table(Books.table) + /// .select() + /// .where(Books.id.gt(10)) + /// .where(Books.title.like('%Dart%')); + /// ``` + PostgrestTypedFilterBuilder where(ColumnFilter filter) => + PostgrestTypedFilterBuilder._( + filter._apply(_filterBuilder), + _table, + _convert, + ); + + /// Only rows satisfying at least one of the [filters]. + /// + /// ```dart + /// client + /// .table(Books.table) + /// .select() + /// .whereAny([Books.id.eq(1), Books.title.eq('foo')]); + /// ``` + PostgrestTypedFilterBuilder whereAny(List filters) { + final fragments = [for (final filter in filters) _orFragment(filter)]; + return PostgrestTypedFilterBuilder._( + _filterBuilder.or(fragments.join(',')), + _table, + _convert, + ); + } + + static String _orFragment(ColumnFilter filter) { + final value = filter.value; + final String rendered; + if (value is List) { + final elements = value.map(_quoteOrElement).join(','); + rendered = filter.operator == 'in' || filter.operator == 'not.in' + ? '($elements)' + : '{$elements}'; + } else { + rendered = _quoteOrElement(value); + } + return '${filter.column}.${filter.operator}.$rendered'; + } + + /// Quotes values inside an `or` fragment so that reserved characters like + /// commas and parentheses cannot break the logic tree. + static String _quoteOrElement(Object? value) { + if (value == null || value is num || value is bool) { + return '$value'; + } + final escaped = '$value'.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + return '"$escaped"'; + } +} diff --git a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart new file mode 100644 index 000000000..0c7140d91 --- /dev/null +++ b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart @@ -0,0 +1,121 @@ +part of 'postgrest_typed_builder.dart'; + +/// {@template postgrest_typed_query_builder} +/// The typed counterpart of [PostgrestQueryBuilder], returned by +/// [PostgrestClient.table]. +/// +/// Query results are converted into [Row] through +/// [PostgrestTable.rowFromJson], so no raw `Map` is exposed. +/// {@endtemplate} +class PostgrestTypedQueryBuilder { + /// {@macro postgrest_typed_query_builder} + PostgrestTypedQueryBuilder( + PostgrestQueryBuilder queryBuilder, + this.table, + ) : _queryBuilder = queryBuilder; + + final PostgrestQueryBuilder _queryBuilder; + + /// The table this builder queries. + final PostgrestTable table; + + /// Perform a SELECT query on the table or view. + /// + /// ```dart + /// final List books = await client.table(Books.table).select(); + /// ``` + PostgrestTypedFilterBuilder> select([String columns = '*']) => + PostgrestTypedFilterBuilder._( + _queryBuilder.select(columns), + table, + (data) => _rowsFromJson(table, data), + ); + + /// Perform an INSERT into the table or view. + /// + /// By default no data is returned. Use a trailing [select] to return the + /// inserted rows typed as [Row]. + /// + /// See [PostgrestQueryBuilder.insert] for [values] and [defaultToNull]. + /// + /// ```dart + /// final Book book = await client + /// .table(Books.table) + /// .insert({'title': 'foo'}) + /// .select() + /// .single(); + /// ``` + PostgrestTypedFilterBuilder insert( + Object values, { + bool defaultToNull = true, + }) => PostgrestTypedFilterBuilder._( + _queryBuilder.insert(values, defaultToNull: defaultToNull), + table, + _toVoid, + ); + + /// Perform an UPSERT on the table or view. + /// + /// By default no data is returned. Use a trailing [select] to return the + /// upserted rows typed as [Row]. + /// + /// See [PostgrestQueryBuilder.upsert] for [values], [onConflict], + /// [ignoreDuplicates] and [defaultToNull]. + PostgrestTypedFilterBuilder upsert( + Object values, { + String? onConflict, + bool ignoreDuplicates = false, + bool defaultToNull = true, + }) => PostgrestTypedFilterBuilder._( + _queryBuilder.upsert( + values, + onConflict: onConflict, + ignoreDuplicates: ignoreDuplicates, + defaultToNull: defaultToNull, + ), + table, + _toVoid, + ); + + /// Perform an UPDATE on the table or view. + /// + /// By default no data is returned. Use a trailing [select] to return the + /// updated rows typed as [Row]. + /// + /// ```dart + /// await client + /// .table(Books.table) + /// .update({'title': 'bar'}) + /// .where(Books.id.eq(1)); + /// ``` + PostgrestTypedFilterBuilder update(Map values) => + PostgrestTypedFilterBuilder._( + _queryBuilder.update(values), + table, + _toVoid, + ); + + /// Perform a DELETE on the table or view. + /// + /// By default no data is returned. Use a trailing [select] to return the + /// deleted rows typed as [Row]. + /// + /// ```dart + /// await client.table(Books.table).delete().where(Books.id.eq(1)); + /// ``` + PostgrestTypedFilterBuilder delete() => + PostgrestTypedFilterBuilder._(_queryBuilder.delete(), table, _toVoid); + + /// Only performs a count query on the table or view. + /// + /// ```dart + /// final int count = await client.table(Books.table).count(); + /// ``` + PostgrestTypedFilterBuilder count([ + CountOption option = CountOption.exact, + ]) => PostgrestTypedFilterBuilder._( + _queryBuilder.count(option), + table, + (data) => data as int, + ); +} diff --git a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart new file mode 100644 index 000000000..96e931453 --- /dev/null +++ b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart @@ -0,0 +1,124 @@ +part of 'postgrest_typed_builder.dart'; + +/// The typed counterpart of [PostgrestTransformBuilder]. +/// +/// [Row] is the type a single row converts into and [T] is the type the +/// request resolves to when awaited. +class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { + PostgrestTypedTransformBuilder._( + PostgrestTransformBuilder super.rawBuilder, + this._table, + super.convert, + ) : super._(); + + final PostgrestTable _table; + + PostgrestTransformBuilder get _transformBuilder => + _rawBuilder as PostgrestTransformBuilder; + + /// Performs horizontal filtering with SELECT, returning the affected rows + /// typed as [Row]. + /// + /// Used after a mutation: + /// ```dart + /// final List books = + /// await client.table(Books.table).insert({'title': 'foo'}).select(); + /// ``` + PostgrestTypedTransformBuilder> select([ + String columns = '*', + ]) => PostgrestTypedTransformBuilder._( + _transformBuilder.select(columns), + _table, + (data) => _rowsFromJson(_table, data), + ); + + /// Orders the result with the specified [column]. + /// + /// See [PostgrestTransformBuilder.order] for [ascending], [nullsFirst] and + /// [referencedTable]. + PostgrestTypedTransformBuilder order( + TableColumn column, { + bool ascending = false, + bool nullsFirst = false, + String? referencedTable, + }) => PostgrestTypedTransformBuilder._( + _transformBuilder.order( + column.name, + ascending: ascending, + nullsFirst: nullsFirst, + referencedTable: referencedTable, + ), + _table, + _convert, + ); + + /// Limits the result with the specified [count]. + PostgrestTypedTransformBuilder limit( + int count, { + String? referencedTable, + }) => PostgrestTypedTransformBuilder._( + _transformBuilder.limit(count, referencedTable: referencedTable), + _table, + _convert, + ); + + /// Limits the result to rows within the specified range, inclusive. + PostgrestTypedTransformBuilder range( + int from, + int to, { + String? referencedTable, + }) => PostgrestTypedTransformBuilder._( + _transformBuilder.range(from, to, referencedTable: referencedTable), + _table, + _convert, + ); + + /// Retrieves only one row from the result as [Row]. + /// + /// The result must be exactly one row, otherwise this will result in an + /// error. + /// + /// ```dart + /// final Book book = await client + /// .table(Books.table) + /// .select() + /// .where(Books.id.eq(1)) + /// .single(); + /// ``` + PostgrestTypedTransformBuilder single() => + PostgrestTypedTransformBuilder._( + _transformBuilder.single(), + _table, + (data) => _rowFromJson(_table, data), + ); + + /// Retrieves at most one row from the result as [Row], or `null` when the + /// result is empty. + PostgrestTypedTransformBuilder maybeSingle() => + PostgrestTypedTransformBuilder._( + _transformBuilder.maybeSingle(), + _table, + (data) => _maybeRowFromJson(_table, data), + ); + + /// Performs additionally to the query a count query. + /// + /// This changes the awaited type to a [PostgrestResponse] carrying both the + /// typed data and the count. + /// + /// ```dart + /// final response = + /// await client.table(Books.table).select().count(CountOption.exact); + /// final List books = response.data; + /// final int count = response.count; + /// ``` + PostgrestTypedBuilder> count([ + CountOption option = CountOption.exact, + ]) => PostgrestTypedBuilder._(_transformBuilder.count(option), (data) { + final response = data as PostgrestResponse; + return PostgrestResponse( + data: _convert(response.data), + count: response.count, + ); + }); +} diff --git a/packages/postgrest/test/typed_query_test.dart b/packages/postgrest/test/typed_query_test.dart new file mode 100644 index 000000000..7d4c56478 --- /dev/null +++ b/packages/postgrest/test/typed_query_test.dart @@ -0,0 +1,330 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +extension type Book(Map json) { + int get id => json['id'] as int; + String get title => json['title'] as String; +} + +class Books { + static const table = PostgrestTable('books', Book.new); + static const id = TableColumn('id'); + static const title = TableColumn('title'); + static const tags = TableColumn>('tags'); + static const ageRange = TableColumn('age_range'); +} + +class MockHttpClient extends BaseClient { + String responseBody = '[]'; + int statusCode = 200; + Map responseHeaders = {'content-type': 'application/json'}; + BaseRequest? lastRequest; + String? lastRequestBody; + + @override + Future send(BaseRequest request) async { + lastRequest = request; + lastRequestBody = utf8.decode(await request.finalize().toBytes()); + return StreamedResponse( + Stream.value(utf8.encode(responseBody)), + statusCode, + headers: responseHeaders, + request: request, + ); + } +} + +void main() { + late MockHttpClient httpClient; + late PostgrestClient client; + + const bookRows = '[{"id":1,"title":"a"},{"id":2,"title":"b"}]'; + + setUp(() { + httpClient = MockHttpClient(); + client = PostgrestClient( + 'http://localhost/rest/v1', + httpClient: httpClient, + ); + }); + + tearDown(() async { + await client.dispose(); + }); + + Map requestParameters() => + httpClient.lastRequest!.url.queryParameters; + + group('select', () { + test('returns rows converted into the table row type', () async { + httpClient.responseBody = bookRows; + + final List books = await client.table(Books.table).select(); + + expect(httpClient.lastRequest!.url.path, '/rest/v1/books'); + expect(requestParameters()['select'], '*'); + expect(books.map((book) => book.title), ['a', 'b']); + }); + + test('single returns one row converted into the table row type', () async { + httpClient.responseBody = '{"id":1,"title":"a"}'; + + final Book book = await client + .table(Books.table) + .select() + .where(Books.id.eq(1)) + .single(); + + expect( + httpClient.lastRequest!.headers['Accept'], + 'application/vnd.pgrst.object+json', + ); + expect(book.title, 'a'); + }); + + test('maybeSingle returns null when no row matches', () async { + httpClient.responseBody = '[]'; + + final Book? book = await client + .table(Books.table) + .select() + .where(Books.id.eq(1)) + .maybeSingle(); + + expect(book, isNull); + }); + + test('maybeSingle returns the row when one matches', () async { + httpClient.responseBody = '[{"id":1,"title":"a"}]'; + + final Book? book = await client + .table(Books.table) + .select() + .where(Books.id.eq(1)) + .maybeSingle(); + + expect(book?.title, 'a'); + }); + + test('count returns typed rows together with the count', () async { + httpClient.responseBody = bookRows; + httpClient.responseHeaders = { + 'content-type': 'application/json', + 'content-range': '0-1/10', + }; + + final PostgrestResponse> response = await client + .table(Books.table) + .select() + .count(CountOption.exact); + + expect( + httpClient.lastRequest!.headers['Prefer'], + contains('count=exact'), + ); + expect(response.data.map((book) => book.id), [1, 2]); + expect(response.count, 10); + }); + }); + + group('where', () { + setUp(() { + httpClient.responseBody = bookRows; + }); + + test('chained filters combine with logical AND', () async { + await client + .table(Books.table) + .select() + .where(Books.id.eq(1)) + .where(Books.title.like('%a%')); + + expect(requestParameters()['id'], 'eq.1'); + expect(requestParameters()['title'], 'like.%a%'); + }); + + test('builds the same URLs as the untyped filters', () async { + final filters = { + Books.id.eq(1): ('id', 'eq.1'), + Books.id.neq(1): ('id', 'neq.1'), + Books.id.gt(1): ('id', 'gt.1'), + Books.id.gte(1): ('id', 'gte.1'), + Books.id.lt(1): ('id', 'lt.1'), + Books.id.lte(1): ('id', 'lte.1'), + Books.id.eq(1).not(): ('id', 'not.eq.1'), + Books.title.isNull(): ('title', 'is.null'), + Books.title.isNotNull(): ('title', 'not.is.null'), + Books.id.inFilter([1, 2]): ('id', 'in.(1,2)'), + Books.id.isDistinctFrom(5): ('id', 'isdistinct.5'), + Books.tags.contains(['a', 'b']): ('tags', 'cs.{"a","b"}'), + Books.tags.containedBy(['a', 'b']): ('tags', 'cd.{"a","b"}'), + Books.ageRange.overlaps('[2,25)'): ('age_range', 'ov.[2,25)'), + Books.ageRange.rangeLt('[2,25)'): ('age_range', 'sl.[2,25)'), + Books.ageRange.rangeGt('[2,25)'): ('age_range', 'sr.[2,25)'), + Books.ageRange.rangeGte('[2,25)'): ('age_range', 'nxl.[2,25)'), + Books.ageRange.rangeLte('[2,25)'): ('age_range', 'nxr.[2,25)'), + Books.ageRange.rangeAdjacent('[2,25)'): ('age_range', 'adj.[2,25)'), + Books.title.ilike('%a%'): ('title', 'ilike.%a%'), + Books.title.likeAllOf(['%a%', '%b%']): ('title', 'like(all).{%a%,%b%}'), + Books.title.likeAnyOf(['%a%', '%b%']): ('title', 'like(any).{%a%,%b%}'), + Books.title.ilikeAllOf(['%a%', '%b%']): ( + 'title', + 'ilike(all).{%a%,%b%}', + ), + Books.title.ilikeAnyOf(['%a%', '%b%']): ( + 'title', + 'ilike(any).{%a%,%b%}', + ), + Books.title.matchRegex('^a'): ('title', 'match.^a'), + Books.title.imatchRegex('^a'): ('title', 'imatch.^a'), + Books.title.textSearch( + "'fat' & 'cat'", + config: 'english', + ): ( + 'title', + "fts(english).'fat' & 'cat'", + ), + Books.title.textSearch('fat cat', type: TextSearchType.websearch): ( + 'title', + 'wfts.fat cat', + ), + }; + + for (final entry in filters.entries) { + await client.table(Books.table).select().where(entry.key); + + final (column, value) = entry.value; + expect( + requestParameters()[column], + value, + reason: 'filter on "$column" with "$value"', + ); + } + }); + + test('whereAny combines filters with logical OR', () async { + await client.table(Books.table).select().whereAny([ + Books.id.eq(1), + Books.title.eq('foo'), + ]); + + expect(requestParameters()['or'], '(id.eq.1,title.eq."foo")'); + }); + + test('whereAny quotes values with reserved characters', () async { + await client.table(Books.table).select().whereAny([ + Books.title.eq('foo,bar'), + Books.id.inFilter([1, 2]), + ]); + + expect(requestParameters()['or'], '(title.eq."foo,bar",id.in.(1,2))'); + }); + + test('negating a filter twice throws', () { + expect(() => Books.id.eq(1).not().not(), throwsStateError); + }); + }); + + group('transforms', () { + setUp(() { + httpClient.responseBody = bookRows; + }); + + test('order, limit and range keep the row type', () async { + final List books = await client + .table(Books.table) + .select() + .where(Books.id.gt(0)) + .order(Books.title, ascending: true) + .limit(2); + + expect(requestParameters()['order'], 'title.asc.nullslast'); + expect(requestParameters()['limit'], '2'); + expect(books, hasLength(2)); + + await client.table(Books.table).select().range(0, 1); + + expect(requestParameters()['offset'], '0'); + expect(requestParameters()['limit'], '2'); + }); + }); + + group('mutations', () { + test('insert posts the values', () async { + httpClient.responseBody = ''; + + await client.table(Books.table).insert({'title': 'foo'}); + + expect(httpClient.lastRequest!.method, 'POST'); + expect(httpClient.lastRequestBody, '{"title":"foo"}'); + }); + + test('insert with a trailing select returns the typed row', () async { + httpClient.responseBody = '{"id":3,"title":"foo"}'; + + final Book book = await client + .table(Books.table) + .insert({'title': 'foo'}) + .select() + .single(); + + expect(httpClient.lastRequest!.method, 'POST'); + expect( + httpClient.lastRequest!.headers['Prefer'], + contains('return=representation'), + ); + expect(book.id, 3); + }); + + test('upsert sets the resolution header', () async { + httpClient.responseBody = ''; + + await client.table(Books.table).upsert({'id': 1, 'title': 'foo'}); + + expect( + httpClient.lastRequest!.headers['Prefer'], + contains('resolution=merge-duplicates'), + ); + }); + + test('update patches the filtered rows', () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .update({'title': 'bar'}) + .where(Books.id.eq(1)); + + expect(httpClient.lastRequest!.method, 'PATCH'); + expect(requestParameters()['id'], 'eq.1'); + expect(httpClient.lastRequestBody, '{"title":"bar"}'); + }); + + test('delete uses the filtered rows', () async { + httpClient.responseBody = ''; + + await client.table(Books.table).delete().where(Books.id.eq(1)); + + expect(httpClient.lastRequest!.method, 'DELETE'); + expect(requestParameters()['id'], 'eq.1'); + }); + }); + + group('count', () { + test('count on the table returns the number of rows', () async { + httpClient.responseBody = ''; + httpClient.responseHeaders = { + 'content-type': 'application/json', + 'content-range': '*/42', + }; + + final int count = await client.table(Books.table).count(); + + expect(httpClient.lastRequest!.method, 'HEAD'); + expect(count, 42); + }); + }); +} diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index f76841589..b17cdf382 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -220,6 +220,22 @@ class SupabaseClient { ); } + /// Perform a typed table operation. + /// + /// Unlike [from], results are converted into the row type of [table] + /// instead of raw `Map` data, and filters are built from + /// [TableColumn]s, which makes them compile-time checked. + /// + /// ```dart + /// final List books = await supabase + /// .table(Books.table) + /// .select() + /// .where(Books.id.gt(10)); + /// ``` + SupabaseTypedQueryBuilder table(PostgrestTable table) { + return SupabaseTypedQueryBuilder(from(table.name), table); + } + /// Select a schema to query or perform an function (rpc) call. /// /// The schema needs to be on the list of exposed schemas inside Supabase. diff --git a/packages/supabase/lib/src/supabase_query_schema.dart b/packages/supabase/lib/src/supabase_query_schema.dart index 64e703a8c..415b37ba8 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -48,6 +48,11 @@ class SupabaseQuerySchema { ); } + /// Perform a typed table operation, see [SupabaseClient.table]. + SupabaseTypedQueryBuilder table(PostgrestTable table) { + return SupabaseTypedQueryBuilder(from(table.name), table); + } + /// {@macro postgrest_rpc} PostgrestFilterBuilder rpc( String fn, { diff --git a/packages/supabase/lib/src/supabase_typed_query_builder.dart b/packages/supabase/lib/src/supabase_typed_query_builder.dart new file mode 100644 index 000000000..d2a50ea03 --- /dev/null +++ b/packages/supabase/lib/src/supabase_typed_query_builder.dart @@ -0,0 +1,47 @@ +import 'package:supabase/supabase.dart'; + +/// The typed counterpart of [SupabaseQueryBuilder], returned by +/// [SupabaseClient.table]. +/// +/// In addition to the typed query methods inherited from +/// [PostgrestTypedQueryBuilder], this builder exposes a typed realtime +/// [stream]. +class SupabaseTypedQueryBuilder extends PostgrestTypedQueryBuilder { + // The query builder is also kept as a field to expose [stream], so it + // cannot become a super parameter. + // ignore: use_super_parameters + SupabaseTypedQueryBuilder( + SupabaseQueryBuilder queryBuilder, + PostgrestTable table, + ) : _queryBuilder = queryBuilder, + super(queryBuilder, table); + + final SupabaseQueryBuilder _queryBuilder; + + /// Returns real-time data from the table as a `Stream` of `List`. + /// + /// The typed counterpart of [SupabaseQueryBuilder.stream]; rows are + /// converted through [PostgrestTable.rowFromJson] and [primaryKey] is + /// expressed with [TableColumn]s. + /// + /// ```dart + /// supabase + /// .table(Books.table) + /// .stream(primaryKey: [Books.id]) + /// .listen((List books) { + /// // ... + /// }); + /// ``` + SupabaseTypedStreamFilterBuilder stream({ + required List> primaryKey, + bool private = false, + }) { + return SupabaseTypedStreamFilterBuilder( + _queryBuilder.stream( + primaryKey: [for (final column in primaryKey) column.name], + private: private, + ), + table, + ); + } +} diff --git a/packages/supabase/lib/src/supabase_typed_stream_builder.dart b/packages/supabase/lib/src/supabase_typed_stream_builder.dart new file mode 100644 index 000000000..9678a8280 --- /dev/null +++ b/packages/supabase/lib/src/supabase_typed_stream_builder.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:supabase/supabase.dart'; + +/// The typed counterpart of [SupabaseStreamBuilder]; emits the rows of the +/// table converted into [Row] through [PostgrestTable.rowFromJson]. +class SupabaseTypedStreamBuilder extends Stream> { + SupabaseTypedStreamBuilder(SupabaseStreamBuilder streamBuilder, this._table) + : _streamBuilder = streamBuilder; + + final SupabaseStreamBuilder _streamBuilder; + final PostgrestTable _table; + + /// Orders the result with the specified [column]. + /// + /// ```dart + /// supabase + /// .table(Books.table) + /// .stream(primaryKey: [Books.id]) + /// .order(Books.title, ascending: true); + /// ``` + SupabaseTypedStreamBuilder order( + TableColumn column, { + bool ascending = false, + }) { + _streamBuilder.order(column.name, ascending: ascending); + return this; + } + + /// Limits the result with the specified [count]. + SupabaseTypedStreamBuilder limit(int count) { + _streamBuilder.limit(count); + return this; + } + + @override + bool get isBroadcast => _streamBuilder.isBroadcast; + + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _streamBuilder + .map( + (rows) => [for (final row in rows) _table.rowFromJson(row)], + ) + .listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +/// A [SupabaseTypedStreamBuilder] that can still be filtered with [filter]. +class SupabaseTypedStreamFilterBuilder + extends SupabaseTypedStreamBuilder { + SupabaseTypedStreamFilterBuilder( + SupabaseStreamFilterBuilder super.streamBuilder, + super.table, + ); + + SupabaseStreamFilterBuilder get _streamFilterBuilder => + _streamBuilder as SupabaseStreamFilterBuilder; + + /// Only rows satisfying [columnFilter]. + /// + /// Named [filter] instead of `where` because [Stream.where] already exists. + /// + /// Only one filter can be applied to a stream, and only equality and + /// comparison filters are supported: [TableColumn.eq], [TableColumn.neq], + /// [TableColumn.lt], [TableColumn.lte], [TableColumn.gt], [TableColumn.gte] + /// and [TableColumn.inFilter]. + /// + /// ```dart + /// supabase + /// .table(Books.table) + /// .stream(primaryKey: [Books.id]) + /// .filter(Books.title.eq('foo')); + /// ``` + SupabaseTypedStreamBuilder filter(ColumnFilter columnFilter) { + switch (columnFilter.operator) { + case 'eq': + _streamFilterBuilder.eq( + columnFilter.column, + columnFilter.value as Object, + ); + case 'neq': + _streamFilterBuilder.neq( + columnFilter.column, + columnFilter.value as Object, + ); + case 'lt': + _streamFilterBuilder.lt( + columnFilter.column, + columnFilter.value as Object, + ); + case 'lte': + _streamFilterBuilder.lte( + columnFilter.column, + columnFilter.value as Object, + ); + case 'gt': + _streamFilterBuilder.gt( + columnFilter.column, + columnFilter.value as Object, + ); + case 'gte': + _streamFilterBuilder.gte( + columnFilter.column, + columnFilter.value as Object, + ); + case 'in': + _streamFilterBuilder.inFilter( + columnFilter.column, + List.from(columnFilter.value as List), + ); + default: + throw ArgumentError.value( + columnFilter.operator, + 'columnFilter', + 'Streams only support the eq, neq, lt, lte, gt, gte and inFilter ' + 'filters.', + ); + } + return this; + } +} diff --git a/packages/supabase/lib/supabase.dart b/packages/supabase/lib/supabase.dart index 00dd67d02..173049ced 100644 --- a/packages/supabase/lib/supabase.dart +++ b/packages/supabase/lib/supabase.dart @@ -19,4 +19,6 @@ export 'src/supabase_query_builder.dart'; export 'src/supabase_query_schema.dart'; export 'src/supabase_realtime_error.dart'; export 'src/supabase_stream_builder.dart'; +export 'src/supabase_typed_query_builder.dart'; +export 'src/supabase_typed_stream_builder.dart'; export 'src/trace_propagation.dart'; diff --git a/packages/supabase/test/mock_test.dart b/packages/supabase/test/mock_test.dart index 6a0a70f6e..5e357013f 100644 --- a/packages/supabase/test/mock_test.dart +++ b/packages/supabase/test/mock_test.dart @@ -7,6 +7,19 @@ import 'dart:io'; import 'package:supabase/supabase.dart'; import 'package:test/test.dart'; +extension type Todo(Map json) { + int get id => json['id'] as int; + String get task => json['task'] as String; + bool get status => json['status'] as bool; +} + +class Todos { + static const table = PostgrestTable('todos', Todo.new); + static const id = TableColumn('id'); + static const task = TableColumn('task'); + static const status = TableColumn('status'); +} + void main() { late SupabaseClient supabase; late SupabaseClient customHeadersClient; @@ -869,4 +882,56 @@ void main() { }); }); }); + + group('typed table access', () { + setUp(() async { + unawaited(handleRequests(mockServer)); + }); + + test('select returns rows converted into the table row type', () async { + final List todos = await supabase.table(Todos.table).select(); + + expect(todos.map((todo) => todo.task), ['task 1', 'task 2']); + }); + + test('stream emits typed rows', () async { + final stream = supabase.table(Todos.table).stream(primaryKey: [Todos.id]); + + final List todos = await stream.first; + + expect(todos.map((todo) => todo.task), ['task 1', 'task 2']); + }); + }); + + group('typed realtime filter', () { + test('can filter typed stream results', () { + unawaited(handleRequests(mockServer, expectedFilter: 'status=eq.true')); + final stream = supabase + .table(Todos.table) + .stream(primaryKey: [Todos.id]) + .filter(Todos.status.eq(true)); + expect( + stream, + emitsInOrder([ + containsAllInOrder([ + {'id': 1, 'task': 'task 1', 'status': true}, + ]), + containsAllInOrder([ + {'id': 1, 'task': 'task 1', 'status': true}, + {'id': 3, 'task': 'task 3', 'status': true}, + ]), + ]), + ); + }); + + test('filters not supported by streams throw', () { + unawaited(handleRequests(mockServer)); + final stream = supabase.table(Todos.table).stream(primaryKey: [Todos.id]); + + expect( + () => stream.filter(Todos.task.like('%task%')), + throwsArgumentError, + ); + }); + }); } diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 385ec1c74..51f7d0ae2 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -426,10 +426,36 @@ features: status: implemented symbols: - PostgrestClient.from + - PostgrestClient.table + - SupabaseClient.table + - PostgrestTable + - PostgrestTable.PostgrestTable + - PostgrestTable.name + - PostgrestTable.rowFromJson + - RowConverter + - TableColumn + - TableColumn.TableColumn + - TableColumn.name + - TableColumn.toString + - PostgrestTypedQueryBuilder + - PostgrestTypedQueryBuilder.PostgrestTypedQueryBuilder + - PostgrestTypedQueryBuilder.table + - SupabaseTypedQueryBuilder + - SupabaseTypedQueryBuilder.SupabaseTypedQueryBuilder + - PostgrestTypedBuilder + - PostgrestTypedBuilder.asStream + - PostgrestTypedBuilder.catchError + - PostgrestTypedBuilder.then + - PostgrestTypedBuilder.timeout + - PostgrestTypedBuilder.whenComplete database.query.select: status: implemented symbols: - PostgrestQueryBuilder.select + - PostgrestTypedQueryBuilder.select + - PostgrestTypedQueryBuilder.count + - PostgrestTypedTransformBuilder + - PostgrestTypedTransformBuilder.count database.query.rpc: status: implemented note: "head and count are applied via type-safe chaining (.head()/.count()) rather than inline call options, which is the idiomatic Dart builder pattern; inline options cannot preserve the distinct return types." @@ -439,90 +465,113 @@ features: status: implemented symbols: - PostgrestClient.schema + - SupabaseQuerySchema.table # database — mutate database.mutate.insert: status: implemented symbols: - PostgrestQueryBuilder.insert + - PostgrestTypedQueryBuilder.insert database.mutate.update: status: implemented symbols: - PostgrestQueryBuilder.update + - PostgrestTypedQueryBuilder.update database.mutate.upsert: status: implemented symbols: - PostgrestQueryBuilder.upsert + - PostgrestTypedQueryBuilder.upsert database.mutate.delete: status: implemented symbols: - PostgrestQueryBuilder.delete + - PostgrestTypedQueryBuilder.delete database.mutate.select_after_mutation: status: implemented symbols: - PostgrestTransformBuilder.select + - PostgrestTypedTransformBuilder.select # database — filters database.using_filters.eq: status: implemented symbols: - PostgrestFilterBuilder.eq + - TableColumn.eq database.using_filters.neq: status: implemented symbols: - PostgrestFilterBuilder.neq + - TableColumn.neq database.using_filters.gt: status: implemented symbols: - PostgrestFilterBuilder.gt + - TableColumn.gt database.using_filters.gte: status: implemented symbols: - PostgrestFilterBuilder.gte + - TableColumn.gte database.using_filters.lt: status: implemented symbols: - PostgrestFilterBuilder.lt + - TableColumn.lt database.using_filters.lte: status: implemented symbols: - PostgrestFilterBuilder.lte + - TableColumn.lte database.using_filters.like: status: implemented symbols: - PostgrestFilterBuilder.like + - TextTableColumnFilters + - TextTableColumnFilters.like database.using_filters.like_all: status: implemented symbols: - PostgrestFilterBuilder.likeAllOf + - TextTableColumnFilters.likeAllOf database.using_filters.like_any: status: implemented symbols: - PostgrestFilterBuilder.likeAnyOf + - TextTableColumnFilters.likeAnyOf database.using_filters.ilike: status: implemented symbols: - PostgrestFilterBuilder.ilike + - TextTableColumnFilters.ilike database.using_filters.ilike_all: status: implemented symbols: - PostgrestFilterBuilder.ilikeAllOf + - TextTableColumnFilters.ilikeAllOf database.using_filters.ilike_any: status: implemented symbols: - PostgrestFilterBuilder.ilikeAnyOf + - TextTableColumnFilters.ilikeAnyOf database.using_filters.is: status: implemented symbols: - PostgrestFilterBuilder.isFilter + - TableColumn.isNull + - TableColumn.isNotNull database.using_filters.is_distinct: status: implemented symbols: - PostgrestFilterBuilder.isDistinct + - TableColumn.isDistinctFrom database.using_filters.in: status: implemented symbols: - PostgrestFilterBuilder.inFilter + - TableColumn.inFilter database.using_filters.not_in: status: implemented symbols: @@ -531,14 +580,17 @@ features: status: implemented symbols: - PostgrestFilterBuilder.contains + - TableColumn.contains database.using_filters.contained_by: status: implemented symbols: - PostgrestFilterBuilder.containedBy + - TableColumn.containedBy database.using_filters.overlaps: status: implemented symbols: - PostgrestFilterBuilder.overlaps + - TableColumn.overlaps database.using_filters.match: status: implemented symbols: @@ -547,68 +599,91 @@ features: status: implemented symbols: - PostgrestFilterBuilder.not + - ColumnFilter.not database.using_filters.or: status: implemented symbols: - PostgrestFilterBuilder.or + - PostgrestTypedFilterBuilder.whereAny database.using_filters.range_gt: status: implemented symbols: - PostgrestFilterBuilder.rangeGt + - TableColumn.rangeGt database.using_filters.range_gte: status: implemented symbols: - PostgrestFilterBuilder.rangeGte + - TableColumn.rangeGte database.using_filters.range_lt: status: implemented symbols: - PostgrestFilterBuilder.rangeLt + - TableColumn.rangeLt database.using_filters.range_lte: status: implemented symbols: - PostgrestFilterBuilder.rangeLte + - TableColumn.rangeLte database.using_filters.range_adjacent: status: implemented symbols: - PostgrestFilterBuilder.rangeAdjacent + - TableColumn.rangeAdjacent database.using_filters.text_search: status: implemented symbols: - PostgrestFilterBuilder.textSearch + - TextTableColumnFilters.textSearch database.using_filters.regex: status: implemented symbols: - PostgrestFilterBuilder.matchRegex + - TextTableColumnFilters.matchRegex database.using_filters.regex_icase: status: implemented symbols: - PostgrestFilterBuilder.imatchRegex + - TextTableColumnFilters.imatchRegex database.using_filters.raw: status: implemented symbols: - PostgrestFilterBuilder.filter + - PostgrestTypedFilterBuilder + - PostgrestTypedFilterBuilder.where + - ColumnFilter + - ColumnFilter.column + - ColumnFilter.operator + - ColumnFilter.value # database — modifiers database.using_modifiers.order: status: implemented symbols: - PostgrestTransformBuilder.order + - PostgrestTypedTransformBuilder.order + - SupabaseTypedStreamBuilder.order database.using_modifiers.limit: status: implemented symbols: - PostgrestTransformBuilder.limit + - PostgrestTypedTransformBuilder.limit + - SupabaseTypedStreamBuilder.limit database.using_modifiers.range: status: implemented symbols: - PostgrestTransformBuilder.range + - PostgrestTypedTransformBuilder.range database.using_modifiers.single_row: status: implemented symbols: - PostgrestTransformBuilder.single + - PostgrestTypedTransformBuilder.single database.using_modifiers.maybe_single_row: status: implemented symbols: - PostgrestTransformBuilder.maybeSingle + - PostgrestTypedTransformBuilder.maybeSingle database.using_modifiers.max_affected_rows: status: implemented symbols: @@ -1428,11 +1503,19 @@ features: - RealtimeSystemPayload.message - RealtimeSystemPayload.status - RealtimeSystemPayload.toString + - SupabaseTypedQueryBuilder.stream + - SupabaseTypedStreamBuilder + - SupabaseTypedStreamBuilder.SupabaseTypedStreamBuilder + - SupabaseTypedStreamBuilder.isBroadcast + - SupabaseTypedStreamBuilder.listen + - SupabaseTypedStreamFilterBuilder + - SupabaseTypedStreamFilterBuilder.SupabaseTypedStreamFilterBuilder realtime.subscriptions.postgres_changes_filter: status: implemented symbols: - PostgresChangeFilter.negate - PostgresChangeFilterType.token + - SupabaseTypedStreamFilterBuilder.filter realtime.subscriptions.subscribe_presence: status: implemented symbols: From 2cac6e2bb90ebdf3e0f4bd42e14f7c7fe2a69334 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 18:08:21 +0200 Subject: [PATCH 2/6] chore: address DCM lint warnings --- .../postgrest/lib/src/postgrest_typed_builder.dart | 2 +- .../lib/src/postgrest_typed_filter_builder.dart | 2 +- .../lib/src/postgrest_typed_query_builder.dart | 2 +- .../lib/src/postgrest_typed_transform_builder.dart | 2 +- packages/postgrest/test/typed_query_test.dart | 13 +++++++------ .../lib/src/supabase_typed_query_builder.dart | 2 +- .../lib/src/supabase_typed_stream_builder.dart | 8 +++++--- packages/supabase/test/mock_test.dart | 9 +++++---- 8 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/postgrest/lib/src/postgrest_typed_builder.dart b/packages/postgrest/lib/src/postgrest_typed_builder.dart index 865ee8329..ec4638214 100644 --- a/packages/postgrest/lib/src/postgrest_typed_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_builder.dart @@ -26,7 +26,7 @@ void _toVoid(dynamic data) {} /// before it is returned, so awaiting it never exposes raw /// `Map` data. class PostgrestTypedBuilder implements Future { - PostgrestTypedBuilder._(this._rawBuilder, this._convert); + const PostgrestTypedBuilder._(this._rawBuilder, this._convert); final PostgrestBuilder _rawBuilder; final T Function(dynamic data) _convert; diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart index 005b6f5c6..33edae051 100644 --- a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -6,7 +6,7 @@ part of 'postgrest_typed_builder.dart'; /// checks the value type of each filter against its column at compile time. class PostgrestTypedFilterBuilder extends PostgrestTypedTransformBuilder { - PostgrestTypedFilterBuilder._( + const PostgrestTypedFilterBuilder._( PostgrestFilterBuilder super.rawBuilder, super.table, super.convert, diff --git a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart index 0c7140d91..21adef49a 100644 --- a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart @@ -9,7 +9,7 @@ part of 'postgrest_typed_builder.dart'; /// {@endtemplate} class PostgrestTypedQueryBuilder { /// {@macro postgrest_typed_query_builder} - PostgrestTypedQueryBuilder( + const PostgrestTypedQueryBuilder( PostgrestQueryBuilder queryBuilder, this.table, ) : _queryBuilder = queryBuilder; diff --git a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart index 96e931453..ac6d03afb 100644 --- a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart @@ -5,7 +5,7 @@ part of 'postgrest_typed_builder.dart'; /// [Row] is the type a single row converts into and [T] is the type the /// request resolves to when awaited. class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { - PostgrestTypedTransformBuilder._( + const PostgrestTypedTransformBuilder._( PostgrestTransformBuilder super.rawBuilder, this._table, super.convert, diff --git a/packages/postgrest/test/typed_query_test.dart b/packages/postgrest/test/typed_query_test.dart index 7d4c56478..256ebd27e 100644 --- a/packages/postgrest/test/typed_query_test.dart +++ b/packages/postgrest/test/typed_query_test.dart @@ -4,11 +4,14 @@ import 'package:http/http.dart'; import 'package:postgrest/postgrest.dart'; import 'package:test/test.dart'; -extension type Book(Map json) { - int get id => json['id'] as int; - String get title => json['title'] as String; +extension type const Book(Map _json) + implements Map { + int get id => _json['id'] as int; + String get title => _json['title'] as String; } +const bookRows = '[{"id":1,"title":"a"},{"id":2,"title":"b"}]'; + class Books { static const table = PostgrestTable('books', Book.new); static const id = TableColumn('id'); @@ -41,8 +44,6 @@ void main() { late MockHttpClient httpClient; late PostgrestClient client; - const bookRows = '[{"id":1,"title":"a"},{"id":2,"title":"b"}]'; - setUp(() { httpClient = MockHttpClient(); client = PostgrestClient( @@ -94,7 +95,7 @@ void main() { .where(Books.id.eq(1)) .maybeSingle(); - expect(book, isNull); + expect(book == null, isTrue); }); test('maybeSingle returns the row when one matches', () async { diff --git a/packages/supabase/lib/src/supabase_typed_query_builder.dart b/packages/supabase/lib/src/supabase_typed_query_builder.dart index d2a50ea03..8b1731426 100644 --- a/packages/supabase/lib/src/supabase_typed_query_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_query_builder.dart @@ -10,7 +10,7 @@ class SupabaseTypedQueryBuilder extends PostgrestTypedQueryBuilder { // 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, ) : _queryBuilder = queryBuilder, diff --git a/packages/supabase/lib/src/supabase_typed_stream_builder.dart b/packages/supabase/lib/src/supabase_typed_stream_builder.dart index 9678a8280..74d8ff31c 100644 --- a/packages/supabase/lib/src/supabase_typed_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_stream_builder.dart @@ -5,8 +5,10 @@ import 'package:supabase/supabase.dart'; /// The typed counterpart of [SupabaseStreamBuilder]; emits the rows of the /// table converted into [Row] through [PostgrestTable.rowFromJson]. class SupabaseTypedStreamBuilder extends Stream> { - SupabaseTypedStreamBuilder(SupabaseStreamBuilder streamBuilder, this._table) - : _streamBuilder = streamBuilder; + const SupabaseTypedStreamBuilder( + SupabaseStreamBuilder streamBuilder, + this._table, + ) : _streamBuilder = streamBuilder; final SupabaseStreamBuilder _streamBuilder; final PostgrestTable _table; @@ -59,7 +61,7 @@ class SupabaseTypedStreamBuilder extends Stream> { /// A [SupabaseTypedStreamBuilder] that can still be filtered with [filter]. class SupabaseTypedStreamFilterBuilder extends SupabaseTypedStreamBuilder { - SupabaseTypedStreamFilterBuilder( + const SupabaseTypedStreamFilterBuilder( SupabaseStreamFilterBuilder super.streamBuilder, super.table, ); diff --git a/packages/supabase/test/mock_test.dart b/packages/supabase/test/mock_test.dart index 5e357013f..bb99b0ffc 100644 --- a/packages/supabase/test/mock_test.dart +++ b/packages/supabase/test/mock_test.dart @@ -7,10 +7,11 @@ import 'dart:io'; import 'package:supabase/supabase.dart'; import 'package:test/test.dart'; -extension type Todo(Map json) { - int get id => json['id'] as int; - String get task => json['task'] as String; - bool get status => json['status'] as bool; +extension type const Todo(Map _json) + implements Map { + int get id => _json['id'] as int; + String get task => _json['task'] as String; + bool get status => _json['status'] as bool; } class Todos { From 9b4170e78a7553940627b7c0d2071c31274d568b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 09:53:07 +0200 Subject: [PATCH 3/6] refactor: make ColumnFilter a sealed hierarchy with operator enums --- .../postgrest/lib/src/postgrest_table.dart | 594 +++++++++++++----- .../src/postgrest_typed_filter_builder.dart | 5 +- .../src/supabase_typed_stream_builder.dart | 60 +- sdk-compliance.yaml | 63 ++ 4 files changed, 509 insertions(+), 213 deletions(-) diff --git a/packages/postgrest/lib/src/postgrest_table.dart b/packages/postgrest/lib/src/postgrest_table.dart index 7f9854fa8..210777aff 100644 --- a/packages/postgrest/lib/src/postgrest_table.dart +++ b/packages/postgrest/lib/src/postgrest_table.dart @@ -60,254 +60,506 @@ class TableColumn { /// Only rows where this column equals [value]. /// /// For `null` equality, use [isNull] instead. - ColumnFilter eq(Value value) => - ColumnFilter._(name, 'eq', value, (builder) => builder.eq(name, value)); + ComparisonFilter eq(Value value) => + ComparisonFilter._(name, ComparisonOperator.eq, value); /// Only rows where this column does not equal [value]. - ColumnFilter neq(Value value) => - ColumnFilter._(name, 'neq', value, (builder) => builder.neq(name, value)); + ComparisonFilter neq(Value value) => + ComparisonFilter._(name, ComparisonOperator.neq, value); /// Only rows where this column is greater than [value]. - ColumnFilter gt(Value value) => - ColumnFilter._(name, 'gt', value, (builder) => builder.gt(name, value)); + ComparisonFilter gt(Value value) => + ComparisonFilter._(name, ComparisonOperator.gt, value); /// Only rows where this column is greater than or equal to [value]. - ColumnFilter gte(Value value) => - ColumnFilter._(name, 'gte', value, (builder) => builder.gte(name, value)); + ComparisonFilter gte(Value value) => + ComparisonFilter._(name, ComparisonOperator.gte, value); /// Only rows where this column is less than [value]. - ColumnFilter lt(Value value) => - ColumnFilter._(name, 'lt', value, (builder) => builder.lt(name, value)); + ComparisonFilter lt(Value value) => + ComparisonFilter._(name, ComparisonOperator.lt, value); /// Only rows where this column is less than or equal to [value]. - ColumnFilter lte(Value value) => - ColumnFilter._(name, 'lte', value, (builder) => builder.lte(name, value)); + ComparisonFilter lte(Value value) => + ComparisonFilter._(name, ComparisonOperator.lte, value); /// Only rows where this column is `null`. - ColumnFilter isNull() => ColumnFilter._( - name, - 'is', - null, - (builder) => builder.isFilter(name, null), - ); + IsNullFilter isNull() => IsNullFilter._(name); /// Only rows where this column is not `null`. ColumnFilter isNotNull() => isNull().not(); /// Only rows where this column equals one of [values]. - ColumnFilter inFilter(List values) => ColumnFilter._( - name, - 'in', - values, - (builder) => builder.inFilter(name, values), - ); + InListFilter inFilter(List values) => InListFilter._(name, values); /// Only rows where this column is not equal to [value], treating `null` as /// a comparable value. - ColumnFilter isDistinctFrom(Value? value) => ColumnFilter._( - name, - 'isdistinct', - value, - (builder) => builder.isDistinct(name, value), - ); + IsDistinctFilter isDistinctFrom(Value? value) => + IsDistinctFilter._(name, value); /// Only rows whose json, array, or range value contains [value]. /// /// See [PostgrestFilterBuilder.contains] for the accepted value shapes. - ColumnFilter contains(Object value) => ColumnFilter._( - name, - 'cs', - value, - (builder) => builder.contains(name, value), - ); + ContainmentFilter contains(Object value) => + ContainmentFilter._(name, ContainmentOperator.contains, value); /// Only rows whose json, array, or range value is contained by [value]. /// /// See [PostgrestFilterBuilder.containedBy] for the accepted value shapes. - ColumnFilter containedBy(Object value) => ColumnFilter._( - name, - 'cd', - value, - (builder) => builder.containedBy(name, value), - ); + ContainmentFilter containedBy(Object value) => + ContainmentFilter._(name, ContainmentOperator.containedBy, value); /// Only rows whose array or range value overlaps with [value]. - ColumnFilter overlaps(Object value) => ColumnFilter._( - name, - 'ov', - value, - (builder) => builder.overlaps(name, value), - ); + ContainmentFilter overlaps(Object value) => + ContainmentFilter._(name, ContainmentOperator.overlaps, value); /// Only rows whose range value is strictly to the left of [range]. - ColumnFilter rangeLt(String range) => ColumnFilter._( - name, - 'sl', - range, - (builder) => builder.rangeLt(name, range), - ); + RangeFilter rangeLt(String range) => + RangeFilter._(name, RangeOperator.rangeLt, range); /// Only rows whose range value is strictly to the right of [range]. - ColumnFilter rangeGt(String range) => ColumnFilter._( - name, - 'sr', - range, - (builder) => builder.rangeGt(name, range), - ); + RangeFilter rangeGt(String range) => + RangeFilter._(name, RangeOperator.rangeGt, range); /// Only rows whose range value does not extend to the left of [range]. - ColumnFilter rangeGte(String range) => ColumnFilter._( - name, - 'nxl', - range, - (builder) => builder.rangeGte(name, range), - ); + RangeFilter rangeGte(String range) => + RangeFilter._(name, RangeOperator.rangeGte, range); /// Only rows whose range value does not extend to the right of [range]. - ColumnFilter rangeLte(String range) => ColumnFilter._( - name, - 'nxr', - range, - (builder) => builder.rangeLte(name, range), - ); + RangeFilter rangeLte(String range) => + RangeFilter._(name, RangeOperator.rangeLte, range); /// Only rows whose range value is adjacent to [range]. - ColumnFilter rangeAdjacent(String range) => ColumnFilter._( - name, - 'adj', - range, - (builder) => builder.rangeAdjacent(name, range), - ); + RangeFilter rangeAdjacent(String range) => + RangeFilter._(name, RangeOperator.rangeAdjacent, range); } /// Filters that only apply to text columns. extension TextTableColumnFilters on TableColumn { /// Only rows whose value matches [pattern] case-sensitively. - ColumnFilter like(String pattern) => ColumnFilter._( - name, - 'like', - pattern, - (builder) => builder.like(name, pattern), - ); - - /// Only rows whose value matches all of [patterns] case-sensitively. - ColumnFilter likeAllOf(List patterns) => ColumnFilter._( - name, - 'like(all)', - patterns, - (builder) => builder.likeAllOf(name, patterns), - ); - - /// Only rows whose value matches any of [patterns] case-sensitively. - ColumnFilter likeAnyOf(List patterns) => ColumnFilter._( - name, - 'like(any)', - patterns, - (builder) => builder.likeAnyOf(name, patterns), - ); + PatternFilter like(String pattern) => + PatternFilter._(name, PatternOperator.like, pattern); /// Only rows whose value matches [pattern] case-insensitively. - ColumnFilter ilike(String pattern) => ColumnFilter._( - name, - 'ilike', - pattern, - (builder) => builder.ilike(name, pattern), - ); - - /// Only rows whose value matches all of [patterns] case-insensitively. - ColumnFilter ilikeAllOf(List patterns) => ColumnFilter._( - name, - 'ilike(all)', - patterns, - (builder) => builder.ilikeAllOf(name, patterns), - ); - - /// Only rows whose value matches any of [patterns] case-insensitively. - ColumnFilter ilikeAnyOf(List patterns) => ColumnFilter._( - name, - 'ilike(any)', - patterns, - (builder) => builder.ilikeAnyOf(name, patterns), - ); + PatternFilter ilike(String pattern) => + PatternFilter._(name, PatternOperator.ilike, pattern); /// Only rows whose value matches [pattern] as a PostgreSQL regular /// expression, case-sensitively. - ColumnFilter matchRegex(String pattern) => ColumnFilter._( - name, - 'match', - pattern, - (builder) => builder.matchRegex(name, pattern), - ); + PatternFilter matchRegex(String pattern) => + PatternFilter._(name, PatternOperator.matchRegex, pattern); /// Only rows whose value matches [pattern] as a PostgreSQL regular /// expression, case-insensitively. - ColumnFilter imatchRegex(String pattern) => ColumnFilter._( - name, - 'imatch', - pattern, - (builder) => builder.imatchRegex(name, pattern), - ); + PatternFilter imatchRegex(String pattern) => + PatternFilter._(name, PatternOperator.imatchRegex, pattern); + + /// Only rows whose value matches all of [patterns] case-sensitively. + PatternListFilter likeAllOf(List patterns) => + PatternListFilter._(name, PatternListOperator.likeAllOf, patterns); + + /// Only rows whose value matches any of [patterns] case-sensitively. + PatternListFilter likeAnyOf(List patterns) => + PatternListFilter._(name, PatternListOperator.likeAnyOf, patterns); + + /// Only rows whose value matches all of [patterns] case-insensitively. + PatternListFilter ilikeAllOf(List patterns) => + PatternListFilter._(name, PatternListOperator.ilikeAllOf, patterns); + + /// Only rows whose value matches any of [patterns] case-insensitively. + PatternListFilter ilikeAnyOf(List patterns) => + PatternListFilter._(name, PatternListOperator.ilikeAnyOf, patterns); /// Only rows whose text or tsvector value matches the tsquery in [query]. /// /// See [PostgrestFilterBuilder.textSearch] for [config] and [type]. - ColumnFilter textSearch( + TextSearchFilter textSearch( String query, { String? config, TextSearchType? type, - }) { - final typePart = switch (type) { - TextSearchType.plain => 'pl', - TextSearchType.phrase => 'ph', - TextSearchType.websearch => 'w', - null => '', - }; - final configPart = config == null ? '' : '($config)'; - return ColumnFilter._( - name, - '${typePart}fts$configPart', - query, - (builder) => builder.textSearch(name, query, config: config, type: type), - ); - } + }) => TextSearchFilter._(name, query, config: config, type: type); } /// A single filter condition on a column, created through the methods on /// [TableColumn] such as [TableColumn.eq]. /// -/// Applied to a typed query with [PostgrestTypedFilterBuilder.where]. -class ColumnFilter { - const ColumnFilter._(this.column, this.operator, this.value, this._apply); +/// Applied to a typed query with [PostgrestTypedFilterBuilder.where]. The +/// concrete subtypes carry the operator as structured data, so consumers can +/// exhaustively match on the filter shape instead of comparing strings; +/// realtime streams for example only accept [ComparisonFilter] and +/// [InListFilter]. +sealed class ColumnFilter { + const ColumnFilter._(); /// Name of the column being filtered on. - final String column; + String get column; - /// The PostgREST operator of this filter, for example `eq` or `like(all)`. - final String operator; + /// The PostgREST wire representation of the operator, for example `eq` or + /// `like(all)`. + String get operator; /// The value the filter compares against. - final Object? value; + Object? get value; - final PostgrestFilterBuilder Function( + PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) - _apply; + ); /// Negates this filter. /// /// ```dart /// client.table(Books.table).select().where(Books.id.eq(1).not()); /// ``` - ColumnFilter not() { - if (operator.startsWith('not.')) { - throw StateError('The filter on "$column" is already negated.'); - } - final positiveOperator = operator; - return ColumnFilter._( - column, - 'not.$operator', - value, - (builder) => builder.not(column, positiveOperator, value), - ); + ColumnFilter not() => _NegatedColumnFilter(this); +} + +/// The comparison applied by a [ComparisonFilter]. +enum ComparisonOperator { + eq('eq'), + neq('neq'), + gt('gt'), + gte('gte'), + lt('lt'), + lte('lte'); + + const ComparisonOperator(this.wireName); + + /// The PostgREST wire representation of the operator. + final String wireName; +} + +/// An equality or ordering comparison against a single value. +/// +/// Besides regular queries, these are the filters that realtime streams +/// support, together with [InListFilter]. +final class ComparisonFilter extends ColumnFilter { + const ComparisonFilter._(this.column, this.comparison, this.value) + : super._(); + + @override + final String column; + + /// The comparison being applied. + final ComparisonOperator comparison; + + @override + final Object value; + + @override + String get operator => comparison.wireName; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => switch (comparison) { + ComparisonOperator.eq => builder.eq(column, value), + ComparisonOperator.neq => builder.neq(column, value), + ComparisonOperator.gt => builder.gt(column, value), + ComparisonOperator.gte => builder.gte(column, value), + ComparisonOperator.lt => builder.lt(column, value), + ComparisonOperator.lte => builder.lte(column, value), + }; +} + +/// A filter matching rows whose column value equals one of [values]. +/// +/// Besides regular queries, this filter is supported by realtime streams, +/// together with [ComparisonFilter]. +final class InListFilter extends ColumnFilter { + const InListFilter._(this.column, this.values) : super._(); + + @override + final String column; + + /// The values the column is compared against. + final List values; + + @override + String get operator => 'in'; + + @override + Object get value => values; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.inFilter(column, values); +} + +/// A filter matching rows whose column value is `null`. +final class IsNullFilter extends ColumnFilter { + const IsNullFilter._(this.column) : super._(); + + @override + final String column; + + @override + String get operator => 'is'; + + @override + Object? get value => null; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.isFilter(column, null); +} + +/// A filter matching rows whose column value is distinct from [value], +/// treating `null` as a comparable value. +final class IsDistinctFilter extends ColumnFilter { + const IsDistinctFilter._(this.column, this.value) : super._(); + + @override + final String column; + + @override + final Object? value; + + @override + String get operator => 'isdistinct'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.isDistinct(column, value); +} + +/// The operator applied by a [ContainmentFilter]. +enum ContainmentOperator { + contains('cs'), + containedBy('cd'), + overlaps('ov'); + + const ContainmentOperator(this.wireName); + + /// The PostgREST wire representation of the operator. + final String wireName; +} + +/// A containment or overlap filter on a json, array, or range column. +final class ContainmentFilter extends ColumnFilter { + const ContainmentFilter._(this.column, this.containment, this.value) + : super._(); + + @override + final String column; + + /// The containment check being applied. + final ContainmentOperator containment; + + @override + final Object value; + + @override + String get operator => containment.wireName; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => switch (containment) { + ContainmentOperator.contains => builder.contains(column, value), + ContainmentOperator.containedBy => builder.containedBy(column, value), + ContainmentOperator.overlaps => builder.overlaps(column, value), + }; +} + +/// The operator applied by a [RangeFilter]. +enum RangeOperator { + rangeLt('sl'), + rangeGt('sr'), + rangeGte('nxl'), + rangeLte('nxr'), + rangeAdjacent('adj'); + + const RangeOperator(this.wireName); + + /// The PostgREST wire representation of the operator. + final String wireName; +} + +/// A filter comparing a range column against the range literal [range]. +final class RangeFilter extends ColumnFilter { + const RangeFilter._(this.column, this.rangeComparison, this.range) + : super._(); + + @override + final String column; + + /// The range comparison being applied. + final RangeOperator rangeComparison; + + /// The PostgREST range literal, for example `[2,25)`. + final String range; + + @override + String get operator => rangeComparison.wireName; + + @override + Object get value => range; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => switch (rangeComparison) { + RangeOperator.rangeLt => builder.rangeLt(column, range), + RangeOperator.rangeGt => builder.rangeGt(column, range), + RangeOperator.rangeGte => builder.rangeGte(column, range), + RangeOperator.rangeLte => builder.rangeLte(column, range), + RangeOperator.rangeAdjacent => builder.rangeAdjacent(column, range), + }; +} + +/// The operator applied by a [PatternFilter]. +enum PatternOperator { + like('like'), + ilike('ilike'), + matchRegex('match'), + imatchRegex('imatch'); + + const PatternOperator(this.wireName); + + /// The PostgREST wire representation of the operator. + final String wireName; +} + +/// A filter matching a text column against a single [pattern]. +final class PatternFilter extends ColumnFilter { + const PatternFilter._(this.column, this.match, this.pattern) : super._(); + + @override + final String column; + + /// The kind of pattern match being applied. + final PatternOperator match; + + /// The pattern the column is matched against. + final String pattern; + + @override + String get operator => match.wireName; + + @override + Object get value => pattern; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => switch (match) { + PatternOperator.like => builder.like(column, pattern), + PatternOperator.ilike => builder.ilike(column, pattern), + PatternOperator.matchRegex => builder.matchRegex(column, pattern), + PatternOperator.imatchRegex => builder.imatchRegex(column, pattern), + }; +} + +/// The operator applied by a [PatternListFilter]. +enum PatternListOperator { + likeAllOf('like(all)'), + likeAnyOf('like(any)'), + ilikeAllOf('ilike(all)'), + ilikeAnyOf('ilike(any)'); + + const PatternListOperator(this.wireName); + + /// The PostgREST wire representation of the operator. + final String wireName; +} + +/// A filter matching a text column against several [patterns] at once. +final class PatternListFilter extends ColumnFilter { + const PatternListFilter._(this.column, this.match, this.patterns) : super._(); + + @override + final String column; + + /// The kind of pattern match being applied. + final PatternListOperator match; + + /// The patterns the column is matched against. + final List patterns; + + @override + String get operator => match.wireName; + + @override + Object get value => patterns; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => switch (match) { + PatternListOperator.likeAllOf => builder.likeAllOf(column, patterns), + PatternListOperator.likeAnyOf => builder.likeAnyOf(column, patterns), + PatternListOperator.ilikeAllOf => builder.ilikeAllOf(column, patterns), + PatternListOperator.ilikeAnyOf => builder.ilikeAnyOf(column, patterns), + }; +} + +/// A full text search filter on a text or tsvector column. +final class TextSearchFilter extends ColumnFilter { + const TextSearchFilter._( + this.column, + this.query, { + this.config, + this.type, + }) : super._(); + + @override + final String column; + + /// The tsquery the column is matched against. + final String query; + + /// The text search configuration to use. + final String? config; + + /// The type of tsquery conversion applied to [query]. + final TextSearchType? type; + + @override + String get operator { + final typePart = switch (type) { + TextSearchType.plain => 'pl', + TextSearchType.phrase => 'ph', + TextSearchType.websearch => 'w', + null => '', + }; + final configPart = config == null ? '' : '($config)'; + return '${typePart}fts$configPart'; } + + @override + Object get value => query; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.textSearch(column, query, config: config, type: type); +} + +/// The negation of another [ColumnFilter], created through +/// [ColumnFilter.not]. +final class _NegatedColumnFilter extends ColumnFilter { + const _NegatedColumnFilter(this._inner) : super._(); + + final ColumnFilter _inner; + + @override + String get column => _inner.column; + + @override + String get operator => 'not.${_inner.operator}'; + + @override + Object? get value => _inner.value; + + @override + ColumnFilter not() => + throw StateError('The filter on "$column" is already negated.'); + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.not(column, _inner.operator, _inner.value); } diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart index 33edae051..f79ab8319 100644 --- a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -51,13 +51,12 @@ class PostgrestTypedFilterBuilder } static String _orFragment(ColumnFilter filter) { + final unwrapped = filter is _NegatedColumnFilter ? filter._inner : filter; final value = filter.value; final String rendered; if (value is List) { final elements = value.map(_quoteOrElement).join(','); - rendered = filter.operator == 'in' || filter.operator == 'not.in' - ? '($elements)' - : '{$elements}'; + rendered = unwrapped is InListFilter ? '($elements)' : '{$elements}'; } else { rendered = _quoteOrElement(value); } diff --git a/packages/supabase/lib/src/supabase_typed_stream_builder.dart b/packages/supabase/lib/src/supabase_typed_stream_builder.dart index 74d8ff31c..a6699b201 100644 --- a/packages/supabase/lib/src/supabase_typed_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_stream_builder.dart @@ -73,8 +73,8 @@ class SupabaseTypedStreamFilterBuilder /// /// Named [filter] instead of `where` because [Stream.where] already exists. /// - /// Only one filter can be applied to a stream, and only equality and - /// comparison filters are supported: [TableColumn.eq], [TableColumn.neq], + /// Only one filter can be applied to a stream, and only [ComparisonFilter] + /// and [InListFilter] are supported: [TableColumn.eq], [TableColumn.neq], /// [TableColumn.lt], [TableColumn.lte], [TableColumn.gt], [TableColumn.gte] /// and [TableColumn.inFilter]. /// @@ -85,45 +85,27 @@ class SupabaseTypedStreamFilterBuilder /// .filter(Books.title.eq('foo')); /// ``` SupabaseTypedStreamBuilder filter(ColumnFilter columnFilter) { - switch (columnFilter.operator) { - case 'eq': - _streamFilterBuilder.eq( - columnFilter.column, - columnFilter.value as Object, - ); - case 'neq': - _streamFilterBuilder.neq( - columnFilter.column, - columnFilter.value as Object, - ); - case 'lt': - _streamFilterBuilder.lt( - columnFilter.column, - columnFilter.value as Object, - ); - case 'lte': - _streamFilterBuilder.lte( - columnFilter.column, - columnFilter.value as Object, - ); - case 'gt': - _streamFilterBuilder.gt( - columnFilter.column, - columnFilter.value as Object, - ); - case 'gte': - _streamFilterBuilder.gte( - columnFilter.column, - columnFilter.value as Object, - ); - case 'in': - _streamFilterBuilder.inFilter( - columnFilter.column, - List.from(columnFilter.value as List), - ); + switch (columnFilter) { + case ComparisonFilter(:final column, :final comparison, :final value): + switch (comparison) { + case ComparisonOperator.eq: + _streamFilterBuilder.eq(column, value); + case ComparisonOperator.neq: + _streamFilterBuilder.neq(column, value); + case ComparisonOperator.lt: + _streamFilterBuilder.lt(column, value); + case ComparisonOperator.lte: + _streamFilterBuilder.lte(column, value); + case ComparisonOperator.gt: + _streamFilterBuilder.gt(column, value); + case ComparisonOperator.gte: + _streamFilterBuilder.gte(column, value); + } + case InListFilter(:final column, :final values): + _streamFilterBuilder.inFilter(column, values); default: throw ArgumentError.value( - columnFilter.operator, + columnFilter, 'columnFilter', 'Streams only support the eq, neq, lt, lte, gt, gte and inFilter ' 'filters.', diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 51f7d0ae2..5c78ad6cf 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -500,6 +500,14 @@ features: symbols: - PostgrestFilterBuilder.eq - TableColumn.eq + - ComparisonFilter + - ComparisonFilter.column + - ComparisonFilter.comparison + - ComparisonFilter.operator + - ComparisonFilter.value + - ComparisonOperator + - ComparisonOperator.ComparisonOperator + - ComparisonOperator.wireName database.using_filters.neq: status: implemented symbols: @@ -531,11 +539,29 @@ features: - PostgrestFilterBuilder.like - TextTableColumnFilters - TextTableColumnFilters.like + - PatternFilter + - PatternFilter.column + - PatternFilter.match + - PatternFilter.operator + - PatternFilter.pattern + - PatternFilter.value + - PatternOperator + - PatternOperator.PatternOperator + - PatternOperator.wireName database.using_filters.like_all: status: implemented symbols: - PostgrestFilterBuilder.likeAllOf - TextTableColumnFilters.likeAllOf + - PatternListFilter + - PatternListFilter.column + - PatternListFilter.match + - PatternListFilter.operator + - PatternListFilter.patterns + - PatternListFilter.value + - PatternListOperator + - PatternListOperator.PatternListOperator + - PatternListOperator.wireName database.using_filters.like_any: status: implemented symbols: @@ -562,16 +588,29 @@ features: - PostgrestFilterBuilder.isFilter - TableColumn.isNull - TableColumn.isNotNull + - IsNullFilter + - IsNullFilter.column + - IsNullFilter.operator + - IsNullFilter.value database.using_filters.is_distinct: status: implemented symbols: - PostgrestFilterBuilder.isDistinct - TableColumn.isDistinctFrom + - IsDistinctFilter + - IsDistinctFilter.column + - IsDistinctFilter.operator + - IsDistinctFilter.value database.using_filters.in: status: implemented symbols: - PostgrestFilterBuilder.inFilter - TableColumn.inFilter + - InListFilter + - InListFilter.column + - InListFilter.operator + - InListFilter.value + - InListFilter.values database.using_filters.not_in: status: implemented symbols: @@ -581,6 +620,14 @@ features: symbols: - PostgrestFilterBuilder.contains - TableColumn.contains + - ContainmentFilter + - ContainmentFilter.column + - ContainmentFilter.containment + - ContainmentFilter.operator + - ContainmentFilter.value + - ContainmentOperator + - ContainmentOperator.ContainmentOperator + - ContainmentOperator.wireName database.using_filters.contained_by: status: implemented symbols: @@ -610,6 +657,15 @@ features: symbols: - PostgrestFilterBuilder.rangeGt - TableColumn.rangeGt + - RangeFilter + - RangeFilter.column + - RangeFilter.operator + - RangeFilter.range + - RangeFilter.rangeComparison + - RangeFilter.value + - RangeOperator + - RangeOperator.RangeOperator + - RangeOperator.wireName database.using_filters.range_gte: status: implemented symbols: @@ -635,6 +691,13 @@ features: symbols: - PostgrestFilterBuilder.textSearch - TextTableColumnFilters.textSearch + - TextSearchFilter + - TextSearchFilter.column + - TextSearchFilter.config + - TextSearchFilter.operator + - TextSearchFilter.query + - TextSearchFilter.type + - TextSearchFilter.value database.using_filters.regex: status: implemented symbols: From 03af7a3b3bad9d3acc2fef232bb321d4f572a964 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 10:02:41 +0200 Subject: [PATCH 4/6] refactor: one sealed ColumnFilter class per operator --- .../postgrest/lib/src/postgrest_table.dart | 530 +++++++++++------- .../src/postgrest_typed_filter_builder.dart | 2 +- .../src/supabase_typed_stream_builder.dart | 42 +- sdk-compliance.yaml | 75 ++- 4 files changed, 414 insertions(+), 235 deletions(-) diff --git a/packages/postgrest/lib/src/postgrest_table.dart b/packages/postgrest/lib/src/postgrest_table.dart index 210777aff..12ffeab6c 100644 --- a/packages/postgrest/lib/src/postgrest_table.dart +++ b/packages/postgrest/lib/src/postgrest_table.dart @@ -60,28 +60,22 @@ class TableColumn { /// Only rows where this column equals [value]. /// /// For `null` equality, use [isNull] instead. - ComparisonFilter eq(Value value) => - ComparisonFilter._(name, ComparisonOperator.eq, value); + EqFilter eq(Value value) => EqFilter._(name, value); /// Only rows where this column does not equal [value]. - ComparisonFilter neq(Value value) => - ComparisonFilter._(name, ComparisonOperator.neq, value); + NeqFilter neq(Value value) => NeqFilter._(name, value); /// Only rows where this column is greater than [value]. - ComparisonFilter gt(Value value) => - ComparisonFilter._(name, ComparisonOperator.gt, value); + GtFilter gt(Value value) => GtFilter._(name, value); /// Only rows where this column is greater than or equal to [value]. - ComparisonFilter gte(Value value) => - ComparisonFilter._(name, ComparisonOperator.gte, value); + GteFilter gte(Value value) => GteFilter._(name, value); /// Only rows where this column is less than [value]. - ComparisonFilter lt(Value value) => - ComparisonFilter._(name, ComparisonOperator.lt, value); + LtFilter lt(Value value) => LtFilter._(name, value); /// Only rows where this column is less than or equal to [value]. - ComparisonFilter lte(Value value) => - ComparisonFilter._(name, ComparisonOperator.lte, value); + LteFilter lte(Value value) => LteFilter._(name, value); /// Only rows where this column is `null`. IsNullFilter isNull() => IsNullFilter._(name); @@ -100,75 +94,67 @@ class TableColumn { /// Only rows whose json, array, or range value contains [value]. /// /// See [PostgrestFilterBuilder.contains] for the accepted value shapes. - ContainmentFilter contains(Object value) => - ContainmentFilter._(name, ContainmentOperator.contains, value); + ContainsFilter contains(Object value) => ContainsFilter._(name, value); /// Only rows whose json, array, or range value is contained by [value]. /// /// See [PostgrestFilterBuilder.containedBy] for the accepted value shapes. - ContainmentFilter containedBy(Object value) => - ContainmentFilter._(name, ContainmentOperator.containedBy, value); + ContainedByFilter containedBy(Object value) => + ContainedByFilter._(name, value); /// Only rows whose array or range value overlaps with [value]. - ContainmentFilter overlaps(Object value) => - ContainmentFilter._(name, ContainmentOperator.overlaps, value); + OverlapsFilter overlaps(Object value) => OverlapsFilter._(name, value); /// Only rows whose range value is strictly to the left of [range]. - RangeFilter rangeLt(String range) => - RangeFilter._(name, RangeOperator.rangeLt, range); + RangeLtFilter rangeLt(String range) => RangeLtFilter._(name, range); /// Only rows whose range value is strictly to the right of [range]. - RangeFilter rangeGt(String range) => - RangeFilter._(name, RangeOperator.rangeGt, range); + RangeGtFilter rangeGt(String range) => RangeGtFilter._(name, range); /// Only rows whose range value does not extend to the left of [range]. - RangeFilter rangeGte(String range) => - RangeFilter._(name, RangeOperator.rangeGte, range); + RangeGteFilter rangeGte(String range) => RangeGteFilter._(name, range); /// Only rows whose range value does not extend to the right of [range]. - RangeFilter rangeLte(String range) => - RangeFilter._(name, RangeOperator.rangeLte, range); + RangeLteFilter rangeLte(String range) => RangeLteFilter._(name, range); /// Only rows whose range value is adjacent to [range]. - RangeFilter rangeAdjacent(String range) => - RangeFilter._(name, RangeOperator.rangeAdjacent, range); + RangeAdjacentFilter rangeAdjacent(String range) => + RangeAdjacentFilter._(name, range); } /// Filters that only apply to text columns. extension TextTableColumnFilters on TableColumn { /// Only rows whose value matches [pattern] case-sensitively. - PatternFilter like(String pattern) => - PatternFilter._(name, PatternOperator.like, pattern); + LikeFilter like(String pattern) => LikeFilter._(name, pattern); /// Only rows whose value matches [pattern] case-insensitively. - PatternFilter ilike(String pattern) => - PatternFilter._(name, PatternOperator.ilike, pattern); + IlikeFilter ilike(String pattern) => IlikeFilter._(name, pattern); /// Only rows whose value matches [pattern] as a PostgreSQL regular /// expression, case-sensitively. - PatternFilter matchRegex(String pattern) => - PatternFilter._(name, PatternOperator.matchRegex, pattern); + MatchRegexFilter matchRegex(String pattern) => + MatchRegexFilter._(name, pattern); /// Only rows whose value matches [pattern] as a PostgreSQL regular /// expression, case-insensitively. - PatternFilter imatchRegex(String pattern) => - PatternFilter._(name, PatternOperator.imatchRegex, pattern); + ImatchRegexFilter imatchRegex(String pattern) => + ImatchRegexFilter._(name, pattern); /// Only rows whose value matches all of [patterns] case-sensitively. - PatternListFilter likeAllOf(List patterns) => - PatternListFilter._(name, PatternListOperator.likeAllOf, patterns); + LikeAllOfFilter likeAllOf(List patterns) => + LikeAllOfFilter._(name, patterns); /// Only rows whose value matches any of [patterns] case-sensitively. - PatternListFilter likeAnyOf(List patterns) => - PatternListFilter._(name, PatternListOperator.likeAnyOf, patterns); + LikeAnyOfFilter likeAnyOf(List patterns) => + LikeAnyOfFilter._(name, patterns); /// Only rows whose value matches all of [patterns] case-insensitively. - PatternListFilter ilikeAllOf(List patterns) => - PatternListFilter._(name, PatternListOperator.ilikeAllOf, patterns); + IlikeAllOfFilter ilikeAllOf(List patterns) => + IlikeAllOfFilter._(name, patterns); /// Only rows whose value matches any of [patterns] case-insensitively. - PatternListFilter ilikeAnyOf(List patterns) => - PatternListFilter._(name, PatternListOperator.ilikeAnyOf, patterns); + IlikeAnyOfFilter ilikeAnyOf(List patterns) => + IlikeAnyOfFilter._(name, patterns); /// Only rows whose text or tsvector value matches the tsquery in [query]. /// @@ -184,10 +170,9 @@ extension TextTableColumnFilters on TableColumn { /// [TableColumn] such as [TableColumn.eq]. /// /// Applied to a typed query with [PostgrestTypedFilterBuilder.where]. The -/// concrete subtypes carry the operator as structured data, so consumers can -/// exhaustively match on the filter shape instead of comparing strings; -/// realtime streams for example only accept [ComparisonFilter] and -/// [InListFilter]. +/// hierarchy is sealed with one class per operator, so consumers can switch +/// on the filter itself instead of comparing operator values; realtime +/// streams for example only accept [ComparisonFilter]s and [InListFilter]. sealed class ColumnFilter { const ColumnFilter._(); @@ -210,61 +195,111 @@ sealed class ColumnFilter { /// ```dart /// client.table(Books.table).select().where(Books.id.eq(1).not()); /// ``` - ColumnFilter not() => _NegatedColumnFilter(this); -} - -/// The comparison applied by a [ComparisonFilter]. -enum ComparisonOperator { - eq('eq'), - neq('neq'), - gt('gt'), - gte('gte'), - lt('lt'), - lte('lte'); - - const ComparisonOperator(this.wireName); - - /// The PostgREST wire representation of the operator. - final String wireName; + NegatedFilter not() => NegatedFilter._(this); } /// An equality or ordering comparison against a single value. /// /// Besides regular queries, these are the filters that realtime streams /// support, together with [InListFilter]. -final class ComparisonFilter extends ColumnFilter { - const ComparisonFilter._(this.column, this.comparison, this.value) - : super._(); +sealed class ComparisonFilter extends ColumnFilter { + const ComparisonFilter._(this.column, this.value) : super._(); @override final String column; - /// The comparison being applied. - final ComparisonOperator comparison; - @override final Object value; +} + +/// Only rows where the column equals [value]; created by [TableColumn.eq]. +final class EqFilter extends ComparisonFilter { + const EqFilter._(super.column, super.value) : super._(); + + @override + String get operator => 'eq'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.eq(column, value); +} + +/// Only rows where the column does not equal [value]; created by +/// [TableColumn.neq]. +final class NeqFilter extends ComparisonFilter { + const NeqFilter._(super.column, super.value) : super._(); + + @override + String get operator => 'neq'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.neq(column, value); +} + +/// Only rows where the column is greater than [value]; created by +/// [TableColumn.gt]. +final class GtFilter extends ComparisonFilter { + const GtFilter._(super.column, super.value) : super._(); + + @override + String get operator => 'gt'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.gt(column, value); +} + +/// Only rows where the column is greater than or equal to [value]; created +/// by [TableColumn.gte]. +final class GteFilter extends ComparisonFilter { + const GteFilter._(super.column, super.value) : super._(); + + @override + String get operator => 'gte'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.gte(column, value); +} + +/// Only rows where the column is less than [value]; created by +/// [TableColumn.lt]. +final class LtFilter extends ComparisonFilter { + const LtFilter._(super.column, super.value) : super._(); + + @override + String get operator => 'lt'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.lt(column, value); +} + +/// Only rows where the column is less than or equal to [value]; created by +/// [TableColumn.lte]. +final class LteFilter extends ComparisonFilter { + const LteFilter._(super.column, super.value) : super._(); @override - String get operator => comparison.wireName; + String get operator => 'lte'; @override PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) => switch (comparison) { - ComparisonOperator.eq => builder.eq(column, value), - ComparisonOperator.neq => builder.neq(column, value), - ComparisonOperator.gt => builder.gt(column, value), - ComparisonOperator.gte => builder.gte(column, value), - ComparisonOperator.lt => builder.lt(column, value), - ComparisonOperator.lte => builder.lte(column, value), - }; + ) => builder.lte(column, value); } -/// A filter matching rows whose column value equals one of [values]. +/// A filter matching rows whose column value equals one of [values]; created +/// by [TableColumn.inFilter]. /// /// Besides regular queries, this filter is supported by realtime streams, -/// together with [ComparisonFilter]. +/// together with [ComparisonFilter]s. final class InListFilter extends ColumnFilter { const InListFilter._(this.column, this.values) : super._(); @@ -278,6 +313,8 @@ final class InListFilter extends ColumnFilter { String get operator => 'in'; @override + // The generic accessor intentionally aliases the semantic field. + // ignore: match-getter-setter-field-names Object get value => values; @override @@ -286,7 +323,8 @@ final class InListFilter extends ColumnFilter { ) => builder.inFilter(column, values); } -/// A filter matching rows whose column value is `null`. +/// A filter matching rows whose column value is `null`; created by +/// [TableColumn.isNull]. final class IsNullFilter extends ColumnFilter { const IsNullFilter._(this.column) : super._(); @@ -306,7 +344,8 @@ final class IsNullFilter extends ColumnFilter { } /// A filter matching rows whose column value is distinct from [value], -/// treating `null` as a comparable value. +/// treating `null` as a comparable value; created by +/// [TableColumn.isDistinctFrom]. final class IsDistinctFilter extends ColumnFilter { const IsDistinctFilter._(this.column, this.value) : super._(); @@ -325,185 +364,293 @@ final class IsDistinctFilter extends ColumnFilter { ) => builder.isDistinct(column, value); } -/// The operator applied by a [ContainmentFilter]. -enum ContainmentOperator { - contains('cs'), - containedBy('cd'), - overlaps('ov'); +/// A containment or overlap filter on a json, array, or range column. +sealed class ContainmentFilter extends ColumnFilter { + const ContainmentFilter._(this.column, this.value) : super._(); - const ContainmentOperator(this.wireName); + @override + final String column; - /// The PostgREST wire representation of the operator. - final String wireName; + @override + final Object value; } -/// A containment or overlap filter on a json, array, or range column. -final class ContainmentFilter extends ColumnFilter { - const ContainmentFilter._(this.column, this.containment, this.value) - : super._(); +/// Only rows whose value contains [value]; created by [TableColumn.contains]. +final class ContainsFilter extends ContainmentFilter { + const ContainsFilter._(super.column, super.value) : super._(); @override - final String column; - - /// The containment check being applied. - final ContainmentOperator containment; + String get operator => 'cs'; @override - final Object value; + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.contains(column, value); +} + +/// Only rows whose value is contained by [value]; created by +/// [TableColumn.containedBy]. +final class ContainedByFilter extends ContainmentFilter { + const ContainedByFilter._(super.column, super.value) : super._(); @override - String get operator => containment.wireName; + String get operator => 'cd'; @override PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) => switch (containment) { - ContainmentOperator.contains => builder.contains(column, value), - ContainmentOperator.containedBy => builder.containedBy(column, value), - ContainmentOperator.overlaps => builder.overlaps(column, value), - }; + ) => builder.containedBy(column, value); } -/// The operator applied by a [RangeFilter]. -enum RangeOperator { - rangeLt('sl'), - rangeGt('sr'), - rangeGte('nxl'), - rangeLte('nxr'), - rangeAdjacent('adj'); +/// Only rows whose value overlaps with [value]; created by +/// [TableColumn.overlaps]. +final class OverlapsFilter extends ContainmentFilter { + const OverlapsFilter._(super.column, super.value) : super._(); - const RangeOperator(this.wireName); + @override + String get operator => 'ov'; - /// The PostgREST wire representation of the operator. - final String wireName; + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.overlaps(column, value); } /// A filter comparing a range column against the range literal [range]. -final class RangeFilter extends ColumnFilter { - const RangeFilter._(this.column, this.rangeComparison, this.range) - : super._(); +sealed class RangeFilter extends ColumnFilter { + const RangeFilter._(this.column, this.range) : super._(); @override final String column; - /// The range comparison being applied. - final RangeOperator rangeComparison; - /// The PostgREST range literal, for example `[2,25)`. final String range; @override - String get operator => rangeComparison.wireName; + // The generic accessor intentionally aliases the semantic field. + // ignore: match-getter-setter-field-names + Object get value => range; +} + +/// Only rows whose range is strictly to the left of [range]; created by +/// [TableColumn.rangeLt]. +final class RangeLtFilter extends RangeFilter { + const RangeLtFilter._(super.column, super.range) : super._(); @override - Object get value => range; + String get operator => 'sl'; @override PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) => switch (rangeComparison) { - RangeOperator.rangeLt => builder.rangeLt(column, range), - RangeOperator.rangeGt => builder.rangeGt(column, range), - RangeOperator.rangeGte => builder.rangeGte(column, range), - RangeOperator.rangeLte => builder.rangeLte(column, range), - RangeOperator.rangeAdjacent => builder.rangeAdjacent(column, range), - }; + ) => builder.rangeLt(column, range); } -/// The operator applied by a [PatternFilter]. -enum PatternOperator { - like('like'), - ilike('ilike'), - matchRegex('match'), - imatchRegex('imatch'); +/// Only rows whose range is strictly to the right of [range]; created by +/// [TableColumn.rangeGt]. +final class RangeGtFilter extends RangeFilter { + const RangeGtFilter._(super.column, super.range) : super._(); - const PatternOperator(this.wireName); + @override + String get operator => 'sr'; - /// The PostgREST wire representation of the operator. - final String wireName; + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.rangeGt(column, range); +} + +/// Only rows whose range does not extend to the left of [range]; created by +/// [TableColumn.rangeGte]. +final class RangeGteFilter extends RangeFilter { + const RangeGteFilter._(super.column, super.range) : super._(); + + @override + String get operator => 'nxl'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.rangeGte(column, range); +} + +/// Only rows whose range does not extend to the right of [range]; created by +/// [TableColumn.rangeLte]. +final class RangeLteFilter extends RangeFilter { + const RangeLteFilter._(super.column, super.range) : super._(); + + @override + String get operator => 'nxr'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.rangeLte(column, range); +} + +/// Only rows whose range is adjacent to [range]; created by +/// [TableColumn.rangeAdjacent]. +final class RangeAdjacentFilter extends RangeFilter { + const RangeAdjacentFilter._(super.column, super.range) : super._(); + + @override + String get operator => 'adj'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.rangeAdjacent(column, range); } /// A filter matching a text column against a single [pattern]. -final class PatternFilter extends ColumnFilter { - const PatternFilter._(this.column, this.match, this.pattern) : super._(); +sealed class PatternFilter extends ColumnFilter { + const PatternFilter._(this.column, this.pattern) : super._(); @override final String column; - /// The kind of pattern match being applied. - final PatternOperator match; - /// The pattern the column is matched against. final String pattern; @override - String get operator => match.wireName; + // The generic accessor intentionally aliases the semantic field. + // ignore: match-getter-setter-field-names + Object get value => pattern; +} + +/// Only rows matching [pattern] case-sensitively; created by +/// [TextTableColumnFilters.like]. +final class LikeFilter extends PatternFilter { + const LikeFilter._(super.column, super.pattern) : super._(); @override - Object get value => pattern; + String get operator => 'like'; @override PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) => switch (match) { - PatternOperator.like => builder.like(column, pattern), - PatternOperator.ilike => builder.ilike(column, pattern), - PatternOperator.matchRegex => builder.matchRegex(column, pattern), - PatternOperator.imatchRegex => builder.imatchRegex(column, pattern), - }; + ) => builder.like(column, pattern); } -/// The operator applied by a [PatternListFilter]. -enum PatternListOperator { - likeAllOf('like(all)'), - likeAnyOf('like(any)'), - ilikeAllOf('ilike(all)'), - ilikeAnyOf('ilike(any)'); +/// Only rows matching [pattern] case-insensitively; created by +/// [TextTableColumnFilters.ilike]. +final class IlikeFilter extends PatternFilter { + const IlikeFilter._(super.column, super.pattern) : super._(); - const PatternListOperator(this.wireName); + @override + String get operator => 'ilike'; - /// The PostgREST wire representation of the operator. - final String wireName; + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.ilike(column, pattern); +} + +/// Only rows matching the regular expression [pattern] case-sensitively; +/// created by [TextTableColumnFilters.matchRegex]. +final class MatchRegexFilter extends PatternFilter { + const MatchRegexFilter._(super.column, super.pattern) : super._(); + + @override + String get operator => 'match'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.matchRegex(column, pattern); +} + +/// Only rows matching the regular expression [pattern] case-insensitively; +/// created by [TextTableColumnFilters.imatchRegex]. +final class ImatchRegexFilter extends PatternFilter { + const ImatchRegexFilter._(super.column, super.pattern) : super._(); + + @override + String get operator => 'imatch'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.imatchRegex(column, pattern); } /// A filter matching a text column against several [patterns] at once. -final class PatternListFilter extends ColumnFilter { - const PatternListFilter._(this.column, this.match, this.patterns) : super._(); +sealed class PatternListFilter extends ColumnFilter { + const PatternListFilter._(this.column, this.patterns) : super._(); @override final String column; - /// The kind of pattern match being applied. - final PatternListOperator match; - /// The patterns the column is matched against. final List patterns; @override - String get operator => match.wireName; + // The generic accessor intentionally aliases the semantic field. + // ignore: match-getter-setter-field-names + Object get value => patterns; +} + +/// Only rows matching all of [patterns] case-sensitively; created by +/// [TextTableColumnFilters.likeAllOf]. +final class LikeAllOfFilter extends PatternListFilter { + const LikeAllOfFilter._(super.column, super.patterns) : super._(); @override - Object get value => patterns; + String get operator => 'like(all)'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.likeAllOf(column, patterns); +} + +/// Only rows matching any of [patterns] case-sensitively; created by +/// [TextTableColumnFilters.likeAnyOf]. +final class LikeAnyOfFilter extends PatternListFilter { + const LikeAnyOfFilter._(super.column, super.patterns) : super._(); + + @override + String get operator => 'like(any)'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.likeAnyOf(column, patterns); +} + +/// Only rows matching all of [patterns] case-insensitively; created by +/// [TextTableColumnFilters.ilikeAllOf]. +final class IlikeAllOfFilter extends PatternListFilter { + const IlikeAllOfFilter._(super.column, super.patterns) : super._(); + + @override + String get operator => 'ilike(all)'; @override PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) => switch (match) { - PatternListOperator.likeAllOf => builder.likeAllOf(column, patterns), - PatternListOperator.likeAnyOf => builder.likeAnyOf(column, patterns), - PatternListOperator.ilikeAllOf => builder.ilikeAllOf(column, patterns), - PatternListOperator.ilikeAnyOf => builder.ilikeAnyOf(column, patterns), - }; + ) => builder.ilikeAllOf(column, patterns); } -/// A full text search filter on a text or tsvector column. +/// Only rows matching any of [patterns] case-insensitively; created by +/// [TextTableColumnFilters.ilikeAnyOf]. +final class IlikeAnyOfFilter extends PatternListFilter { + const IlikeAnyOfFilter._(super.column, super.patterns) : super._(); + + @override + String get operator => 'ilike(any)'; + + @override + PostgrestFilterBuilder _apply( + PostgrestFilterBuilder builder, + ) => builder.ilikeAnyOf(column, patterns); +} + +/// A full text search filter on a text or tsvector column; created by +/// [TextTableColumnFilters.textSearch]. final class TextSearchFilter extends ColumnFilter { - const TextSearchFilter._( - this.column, - this.query, { - this.config, - this.type, - }) : super._(); + const TextSearchFilter._(this.column, this.query, {this.config, this.type}) + : super._(); @override final String column; @@ -530,6 +677,8 @@ final class TextSearchFilter extends ColumnFilter { } @override + // The generic accessor intentionally aliases the semantic field. + // ignore: match-getter-setter-field-names Object get value => query; @override @@ -540,26 +689,27 @@ final class TextSearchFilter extends ColumnFilter { /// The negation of another [ColumnFilter], created through /// [ColumnFilter.not]. -final class _NegatedColumnFilter extends ColumnFilter { - const _NegatedColumnFilter(this._inner) : super._(); +final class NegatedFilter extends ColumnFilter { + const NegatedFilter._(this.inner) : super._(); - final ColumnFilter _inner; + /// The filter being negated. + final ColumnFilter inner; @override - String get column => _inner.column; + String get column => inner.column; @override - String get operator => 'not.${_inner.operator}'; + String get operator => 'not.${inner.operator}'; @override - Object? get value => _inner.value; + Object? get value => inner.value; @override - ColumnFilter not() => + NegatedFilter not() => throw StateError('The filter on "$column" is already negated.'); @override PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) => builder.not(column, _inner.operator, _inner.value); + ) => builder.not(column, inner.operator, inner.value); } diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart index f79ab8319..1adacdfc1 100644 --- a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -51,7 +51,7 @@ class PostgrestTypedFilterBuilder } static String _orFragment(ColumnFilter filter) { - final unwrapped = filter is _NegatedColumnFilter ? filter._inner : filter; + final unwrapped = filter is NegatedFilter ? filter.inner : filter; final value = filter.value; final String rendered; if (value is List) { diff --git a/packages/supabase/lib/src/supabase_typed_stream_builder.dart b/packages/supabase/lib/src/supabase_typed_stream_builder.dart index a6699b201..f0ad7a7ec 100644 --- a/packages/supabase/lib/src/supabase_typed_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_stream_builder.dart @@ -73,7 +73,7 @@ class SupabaseTypedStreamFilterBuilder /// /// Named [filter] instead of `where` because [Stream.where] already exists. /// - /// Only one filter can be applied to a stream, and only [ComparisonFilter] + /// Only one filter can be applied to a stream, and only [ComparisonFilter]s /// and [InListFilter] are supported: [TableColumn.eq], [TableColumn.neq], /// [TableColumn.lt], [TableColumn.lte], [TableColumn.gt], [TableColumn.gte] /// and [TableColumn.inFilter]. @@ -86,24 +86,28 @@ class SupabaseTypedStreamFilterBuilder /// ``` SupabaseTypedStreamBuilder filter(ColumnFilter columnFilter) { switch (columnFilter) { - case ComparisonFilter(:final column, :final comparison, :final value): - switch (comparison) { - case ComparisonOperator.eq: - _streamFilterBuilder.eq(column, value); - case ComparisonOperator.neq: - _streamFilterBuilder.neq(column, value); - case ComparisonOperator.lt: - _streamFilterBuilder.lt(column, value); - case ComparisonOperator.lte: - _streamFilterBuilder.lte(column, value); - case ComparisonOperator.gt: - _streamFilterBuilder.gt(column, value); - case ComparisonOperator.gte: - _streamFilterBuilder.gte(column, value); - } - case InListFilter(:final column, :final values): - _streamFilterBuilder.inFilter(column, values); - default: + case EqFilter(): + _streamFilterBuilder.eq(columnFilter.column, columnFilter.value); + case NeqFilter(): + _streamFilterBuilder.neq(columnFilter.column, columnFilter.value); + case LtFilter(): + _streamFilterBuilder.lt(columnFilter.column, columnFilter.value); + case LteFilter(): + _streamFilterBuilder.lte(columnFilter.column, columnFilter.value); + case GtFilter(): + _streamFilterBuilder.gt(columnFilter.column, columnFilter.value); + case GteFilter(): + _streamFilterBuilder.gte(columnFilter.column, columnFilter.value); + case InListFilter(): + _streamFilterBuilder.inFilter(columnFilter.column, columnFilter.values); + case IsNullFilter() || + IsDistinctFilter() || + ContainmentFilter() || + RangeFilter() || + PatternFilter() || + PatternListFilter() || + TextSearchFilter() || + NegatedFilter(): throw ArgumentError.value( columnFilter, 'columnFilter', diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 5c78ad6cf..f73fe5ac5 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -502,37 +502,44 @@ features: - TableColumn.eq - ComparisonFilter - ComparisonFilter.column - - ComparisonFilter.comparison - - ComparisonFilter.operator - ComparisonFilter.value - - ComparisonOperator - - ComparisonOperator.ComparisonOperator - - ComparisonOperator.wireName + - EqFilter + - EqFilter.operator database.using_filters.neq: status: implemented symbols: - PostgrestFilterBuilder.neq - TableColumn.neq + - NeqFilter + - NeqFilter.operator database.using_filters.gt: status: implemented symbols: - PostgrestFilterBuilder.gt - TableColumn.gt + - GtFilter + - GtFilter.operator database.using_filters.gte: status: implemented symbols: - PostgrestFilterBuilder.gte - TableColumn.gte + - GteFilter + - GteFilter.operator database.using_filters.lt: status: implemented symbols: - PostgrestFilterBuilder.lt - TableColumn.lt + - LtFilter + - LtFilter.operator database.using_filters.lte: status: implemented symbols: - PostgrestFilterBuilder.lte - TableColumn.lte + - LteFilter + - LteFilter.operator database.using_filters.like: status: implemented symbols: @@ -541,13 +548,10 @@ features: - TextTableColumnFilters.like - PatternFilter - PatternFilter.column - - PatternFilter.match - - PatternFilter.operator - PatternFilter.pattern - PatternFilter.value - - PatternOperator - - PatternOperator.PatternOperator - - PatternOperator.wireName + - LikeFilter + - LikeFilter.operator database.using_filters.like_all: status: implemented symbols: @@ -555,33 +559,38 @@ features: - TextTableColumnFilters.likeAllOf - PatternListFilter - PatternListFilter.column - - PatternListFilter.match - - PatternListFilter.operator - PatternListFilter.patterns - PatternListFilter.value - - PatternListOperator - - PatternListOperator.PatternListOperator - - PatternListOperator.wireName + - LikeAllOfFilter + - LikeAllOfFilter.operator database.using_filters.like_any: status: implemented symbols: - PostgrestFilterBuilder.likeAnyOf - TextTableColumnFilters.likeAnyOf + - LikeAnyOfFilter + - LikeAnyOfFilter.operator database.using_filters.ilike: status: implemented symbols: - PostgrestFilterBuilder.ilike - TextTableColumnFilters.ilike + - IlikeFilter + - IlikeFilter.operator database.using_filters.ilike_all: status: implemented symbols: - PostgrestFilterBuilder.ilikeAllOf - TextTableColumnFilters.ilikeAllOf + - IlikeAllOfFilter + - IlikeAllOfFilter.operator database.using_filters.ilike_any: status: implemented symbols: - PostgrestFilterBuilder.ilikeAnyOf - TextTableColumnFilters.ilikeAnyOf + - IlikeAnyOfFilter + - IlikeAnyOfFilter.operator database.using_filters.is: status: implemented symbols: @@ -622,22 +631,23 @@ features: - TableColumn.contains - ContainmentFilter - ContainmentFilter.column - - ContainmentFilter.containment - - ContainmentFilter.operator - ContainmentFilter.value - - ContainmentOperator - - ContainmentOperator.ContainmentOperator - - ContainmentOperator.wireName + - ContainsFilter + - ContainsFilter.operator database.using_filters.contained_by: status: implemented symbols: - PostgrestFilterBuilder.containedBy - TableColumn.containedBy + - ContainedByFilter + - ContainedByFilter.operator database.using_filters.overlaps: status: implemented symbols: - PostgrestFilterBuilder.overlaps - TableColumn.overlaps + - OverlapsFilter + - OverlapsFilter.operator database.using_filters.match: status: implemented symbols: @@ -647,6 +657,12 @@ features: symbols: - PostgrestFilterBuilder.not - ColumnFilter.not + - NegatedFilter + - NegatedFilter.column + - NegatedFilter.inner + - NegatedFilter.not + - NegatedFilter.operator + - NegatedFilter.value database.using_filters.or: status: implemented symbols: @@ -659,33 +675,38 @@ features: - TableColumn.rangeGt - RangeFilter - RangeFilter.column - - RangeFilter.operator - RangeFilter.range - - RangeFilter.rangeComparison - RangeFilter.value - - RangeOperator - - RangeOperator.RangeOperator - - RangeOperator.wireName + - RangeGtFilter + - RangeGtFilter.operator database.using_filters.range_gte: status: implemented symbols: - PostgrestFilterBuilder.rangeGte - TableColumn.rangeGte + - RangeGteFilter + - RangeGteFilter.operator database.using_filters.range_lt: status: implemented symbols: - PostgrestFilterBuilder.rangeLt - TableColumn.rangeLt + - RangeLtFilter + - RangeLtFilter.operator database.using_filters.range_lte: status: implemented symbols: - PostgrestFilterBuilder.rangeLte - TableColumn.rangeLte + - RangeLteFilter + - RangeLteFilter.operator database.using_filters.range_adjacent: status: implemented symbols: - PostgrestFilterBuilder.rangeAdjacent - TableColumn.rangeAdjacent + - RangeAdjacentFilter + - RangeAdjacentFilter.operator database.using_filters.text_search: status: implemented symbols: @@ -703,11 +724,15 @@ features: symbols: - PostgrestFilterBuilder.matchRegex - TextTableColumnFilters.matchRegex + - MatchRegexFilter + - MatchRegexFilter.operator database.using_filters.regex_icase: status: implemented symbols: - PostgrestFilterBuilder.imatchRegex - TextTableColumnFilters.imatchRegex + - ImatchRegexFilter + - ImatchRegexFilter.operator database.using_filters.raw: status: implemented symbols: From 9fdbc8e236b852b36d42e58cf7cd43b6629e4752 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 11:21:51 +0200 Subject: [PATCH 5/6] fix(postgrest): review fixes for typed filters and asStream --- .../postgrest/lib/src/postgrest_table.dart | 11 ++++- .../lib/src/postgrest_typed_builder.dart | 20 ++++++++- .../src/postgrest_typed_filter_builder.dart | 11 ++++- packages/postgrest/test/typed_query_test.dart | 45 +++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/postgrest/lib/src/postgrest_table.dart b/packages/postgrest/lib/src/postgrest_table.dart index 12ffeab6c..9a7990cc2 100644 --- a/packages/postgrest/lib/src/postgrest_table.dart +++ b/packages/postgrest/lib/src/postgrest_table.dart @@ -711,5 +711,14 @@ final class NegatedFilter extends ColumnFilter { @override PostgrestFilterBuilder _apply( PostgrestFilterBuilder builder, - ) => builder.not(column, inner.operator, inner.value); + ) { + // The untyped `not` stringifies map values with `Map.toString`, unlike + // the json-encoding positive paths such as `contains`, so encode here. + final innerValue = inner.value; + return builder.not( + column, + inner.operator, + innerValue is Map ? json.encode(innerValue) : innerValue, + ); + } } diff --git a/packages/postgrest/lib/src/postgrest_typed_builder.dart b/packages/postgrest/lib/src/postgrest_typed_builder.dart index ec4638214..7bb7014c9 100644 --- a/packages/postgrest/lib/src/postgrest_typed_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_builder.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:postgrest/postgrest.dart'; @@ -37,7 +38,24 @@ class PostgrestTypedBuilder implements Future { } @override - Stream asStream() => _execute().asStream(); + Stream asStream() { + // Mirrors [PostgrestBuilder.asStream], which returns a broadcast stream. + final controller = StreamController.broadcast(); + + unawaited( + then((value) { + controller.add(value); + }) + .catchError((Object error, StackTrace stack) { + controller.addError(error, stack); + }) + .whenComplete(() { + unawaited(controller.close()); + }), + ); + + return controller.stream; + } @override Future catchError(Function onError, {bool Function(Object error)? test}) => diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart index 1adacdfc1..aa973f453 100644 --- a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -42,6 +42,13 @@ class PostgrestTypedFilterBuilder /// .whereAny([Books.id.eq(1), Books.title.eq('foo')]); /// ``` PostgrestTypedFilterBuilder whereAny(List filters) { + if (filters.isEmpty) { + throw ArgumentError.value( + filters, + 'filters', + 'whereAny needs at least one filter', + ); + } final fragments = [for (final filter in filters) _orFragment(filter)]; return PostgrestTypedFilterBuilder._( _filterBuilder.or(fragments.join(',')), @@ -69,7 +76,9 @@ class PostgrestTypedFilterBuilder if (value == null || value is num || value is bool) { return '$value'; } - final escaped = '$value'.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + // Maps are encoded as json, matching the positive containment paths. + final rendered = value is Map ? json.encode(value) : '$value'; + final escaped = rendered.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); return '"$escaped"'; } } diff --git a/packages/postgrest/test/typed_query_test.dart b/packages/postgrest/test/typed_query_test.dart index 256ebd27e..86673a74e 100644 --- a/packages/postgrest/test/typed_query_test.dart +++ b/packages/postgrest/test/typed_query_test.dart @@ -18,6 +18,7 @@ class Books { static const title = TableColumn('title'); static const tags = TableColumn>('tags'); static const ageRange = TableColumn('age_range'); + static const metadata = TableColumn>('metadata'); } class MockHttpClient extends BaseClient { @@ -227,6 +228,50 @@ void main() { test('negating a filter twice throws', () { expect(() => Books.id.eq(1).not().not(), throwsStateError); }); + + test('negated json containment encodes the value as json', () async { + await client + .table(Books.table) + .select() + .where(Books.metadata.contains({'a': 1}).not()); + + expect(requestParameters()['metadata'], 'not.cs.{"a":1}'); + }); + + test('whereAny encodes json containment values', () async { + await client.table(Books.table).select().whereAny([ + Books.metadata.contains({'a': 1}), + Books.id.eq(1), + ]); + + expect( + requestParameters()['or'], + r'(metadata.cs."{\"a\":1}",id.eq.1)', + ); + }); + + test('whereAny without filters throws', () { + expect( + () => client.table(Books.table).select().whereAny([]), + throwsArgumentError, + ); + }); + }); + + group('asStream', () { + test('returns a broadcast stream that supports multiple listeners', () { + httpClient.responseBody = bookRows; + + final stream = client.table(Books.table).select().asStream(); + + expect(stream.isBroadcast, isTrue); + stream.listen( + expectAsync1((books) { + expect(books, hasLength(2)); + }), + ); + stream.listen(expectAsync1((books) {})); + }); }); group('transforms', () { From 6a641434c46eadb8f9d17ff87ce63a6469c99482 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 12:17:58 +0200 Subject: [PATCH 6/6] feat: mark the typed table access API as experimental --- packages/postgrest/lib/src/postgrest.dart | 1 + .../postgrest/lib/src/postgrest_table.dart | 37 +++++++++++++++++++ .../lib/src/postgrest_typed_builder.dart | 2 + .../src/postgrest_typed_filter_builder.dart | 1 + .../src/postgrest_typed_query_builder.dart | 1 + .../postgrest_typed_transform_builder.dart | 1 + .../supabase/lib/src/supabase_client.dart | 2 + .../lib/src/supabase_query_schema.dart | 2 + .../lib/src/supabase_typed_query_builder.dart | 2 + .../src/supabase_typed_stream_builder.dart | 3 ++ packages/supabase/test/mock_test.dart | 2 + 11 files changed, 54 insertions(+) diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index 3c67ba8f8..ff259b77c 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -126,6 +126,7 @@ class PostgrestClient { /// .select() /// .where(Books.id.gt(10)); /// ``` + @experimental PostgrestTypedQueryBuilder table(PostgrestTable table) { return PostgrestTypedQueryBuilder(from(table.name), table); } diff --git a/packages/postgrest/lib/src/postgrest_table.dart b/packages/postgrest/lib/src/postgrest_table.dart index 9a7990cc2..d6f52a892 100644 --- a/packages/postgrest/lib/src/postgrest_table.dart +++ b/packages/postgrest/lib/src/postgrest_table.dart @@ -1,6 +1,7 @@ part of 'postgrest_typed_builder.dart'; /// Converts a single decoded PostgREST row into [Row]. +@experimental typedef RowConverter = Row Function(Map json); /// Describes a database table (or view) together with the Dart type its rows @@ -31,6 +32,7 @@ typedef RowConverter = Row Function(Map json); /// row representation since they carry no conversion cost and tolerate /// partial selects, but any converter works, for example `Book.fromJson` on a /// regular data class. +@experimental class PostgrestTable { const PostgrestTable(this.name, this.rowFromJson); @@ -48,6 +50,7 @@ class PostgrestTable { /// /// [Value] is always the non-nullable value type of the column. Null checks /// are expressed with [isNull] and [isNotNull] instead of nullable values. +@experimental class TableColumn { const TableColumn(this.name); @@ -123,6 +126,7 @@ class TableColumn { } /// Filters that only apply to text columns. +@experimental extension TextTableColumnFilters on TableColumn { /// Only rows whose value matches [pattern] case-sensitively. LikeFilter like(String pattern) => LikeFilter._(name, pattern); @@ -173,6 +177,7 @@ extension TextTableColumnFilters on TableColumn { /// hierarchy is sealed with one class per operator, so consumers can switch /// on the filter itself instead of comparing operator values; realtime /// streams for example only accept [ComparisonFilter]s and [InListFilter]. +@experimental sealed class ColumnFilter { const ColumnFilter._(); @@ -202,6 +207,7 @@ sealed class ColumnFilter { /// /// Besides regular queries, these are the filters that realtime streams /// support, together with [InListFilter]. +@experimental sealed class ComparisonFilter extends ColumnFilter { const ComparisonFilter._(this.column, this.value) : super._(); @@ -213,6 +219,7 @@ sealed class ComparisonFilter extends ColumnFilter { } /// Only rows where the column equals [value]; created by [TableColumn.eq]. +@experimental final class EqFilter extends ComparisonFilter { const EqFilter._(super.column, super.value) : super._(); @@ -227,6 +234,7 @@ final class EqFilter extends ComparisonFilter { /// Only rows where the column does not equal [value]; created by /// [TableColumn.neq]. +@experimental final class NeqFilter extends ComparisonFilter { const NeqFilter._(super.column, super.value) : super._(); @@ -241,6 +249,7 @@ final class NeqFilter extends ComparisonFilter { /// Only rows where the column is greater than [value]; created by /// [TableColumn.gt]. +@experimental final class GtFilter extends ComparisonFilter { const GtFilter._(super.column, super.value) : super._(); @@ -255,6 +264,7 @@ final class GtFilter extends ComparisonFilter { /// Only rows where the column is greater than or equal to [value]; created /// by [TableColumn.gte]. +@experimental final class GteFilter extends ComparisonFilter { const GteFilter._(super.column, super.value) : super._(); @@ -269,6 +279,7 @@ final class GteFilter extends ComparisonFilter { /// Only rows where the column is less than [value]; created by /// [TableColumn.lt]. +@experimental final class LtFilter extends ComparisonFilter { const LtFilter._(super.column, super.value) : super._(); @@ -283,6 +294,7 @@ final class LtFilter extends ComparisonFilter { /// Only rows where the column is less than or equal to [value]; created by /// [TableColumn.lte]. +@experimental final class LteFilter extends ComparisonFilter { const LteFilter._(super.column, super.value) : super._(); @@ -300,6 +312,7 @@ final class LteFilter extends ComparisonFilter { /// /// Besides regular queries, this filter is supported by realtime streams, /// together with [ComparisonFilter]s. +@experimental final class InListFilter extends ColumnFilter { const InListFilter._(this.column, this.values) : super._(); @@ -325,6 +338,7 @@ final class InListFilter extends ColumnFilter { /// A filter matching rows whose column value is `null`; created by /// [TableColumn.isNull]. +@experimental final class IsNullFilter extends ColumnFilter { const IsNullFilter._(this.column) : super._(); @@ -346,6 +360,7 @@ final class IsNullFilter extends ColumnFilter { /// A filter matching rows whose column value is distinct from [value], /// treating `null` as a comparable value; created by /// [TableColumn.isDistinctFrom]. +@experimental final class IsDistinctFilter extends ColumnFilter { const IsDistinctFilter._(this.column, this.value) : super._(); @@ -365,6 +380,7 @@ final class IsDistinctFilter extends ColumnFilter { } /// A containment or overlap filter on a json, array, or range column. +@experimental sealed class ContainmentFilter extends ColumnFilter { const ContainmentFilter._(this.column, this.value) : super._(); @@ -376,6 +392,7 @@ sealed class ContainmentFilter extends ColumnFilter { } /// Only rows whose value contains [value]; created by [TableColumn.contains]. +@experimental final class ContainsFilter extends ContainmentFilter { const ContainsFilter._(super.column, super.value) : super._(); @@ -390,6 +407,7 @@ final class ContainsFilter extends ContainmentFilter { /// Only rows whose value is contained by [value]; created by /// [TableColumn.containedBy]. +@experimental final class ContainedByFilter extends ContainmentFilter { const ContainedByFilter._(super.column, super.value) : super._(); @@ -404,6 +422,7 @@ final class ContainedByFilter extends ContainmentFilter { /// Only rows whose value overlaps with [value]; created by /// [TableColumn.overlaps]. +@experimental final class OverlapsFilter extends ContainmentFilter { const OverlapsFilter._(super.column, super.value) : super._(); @@ -417,6 +436,7 @@ final class OverlapsFilter extends ContainmentFilter { } /// A filter comparing a range column against the range literal [range]. +@experimental sealed class RangeFilter extends ColumnFilter { const RangeFilter._(this.column, this.range) : super._(); @@ -434,6 +454,7 @@ sealed class RangeFilter extends ColumnFilter { /// Only rows whose range is strictly to the left of [range]; created by /// [TableColumn.rangeLt]. +@experimental final class RangeLtFilter extends RangeFilter { const RangeLtFilter._(super.column, super.range) : super._(); @@ -448,6 +469,7 @@ final class RangeLtFilter extends RangeFilter { /// Only rows whose range is strictly to the right of [range]; created by /// [TableColumn.rangeGt]. +@experimental final class RangeGtFilter extends RangeFilter { const RangeGtFilter._(super.column, super.range) : super._(); @@ -462,6 +484,7 @@ final class RangeGtFilter extends RangeFilter { /// Only rows whose range does not extend to the left of [range]; created by /// [TableColumn.rangeGte]. +@experimental final class RangeGteFilter extends RangeFilter { const RangeGteFilter._(super.column, super.range) : super._(); @@ -476,6 +499,7 @@ final class RangeGteFilter extends RangeFilter { /// Only rows whose range does not extend to the right of [range]; created by /// [TableColumn.rangeLte]. +@experimental final class RangeLteFilter extends RangeFilter { const RangeLteFilter._(super.column, super.range) : super._(); @@ -490,6 +514,7 @@ final class RangeLteFilter extends RangeFilter { /// Only rows whose range is adjacent to [range]; created by /// [TableColumn.rangeAdjacent]. +@experimental final class RangeAdjacentFilter extends RangeFilter { const RangeAdjacentFilter._(super.column, super.range) : super._(); @@ -503,6 +528,7 @@ final class RangeAdjacentFilter extends RangeFilter { } /// A filter matching a text column against a single [pattern]. +@experimental sealed class PatternFilter extends ColumnFilter { const PatternFilter._(this.column, this.pattern) : super._(); @@ -520,6 +546,7 @@ sealed class PatternFilter extends ColumnFilter { /// Only rows matching [pattern] case-sensitively; created by /// [TextTableColumnFilters.like]. +@experimental final class LikeFilter extends PatternFilter { const LikeFilter._(super.column, super.pattern) : super._(); @@ -534,6 +561,7 @@ final class LikeFilter extends PatternFilter { /// Only rows matching [pattern] case-insensitively; created by /// [TextTableColumnFilters.ilike]. +@experimental final class IlikeFilter extends PatternFilter { const IlikeFilter._(super.column, super.pattern) : super._(); @@ -548,6 +576,7 @@ final class IlikeFilter extends PatternFilter { /// Only rows matching the regular expression [pattern] case-sensitively; /// created by [TextTableColumnFilters.matchRegex]. +@experimental final class MatchRegexFilter extends PatternFilter { const MatchRegexFilter._(super.column, super.pattern) : super._(); @@ -562,6 +591,7 @@ final class MatchRegexFilter extends PatternFilter { /// Only rows matching the regular expression [pattern] case-insensitively; /// created by [TextTableColumnFilters.imatchRegex]. +@experimental final class ImatchRegexFilter extends PatternFilter { const ImatchRegexFilter._(super.column, super.pattern) : super._(); @@ -575,6 +605,7 @@ final class ImatchRegexFilter extends PatternFilter { } /// A filter matching a text column against several [patterns] at once. +@experimental sealed class PatternListFilter extends ColumnFilter { const PatternListFilter._(this.column, this.patterns) : super._(); @@ -592,6 +623,7 @@ sealed class PatternListFilter extends ColumnFilter { /// Only rows matching all of [patterns] case-sensitively; created by /// [TextTableColumnFilters.likeAllOf]. +@experimental final class LikeAllOfFilter extends PatternListFilter { const LikeAllOfFilter._(super.column, super.patterns) : super._(); @@ -606,6 +638,7 @@ final class LikeAllOfFilter extends PatternListFilter { /// Only rows matching any of [patterns] case-sensitively; created by /// [TextTableColumnFilters.likeAnyOf]. +@experimental final class LikeAnyOfFilter extends PatternListFilter { const LikeAnyOfFilter._(super.column, super.patterns) : super._(); @@ -620,6 +653,7 @@ final class LikeAnyOfFilter extends PatternListFilter { /// Only rows matching all of [patterns] case-insensitively; created by /// [TextTableColumnFilters.ilikeAllOf]. +@experimental final class IlikeAllOfFilter extends PatternListFilter { const IlikeAllOfFilter._(super.column, super.patterns) : super._(); @@ -634,6 +668,7 @@ final class IlikeAllOfFilter extends PatternListFilter { /// Only rows matching any of [patterns] case-insensitively; created by /// [TextTableColumnFilters.ilikeAnyOf]. +@experimental final class IlikeAnyOfFilter extends PatternListFilter { const IlikeAnyOfFilter._(super.column, super.patterns) : super._(); @@ -648,6 +683,7 @@ final class IlikeAnyOfFilter extends PatternListFilter { /// A full text search filter on a text or tsvector column; created by /// [TextTableColumnFilters.textSearch]. +@experimental final class TextSearchFilter extends ColumnFilter { const TextSearchFilter._(this.column, this.query, {this.config, this.type}) : super._(); @@ -689,6 +725,7 @@ final class TextSearchFilter extends ColumnFilter { /// The negation of another [ColumnFilter], created through /// [ColumnFilter.not]. +@experimental final class NegatedFilter extends ColumnFilter { const NegatedFilter._(this.inner) : super._(); diff --git a/packages/postgrest/lib/src/postgrest_typed_builder.dart b/packages/postgrest/lib/src/postgrest_typed_builder.dart index 7bb7014c9..29062c358 100644 --- a/packages/postgrest/lib/src/postgrest_typed_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_builder.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:meta/meta.dart'; import 'package:postgrest/postgrest.dart'; part 'postgrest_table.dart'; @@ -26,6 +27,7 @@ void _toVoid(dynamic data) {} /// Wraps an untyped [PostgrestBuilder] and converts its result into [T] /// before it is returned, so awaiting it never exposes raw /// `Map` data. +@experimental class PostgrestTypedBuilder implements Future { const PostgrestTypedBuilder._(this._rawBuilder, this._convert); diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart index aa973f453..2cf30995e 100644 --- a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -4,6 +4,7 @@ part of 'postgrest_typed_builder.dart'; /// /// Filters are built from [TableColumn]s and applied with [where], which /// checks the value type of each filter against its column at compile time. +@experimental class PostgrestTypedFilterBuilder extends PostgrestTypedTransformBuilder { const PostgrestTypedFilterBuilder._( diff --git a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart index 21adef49a..2d675c457 100644 --- a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart @@ -7,6 +7,7 @@ part of 'postgrest_typed_builder.dart'; /// Query results are converted into [Row] through /// [PostgrestTable.rowFromJson], so no raw `Map` is exposed. /// {@endtemplate} +@experimental class PostgrestTypedQueryBuilder { /// {@macro postgrest_typed_query_builder} const PostgrestTypedQueryBuilder( diff --git a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart index ac6d03afb..ff87790d2 100644 --- a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart @@ -4,6 +4,7 @@ part of 'postgrest_typed_builder.dart'; /// /// [Row] is the type a single row converts into and [T] is the type the /// request resolves to when awaited. +@experimental class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { const PostgrestTypedTransformBuilder._( PostgrestTransformBuilder super.rawBuilder, diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index b17cdf382..905123ca2 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:http/http.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:supabase/src/constants.dart'; import 'package:supabase/src/version.dart'; import 'package:supabase/supabase.dart'; @@ -232,6 +233,7 @@ class SupabaseClient { /// .select() /// .where(Books.id.gt(10)); /// ``` + @experimental SupabaseTypedQueryBuilder table(PostgrestTable table) { return SupabaseTypedQueryBuilder(from(table.name), table); } diff --git a/packages/supabase/lib/src/supabase_query_schema.dart b/packages/supabase/lib/src/supabase_query_schema.dart index 415b37ba8..bfbcfc9e0 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -1,4 +1,5 @@ import 'package:http/http.dart'; +import 'package:meta/meta.dart'; import 'package:supabase/supabase.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; @@ -49,6 +50,7 @@ class SupabaseQuerySchema { } /// Perform a typed table operation, see [SupabaseClient.table]. + @experimental SupabaseTypedQueryBuilder table(PostgrestTable table) { return SupabaseTypedQueryBuilder(from(table.name), table); } diff --git a/packages/supabase/lib/src/supabase_typed_query_builder.dart b/packages/supabase/lib/src/supabase_typed_query_builder.dart index 8b1731426..e22bec9b6 100644 --- a/packages/supabase/lib/src/supabase_typed_query_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_query_builder.dart @@ -1,3 +1,4 @@ +import 'package:meta/meta.dart'; import 'package:supabase/supabase.dart'; /// The typed counterpart of [SupabaseQueryBuilder], returned by @@ -6,6 +7,7 @@ import 'package:supabase/supabase.dart'; /// In addition to the typed query methods inherited from /// [PostgrestTypedQueryBuilder], this builder exposes a typed realtime /// [stream]. +@experimental class SupabaseTypedQueryBuilder extends PostgrestTypedQueryBuilder { // The query builder is also kept as a field to expose [stream], so it // cannot become a super parameter. diff --git a/packages/supabase/lib/src/supabase_typed_stream_builder.dart b/packages/supabase/lib/src/supabase_typed_stream_builder.dart index f0ad7a7ec..974daeeb4 100644 --- a/packages/supabase/lib/src/supabase_typed_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_stream_builder.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:supabase/supabase.dart'; /// The typed counterpart of [SupabaseStreamBuilder]; emits the rows of the /// table converted into [Row] through [PostgrestTable.rowFromJson]. +@experimental class SupabaseTypedStreamBuilder extends Stream> { const SupabaseTypedStreamBuilder( SupabaseStreamBuilder streamBuilder, @@ -59,6 +61,7 @@ class SupabaseTypedStreamBuilder extends Stream> { } /// A [SupabaseTypedStreamBuilder] that can still be filtered with [filter]. +@experimental class SupabaseTypedStreamFilterBuilder extends SupabaseTypedStreamBuilder { const SupabaseTypedStreamFilterBuilder( diff --git a/packages/supabase/test/mock_test.dart b/packages/supabase/test/mock_test.dart index bb99b0ffc..8f8a694ba 100644 --- a/packages/supabase/test/mock_test.dart +++ b/packages/supabase/test/mock_test.dart @@ -1,4 +1,6 @@ // ignore_for_file: deprecated_member_use_from_same_package +// The typed table access API under test is annotated @experimental. +// ignore_for_file: experimental_member_use import 'dart:async'; import 'dart:convert';