From 8939c92c299dd3e50aa118f3b8e5815a699c7150 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 18:02:13 +0200 Subject: [PATCH 01/25] 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 02/25] 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 03/25] 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 04/25] 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 05/25] 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 06/25] 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'; From 280c870c8a02b981be17253387639ded17e179f7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 09:13:25 +0200 Subject: [PATCH 07/25] feat: add supabase_typegen package generating typed table definitions --- .sdk-parse-ignore | 4 + packages/supabase_typegen/README.md | 53 ++- .../bin/supabase_typegen.dart | 113 +++++ .../lib/src/dart_generator.dart | 413 ++++++++++++++++++ .../supabase_typegen/lib/src/identifiers.dart | 132 ++++++ .../lib/src/openapi_parser.dart | 105 +++++ .../lib/src/schema_description.dart | 117 +++++ .../lib/supabase_typegen.dart | 12 +- packages/supabase_typegen/pubspec.yaml | 9 + .../test/dart_generator_test.dart | 53 +++ .../test/fixtures/openapi.json | 111 +++++ .../test/generated_schema_behavior_test.dart | 116 +++++ .../test/goldens/supabase_schema.dart | 208 +++++++++ .../test/identifiers_test.dart | 40 ++ .../test/openapi_parser_test.dart | 81 ++++ .../test/supabase_typegen_test.dart | 7 - .../tool/regenerate_goldens.dart | 17 + 17 files changed, 1573 insertions(+), 18 deletions(-) create mode 100644 packages/supabase_typegen/bin/supabase_typegen.dart create mode 100644 packages/supabase_typegen/lib/src/dart_generator.dart create mode 100644 packages/supabase_typegen/lib/src/identifiers.dart create mode 100644 packages/supabase_typegen/lib/src/openapi_parser.dart create mode 100644 packages/supabase_typegen/lib/src/schema_description.dart create mode 100644 packages/supabase_typegen/test/dart_generator_test.dart create mode 100644 packages/supabase_typegen/test/fixtures/openapi.json create mode 100644 packages/supabase_typegen/test/generated_schema_behavior_test.dart create mode 100644 packages/supabase_typegen/test/goldens/supabase_schema.dart create mode 100644 packages/supabase_typegen/test/identifiers_test.dart create mode 100644 packages/supabase_typegen/test/openapi_parser_test.dart delete mode 100644 packages/supabase_typegen/test/supabase_typegen_test.dart create mode 100644 packages/supabase_typegen/tool/regenerate_goldens.dart diff --git a/.sdk-parse-ignore b/.sdk-parse-ignore index fe7ff1295..8e555b5ad 100644 --- a/.sdk-parse-ignore +++ b/.sdk-parse-ignore @@ -21,3 +21,7 @@ packages/supabase_typegen/ # The examples are standalone demo apps, not part of the published SDK, so their # public classes are not capability-matrix symbols. examples/ + +# supabase_typegen is a development-time code generator invoked through its CLI; +# its library API is tool internals, not SDK client surface. +packages/supabase_typegen/ diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 103bf11e5..6e1decb3a 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -1,10 +1,51 @@ # supabase_typegen -> [!WARNING] -> This is a placeholder release that reserves the package name on pub.dev. The -> code generator is still under development and this version does nothing yet. +Generates typed Supabase table definitions from your database schema, so +query results never expose raw `Map` data. -A command-line code generator that turns a Supabase database schema into typed -Dart table definitions for use with the Supabase client packages. +For every table the generator emits: -The generator implementation will land in a future release. +- a zero-cost row extension type over the decoded JSON map with typed getters, +- `Insert` and `Update` value types that enforce required columns at the + construction site, +- a `PostgrestTable` definition and `TableColumn` tokens for compile-time + checked filters, +- Dart enums for Postgres enums, with wire-name mapping. + +## Usage + +```sh +dart run supabase_typegen \ + --url https://your-project.supabase.co \ + --key $SUPABASE_ANON_KEY \ + --output lib/supabase_schema.g.dart +``` + +`--url` and `--key` fall back to the `SUPABASE_URL` and `SUPABASE_ANON_KEY` +environment variables. Use `--schema` to generate for a schema other than +`public`, and `--import` to change which library the generated file imports +`PostgrestTable` and `TableColumn` from. + +The schema is read from the OpenAPI description that PostgREST serves at the +API root, so the key only needs read access; tables hidden from the key by +row level security settings are not included. + +## Generated code in action + +```dart +final books = await client.table(Books.table) + .select() + .where(Books.mood.eq(Mood.happy)) + .order(Books.createdAt, ascending: false); // List + +await client.table(Books.table).insert( + BooksInsert(title: 'A typed row', tags: ['dart']), +); +``` + +## Known limitations + +The OpenAPI description does not distinguish nullable columns from `NOT NULL` +columns with a database default, so getters for defaulted columns other than +primary keys are conservatively nullable. Foreign key relationship getters +and typed functions (rpc) are not generated yet. diff --git a/packages/supabase_typegen/bin/supabase_typegen.dart b/packages/supabase_typegen/bin/supabase_typegen.dart new file mode 100644 index 000000000..962cbe12d --- /dev/null +++ b/packages/supabase_typegen/bin/supabase_typegen.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:http/http.dart' as http; +import 'package:supabase_typegen/supabase_typegen.dart'; + +final _argParser = ArgParser() + ..addOption( + 'url', + help: + 'The Supabase project URL, for example https://xyz.supabase.co. ' + 'Falls back to the SUPABASE_URL environment variable.', + ) + ..addOption( + 'key', + help: + 'The API key used to read the schema description. Falls back to ' + 'the SUPABASE_ANON_KEY or SUPABASE_KEY environment variable.', + ) + ..addOption( + 'schema', + defaultsTo: 'public', + help: 'The database schema to generate types for.', + ) + ..addOption( + 'output', + abbr: 'o', + defaultsTo: 'lib/supabase_schema.g.dart', + help: 'Path of the generated Dart file.', + ) + ..addOption( + 'import', + defaultsTo: 'package:postgrest/postgrest.dart', + help: + 'The import the generated file uses for PostgrestTable and ' + 'TableColumn.', + ) + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this usage.'); + +Future main(List arguments) async { + final ArgResults options; + try { + options = _argParser.parse(arguments); + } on FormatException catch (error) { + stderr + ..writeln(error.message) + ..writeln(_argParser.usage); + return 64; + } + + if (options.flag('help')) { + stdout + ..writeln('Generates typed Supabase table definitions from a schema.') + ..writeln() + ..writeln('Usage: dart run supabase_typegen [options]') + ..writeln(_argParser.usage); + return 0; + } + + final url = options.option('url') ?? Platform.environment['SUPABASE_URL']; + final key = + options.option('key') ?? + Platform.environment['SUPABASE_ANON_KEY'] ?? + Platform.environment['SUPABASE_KEY']; + if (url == null || key == null) { + stderr.writeln( + 'Both --url and --key are required, either as options or through the ' + 'SUPABASE_URL and SUPABASE_ANON_KEY environment variables.', + ); + return 64; + } + + final schemaName = options.option('schema')!; + final endpoint = Uri.parse('$url/rest/v1/'); + final http.Response response; + try { + response = await http.get( + endpoint, + headers: { + 'apikey': key, + 'Authorization': 'Bearer $key', + 'Accept-Profile': schemaName, + }, + ); + } on http.ClientException catch (error) { + stderr.writeln('Failed to reach $endpoint: $error'); + return 1; + } + if (response.statusCode != 200) { + stderr.writeln( + 'Failed to fetch the schema description from $endpoint ' + '(HTTP ${response.statusCode}): ${response.body}', + ); + return 1; + } + + final schema = parseOpenApiDocument( + jsonDecode(response.body) as Map, + schemaName: schemaName, + ); + final code = generateDartCode(schema, importUri: options.option('import')!); + + final outputFile = File(options.option('output')!); + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(code); + + stdout.writeln( + 'Generated ${outputFile.path} with ${schema.tables.length} tables and ' + '${schema.enums.length} enums from schema "$schemaName".', + ); + return 0; +} diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart new file mode 100644 index 000000000..12492285b --- /dev/null +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -0,0 +1,413 @@ +import 'package:dart_style/dart_style.dart'; + +import 'identifiers.dart'; +import 'schema_description.dart'; + +enum _Kind { direct, floating, dateTime, list, enumType, json } + +class _Binding { + const _Binding(this.dartType, this.kind); + + /// The non-nullable Dart type of the column. + final String dartType; + final _Kind kind; +} + +const _integerFormats = { + 'smallint', + 'integer', + 'bigint', + 'int2', + 'int4', + 'int8', +}; +const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; +const _numericFormats = {'numeric', 'decimal'}; +const _dateTimeFormats = { + 'date', + 'timestamp', + 'timestamp without time zone', + 'timestamp with time zone', + 'timestamptz', +}; +const _jsonFormats = {'json', 'jsonb'}; + +/// Generates a Dart source file with typed table definitions, row extension +/// types, insert and update value types, column tokens and Postgres enums for +/// [schema]. +/// +/// The generated code depends only on the library at [importUri], which must +/// export the typed table access API of `package:postgrest` (`PostgrestTable` +/// and `TableColumn`). +String generateDartCode( + SchemaDescription schema, { + String importUri = 'package:postgrest/postgrest.dart', +}) { + final buffer = StringBuffer() + ..writeln('// Generated by supabase_typegen. Do not edit by hand.') + ..writeln('//') + ..writeln('// Source schema: ${schema.schemaName}') + ..writeln() + ..writeln("import '$importUri';") + ..writeln(); + + final typeNames = _TypeNameRegistry(); + final enumTypeNames = {}; + + for (final enumDescription in schema.enums) { + final typeName = typeNames.claim(pascalCase(enumDescription.name)); + enumTypeNames[enumDescription.qualifiedName] = typeName; + _writeEnum(buffer, enumDescription, typeName); + } + + for (final table in schema.tables) { + if (table.columns.isEmpty) continue; + _writeTable(buffer, table, typeNames, enumTypeNames); + } + + return DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ).format(buffer.toString()); +} + +/// Hands out unique top level type names, suffixing `$` on collisions. +class _TypeNameRegistry { + final _used = {}; + + String claim(String name) { + var candidate = name; + while (!_used.add(candidate)) { + candidate = '$candidate\$'; + } + return candidate; + } +} + +void _writeEnum( + StringBuffer buffer, + EnumDescription enumDescription, + String typeName, +) { + final valueNames = _uniqueMemberNames(enumDescription.values); + + buffer + ..writeln('/// Postgres enum `${enumDescription.qualifiedName}`.') + ..writeln('enum $typeName {'); + for (final value in enumDescription.values) { + buffer.writeln(" ${valueNames[value]}(${_stringLiteral(value)}),"); + } + buffer + ..writeln(';') + ..writeln() + ..writeln(' const $typeName(this.wireName);') + ..writeln() + ..writeln(' /// The value as stored in the database.') + ..writeln(' final String wireName;') + ..writeln() + ..writeln(' /// Parses the database representation of the enum.') + ..writeln(' static $typeName fromWire(String wireName) =>') + ..writeln(' values.firstWhere((value) => value.wireName == wireName);') + ..writeln() + ..writeln(' @override') + ..writeln(' String toString() => wireName;') + ..writeln('}') + ..writeln(); +} + +void _writeTable( + StringBuffer buffer, + TableDescription table, + _TypeNameRegistry typeNames, + Map enumTypeNames, +) { + final baseName = pascalCase(table.name); + final rowType = typeNames.claim('${baseName}Row'); + final insertType = typeNames.claim('${baseName}Insert'); + final updateType = typeNames.claim('${baseName}Update'); + final namespaceType = typeNames.claim(baseName); + + final memberNames = _uniqueMemberNames([ + for (final column in table.columns) column.name, + ]); + final bindings = { + for (final column in table.columns) + column.name: _bindingFor(column, enumTypeNames), + }; + + _writeRow(buffer, table, rowType, memberNames, bindings); + _writeValues( + buffer, + table, + insertType, + memberNames, + bindings, + requireRequiredColumns: true, + docLine: + 'Values for inserting a row into `${table.name}`. Columns that are ' + 'nullable, part of a generated primary key, or covered by a database ' + 'default are optional; passing `null` omits the column so the ' + 'database default applies.', + ); + _writeValues( + buffer, + table, + updateType, + memberNames, + bindings, + requireRequiredColumns: false, + docLine: + 'Values for updating rows of `${table.name}`. All columns are ' + 'optional; passing `null` leaves the column unchanged.', + ); + _writeNamespace(buffer, table, namespaceType, rowType, memberNames, bindings); +} + +void _writeRow( + StringBuffer buffer, + TableDescription table, + String rowType, + Map memberNames, + Map bindings, +) { + buffer.writeln('/// A row of the `${table.name}` table.'); + _writeDocComment(buffer, table.comment); + buffer + ..writeln('extension type const $rowType(Map _json)') + ..writeln(' implements Map {'); + for (final column in table.columns) { + final binding = bindings[column.name]!; + _writeDocComment(buffer, column.comment, indent: ' '); + buffer.writeln( + ' ${_getterType(column, binding)} get ${memberNames[column.name]} => ' + '${_readExpression(column, binding)};', + ); + } + buffer + ..writeln('}') + ..writeln(); +} + +void _writeValues( + StringBuffer buffer, + TableDescription table, + String typeName, + Map memberNames, + Map bindings, { + required bool requireRequiredColumns, + required String docLine, +}) { + bool isRequired(ColumnDescription column) => + requireRequiredColumns && column.isRequired; + + buffer + ..writeln('/// $docLine') + ..writeln('extension type const $typeName._(Map _json)') + ..writeln(' implements Map {') + ..writeln(' $typeName({'); + for (final column in table.columns) { + final binding = bindings[column.name]!; + final name = memberNames[column.name]!; + if (isRequired(column)) { + buffer.writeln(' required ${binding.dartType} $name,'); + } else { + buffer.writeln(' ${binding.dartType}? $name,'); + } + } + buffer.writeln(' }) : this._({'); + for (final column in table.columns) { + final binding = bindings[column.name]!; + final name = memberNames[column.name]!; + final key = _stringLiteral(column.name); + if (isRequired(column)) { + buffer.writeln( + ' $key: ${_writeExpression(name, binding, nullable: false)},', + ); + } else { + buffer.writeln( + ' $key: ?${_writeExpression(name, binding, nullable: true)},', + ); + } + } + buffer + ..writeln(' });') + ..writeln('}') + ..writeln(); +} + +void _writeNamespace( + StringBuffer buffer, + TableDescription table, + String namespaceType, + String rowType, + Map memberNames, + Map bindings, +) { + final columnNames = _uniqueMemberNames( + [for (final column in table.columns) column.name], + reserved: {'table'}, + existing: memberNames, + ); + + buffer + ..writeln('/// Typed access to the `${table.name}` table.') + ..writeln('class $namespaceType {') + ..writeln(' const $namespaceType._();') + ..writeln() + ..writeln(' /// Table definition for [PostgrestClient.table].') + ..writeln( + ' static const table = PostgrestTable' + '(${_stringLiteral(table.name)}, $rowType.new);', + ) + ..writeln(); + for (final column in table.columns) { + final binding = bindings[column.name]!; + buffer.writeln( + ' static const ${columnNames[column.name]} = ' + 'TableColumn<${binding.dartType}>(${_stringLiteral(column.name)});', + ); + } + buffer + ..writeln('}') + ..writeln(); +} + +_Binding _bindingFor( + ColumnDescription column, + Map enumTypeNames, +) { + final format = column.postgresFormat; + + final enumTypeName = enumTypeNames[format]; + if (enumTypeName != null) { + return _Binding(enumTypeName, _Kind.enumType); + } + if (format.endsWith('[]')) { + return _Binding( + 'List<${_arrayElementType(column.arrayElementJsonType)}>', + _Kind.list, + ); + } + if (_integerFormats.contains(format)) { + return const _Binding('int', _Kind.direct); + } + if (_floatingFormats.contains(format)) { + return const _Binding('double', _Kind.floating); + } + if (_numericFormats.contains(format)) { + return const _Binding('num', _Kind.direct); + } + if (format == 'boolean') { + return const _Binding('bool', _Kind.direct); + } + if (_dateTimeFormats.contains(format)) { + return const _Binding('DateTime', _Kind.dateTime); + } + if (_jsonFormats.contains(format)) { + return const _Binding('Object', _Kind.json); + } + return switch (column.jsonType) { + 'integer' => const _Binding('int', _Kind.direct), + 'number' => const _Binding('num', _Kind.direct), + 'boolean' => const _Binding('bool', _Kind.direct), + 'string' => const _Binding('String', _Kind.direct), + _ => const _Binding('Object', _Kind.json), + }; +} + +String _arrayElementType(String? elementJsonType) => switch (elementJsonType) { + 'integer' => 'int', + 'number' => 'num', + 'boolean' => 'bool', + 'string' => 'String', + _ => 'Object', +}; + +String _getterType(ColumnDescription column, _Binding binding) { + if (binding.kind == _Kind.json) return 'Object?'; + return column.isNullable ? '${binding.dartType}?' : binding.dartType; +} + +String _readExpression(ColumnDescription column, _Binding binding) { + final access = "_json[${_stringLiteral(column.name)}]"; + final nullable = column.isNullable; + return switch (binding.kind) { + _Kind.direct => '$access as ${binding.dartType}${nullable ? '?' : ''}', + _Kind.floating => + nullable + ? '($access as num?)?.toDouble()' + : '($access as num).toDouble()', + _Kind.list => + nullable + ? '($access as List?)?.cast()' + : '($access as List).cast()', + _Kind.dateTime => + nullable + ? _nullableSwitch(access, 'DateTime.parse(value as String)') + : 'DateTime.parse($access as String)', + _Kind.enumType => + nullable + ? _nullableSwitch( + access, + '${binding.dartType}.fromWire(value as String)', + ) + : '${binding.dartType}.fromWire($access as String)', + _Kind.json => '$access as Object?', + }; +} + +String _nullableSwitch(String access, String conversion) => + 'switch ($access) { null => null, final Object value => $conversion }'; + +String _writeExpression( + String parameterName, + _Binding binding, { + required bool nullable, +}) { + final access = nullable ? '$parameterName?' : parameterName; + return switch (binding.kind) { + _Kind.dateTime => '$access.toIso8601String()', + _Kind.enumType => '$access.wireName', + _Kind.direct || _Kind.floating || _Kind.list || _Kind.json => parameterName, + }; +} + +/// Maps raw database names to unique Dart member identifiers. +/// +/// [reserved] seeds identifiers that must not be produced. When [existing] is +/// given, names are kept identical to it where possible so that, for example, +/// column tokens and row getters share their spelling. +Map _uniqueMemberNames( + List names, { + Set reserved = const {}, + Map? existing, +}) { + final used = {...reserved}; + final result = {}; + for (final name in names) { + var candidate = existing?[name] ?? memberIdentifier(name); + while (!used.add(candidate)) { + candidate = '$candidate\$'; + } + result[name] = candidate; + } + return result; +} + +void _writeDocComment( + StringBuffer buffer, + String? comment, { + String indent = '', +}) { + if (comment == null) return; + for (final line in comment.trim().split('\n')) { + buffer.writeln('$indent/// ${line.trim()}'); + } +} + +String _stringLiteral(String value) { + final escaped = value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$'); + return "'$escaped'"; +} diff --git a/packages/supabase_typegen/lib/src/identifiers.dart b/packages/supabase_typegen/lib/src/identifiers.dart new file mode 100644 index 000000000..130c435e5 --- /dev/null +++ b/packages/supabase_typegen/lib/src/identifiers.dart @@ -0,0 +1,132 @@ +const _reservedWords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'type', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; + +/// Members that already exist on `Map`, which generated row +/// extension types implement, so column getters cannot use these names. +const _mapMembers = { + 'addAll', + 'addEntries', + 'cast', + 'clear', + 'containsKey', + 'containsValue', + 'entries', + 'forEach', + 'hashCode', + 'isEmpty', + 'isNotEmpty', + 'keys', + 'length', + 'map', + 'noSuchMethod', + 'putIfAbsent', + 'remove', + 'removeWhere', + 'runtimeType', + 'toString', + 'update', + 'updateAll', + 'values', +}; + +final _wordSeparator = RegExp('[^a-zA-Z0-9]+'); + +List _words(String name) => + name.split(_wordSeparator).where((word) => word.isNotEmpty).toList(); + +/// Converts [name] to PascalCase, for example `author_stats` to +/// `AuthorStats`. +String pascalCase(String name) { + final words = _words(name); + if (words.isEmpty) return r'$'; + final pascal = [ + for (final word in words) + word[0].toUpperCase() + word.substring(1).toLowerCase(), + ].join(); + return pascal.startsWith(RegExp('[0-9]')) ? '\$$pascal' : pascal; +} + +/// Converts [name] to camelCase, for example `created_at` to `createdAt`. +String camelCase(String name) { + final pascal = pascalCase(name); + return pascal[0].toLowerCase() + pascal.substring(1); +} + +/// Converts [name] to a valid Dart member identifier in camelCase. +/// +/// Reserved words and members that would collide with `Map` +/// get a `$` suffix, for example `class` becomes `class$`. +String memberIdentifier(String name) { + final identifier = camelCase(name); + if (_reservedWords.contains(identifier) || _mapMembers.contains(identifier)) { + return '$identifier\$'; + } + return identifier; +} diff --git a/packages/supabase_typegen/lib/src/openapi_parser.dart b/packages/supabase_typegen/lib/src/openapi_parser.dart new file mode 100644 index 000000000..3a7b838d8 --- /dev/null +++ b/packages/supabase_typegen/lib/src/openapi_parser.dart @@ -0,0 +1,105 @@ +import 'schema_description.dart'; + +final _foreignKeyPattern = RegExp(""); + +/// Parses the OpenAPI (Swagger 2.0) document that PostgREST serves at the +/// API root into a [SchemaDescription]. +/// +/// PostgREST encodes primary keys and foreign keys as `` and +/// `` markers inside column descriptions, and +/// lists `NOT NULL` columns without a database default under `required`. +SchemaDescription parseOpenApiDocument( + Map document, { + String schemaName = 'public', +}) { + final definitions = + document['definitions'] as Map? ?? const {}; + + final tables = []; + final enumsByQualifiedName = {}; + + for (final MapEntry(key: tableName, value: definition) + in definitions.entries) { + definition as Map; + final required = { + ...?(definition['required'] as List?)?.cast(), + }; + final properties = + definition['properties'] as Map? ?? const {}; + + final columns = []; + for (final MapEntry(key: columnName, value: property) + in properties.entries) { + property as Map; + final description = property['description'] as String?; + final format = property['format'] as String? ?? ''; + final enumValues = (property['enum'] as List?)?.cast(); + + if (enumValues != null) { + enumsByQualifiedName.putIfAbsent( + format, + () => EnumDescription(qualifiedName: format, values: enumValues), + ); + } + + final foreignKeyMatch = description == null + ? null + : _foreignKeyPattern.firstMatch(description); + + columns.add( + ColumnDescription( + name: columnName, + postgresFormat: format, + jsonType: property['type'] as String? ?? '', + arrayElementJsonType: + (property['items'] as Map?)?['type'] as String?, + enumValues: enumValues, + isRequired: required.contains(columnName), + isPrimaryKey: description?.contains('') ?? false, + hasDefault: property.containsKey('default'), + comment: _cleanComment(description), + foreignKey: foreignKeyMatch == null + ? null + : ForeignKeyDescription( + table: foreignKeyMatch.group(1)!, + column: foreignKeyMatch.group(2)!, + ), + ), + ); + } + + tables.add( + TableDescription( + name: tableName, + comment: _cleanComment(definition['description'] as String?), + columns: columns, + ), + ); + } + + tables.sort((a, b) => a.name.compareTo(b.name)); + final enums = enumsByQualifiedName.values.toList() + ..sort((a, b) => a.qualifiedName.compareTo(b.qualifiedName)); + + return SchemaDescription( + schemaName: schemaName, + tables: tables, + enums: enums, + ); +} + +/// Strips the PostgREST key markers from a column or table description, +/// keeping only the human written comment. +String? _cleanComment(String? description) { + if (description == null) return null; + final cleaned = description + .replaceAll(_foreignKeyPattern, '') + .replaceAll('', '') + .replaceAll(RegExp(r'Note:\s*'), '') + .replaceAll( + RegExp(r'This is a (Primary|Foreign) Key( to `[^`]+`)?\.'), + '', + ) + .trim(); + return cleaned.isEmpty ? null : cleaned; +} diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart new file mode 100644 index 000000000..f986acae1 --- /dev/null +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -0,0 +1,117 @@ +/// Description of a single database schema, the input to the code generator. +class SchemaDescription { + const SchemaDescription({ + required this.schemaName, + required this.tables, + required this.enums, + }); + + /// Name of the database schema, for example `public`. + final String schemaName; + + /// Tables and views of the schema, sorted by name. + final List tables; + + /// Postgres enums referenced by the tables, sorted by name. + final List enums; +} + +/// Description of a table or view. +class TableDescription { + const TableDescription({ + required this.name, + required this.columns, + this.comment, + }); + + /// Name of the table in the database. + final String name; + + /// The table comment, when one is set. + final String? comment; + + /// Columns of the table, in database order. + final List columns; +} + +/// Description of a single table column. +class ColumnDescription { + const ColumnDescription({ + required this.name, + required this.postgresFormat, + required this.jsonType, + required this.isRequired, + required this.isPrimaryKey, + required this.hasDefault, + this.arrayElementJsonType, + this.enumValues, + this.foreignKey, + this.comment, + }); + + /// Name of the column in the database. + final String name; + + /// The Postgres type, for example `bigint`, `text[]` or `public.mood`. + final String postgresFormat; + + /// The JSON schema type, for example `integer` or `string`. + final String jsonType; + + /// The JSON schema type of the array elements for array columns. + final String? arrayElementJsonType; + + /// The values of the Postgres enum for enum columns. + final List? enumValues; + + /// Whether the column is `NOT NULL` without a database default, which makes + /// it required on insert. + final bool isRequired; + + /// Whether the column is part of the primary key. + final bool isPrimaryKey; + + /// Whether the column has a database default. + final bool hasDefault; + + /// The column comment, when one is set. + final String? comment; + + /// The referenced table and column for foreign key columns. + final ForeignKeyDescription? foreignKey; + + /// Whether the column can be `null` in query results. + /// + /// Derived from the OpenAPI description: columns in the `required` list are + /// `NOT NULL`, and primary keys are always `NOT NULL`. Other columns are + /// treated as nullable, which is safe but over-approximates for `NOT NULL` + /// columns that have a database default. + bool get isNullable => !isRequired && !isPrimaryKey; +} + +/// The target of a foreign key column. +class ForeignKeyDescription { + const ForeignKeyDescription({required this.table, required this.column}); + + /// The referenced table. + final String table; + + /// The referenced column. + final String column; +} + +/// Description of a Postgres enum type. +class EnumDescription { + const EnumDescription({required this.qualifiedName, required this.values}); + + /// The schema-qualified name of the enum, for example `public.mood`. + final String qualifiedName; + + /// The values of the enum, in declaration order. + final List values; + + /// The enum name without the schema qualifier. + String get name => qualifiedName.contains('.') + ? qualifiedName.split('.').last + : qualifiedName; +} diff --git a/packages/supabase_typegen/lib/supabase_typegen.dart b/packages/supabase_typegen/lib/supabase_typegen.dart index 4b0d261bf..1373314f0 100644 --- a/packages/supabase_typegen/lib/supabase_typegen.dart +++ b/packages/supabase_typegen/lib/supabase_typegen.dart @@ -1,6 +1,8 @@ -/// Command-line code generator that turns a Supabase database schema into -/// typed Dart table definitions. -/// -/// This is a placeholder release that reserves the package name. The generator -/// implementation is not available yet. +/// Generates typed Supabase table definitions, row extension types and +/// column tokens from a database schema. library; + +export 'src/dart_generator.dart'; +export 'src/identifiers.dart'; +export 'src/openapi_parser.dart'; +export 'src/schema_description.dart'; diff --git a/packages/supabase_typegen/pubspec.yaml b/packages/supabase_typegen/pubspec.yaml index 28d63da94..657ceed93 100644 --- a/packages/supabase_typegen/pubspec.yaml +++ b/packages/supabase_typegen/pubspec.yaml @@ -14,6 +14,15 @@ environment: resolution: workspace +executables: + supabase_typegen: + +dependencies: + args: ^2.7.0 + dart_style: ^3.1.0 + http: ^1.6.0 + dev_dependencies: + postgrest: ^2.9.0 supabase_lints: ^0.1.1 test: ^1.25.0 diff --git a/packages/supabase_typegen/test/dart_generator_test.dart b/packages/supabase_typegen/test/dart_generator_test.dart new file mode 100644 index 000000000..82ddbc115 --- /dev/null +++ b/packages/supabase_typegen/test/dart_generator_test.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase_typegen/supabase_typegen.dart'; +import 'package:test/test.dart'; + +final _whitespace = RegExp(r'\s+'); + +/// Collapses whitespace so the comparison is stable across formatter +/// versions; `tool/regenerate_goldens.dart` refreshes the golden. +String _normalize(String code) => code.replaceAll(_whitespace, ' ').trim(); + +void main() { + late SchemaDescription schema; + + setUpAll(() { + final document = + jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + as Map; + schema = parseOpenApiDocument(document); + }); + + test('matches the golden output', () { + final golden = File('test/goldens/supabase_schema.dart').readAsStringSync(); + + expect( + _normalize(generateDartCode(schema)), + _normalize(golden), + reason: + 'The generator output changed. Regenerate the golden with ' + '`dart run tool/regenerate_goldens.dart` and review the diff.', + ); + }); + + test('respects a custom import', () { + final code = generateDartCode( + schema, + importUri: 'package:supabase_flutter/supabase_flutter.dart', + ); + + expect( + code, + contains("import 'package:supabase_flutter/supabase_flutter.dart';"), + ); + }); + + test('marks not null columns without default as required on insert', () { + final code = generateDartCode(schema); + + expect(code, contains('required String title')); + expect(code, contains('int? id')); + }); +} diff --git a/packages/supabase_typegen/test/fixtures/openapi.json b/packages/supabase_typegen/test/fixtures/openapi.json new file mode 100644 index 000000000..a64a325ad --- /dev/null +++ b/packages/supabase_typegen/test/fixtures/openapi.json @@ -0,0 +1,111 @@ +{ + "swagger": "2.0", + "info": { + "title": "PostgREST API", + "description": "standard public schema", + "version": "12.2.0" + }, + "definitions": { + "books": { + "description": "Books available in the library", + "required": ["title", "author_id"], + "properties": { + "id": { + "description": "Note:\nThis is a Primary Key.", + "format": "bigint", + "type": "integer", + "default": "nextval('books_id_seq'::regclass)" + }, + "title": { + "format": "text", + "type": "string" + }, + "author_id": { + "description": "Note:\nThis is a Foreign Key to `authors.id`.", + "format": "bigint", + "type": "integer" + }, + "price": { + "format": "numeric", + "type": "number" + }, + "rating": { + "format": "double precision", + "type": "number" + }, + "in_print": { + "format": "boolean", + "type": "boolean", + "default": true + }, + "mood": { + "enum": ["happy", "very happy", "sad"], + "format": "public.mood", + "type": "string" + }, + "tags": { + "format": "text[]", + "type": "array", + "items": { + "type": "string" + } + }, + "page_counts": { + "format": "integer[]", + "type": "array", + "items": { + "type": "integer" + } + }, + "metadata": { + "format": "jsonb" + }, + "cover_uuid": { + "format": "uuid", + "type": "string" + }, + "published_on": { + "format": "date", + "type": "string" + }, + "created_at": { + "description": "When the row was created", + "format": "timestamp with time zone", + "type": "string", + "default": "now()" + } + }, + "type": "object" + }, + "authors": { + "required": ["id", "name"], + "properties": { + "id": { + "description": "Note:\nThis is a Primary Key.", + "format": "bigint", + "type": "integer" + }, + "name": { + "format": "text", + "type": "string" + } + }, + "type": "object" + }, + "author_stats": { + "description": "Aggregated statistics per author", + "properties": { + "author_id": { + "format": "bigint", + "type": "integer" + }, + "book_count": { + "format": "bigint", + "type": "integer" + } + }, + "type": "object" + } + }, + "paths": {} +} diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart new file mode 100644 index 000000000..512606b95 --- /dev/null +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +import 'goldens/supabase_schema.dart'; + +class MockHttpClient extends BaseClient { + String responseBody = '[]'; + 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)), + 200, + headers: {'content-type': 'application/json'}, + request: request, + ); + } +} + +void main() { + late MockHttpClient httpClient; + late PostgrestClient client; + + setUp(() { + httpClient = MockHttpClient(); + client = PostgrestClient( + 'http://localhost/rest/v1', + httpClient: httpClient, + ); + }); + + tearDown(() async { + await client.dispose(); + }); + + test('select returns typed rows with converted values', () async { + httpClient.responseBody = jsonEncode([ + { + 'id': 1, + 'title': 'A typed row', + 'author_id': 7, + 'price': 12.5, + 'rating': 4, + 'mood': 'very happy', + 'tags': ['dart', 'types'], + 'metadata': {'reprint': true}, + 'created_at': '2026-07-23T10:00:00Z', + 'published_on': null, + }, + ]); + + final List books = await client.table(Books.table).select(); + + final book = books.single; + expect(book.id, 1); + expect(book.title, 'A typed row'); + expect(book.rating, 4.0); + expect(book.mood, Mood.veryHappy); + expect(book.tags, ['dart', 'types']); + expect(book.metadata, {'reprint': true}); + expect(book.createdAt, DateTime.utc(2026, 7, 23, 10)); + expect(book.publishedOn == null, isTrue); + }); + + test('enum column tokens filter with the wire name', () async { + await client.table(Books.table).select().where(Books.mood.eq(Mood.happy)); + + expect( + httpClient.lastRequest!.url.queryParameters['mood'], + 'eq.happy', + ); + }); + + test('insert sends converted values and omits absent columns', () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .insert( + BooksInsert( + title: 'A typed row', + authorId: 7, + mood: Mood.happy, + createdAt: DateTime.utc(2026, 7, 23, 10), + ), + ); + + final sent = + jsonDecode(httpClient.lastRequestBody!) as Map; + expect(sent, { + 'title': 'A typed row', + 'author_id': 7, + 'mood': 'happy', + 'created_at': '2026-07-23T10:00:00.000Z', + }); + }); + + test('update sends only the provided columns', () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .update(BooksUpdate(inPrint: false)) + .where(Books.id.eq(1)); + + expect(jsonDecode(httpClient.lastRequestBody!), {'in_print': false}); + expect(httpClient.lastRequest!.url.queryParameters['id'], 'eq.1'); + }); +} diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart new file mode 100644 index 000000000..d2add44cb --- /dev/null +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -0,0 +1,208 @@ +// Generated by supabase_typegen. Do not edit by hand. +// +// Source schema: public + +import 'package:postgrest/postgrest.dart'; + +/// Postgres enum `public.mood`. +enum Mood { + happy('happy'), + veryHappy('very happy'), + sad('sad'); + + const Mood(this.wireName); + + /// The value as stored in the database. + final String wireName; + + /// Parses the database representation of the enum. + static Mood fromWire(String wireName) => + values.firstWhere((value) => value.wireName == wireName); + + @override + String toString() => wireName; +} + +/// A row of the `author_stats` table. +/// Aggregated statistics per author +extension type const AuthorStatsRow(Map _json) + implements Map { + int? get authorId => _json['author_id'] as int?; + int? get bookCount => _json['book_count'] as int?; +} + +/// Values for inserting a row into `author_stats`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +extension type const AuthorStatsInsert._(Map _json) + implements Map { + AuthorStatsInsert({int? authorId, int? bookCount}) + : this._({'author_id': ?authorId, 'book_count': ?bookCount}); +} + +/// Values for updating rows of `author_stats`. All columns are optional; passing `null` leaves the column unchanged. +extension type const AuthorStatsUpdate._(Map _json) + implements Map { + AuthorStatsUpdate({int? authorId, int? bookCount}) + : this._({'author_id': ?authorId, 'book_count': ?bookCount}); +} + +/// Typed access to the `author_stats` table. +class AuthorStats { + const AuthorStats._(); + + /// Table definition for [PostgrestClient.table]. + static const table = PostgrestTable('author_stats', AuthorStatsRow.new); + + static const authorId = TableColumn('author_id'); + static const bookCount = TableColumn('book_count'); +} + +/// A row of the `authors` table. +extension type const AuthorsRow(Map _json) + implements Map { + int get id => _json['id'] as int; + String get name => _json['name'] as String; +} + +/// Values for inserting a row into `authors`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +extension type const AuthorsInsert._(Map _json) + implements Map { + AuthorsInsert({required int id, required String name}) + : this._({'id': id, 'name': name}); +} + +/// Values for updating rows of `authors`. All columns are optional; passing `null` leaves the column unchanged. +extension type const AuthorsUpdate._(Map _json) + implements Map { + AuthorsUpdate({int? id, String? name}) : this._({'id': ?id, 'name': ?name}); +} + +/// Typed access to the `authors` table. +class Authors { + const Authors._(); + + /// Table definition for [PostgrestClient.table]. + static const table = PostgrestTable('authors', AuthorsRow.new); + + static const id = TableColumn('id'); + static const name = TableColumn('name'); +} + +/// A row of the `books` table. +/// Books available in the library +extension type const BooksRow(Map _json) + implements Map { + int get id => _json['id'] as int; + String get title => _json['title'] as String; + int get authorId => _json['author_id'] as int; + num? get price => _json['price'] as num?; + double? get rating => (_json['rating'] as num?)?.toDouble(); + bool? get inPrint => _json['in_print'] as bool?; + Mood? get mood => switch (_json['mood']) { + null => null, + final Object value => Mood.fromWire(value as String), + }; + List? get tags => (_json['tags'] as List?)?.cast(); + List? get pageCounts => (_json['page_counts'] as List?)?.cast(); + Object? get metadata => _json['metadata'] as Object?; + String? get coverUuid => _json['cover_uuid'] as String?; + DateTime? get publishedOn => switch (_json['published_on']) { + null => null, + final Object value => DateTime.parse(value as String), + }; + + /// When the row was created + DateTime? get createdAt => switch (_json['created_at']) { + null => null, + final Object value => DateTime.parse(value as String), + }; +} + +/// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +extension type const BooksInsert._(Map _json) + implements Map { + BooksInsert({ + int? id, + required String title, + required int authorId, + num? price, + double? rating, + bool? inPrint, + Mood? mood, + List? tags, + List? pageCounts, + Object? metadata, + String? coverUuid, + DateTime? publishedOn, + DateTime? createdAt, + }) : this._({ + 'id': ?id, + 'title': title, + 'author_id': authorId, + 'price': ?price, + 'rating': ?rating, + 'in_print': ?inPrint, + 'mood': ?mood?.wireName, + 'tags': ?tags, + 'page_counts': ?pageCounts, + 'metadata': ?metadata, + 'cover_uuid': ?coverUuid, + 'published_on': ?publishedOn?.toIso8601String(), + 'created_at': ?createdAt?.toIso8601String(), + }); +} + +/// Values for updating rows of `books`. All columns are optional; passing `null` leaves the column unchanged. +extension type const BooksUpdate._(Map _json) + implements Map { + BooksUpdate({ + int? id, + String? title, + int? authorId, + num? price, + double? rating, + bool? inPrint, + Mood? mood, + List? tags, + List? pageCounts, + Object? metadata, + String? coverUuid, + DateTime? publishedOn, + DateTime? createdAt, + }) : this._({ + 'id': ?id, + 'title': ?title, + 'author_id': ?authorId, + 'price': ?price, + 'rating': ?rating, + 'in_print': ?inPrint, + 'mood': ?mood?.wireName, + 'tags': ?tags, + 'page_counts': ?pageCounts, + 'metadata': ?metadata, + 'cover_uuid': ?coverUuid, + 'published_on': ?publishedOn?.toIso8601String(), + 'created_at': ?createdAt?.toIso8601String(), + }); +} + +/// Typed access to the `books` table. +class Books { + const Books._(); + + /// Table definition for [PostgrestClient.table]. + static const table = PostgrestTable('books', BooksRow.new); + + static const id = TableColumn('id'); + static const title = TableColumn('title'); + static const authorId = TableColumn('author_id'); + static const price = TableColumn('price'); + static const rating = TableColumn('rating'); + static const inPrint = TableColumn('in_print'); + static const mood = TableColumn('mood'); + static const tags = TableColumn>('tags'); + static const pageCounts = TableColumn>('page_counts'); + static const metadata = TableColumn('metadata'); + static const coverUuid = TableColumn('cover_uuid'); + static const publishedOn = TableColumn('published_on'); + static const createdAt = TableColumn('created_at'); +} diff --git a/packages/supabase_typegen/test/identifiers_test.dart b/packages/supabase_typegen/test/identifiers_test.dart new file mode 100644 index 000000000..6907f96bb --- /dev/null +++ b/packages/supabase_typegen/test/identifiers_test.dart @@ -0,0 +1,40 @@ +import 'package:supabase_typegen/supabase_typegen.dart'; +import 'package:test/test.dart'; + +void main() { + group('pascalCase', () { + test('converts snake case names', () { + expect(pascalCase('author_stats'), 'AuthorStats'); + expect(pascalCase('books'), 'Books'); + expect(pascalCase('user-profiles'), 'UserProfiles'); + }); + + test('prefixes names starting with a digit', () { + expect(pascalCase('2fa_codes'), r'$2faCodes'); + }); + }); + + group('camelCase', () { + test('converts snake case names', () { + expect(camelCase('created_at'), 'createdAt'); + expect(camelCase('id'), 'id'); + }); + }); + + group('memberIdentifier', () { + test('suffixes reserved words', () { + expect(memberIdentifier('class'), r'class$'); + expect(memberIdentifier('in'), r'in$'); + }); + + test('suffixes Map member names', () { + expect(memberIdentifier('length'), r'length$'); + expect(memberIdentifier('keys'), r'keys$'); + }); + + test('keeps regular names untouched', () { + expect(memberIdentifier('title'), 'title'); + expect(memberIdentifier('author_id'), 'authorId'); + }); + }); +} diff --git a/packages/supabase_typegen/test/openapi_parser_test.dart b/packages/supabase_typegen/test/openapi_parser_test.dart new file mode 100644 index 000000000..44f5bf5e3 --- /dev/null +++ b/packages/supabase_typegen/test/openapi_parser_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase_typegen/supabase_typegen.dart'; +import 'package:test/test.dart'; + +void main() { + late SchemaDescription schema; + + setUpAll(() { + final document = + jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + as Map; + schema = parseOpenApiDocument(document); + }); + + test('parses all tables sorted by name', () { + expect(schema.tables.map((table) => table.name), [ + 'author_stats', + 'authors', + 'books', + ]); + }); + + test('parses table comments', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + expect(books.comment, 'Books available in the library'); + }); + + test('parses primary keys, requiredness and defaults', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final id = books.columns.singleWhere((column) => column.name == 'id'); + expect(id.isPrimaryKey, isTrue); + expect(id.isRequired, isFalse); + expect(id.hasDefault, isTrue); + expect(id.isNullable, isFalse); + + final title = books.columns.singleWhere((column) => column.name == 'title'); + expect(title.isRequired, isTrue); + expect(title.isNullable, isFalse); + + final price = books.columns.singleWhere((column) => column.name == 'price'); + expect(price.isRequired, isFalse); + expect(price.isNullable, isTrue); + }); + + test('parses foreign keys', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final authorId = books.columns.singleWhere( + (column) => column.name == 'author_id', + ); + expect(authorId.foreignKey?.table, 'authors'); + expect(authorId.foreignKey?.column, 'id'); + }); + + test('collects Postgres enums', () { + expect(schema.enums, hasLength(1)); + final mood = schema.enums.single; + expect(mood.qualifiedName, 'public.mood'); + expect(mood.name, 'mood'); + expect(mood.values, ['happy', 'very happy', 'sad']); + }); + + test('parses array columns', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final tags = books.columns.singleWhere((column) => column.name == 'tags'); + expect(tags.postgresFormat, 'text[]'); + expect(tags.arrayElementJsonType, 'string'); + }); + + test('keeps human column comments without the key markers', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final id = books.columns.singleWhere((column) => column.name == 'id'); + expect(id.comment, isNull); + + final createdAt = books.columns.singleWhere( + (column) => column.name == 'created_at', + ); + expect(createdAt.comment, 'When the row was created'); + }); +} diff --git a/packages/supabase_typegen/test/supabase_typegen_test.dart b/packages/supabase_typegen/test/supabase_typegen_test.dart deleted file mode 100644 index 7ff71c15d..000000000 --- a/packages/supabase_typegen/test/supabase_typegen_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:test/test.dart'; - -void main() { - test('supabase_typegen placeholder', () { - expect(true, isTrue); - }); -} diff --git a/packages/supabase_typegen/tool/regenerate_goldens.dart b/packages/supabase_typegen/tool/regenerate_goldens.dart new file mode 100644 index 000000000..9d453d5dd --- /dev/null +++ b/packages/supabase_typegen/tool/regenerate_goldens.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase_typegen/supabase_typegen.dart'; + +/// Regenerates the golden files under `test/goldens` from the fixtures. +/// +/// Run from the package root with `dart run tool/regenerate_goldens.dart`. +void main() { + final document = + jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + as Map; + final schema = parseOpenApiDocument(document); + File( + 'test/goldens/supabase_schema.dart', + ).writeAsStringSync(generateDartCode(schema)); +} From df80c5de1b142f325a2b1a106d6ee3e84e1b82af Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 09:16:48 +0200 Subject: [PATCH 08/25] chore: trigger CI From c0e83b0c092c134ddb5bc7e34c65c19218f1cf03 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 09:56:42 +0200 Subject: [PATCH 09/25] refactor: model column type kinds as an enum in the schema description --- .../lib/src/dart_generator.dart | 135 ++++++++---------- .../lib/src/openapi_parser.dart | 67 ++++++++- .../lib/src/schema_description.dart | 49 ++++++- .../test/openapi_parser_test.dart | 19 ++- 4 files changed, 182 insertions(+), 88 deletions(-) diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index 12492285b..ac3e27df5 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -3,35 +3,14 @@ import 'package:dart_style/dart_style.dart'; import 'identifiers.dart'; import 'schema_description.dart'; -enum _Kind { direct, floating, dateTime, list, enumType, json } - class _Binding { const _Binding(this.dartType, this.kind); /// The non-nullable Dart type of the column. final String dartType; - final _Kind kind; + final ColumnTypeKind kind; } -const _integerFormats = { - 'smallint', - 'integer', - 'bigint', - 'int2', - 'int4', - 'int8', -}; -const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; -const _numericFormats = {'numeric', 'decimal'}; -const _dateTimeFormats = { - 'date', - 'timestamp', - 'timestamp without time zone', - 'timestamp with time zone', - 'timestamptz', -}; -const _jsonFormats = {'json', 'jsonb'}; - /// Generates a Dart source file with typed table definitions, row extension /// types, insert and update value types, column tokens and Postgres enums for /// [schema]. @@ -274,56 +253,45 @@ void _writeNamespace( _Binding _bindingFor( ColumnDescription column, Map enumTypeNames, -) { - final format = column.postgresFormat; - - final enumTypeName = enumTypeNames[format]; - if (enumTypeName != null) { - return _Binding(enumTypeName, _Kind.enumType); - } - if (format.endsWith('[]')) { - return _Binding( - 'List<${_arrayElementType(column.arrayElementJsonType)}>', - _Kind.list, - ); - } - if (_integerFormats.contains(format)) { - return const _Binding('int', _Kind.direct); - } - if (_floatingFormats.contains(format)) { - return const _Binding('double', _Kind.floating); - } - if (_numericFormats.contains(format)) { - return const _Binding('num', _Kind.direct); - } - if (format == 'boolean') { - return const _Binding('bool', _Kind.direct); - } - if (_dateTimeFormats.contains(format)) { - return const _Binding('DateTime', _Kind.dateTime); - } - if (_jsonFormats.contains(format)) { - return const _Binding('Object', _Kind.json); - } - return switch (column.jsonType) { - 'integer' => const _Binding('int', _Kind.direct), - 'number' => const _Binding('num', _Kind.direct), - 'boolean' => const _Binding('bool', _Kind.direct), - 'string' => const _Binding('String', _Kind.direct), - _ => const _Binding('Object', _Kind.json), - }; -} - -String _arrayElementType(String? elementJsonType) => switch (elementJsonType) { - 'integer' => 'int', - 'number' => 'num', - 'boolean' => 'bool', - 'string' => 'String', - _ => 'Object', +) => switch (column.typeKind) { + ColumnTypeKind.enumType => _Binding( + enumTypeNames[column.postgresFormat]!, + ColumnTypeKind.enumType, + ), + ColumnTypeKind.array => _Binding( + 'List<${_elementDartType(column.elementTypeKind)}>', + ColumnTypeKind.array, + ), + ColumnTypeKind.integer => const _Binding('int', ColumnTypeKind.integer), + ColumnTypeKind.floating => const _Binding('double', ColumnTypeKind.floating), + ColumnTypeKind.numeric => const _Binding('num', ColumnTypeKind.numeric), + ColumnTypeKind.boolean => const _Binding('bool', ColumnTypeKind.boolean), + ColumnTypeKind.dateTime => const _Binding( + 'DateTime', + ColumnTypeKind.dateTime, + ), + ColumnTypeKind.text => const _Binding('String', ColumnTypeKind.text), + ColumnTypeKind.json || + ColumnTypeKind.unknown => const _Binding('Object', ColumnTypeKind.json), }; +String _elementDartType(ColumnTypeKind? elementTypeKind) => + switch (elementTypeKind) { + ColumnTypeKind.integer => 'int', + ColumnTypeKind.floating => 'double', + ColumnTypeKind.numeric => 'num', + ColumnTypeKind.boolean => 'bool', + ColumnTypeKind.text => 'String', + ColumnTypeKind.dateTime || + ColumnTypeKind.json || + ColumnTypeKind.enumType || + ColumnTypeKind.array || + ColumnTypeKind.unknown || + null => 'Object', + }; + String _getterType(ColumnDescription column, _Binding binding) { - if (binding.kind == _Kind.json) return 'Object?'; + if (binding.kind == ColumnTypeKind.json) return 'Object?'; return column.isNullable ? '${binding.dartType}?' : binding.dartType; } @@ -331,27 +299,31 @@ String _readExpression(ColumnDescription column, _Binding binding) { final access = "_json[${_stringLiteral(column.name)}]"; final nullable = column.isNullable; return switch (binding.kind) { - _Kind.direct => '$access as ${binding.dartType}${nullable ? '?' : ''}', - _Kind.floating => + ColumnTypeKind.integer || + ColumnTypeKind.numeric || + ColumnTypeKind.boolean || + ColumnTypeKind.text => + '$access as ${binding.dartType}${nullable ? '?' : ''}', + ColumnTypeKind.floating => nullable ? '($access as num?)?.toDouble()' : '($access as num).toDouble()', - _Kind.list => + ColumnTypeKind.array => nullable ? '($access as List?)?.cast()' : '($access as List).cast()', - _Kind.dateTime => + ColumnTypeKind.dateTime => nullable ? _nullableSwitch(access, 'DateTime.parse(value as String)') : 'DateTime.parse($access as String)', - _Kind.enumType => + ColumnTypeKind.enumType => nullable ? _nullableSwitch( access, '${binding.dartType}.fromWire(value as String)', ) : '${binding.dartType}.fromWire($access as String)', - _Kind.json => '$access as Object?', + ColumnTypeKind.json || ColumnTypeKind.unknown => '$access as Object?', }; } @@ -365,9 +337,16 @@ String _writeExpression( }) { final access = nullable ? '$parameterName?' : parameterName; return switch (binding.kind) { - _Kind.dateTime => '$access.toIso8601String()', - _Kind.enumType => '$access.wireName', - _Kind.direct || _Kind.floating || _Kind.list || _Kind.json => parameterName, + ColumnTypeKind.dateTime => '$access.toIso8601String()', + ColumnTypeKind.enumType => '$access.wireName', + ColumnTypeKind.integer || + ColumnTypeKind.floating || + ColumnTypeKind.numeric || + ColumnTypeKind.boolean || + ColumnTypeKind.text || + ColumnTypeKind.array || + ColumnTypeKind.json || + ColumnTypeKind.unknown => parameterName, }; } diff --git a/packages/supabase_typegen/lib/src/openapi_parser.dart b/packages/supabase_typegen/lib/src/openapi_parser.dart index 3a7b838d8..0b37d5b20 100644 --- a/packages/supabase_typegen/lib/src/openapi_parser.dart +++ b/packages/supabase_typegen/lib/src/openapi_parser.dart @@ -2,6 +2,62 @@ import 'schema_description.dart'; final _foreignKeyPattern = RegExp(""); +const _integerFormats = { + 'smallint', + 'integer', + 'bigint', + 'int2', + 'int4', + 'int8', +}; +const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; +const _numericFormats = {'numeric', 'decimal'}; +const _dateTimeFormats = { + 'date', + 'timestamp', + 'timestamp without time zone', + 'timestamp with time zone', + 'timestamptz', +}; +const _jsonFormats = {'json', 'jsonb'}; + +/// Derives the [ColumnTypeKind] from the Postgres [format] and JSON schema +/// [jsonType] of a column. This is the single place where type names are +/// compared as strings; everything downstream works with the enum. +ColumnTypeKind _typeKind({ + required String format, + required String? jsonType, + required bool isEnum, +}) { + if (isEnum) return ColumnTypeKind.enumType; + if (format.endsWith('[]') || jsonType == 'array') { + return ColumnTypeKind.array; + } + if (_integerFormats.contains(format)) return ColumnTypeKind.integer; + if (_floatingFormats.contains(format)) return ColumnTypeKind.floating; + if (_numericFormats.contains(format)) return ColumnTypeKind.numeric; + if (format == 'boolean') return ColumnTypeKind.boolean; + if (_dateTimeFormats.contains(format)) return ColumnTypeKind.dateTime; + if (_jsonFormats.contains(format)) return ColumnTypeKind.json; + return switch (jsonType) { + 'integer' => ColumnTypeKind.integer, + 'number' => ColumnTypeKind.numeric, + 'boolean' => ColumnTypeKind.boolean, + 'string' => ColumnTypeKind.text, + _ => ColumnTypeKind.unknown, + }; +} + +ColumnTypeKind? _elementTypeKind(String? itemsJsonType) => itemsJsonType == null + ? null + : switch (itemsJsonType) { + 'integer' => ColumnTypeKind.integer, + 'number' => ColumnTypeKind.numeric, + 'boolean' => ColumnTypeKind.boolean, + 'string' => ColumnTypeKind.text, + _ => ColumnTypeKind.unknown, + }; + /// Parses the OpenAPI (Swagger 2.0) document that PostgREST serves at the /// API root into a [SchemaDescription]. /// @@ -50,9 +106,14 @@ SchemaDescription parseOpenApiDocument( ColumnDescription( name: columnName, postgresFormat: format, - jsonType: property['type'] as String? ?? '', - arrayElementJsonType: - (property['items'] as Map?)?['type'] as String?, + typeKind: _typeKind( + format: format, + jsonType: property['type'] as String?, + isEnum: enumValues != null, + ), + elementTypeKind: _elementTypeKind( + (property['items'] as Map?)?['type'] as String?, + ), enumValues: enumValues, isRequired: required.contains(columnName), isPrimaryKey: description?.contains('') ?? false, diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart index f986acae1..e94c64650 100644 --- a/packages/supabase_typegen/lib/src/schema_description.dart +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -1,3 +1,39 @@ +/// The Dart-relevant type of a column, derived from the Postgres type at +/// parse time so that later stages never have to compare type name strings. +enum ColumnTypeKind { + /// Whole number types such as `smallint`, `integer` and `bigint`. + integer, + + /// Floating point types such as `real` and `double precision`. + floating, + + /// Arbitrary precision types such as `numeric`, mapped to `num` since the + /// decoded JSON value may be either an integer or a double. + numeric, + + /// The `boolean` type. + boolean, + + /// Date and timestamp types, mapped to `DateTime`. + dateTime, + + /// Types carried as text, such as `text`, `uuid` and `character varying`. + text, + + /// The `json` and `jsonb` types, mapped to `Object?`. + json, + + /// A Postgres enum type. + enumType, + + /// An array type; the element type is in + /// [ColumnDescription.elementTypeKind]. + array, + + /// A type without a specific mapping, treated like [json]. + unknown, +} + /// Description of a single database schema, the input to the code generator. class SchemaDescription { const SchemaDescription({ @@ -39,11 +75,11 @@ class ColumnDescription { const ColumnDescription({ required this.name, required this.postgresFormat, - required this.jsonType, + required this.typeKind, required this.isRequired, required this.isPrimaryKey, required this.hasDefault, - this.arrayElementJsonType, + this.elementTypeKind, this.enumValues, this.foreignKey, this.comment, @@ -55,11 +91,12 @@ class ColumnDescription { /// The Postgres type, for example `bigint`, `text[]` or `public.mood`. final String postgresFormat; - /// The JSON schema type, for example `integer` or `string`. - final String jsonType; + /// The kind of Dart type the column maps to. + final ColumnTypeKind typeKind; - /// The JSON schema type of the array elements for array columns. - final String? arrayElementJsonType; + /// The kind of Dart type of the array elements for [ColumnTypeKind.array] + /// columns. + final ColumnTypeKind? elementTypeKind; /// The values of the Postgres enum for enum columns. final List? enumValues; diff --git a/packages/supabase_typegen/test/openapi_parser_test.dart b/packages/supabase_typegen/test/openapi_parser_test.dart index 44f5bf5e3..a3129d360 100644 --- a/packages/supabase_typegen/test/openapi_parser_test.dart +++ b/packages/supabase_typegen/test/openapi_parser_test.dart @@ -53,6 +53,22 @@ void main() { expect(authorId.foreignKey?.column, 'id'); }); + test('derives type kinds from formats', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + ColumnTypeKind kindOf(String name) => + books.columns.singleWhere((column) => column.name == name).typeKind; + + expect(kindOf('id'), ColumnTypeKind.integer); + expect(kindOf('title'), ColumnTypeKind.text); + expect(kindOf('price'), ColumnTypeKind.numeric); + expect(kindOf('rating'), ColumnTypeKind.floating); + expect(kindOf('in_print'), ColumnTypeKind.boolean); + expect(kindOf('mood'), ColumnTypeKind.enumType); + expect(kindOf('metadata'), ColumnTypeKind.json); + expect(kindOf('created_at'), ColumnTypeKind.dateTime); + expect(kindOf('cover_uuid'), ColumnTypeKind.text); + }); + test('collects Postgres enums', () { expect(schema.enums, hasLength(1)); final mood = schema.enums.single; @@ -65,7 +81,8 @@ void main() { final books = schema.tables.singleWhere((table) => table.name == 'books'); final tags = books.columns.singleWhere((column) => column.name == 'tags'); expect(tags.postgresFormat, 'text[]'); - expect(tags.arrayElementJsonType, 'string'); + expect(tags.typeKind, ColumnTypeKind.array); + expect(tags.elementTypeKind, ColumnTypeKind.text); }); test('keeps human column comments without the key markers', () { From ad8fdf502bdc5d67481c7d3623d284c29cbfcb9d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 10:04:36 +0200 Subject: [PATCH 10/25] chore: trigger CI From 514450c45d592c5b67550506f12bd15b29f95d69 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 11:18:30 +0200 Subject: [PATCH 11/25] fix(supabase_typegen): review fixes for wire correctness and CLI robustness --- packages/supabase_typegen/README.md | 22 +++-- .../bin/supabase_typegen.dart | 41 +++++++-- .../lib/src/dart_generator.dart | 85 ++++++++++++++++--- .../supabase_typegen/lib/src/identifiers.dart | 7 +- .../lib/src/openapi_parser.dart | 32 +++---- .../lib/src/schema_description.dart | 13 ++- .../test/fixtures/openapi.json | 20 ++++- .../test/generated_schema_behavior_test.dart | 39 +++++++++ .../test/goldens/supabase_schema.dart | 44 ++++++++-- .../test/identifiers_test.dart | 6 ++ .../test/openapi_parser_test.dart | 4 +- 11 files changed, 254 insertions(+), 59 deletions(-) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 6e1decb3a..98ca3d8cb 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -27,8 +27,8 @@ environment variables. Use `--schema` to generate for a schema other than `PostgrestTable` and `TableColumn` from. The schema is read from the OpenAPI description that PostgREST serves at the -API root, so the key only needs read access; tables hidden from the key by -row level security settings are not included. +API root, so the key only needs read access; tables whose role lacks +privileges (grants, not row level security) are not included. ## Generated code in action @@ -45,7 +45,17 @@ await client.table(Books.table).insert( ## Known limitations -The OpenAPI description does not distinguish nullable columns from `NOT NULL` -columns with a database default, so getters for defaulted columns other than -primary keys are conservatively nullable. Foreign key relationship getters -and typed functions (rpc) are not generated yet. +- The OpenAPI description does not distinguish nullable columns from + `NOT NULL` columns with a database default, so getters for defaulted + columns other than primary keys are conservatively nullable. +- Passing `null` to an `Insert`/`Update` parameter omits the column. To write + SQL NULL explicitly, set the raw key: `BooksUpdate()..['price'] = null`. +- Array elements are assumed non-null (`text[]` maps to `List`), + matching the supabase-js type generator; arrays containing SQL NULL + elements throw when the element is read. Enum array columns degrade to + `List`. +- `timestamptz` values are written back in UTC, naive `timestamp` values as + local wall time, and `date` values date-only, so calendar dates never + shift with the client timezone. +- Foreign key relationship getters and typed functions (rpc) are not + generated yet. diff --git a/packages/supabase_typegen/bin/supabase_typegen.dart b/packages/supabase_typegen/bin/supabase_typegen.dart index 962cbe12d..e04e9dc00 100644 --- a/packages/supabase_typegen/bin/supabase_typegen.dart +++ b/packages/supabase_typegen/bin/supabase_typegen.dart @@ -38,7 +38,13 @@ final _argParser = ArgParser() ) ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this usage.'); -Future main(List arguments) async { +Future main(List arguments) async { + // The value returned from main is ignored by the Dart VM, so the exit + // code has to be set explicitly. + exitCode = await _run(arguments); +} + +Future _run(List arguments) async { final ArgResults options; try { options = _argParser.parse(arguments); @@ -72,7 +78,8 @@ Future main(List arguments) async { } final schemaName = options.option('schema')!; - final endpoint = Uri.parse('$url/rest/v1/'); + final baseUrl = url.replaceAll(RegExp(r'/+$'), ''); + final endpoint = Uri.parse('$baseUrl/rest/v1/'); final http.Response response; try { response = await http.get( @@ -95,19 +102,37 @@ Future main(List arguments) async { return 1; } - final schema = parseOpenApiDocument( - jsonDecode(response.body) as Map, - schemaName: schemaName, - ); + final Map document; + try { + document = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + } on FormatException catch (error) { + stderr.writeln('The response from $endpoint is not valid JSON: $error'); + return 1; + } on TypeError { + stderr.writeln( + 'The response from $endpoint is not an OpenAPI document. Check that ' + 'the URL points to a Supabase project or PostgREST instance.', + ); + return 1; + } + + final schema = parseOpenApiDocument(document, schemaName: schemaName); final code = generateDartCode(schema, importUri: options.option('import')!); final outputFile = File(options.option('output')!); outputFile.parent.createSync(recursive: true); outputFile.writeAsStringSync(code); + final emittedTables = schema.tables + .where((table) => table.columns.isNotEmpty) + .length; + final skippedTables = schema.tables.length - emittedTables; stdout.writeln( - 'Generated ${outputFile.path} with ${schema.tables.length} tables and ' - '${schema.enums.length} enums from schema "$schemaName".', + 'Generated ${outputFile.path} with $emittedTables tables and ' + '${schema.enums.length} enums from schema "$schemaName".' + '${skippedTables == 0 ? '' : ' Skipped $skippedTables tables ' + 'without columns.'}', ); return 0; } diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index ac3e27df5..d032fcd2e 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -22,6 +22,11 @@ String generateDartCode( SchemaDescription schema, { String importUri = 'package:postgrest/postgrest.dart', }) { + final usesDateColumns = schema.tables.any( + (table) => table.columns.any( + (column) => column.typeKind == ColumnTypeKind.date, + ), + ); final buffer = StringBuffer() ..writeln('// Generated by supabase_typegen. Do not edit by hand.') ..writeln('//') @@ -44,6 +49,15 @@ String generateDartCode( _writeTable(buffer, table, typeNames, enumTypeNames); } + if (usesDateColumns) { + buffer + ..writeln('String _dateString(DateTime date) =>') + ..writeln(" '\${date.year.toString().padLeft(4, '0')}-'") + ..writeln(" '\${date.month.toString().padLeft(2, '0')}-'") + ..writeln(" '\${date.day.toString().padLeft(2, '0')}';") + ..writeln(); + } + return DartFormatter( languageVersion: DartFormatter.latestLanguageVersion, ).format(buffer.toString()); @@ -67,7 +81,21 @@ void _writeEnum( EnumDescription enumDescription, String typeName, ) { - final valueNames = _uniqueMemberNames(enumDescription.values); + final valueNames = _uniqueMemberNames( + enumDescription.values, + reserved: { + typeName, + 'index', + 'name', + 'values', + 'wireName', + 'fromWire', + 'toString', + 'hashCode', + 'runtimeType', + 'noSuchMethod', + }, + ); buffer ..writeln('/// Postgres enum `${enumDescription.qualifiedName}`.') @@ -85,7 +113,14 @@ void _writeEnum( ..writeln() ..writeln(' /// Parses the database representation of the enum.') ..writeln(' static $typeName fromWire(String wireName) =>') - ..writeln(' values.firstWhere((value) => value.wireName == wireName);') + ..writeln(' values.firstWhere(') + ..writeln(' (value) => value.wireName == wireName,') + ..writeln(' orElse: () => throw ArgumentError.value(') + ..writeln(' wireName,') + ..writeln(" 'wireName',") + ..writeln(" 'No $typeName value with this wire name',") + ..writeln(' ),') + ..writeln(' );') ..writeln() ..writeln(' @override') ..writeln(' String toString() => wireName;') @@ -105,9 +140,10 @@ void _writeTable( final updateType = typeNames.claim('${baseName}Update'); final namespaceType = typeNames.claim(baseName); - final memberNames = _uniqueMemberNames([ - for (final column in table.columns) column.name, - ]); + final memberNames = _uniqueMemberNames( + [for (final column in table.columns) column.name], + reserved: {rowType, insertType, updateType}, + ); final bindings = { for (final column in table.columns) column.name: _bindingFor(column, enumTypeNames), @@ -136,7 +172,9 @@ void _writeTable( requireRequiredColumns: false, docLine: 'Values for updating rows of `${table.name}`. All columns are ' - 'optional; passing `null` leaves the column unchanged.', + 'optional; passing `null` omits the column, leaving it unchanged. ' + 'To write SQL NULL explicitly, set the raw key: ' + '`$updateType()..[\'column_name\'] = null`.', ); _writeNamespace(buffer, table, namespaceType, rowType, memberNames, bindings); } @@ -223,7 +261,7 @@ void _writeNamespace( ) { final columnNames = _uniqueMemberNames( [for (final column in table.columns) column.name], - reserved: {'table'}, + reserved: {'table', namespaceType}, existing: memberNames, ); @@ -266,9 +304,14 @@ _Binding _bindingFor( ColumnTypeKind.floating => const _Binding('double', ColumnTypeKind.floating), ColumnTypeKind.numeric => const _Binding('num', ColumnTypeKind.numeric), ColumnTypeKind.boolean => const _Binding('bool', ColumnTypeKind.boolean), - ColumnTypeKind.dateTime => const _Binding( + ColumnTypeKind.date => const _Binding('DateTime', ColumnTypeKind.date), + ColumnTypeKind.timestamp => const _Binding( + 'DateTime', + ColumnTypeKind.timestamp, + ), + ColumnTypeKind.timestampWithTimeZone => const _Binding( 'DateTime', - ColumnTypeKind.dateTime, + ColumnTypeKind.timestampWithTimeZone, ), ColumnTypeKind.text => const _Binding('String', ColumnTypeKind.text), ColumnTypeKind.json || @@ -282,7 +325,9 @@ String _elementDartType(ColumnTypeKind? elementTypeKind) => ColumnTypeKind.numeric => 'num', ColumnTypeKind.boolean => 'bool', ColumnTypeKind.text => 'String', - ColumnTypeKind.dateTime || + ColumnTypeKind.date || + ColumnTypeKind.timestamp || + ColumnTypeKind.timestampWithTimeZone || ColumnTypeKind.json || ColumnTypeKind.enumType || ColumnTypeKind.array || @@ -312,7 +357,9 @@ String _readExpression(ColumnDescription column, _Binding binding) { nullable ? '($access as List?)?.cast()' : '($access as List).cast()', - ColumnTypeKind.dateTime => + ColumnTypeKind.date || + ColumnTypeKind.timestamp || + ColumnTypeKind.timestampWithTimeZone => nullable ? _nullableSwitch(access, 'DateTime.parse(value as String)') : 'DateTime.parse($access as String)', @@ -337,7 +384,16 @@ String _writeExpression( }) { final access = nullable ? '$parameterName?' : parameterName; return switch (binding.kind) { - ColumnTypeKind.dateTime => '$access.toIso8601String()', + ColumnTypeKind.date => + nullable + ? 'switch ($parameterName) ' + '{ null => null, final value => _dateString(value) }' + : '_dateString($parameterName)', + ColumnTypeKind.timestamp => '$access.toIso8601String()', + ColumnTypeKind.timestampWithTimeZone => + nullable + ? '$access.toUtc().toIso8601String()' + : '$parameterName.toUtc().toIso8601String()', ColumnTypeKind.enumType => '$access.wireName', ColumnTypeKind.integer || ColumnTypeKind.floating || @@ -387,6 +443,9 @@ String _stringLiteral(String value) { final escaped = value .replaceAll(r'\', r'\\') .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); + .replaceAll(r'$', r'\$') + .replaceAll('\n', r'\n') + .replaceAll('\r', r'\r') + .replaceAll('\t', r'\t'); return "'$escaped'"; } diff --git a/packages/supabase_typegen/lib/src/identifiers.dart b/packages/supabase_typegen/lib/src/identifiers.dart index 130c435e5..0c8dcb8b9 100644 --- a/packages/supabase_typegen/lib/src/identifiers.dart +++ b/packages/supabase_typegen/lib/src/identifiers.dart @@ -97,9 +97,12 @@ const _mapMembers = { }; final _wordSeparator = RegExp('[^a-zA-Z0-9]+'); +final _camelHumpBoundary = RegExp('(?<=[a-z0-9])(?=[A-Z])'); -List _words(String name) => - name.split(_wordSeparator).where((word) => word.isNotEmpty).toList(); +List _words(String name) => [ + for (final part in name.split(_wordSeparator)) + ...part.split(_camelHumpBoundary), +].where((word) => word.isNotEmpty).toList(); /// Converts [name] to PascalCase, for example `author_stats` to /// `AuthorStats`. diff --git a/packages/supabase_typegen/lib/src/openapi_parser.dart b/packages/supabase_typegen/lib/src/openapi_parser.dart index 0b37d5b20..44925d9c1 100644 --- a/packages/supabase_typegen/lib/src/openapi_parser.dart +++ b/packages/supabase_typegen/lib/src/openapi_parser.dart @@ -12,10 +12,8 @@ const _integerFormats = { }; const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; const _numericFormats = {'numeric', 'decimal'}; -const _dateTimeFormats = { - 'date', - 'timestamp', - 'timestamp without time zone', +const _timestampFormats = {'timestamp', 'timestamp without time zone'}; +const _timestampWithTimeZoneFormats = { 'timestamp with time zone', 'timestamptz', }; @@ -29,15 +27,19 @@ ColumnTypeKind _typeKind({ required String? jsonType, required bool isEnum, }) { - if (isEnum) return ColumnTypeKind.enumType; if (format.endsWith('[]') || jsonType == 'array') { return ColumnTypeKind.array; } + if (isEnum) return ColumnTypeKind.enumType; if (_integerFormats.contains(format)) return ColumnTypeKind.integer; if (_floatingFormats.contains(format)) return ColumnTypeKind.floating; if (_numericFormats.contains(format)) return ColumnTypeKind.numeric; if (format == 'boolean') return ColumnTypeKind.boolean; - if (_dateTimeFormats.contains(format)) return ColumnTypeKind.dateTime; + if (format == 'date') return ColumnTypeKind.date; + if (_timestampFormats.contains(format)) return ColumnTypeKind.timestamp; + if (_timestampWithTimeZoneFormats.contains(format)) { + return ColumnTypeKind.timestampWithTimeZone; + } if (_jsonFormats.contains(format)) return ColumnTypeKind.json; return switch (jsonType) { 'integer' => ColumnTypeKind.integer, @@ -76,7 +78,7 @@ SchemaDescription parseOpenApiDocument( for (final MapEntry(key: tableName, value: definition) in definitions.entries) { - definition as Map; + if (definition is! Map) continue; final required = { ...?(definition['required'] as List?)?.cast(), }; @@ -90,8 +92,13 @@ SchemaDescription parseOpenApiDocument( final description = property['description'] as String?; final format = property['format'] as String? ?? ''; final enumValues = (property['enum'] as List?)?.cast(); + final typeKind = _typeKind( + format: format, + jsonType: property['type'] as String?, + isEnum: enumValues != null, + ); - if (enumValues != null) { + if (enumValues != null && typeKind == ColumnTypeKind.enumType) { enumsByQualifiedName.putIfAbsent( format, () => EnumDescription(qualifiedName: format, values: enumValues), @@ -106,11 +113,7 @@ SchemaDescription parseOpenApiDocument( ColumnDescription( name: columnName, postgresFormat: format, - typeKind: _typeKind( - format: format, - jsonType: property['type'] as String?, - isEnum: enumValues != null, - ), + typeKind: typeKind, elementTypeKind: _elementTypeKind( (property['items'] as Map?)?['type'] as String?, ), @@ -156,9 +159,8 @@ String? _cleanComment(String? description) { final cleaned = description .replaceAll(_foreignKeyPattern, '') .replaceAll('', '') - .replaceAll(RegExp(r'Note:\s*'), '') .replaceAll( - RegExp(r'This is a (Primary|Foreign) Key( to `[^`]+`)?\.'), + RegExp(r'Note:\s*This is a (Primary|Foreign) Key( to `[^`]+`)?\.'), '', ) .trim(); diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart index e94c64650..6ab5a59b4 100644 --- a/packages/supabase_typegen/lib/src/schema_description.dart +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -14,8 +14,17 @@ enum ColumnTypeKind { /// The `boolean` type. boolean, - /// Date and timestamp types, mapped to `DateTime`. - dateTime, + /// The `date` type, mapped to `DateTime` and written back date-only so + /// the calendar date never shifts with the client timezone. + date, + + /// Timestamps without a timezone, mapped to `DateTime` and written back as + /// the local wall time. + timestamp, + + /// Timestamps with a timezone, mapped to `DateTime` and written back in + /// UTC. + timestampWithTimeZone, /// Types carried as text, such as `text`, `uuid` and `character varying`. text, diff --git a/packages/supabase_typegen/test/fixtures/openapi.json b/packages/supabase_typegen/test/fixtures/openapi.json index a64a325ad..8b215ab99 100644 --- a/packages/supabase_typegen/test/fixtures/openapi.json +++ b/packages/supabase_typegen/test/fixtures/openapi.json @@ -8,7 +8,10 @@ "definitions": { "books": { "description": "Books available in the library", - "required": ["title", "author_id"], + "required": [ + "title", + "author_id" + ], "properties": { "id": { "description": "Note:\nThis is a Primary Key.", @@ -39,7 +42,11 @@ "default": true }, "mood": { - "enum": ["happy", "very happy", "sad"], + "enum": [ + "happy", + "very happy", + "sad" + ], "format": "public.mood", "type": "string" }, @@ -73,12 +80,19 @@ "format": "timestamp with time zone", "type": "string", "default": "now()" + }, + "updated_at": { + "format": "timestamp without time zone", + "type": "string" } }, "type": "object" }, "authors": { - "required": ["id", "name"], + "required": [ + "id", + "name" + ], "properties": { "id": { "description": "Note:\nThis is a Primary Key.", diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 512606b95..14cc69b96 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -102,6 +102,45 @@ void main() { }); }); + test( + 'timestamps are sent as UTC instants and dates keep their day', + () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .insert( + BooksInsert( + title: 'A typed row', + authorId: 7, + createdAt: DateTime(2026, 7, 23, 10), // local wall time + publishedOn: DateTime(2026, 7, 23, 23, 30), + ), + ); + + final sent = + jsonDecode(httpClient.lastRequestBody!) as Map; + expect( + sent['created_at'], + DateTime(2026, 7, 23, 10).toUtc().toIso8601String(), + ); + expect(sent['published_on'], '2026-07-23'); + }, + ); + + test('unknown enum wire values throw a descriptive error', () { + expect( + () => Mood.fromWire('grumpy'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('No Mood value'), + ), + ), + ); + }); + test('update sends only the provided columns', () async { httpClient.responseBody = ''; diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index d2add44cb..d7f29262d 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -16,8 +16,14 @@ enum Mood { final String wireName; /// Parses the database representation of the enum. - static Mood fromWire(String wireName) => - values.firstWhere((value) => value.wireName == wireName); + static Mood fromWire(String wireName) => values.firstWhere( + (value) => value.wireName == wireName, + orElse: () => throw ArgumentError.value( + wireName, + 'wireName', + 'No Mood value with this wire name', + ), + ); @override String toString() => wireName; @@ -38,7 +44,7 @@ extension type const AuthorStatsInsert._(Map _json) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); } -/// Values for updating rows of `author_stats`. All columns are optional; passing `null` leaves the column unchanged. +/// Values for updating rows of `author_stats`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorStatsUpdate()..['column_name'] = null`. extension type const AuthorStatsUpdate._(Map _json) implements Map { AuthorStatsUpdate({int? authorId, int? bookCount}) @@ -70,7 +76,7 @@ extension type const AuthorsInsert._(Map _json) : this._({'id': id, 'name': name}); } -/// Values for updating rows of `authors`. All columns are optional; passing `null` leaves the column unchanged. +/// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorsUpdate()..['column_name'] = null`. extension type const AuthorsUpdate._(Map _json) implements Map { AuthorsUpdate({int? id, String? name}) : this._({'id': ?id, 'name': ?name}); @@ -115,6 +121,10 @@ extension type const BooksRow(Map _json) null => null, final Object value => DateTime.parse(value as String), }; + DateTime? get updatedAt => switch (_json['updated_at']) { + null => null, + final Object value => DateTime.parse(value as String), + }; } /// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. @@ -134,6 +144,7 @@ extension type const BooksInsert._(Map _json) String? coverUuid, DateTime? publishedOn, DateTime? createdAt, + DateTime? updatedAt, }) : this._({ 'id': ?id, 'title': title, @@ -146,12 +157,16 @@ extension type const BooksInsert._(Map _json) 'page_counts': ?pageCounts, 'metadata': ?metadata, 'cover_uuid': ?coverUuid, - 'published_on': ?publishedOn?.toIso8601String(), - 'created_at': ?createdAt?.toIso8601String(), + 'published_on': ?switch (publishedOn) { + null => null, + final value => _dateString(value), + }, + 'created_at': ?createdAt?.toUtc().toIso8601String(), + 'updated_at': ?updatedAt?.toIso8601String(), }); } -/// Values for updating rows of `books`. All columns are optional; passing `null` leaves the column unchanged. +/// Values for updating rows of `books`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `BooksUpdate()..['column_name'] = null`. extension type const BooksUpdate._(Map _json) implements Map { BooksUpdate({ @@ -168,6 +183,7 @@ extension type const BooksUpdate._(Map _json) String? coverUuid, DateTime? publishedOn, DateTime? createdAt, + DateTime? updatedAt, }) : this._({ 'id': ?id, 'title': ?title, @@ -180,8 +196,12 @@ extension type const BooksUpdate._(Map _json) 'page_counts': ?pageCounts, 'metadata': ?metadata, 'cover_uuid': ?coverUuid, - 'published_on': ?publishedOn?.toIso8601String(), - 'created_at': ?createdAt?.toIso8601String(), + 'published_on': ?switch (publishedOn) { + null => null, + final value => _dateString(value), + }, + 'created_at': ?createdAt?.toUtc().toIso8601String(), + 'updated_at': ?updatedAt?.toIso8601String(), }); } @@ -205,4 +225,10 @@ class Books { static const coverUuid = TableColumn('cover_uuid'); static const publishedOn = TableColumn('published_on'); static const createdAt = TableColumn('created_at'); + static const updatedAt = TableColumn('updated_at'); } + +String _dateString(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; diff --git a/packages/supabase_typegen/test/identifiers_test.dart b/packages/supabase_typegen/test/identifiers_test.dart index 6907f96bb..5755922e9 100644 --- a/packages/supabase_typegen/test/identifiers_test.dart +++ b/packages/supabase_typegen/test/identifiers_test.dart @@ -9,6 +9,12 @@ void main() { expect(pascalCase('user-profiles'), 'UserProfiles'); }); + test('keeps existing camel humps', () { + expect(pascalCase('UserProfiles'), 'UserProfiles'); + expect(pascalCase('userId'), 'UserId'); + expect(camelCase('userId'), 'userId'); + }); + test('prefixes names starting with a digit', () { expect(pascalCase('2fa_codes'), r'$2faCodes'); }); diff --git a/packages/supabase_typegen/test/openapi_parser_test.dart b/packages/supabase_typegen/test/openapi_parser_test.dart index a3129d360..2c71355f7 100644 --- a/packages/supabase_typegen/test/openapi_parser_test.dart +++ b/packages/supabase_typegen/test/openapi_parser_test.dart @@ -65,7 +65,9 @@ void main() { expect(kindOf('in_print'), ColumnTypeKind.boolean); expect(kindOf('mood'), ColumnTypeKind.enumType); expect(kindOf('metadata'), ColumnTypeKind.json); - expect(kindOf('created_at'), ColumnTypeKind.dateTime); + expect(kindOf('created_at'), ColumnTypeKind.timestampWithTimeZone); + expect(kindOf('updated_at'), ColumnTypeKind.timestamp); + expect(kindOf('published_on'), ColumnTypeKind.date); expect(kindOf('cover_uuid'), ColumnTypeKind.text); }); From 1d4a73707eaeae260c140ad6882d0bea0f15169d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 12:08:19 +0200 Subject: [PATCH 12/25] feat(supabase_typegen): generate setXToNull methods for explicit SQL NULL writes --- packages/supabase_typegen/README.md | 4 +- .../lib/src/dart_generator.dart | 23 +++- .../test/generated_schema_behavior_test.dart | 33 ++++++ .../test/goldens/supabase_schema.dart | 106 +++++++++++++++++- 4 files changed, 155 insertions(+), 11 deletions(-) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 98ca3d8cb..2c2828844 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -49,7 +49,9 @@ await client.table(Books.table).insert( `NOT NULL` columns with a database default, so getters for defaulted columns other than primary keys are conservatively nullable. - Passing `null` to an `Insert`/`Update` parameter omits the column. To write - SQL NULL explicitly, set the raw key: `BooksUpdate()..['price'] = null`. + SQL NULL explicitly, use the generated `set…ToNull` methods, for example + `BooksUpdate(inPrint: false).setPriceToNull()`; they only exist for + nullable columns, so nulling a `NOT NULL` column is a compile error. - Array elements are assumed non-null (`text[]` maps to `List`), matching the supabase-js type generator; arrays containing SQL NULL elements throw when the element is read. Enum array columns degrade to diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index d032fcd2e..ba97d2333 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -161,7 +161,8 @@ void _writeTable( 'Values for inserting a row into `${table.name}`. Columns that are ' 'nullable, part of a generated primary key, or covered by a database ' 'default are optional; passing `null` omits the column so the ' - 'database default applies.', + 'database default applies. Use the `set…ToNull` methods to insert ' + 'SQL NULL explicitly.', ); _writeValues( buffer, @@ -173,8 +174,7 @@ void _writeTable( docLine: 'Values for updating rows of `${table.name}`. All columns are ' 'optional; passing `null` omits the column, leaving it unchanged. ' - 'To write SQL NULL explicitly, set the raw key: ' - '`$updateType()..[\'column_name\'] = null`.', + 'Use the `set…ToNull` methods to write SQL NULL explicitly.', ); _writeNamespace(buffer, table, namespaceType, rowType, memberNames, bindings); } @@ -245,8 +245,23 @@ void _writeValues( ); } } + buffer.writeln(' });'); + for (final column in table.columns) { + if (!column.isNullable) continue; + final name = memberNames[column.name]!; + final methodName = 'set${name[0].toUpperCase()}${name.substring(1)}ToNull'; + buffer + ..writeln() + ..writeln( + ' /// Returns a copy with `${column.name}` set to SQL NULL, ' + 'overriding any database default.', + ) + ..writeln( + ' $typeName $methodName() => ' + '$typeName._({..._json, ${_stringLiteral(column.name)}: null});', + ); + } buffer - ..writeln(' });') ..writeln('}') ..writeln(); } diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 14cc69b96..50164f79f 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -152,4 +152,37 @@ void main() { expect(jsonDecode(httpClient.lastRequestBody!), {'in_print': false}); expect(httpClient.lastRequest!.url.queryParameters['id'], 'eq.1'); }); + + test('setXToNull writes SQL NULL explicitly', () async { + httpClient.responseBody = ''; + + final update = BooksUpdate(inPrint: false); + await client + .table(Books.table) + .update(update.setPriceToNull().setMoodToNull()) + .where(Books.id.eq(1)); + + expect(jsonDecode(httpClient.lastRequestBody!), { + 'in_print': false, + 'price': null, + 'mood': null, + }); + expect( + update.containsKey('price'), + isFalse, + reason: 'setPriceToNull returns a copy and must not mutate', + ); + + await client + .table(Books.table) + .insert( + BooksInsert(title: 'x', authorId: 7).setPublishedOnToNull(), + ); + + expect(jsonDecode(httpClient.lastRequestBody!), { + 'title': 'x', + 'author_id': 7, + 'published_on': null, + }); + }); } diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index d7f29262d..a581d6f21 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -37,18 +37,34 @@ extension type const AuthorStatsRow(Map _json) int? get bookCount => _json['book_count'] as int?; } -/// Values for inserting a row into `author_stats`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +/// Values for inserting a row into `author_stats`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const AuthorStatsInsert._(Map _json) implements Map { AuthorStatsInsert({int? authorId, int? bookCount}) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); + + /// Returns a copy with `author_id` set to SQL NULL, overriding any database default. + AuthorStatsInsert setAuthorIdToNull() => + AuthorStatsInsert._({..._json, 'author_id': null}); + + /// Returns a copy with `book_count` set to SQL NULL, overriding any database default. + AuthorStatsInsert setBookCountToNull() => + AuthorStatsInsert._({..._json, 'book_count': null}); } -/// Values for updating rows of `author_stats`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorStatsUpdate()..['column_name'] = null`. +/// Values for updating rows of `author_stats`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. extension type const AuthorStatsUpdate._(Map _json) implements Map { AuthorStatsUpdate({int? authorId, int? bookCount}) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); + + /// Returns a copy with `author_id` set to SQL NULL, overriding any database default. + AuthorStatsUpdate setAuthorIdToNull() => + AuthorStatsUpdate._({..._json, 'author_id': null}); + + /// Returns a copy with `book_count` set to SQL NULL, overriding any database default. + AuthorStatsUpdate setBookCountToNull() => + AuthorStatsUpdate._({..._json, 'book_count': null}); } /// Typed access to the `author_stats` table. @@ -69,14 +85,14 @@ extension type const AuthorsRow(Map _json) String get name => _json['name'] as String; } -/// Values for inserting a row into `authors`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +/// Values for inserting a row into `authors`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const AuthorsInsert._(Map _json) implements Map { AuthorsInsert({required int id, required String name}) : this._({'id': id, 'name': name}); } -/// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorsUpdate()..['column_name'] = null`. +/// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. extension type const AuthorsUpdate._(Map _json) implements Map { AuthorsUpdate({int? id, String? name}) : this._({'id': ?id, 'name': ?name}); @@ -127,7 +143,7 @@ extension type const BooksRow(Map _json) }; } -/// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +/// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const BooksInsert._(Map _json) implements Map { BooksInsert({ @@ -164,9 +180,48 @@ extension type const BooksInsert._(Map _json) 'created_at': ?createdAt?.toUtc().toIso8601String(), 'updated_at': ?updatedAt?.toIso8601String(), }); + + /// Returns a copy with `price` set to SQL NULL, overriding any database default. + BooksInsert setPriceToNull() => BooksInsert._({..._json, 'price': null}); + + /// Returns a copy with `rating` set to SQL NULL, overriding any database default. + BooksInsert setRatingToNull() => BooksInsert._({..._json, 'rating': null}); + + /// Returns a copy with `in_print` set to SQL NULL, overriding any database default. + BooksInsert setInPrintToNull() => BooksInsert._({..._json, 'in_print': null}); + + /// Returns a copy with `mood` set to SQL NULL, overriding any database default. + BooksInsert setMoodToNull() => BooksInsert._({..._json, 'mood': null}); + + /// Returns a copy with `tags` set to SQL NULL, overriding any database default. + BooksInsert setTagsToNull() => BooksInsert._({..._json, 'tags': null}); + + /// Returns a copy with `page_counts` set to SQL NULL, overriding any database default. + BooksInsert setPageCountsToNull() => + BooksInsert._({..._json, 'page_counts': null}); + + /// Returns a copy with `metadata` set to SQL NULL, overriding any database default. + BooksInsert setMetadataToNull() => + BooksInsert._({..._json, 'metadata': null}); + + /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database default. + BooksInsert setCoverUuidToNull() => + BooksInsert._({..._json, 'cover_uuid': null}); + + /// Returns a copy with `published_on` set to SQL NULL, overriding any database default. + BooksInsert setPublishedOnToNull() => + BooksInsert._({..._json, 'published_on': null}); + + /// Returns a copy with `created_at` set to SQL NULL, overriding any database default. + BooksInsert setCreatedAtToNull() => + BooksInsert._({..._json, 'created_at': null}); + + /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. + BooksInsert setUpdatedAtToNull() => + BooksInsert._({..._json, 'updated_at': null}); } -/// Values for updating rows of `books`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `BooksUpdate()..['column_name'] = null`. +/// Values for updating rows of `books`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. extension type const BooksUpdate._(Map _json) implements Map { BooksUpdate({ @@ -203,6 +258,45 @@ extension type const BooksUpdate._(Map _json) 'created_at': ?createdAt?.toUtc().toIso8601String(), 'updated_at': ?updatedAt?.toIso8601String(), }); + + /// Returns a copy with `price` set to SQL NULL, overriding any database default. + BooksUpdate setPriceToNull() => BooksUpdate._({..._json, 'price': null}); + + /// Returns a copy with `rating` set to SQL NULL, overriding any database default. + BooksUpdate setRatingToNull() => BooksUpdate._({..._json, 'rating': null}); + + /// Returns a copy with `in_print` set to SQL NULL, overriding any database default. + BooksUpdate setInPrintToNull() => BooksUpdate._({..._json, 'in_print': null}); + + /// Returns a copy with `mood` set to SQL NULL, overriding any database default. + BooksUpdate setMoodToNull() => BooksUpdate._({..._json, 'mood': null}); + + /// Returns a copy with `tags` set to SQL NULL, overriding any database default. + BooksUpdate setTagsToNull() => BooksUpdate._({..._json, 'tags': null}); + + /// Returns a copy with `page_counts` set to SQL NULL, overriding any database default. + BooksUpdate setPageCountsToNull() => + BooksUpdate._({..._json, 'page_counts': null}); + + /// Returns a copy with `metadata` set to SQL NULL, overriding any database default. + BooksUpdate setMetadataToNull() => + BooksUpdate._({..._json, 'metadata': null}); + + /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database default. + BooksUpdate setCoverUuidToNull() => + BooksUpdate._({..._json, 'cover_uuid': null}); + + /// Returns a copy with `published_on` set to SQL NULL, overriding any database default. + BooksUpdate setPublishedOnToNull() => + BooksUpdate._({..._json, 'published_on': null}); + + /// Returns a copy with `created_at` set to SQL NULL, overriding any database default. + BooksUpdate setCreatedAtToNull() => + BooksUpdate._({..._json, 'created_at': null}); + + /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. + BooksUpdate setUpdatedAtToNull() => + BooksUpdate._({..._json, 'updated_at': null}); } /// Typed access to the `books` table. From 7f69d9dcaa2e2897c9c261c5ca58a6db4e7cc7e6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 12:18:48 +0200 Subject: [PATCH 13/25] feat: mark typed table access as experimental in typegen output --- packages/supabase_typegen/lib/src/dart_generator.dart | 3 +++ .../supabase_typegen/test/generated_schema_behavior_test.dart | 3 +++ packages/supabase_typegen/test/goldens/supabase_schema.dart | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index ba97d2333..316a9b2b6 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -32,6 +32,9 @@ String generateDartCode( ..writeln('//') ..writeln('// Source schema: ${schema.schemaName}') ..writeln() + ..writeln('// The typed table access API is still experimental.') + ..writeln('// ignore_for_file: experimental_member_use') + ..writeln() ..writeln("import '$importUri';") ..writeln(); diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 50164f79f..2c749c1e2 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -1,3 +1,6 @@ +// The typed table access API under test is annotated @experimental. +// ignore_for_file: experimental_member_use + import 'dart:convert'; import 'package:http/http.dart'; diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index a581d6f21..e73e20e7f 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -2,6 +2,9 @@ // // Source schema: public +// The typed table access API is still experimental. +// ignore_for_file: experimental_member_use + import 'package:postgrest/postgrest.dart'; /// Postgres enum `public.mood`. From c795ded39171ac3d1ec615d350562c0de1c8d1d4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 13:52:10 +0200 Subject: [PATCH 14/25] chore: drop duplicate sdk-parse-ignore entry --- .sdk-parse-ignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.sdk-parse-ignore b/.sdk-parse-ignore index 8e555b5ad..fe7ff1295 100644 --- a/.sdk-parse-ignore +++ b/.sdk-parse-ignore @@ -21,7 +21,3 @@ packages/supabase_typegen/ # The examples are standalone demo apps, not part of the published SDK, so their # public classes are not capability-matrix symbols. examples/ - -# supabase_typegen is a development-time code generator invoked through its CLI; -# its library API is tool internals, not SDK client surface. -packages/supabase_typegen/ From abd8e1f51e17614a9a1dc926a7141a449b61de00 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 10:06:11 +0200 Subject: [PATCH 15/25] test(supabase): match the extension type style of the other typed tests --- packages/supabase/test/stream_filter_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/supabase/test/stream_filter_test.dart b/packages/supabase/test/stream_filter_test.dart index f566b6b01..693186df4 100644 --- a/packages/supabase/test/stream_filter_test.dart +++ b/packages/supabase/test/stream_filter_test.dart @@ -272,8 +272,9 @@ final _testCases = <_TestCase>[ ), ]; -extension type _User(Map json) { - String get username => json['username'] as String; +extension type const _User(Map _json) + implements Map { + String get username => _json['username'] as String; } class _Users { From 4e2aa86fb910a7917f224ea7a498b6feba1d0a99 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 17 Aug 2026 15:15:47 +0200 Subject: [PATCH 16/25] feat(supabase_typegen): read the postgres-meta json metadata instead of the OpenAPI description --- packages/supabase_typegen/README.md | 35 +- .../bin/supabase_typegen.dart | 88 ++-- .../lib/src/dart_generator.dart | 18 +- .../lib/src/openapi_parser.dart | 168 ------- .../lib/src/postgres_meta_parser.dart | 231 +++++++++ .../lib/src/schema_description.dart | 16 +- .../lib/supabase_typegen.dart | 2 +- packages/supabase_typegen/pubspec.yaml | 2 +- .../test/dart_generator_test.dart | 29 +- .../test/fixtures/openapi.json | 125 ----- .../test/fixtures/postgres_meta_schema.json | 440 ++++++++++++++++++ .../test/generated_schema_behavior_test.dart | 2 + .../test/goldens/supabase_schema.dart | 32 +- ...st.dart => postgres_meta_parser_test.dart} | 93 +++- .../tool/regenerate_goldens.dart | 6 +- pubspec.lock | 30 +- 16 files changed, 889 insertions(+), 428 deletions(-) delete mode 100644 packages/supabase_typegen/lib/src/openapi_parser.dart create mode 100644 packages/supabase_typegen/lib/src/postgres_meta_parser.dart delete mode 100644 packages/supabase_typegen/test/fixtures/openapi.json create mode 100644 packages/supabase_typegen/test/fixtures/postgres_meta_schema.json rename packages/supabase_typegen/test/{openapi_parser_test.dart => postgres_meta_parser_test.dart} (52%) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 2c2828844..e70289ed4 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -14,21 +14,33 @@ For every table the generator emits: ## Usage +First dump the schema metadata with the Supabase CLI, then generate: + ```sh -dart run supabase_typegen \ - --url https://your-project.supabase.co \ - --key $SUPABASE_ANON_KEY \ +supabase gen types --lang json --local > schema.json +dart run supabase_typegen --input schema.json \ --output lib/supabase_schema.g.dart ``` -`--url` and `--key` fall back to the `SUPABASE_URL` and `SUPABASE_ANON_KEY` -environment variables. Use `--schema` to generate for a schema other than -`public`, and `--import` to change which library the generated file imports -`PostgrestTable` and `TableColumn` from. +Any of the CLI's connection flags work (`--local`, `--linked`, +`--db-url`, `--project-id`). Until CLI support for `--lang json` ships, the +same document comes straight from +[postgres-meta](https://github.com/supabase/postgres-meta) with +`PG_META_GENERATE_TYPES=json` or its `/generators/json` endpoint. Pass +`--input -` to read the document from stdin: + +```sh +supabase gen types --lang json --local | dart run supabase_typegen --input - +``` + +Use `--schema` to generate for a schema other than `public`, and `--import` +to change which library the generated file imports `PostgrestTable` and +`TableColumn` from. -The schema is read from the OpenAPI description that PostgREST serves at the -API root, so the key only needs read access; tables whose role lacks -privileges (grants, not row level security) are not included. +The metadata comes from the database catalog, so nullability, database +defaults, and identity columns are exact: a `NOT NULL` column with a default +reads as non-nullable but stays optional on insert, and `GENERATED ALWAYS` +columns appear in the row type but not in the insert and update types. ## Generated code in action @@ -45,9 +57,6 @@ await client.table(Books.table).insert( ## Known limitations -- The OpenAPI description does not distinguish nullable columns from - `NOT NULL` columns with a database default, so getters for defaulted - columns other than primary keys are conservatively nullable. - Passing `null` to an `Insert`/`Update` parameter omits the column. To write SQL NULL explicitly, use the generated `set…ToNull` methods, for example `BooksUpdate(inPrint: false).setPriceToNull()`; they only exist for diff --git a/packages/supabase_typegen/bin/supabase_typegen.dart b/packages/supabase_typegen/bin/supabase_typegen.dart index e04e9dc00..57af6626a 100644 --- a/packages/supabase_typegen/bin/supabase_typegen.dart +++ b/packages/supabase_typegen/bin/supabase_typegen.dart @@ -2,21 +2,16 @@ import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; -import 'package:http/http.dart' as http; import 'package:supabase_typegen/supabase_typegen.dart'; final _argParser = ArgParser() ..addOption( - 'url', + 'input', + abbr: 'i', help: - 'The Supabase project URL, for example https://xyz.supabase.co. ' - 'Falls back to the SUPABASE_URL environment variable.', - ) - ..addOption( - 'key', - help: - 'The API key used to read the schema description. Falls back to ' - 'the SUPABASE_ANON_KEY or SUPABASE_KEY environment variable.', + 'Path of the postgres-meta generator metadata document, or - to ' + 'read it from stdin. Produce it with ' + '`supabase gen types --lang json`.', ) ..addOption( 'schema', @@ -57,67 +52,56 @@ Future _run(List arguments) async { if (options.flag('help')) { stdout - ..writeln('Generates typed Supabase table definitions from a schema.') + ..writeln( + 'Generates typed Supabase table definitions from the schema ' + 'metadata that postgres-meta emits.', + ) ..writeln() - ..writeln('Usage: dart run supabase_typegen [options]') + ..writeln('Usage: dart run supabase_typegen --input schema.json') ..writeln(_argParser.usage); return 0; } - final url = options.option('url') ?? Platform.environment['SUPABASE_URL']; - final key = - options.option('key') ?? - Platform.environment['SUPABASE_ANON_KEY'] ?? - Platform.environment['SUPABASE_KEY']; - if (url == null || key == null) { + final input = options.option('input'); + if (input == null) { stderr.writeln( - 'Both --url and --key are required, either as options or through the ' - 'SUPABASE_URL and SUPABASE_ANON_KEY environment variables.', + '--input is required: the path of a postgres-meta generator metadata ' + 'document, or - to read it from stdin. Produce it with ' + '`supabase gen types --lang json`.', ); return 64; } - final schemaName = options.option('schema')!; - final baseUrl = url.replaceAll(RegExp(r'/+$'), ''); - final endpoint = Uri.parse('$baseUrl/rest/v1/'); - final http.Response response; - try { - response = await http.get( - endpoint, - headers: { - 'apikey': key, - 'Authorization': 'Bearer $key', - 'Accept-Profile': schemaName, - }, - ); - } on http.ClientException catch (error) { - stderr.writeln('Failed to reach $endpoint: $error'); - return 1; - } - if (response.statusCode != 200) { - stderr.writeln( - 'Failed to fetch the schema description from $endpoint ' - '(HTTP ${response.statusCode}): ${response.body}', - ); - return 1; + final String contents; + if (input == '-') { + contents = await utf8.decodeStream(stdin); + } else { + final inputFile = File(input); + if (!inputFile.existsSync()) { + stderr.writeln('The input file $input does not exist.'); + return 66; + } + contents = inputFile.readAsStringSync(); } - final Map document; + final schemaName = options.option('schema')!; + final SchemaDescription schema; try { - document = - jsonDecode(utf8.decode(response.bodyBytes)) as Map; + schema = parsePostgresMetaDocument( + jsonDecode(contents) as Map, + schemaName: schemaName, + ); } on FormatException catch (error) { - stderr.writeln('The response from $endpoint is not valid JSON: $error'); - return 1; + stderr.writeln('Could not parse $input: ${error.message}'); + return 65; } on TypeError { stderr.writeln( - 'The response from $endpoint is not an OpenAPI document. Check that ' - 'the URL points to a Supabase project or PostgREST instance.', + 'The document in $input is not postgres-meta generator metadata. ' + 'Produce it with `supabase gen types --lang json`.', ); - return 1; + return 65; } - final schema = parseOpenApiDocument(document, schemaName: schemaName); final code = generateDartCode(schema, importUri: options.option('import')!); final outputFile = File(options.option('output')!); diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index 316a9b2b6..6944dbbc2 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -162,10 +162,10 @@ void _writeTable( requireRequiredColumns: true, docLine: 'Values for inserting a row into `${table.name}`. Columns that are ' - 'nullable, part of a generated primary key, or covered by a database ' - 'default are optional; passing `null` omits the column so the ' - 'database default applies. Use the `set…ToNull` methods to insert ' - 'SQL NULL explicitly.', + 'nullable, identity, or covered by a database default are optional; ' + 'passing `null` omits the column so the database default applies. ' + 'Columns the database always generates itself are left out entirely. ' + 'Use the `set…ToNull` methods to insert SQL NULL explicitly.', ); _writeValues( buffer, @@ -218,13 +218,17 @@ void _writeValues( }) { bool isRequired(ColumnDescription column) => requireRequiredColumns && column.isRequired; + final writableColumns = [ + for (final column in table.columns) + if (!column.isReadOnly) column, + ]; buffer ..writeln('/// $docLine') ..writeln('extension type const $typeName._(Map _json)') ..writeln(' implements Map {') ..writeln(' $typeName({'); - for (final column in table.columns) { + for (final column in writableColumns) { final binding = bindings[column.name]!; final name = memberNames[column.name]!; if (isRequired(column)) { @@ -234,7 +238,7 @@ void _writeValues( } } buffer.writeln(' }) : this._({'); - for (final column in table.columns) { + for (final column in writableColumns) { final binding = bindings[column.name]!; final name = memberNames[column.name]!; final key = _stringLiteral(column.name); @@ -249,7 +253,7 @@ void _writeValues( } } buffer.writeln(' });'); - for (final column in table.columns) { + for (final column in writableColumns) { if (!column.isNullable) continue; final name = memberNames[column.name]!; final methodName = 'set${name[0].toUpperCase()}${name.substring(1)}ToNull'; diff --git a/packages/supabase_typegen/lib/src/openapi_parser.dart b/packages/supabase_typegen/lib/src/openapi_parser.dart deleted file mode 100644 index 44925d9c1..000000000 --- a/packages/supabase_typegen/lib/src/openapi_parser.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'schema_description.dart'; - -final _foreignKeyPattern = RegExp(""); - -const _integerFormats = { - 'smallint', - 'integer', - 'bigint', - 'int2', - 'int4', - 'int8', -}; -const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; -const _numericFormats = {'numeric', 'decimal'}; -const _timestampFormats = {'timestamp', 'timestamp without time zone'}; -const _timestampWithTimeZoneFormats = { - 'timestamp with time zone', - 'timestamptz', -}; -const _jsonFormats = {'json', 'jsonb'}; - -/// Derives the [ColumnTypeKind] from the Postgres [format] and JSON schema -/// [jsonType] of a column. This is the single place where type names are -/// compared as strings; everything downstream works with the enum. -ColumnTypeKind _typeKind({ - required String format, - required String? jsonType, - required bool isEnum, -}) { - if (format.endsWith('[]') || jsonType == 'array') { - return ColumnTypeKind.array; - } - if (isEnum) return ColumnTypeKind.enumType; - if (_integerFormats.contains(format)) return ColumnTypeKind.integer; - if (_floatingFormats.contains(format)) return ColumnTypeKind.floating; - if (_numericFormats.contains(format)) return ColumnTypeKind.numeric; - if (format == 'boolean') return ColumnTypeKind.boolean; - if (format == 'date') return ColumnTypeKind.date; - if (_timestampFormats.contains(format)) return ColumnTypeKind.timestamp; - if (_timestampWithTimeZoneFormats.contains(format)) { - return ColumnTypeKind.timestampWithTimeZone; - } - if (_jsonFormats.contains(format)) return ColumnTypeKind.json; - return switch (jsonType) { - 'integer' => ColumnTypeKind.integer, - 'number' => ColumnTypeKind.numeric, - 'boolean' => ColumnTypeKind.boolean, - 'string' => ColumnTypeKind.text, - _ => ColumnTypeKind.unknown, - }; -} - -ColumnTypeKind? _elementTypeKind(String? itemsJsonType) => itemsJsonType == null - ? null - : switch (itemsJsonType) { - 'integer' => ColumnTypeKind.integer, - 'number' => ColumnTypeKind.numeric, - 'boolean' => ColumnTypeKind.boolean, - 'string' => ColumnTypeKind.text, - _ => ColumnTypeKind.unknown, - }; - -/// Parses the OpenAPI (Swagger 2.0) document that PostgREST serves at the -/// API root into a [SchemaDescription]. -/// -/// PostgREST encodes primary keys and foreign keys as `` and -/// `` markers inside column descriptions, and -/// lists `NOT NULL` columns without a database default under `required`. -SchemaDescription parseOpenApiDocument( - Map document, { - String schemaName = 'public', -}) { - final definitions = - document['definitions'] as Map? ?? const {}; - - final tables = []; - final enumsByQualifiedName = {}; - - for (final MapEntry(key: tableName, value: definition) - in definitions.entries) { - if (definition is! Map) continue; - final required = { - ...?(definition['required'] as List?)?.cast(), - }; - final properties = - definition['properties'] as Map? ?? const {}; - - final columns = []; - for (final MapEntry(key: columnName, value: property) - in properties.entries) { - property as Map; - final description = property['description'] as String?; - final format = property['format'] as String? ?? ''; - final enumValues = (property['enum'] as List?)?.cast(); - final typeKind = _typeKind( - format: format, - jsonType: property['type'] as String?, - isEnum: enumValues != null, - ); - - if (enumValues != null && typeKind == ColumnTypeKind.enumType) { - enumsByQualifiedName.putIfAbsent( - format, - () => EnumDescription(qualifiedName: format, values: enumValues), - ); - } - - final foreignKeyMatch = description == null - ? null - : _foreignKeyPattern.firstMatch(description); - - columns.add( - ColumnDescription( - name: columnName, - postgresFormat: format, - typeKind: typeKind, - elementTypeKind: _elementTypeKind( - (property['items'] as Map?)?['type'] as String?, - ), - enumValues: enumValues, - isRequired: required.contains(columnName), - isPrimaryKey: description?.contains('') ?? false, - hasDefault: property.containsKey('default'), - comment: _cleanComment(description), - foreignKey: foreignKeyMatch == null - ? null - : ForeignKeyDescription( - table: foreignKeyMatch.group(1)!, - column: foreignKeyMatch.group(2)!, - ), - ), - ); - } - - tables.add( - TableDescription( - name: tableName, - comment: _cleanComment(definition['description'] as String?), - columns: columns, - ), - ); - } - - tables.sort((a, b) => a.name.compareTo(b.name)); - final enums = enumsByQualifiedName.values.toList() - ..sort((a, b) => a.qualifiedName.compareTo(b.qualifiedName)); - - return SchemaDescription( - schemaName: schemaName, - tables: tables, - enums: enums, - ); -} - -/// Strips the PostgREST key markers from a column or table description, -/// keeping only the human written comment. -String? _cleanComment(String? description) { - if (description == null) return null; - final cleaned = description - .replaceAll(_foreignKeyPattern, '') - .replaceAll('', '') - .replaceAll( - RegExp(r'Note:\s*This is a (Primary|Foreign) Key( to `[^`]+`)?\.'), - '', - ) - .trim(); - return cleaned.isEmpty ? null : cleaned; -} diff --git a/packages/supabase_typegen/lib/src/postgres_meta_parser.dart b/packages/supabase_typegen/lib/src/postgres_meta_parser.dart new file mode 100644 index 000000000..9eff89af1 --- /dev/null +++ b/packages/supabase_typegen/lib/src/postgres_meta_parser.dart @@ -0,0 +1,231 @@ +import 'schema_description.dart'; + +/// The metadata document version this parser understands. +const supportedPostgresMetaVersion = 1; + +const _integerFormats = {'int2', 'int4', 'int8'}; +const _floatingFormats = {'float4', 'float8'}; +const _textFormats = { + 'text', + 'citext', + 'varchar', + 'bpchar', + 'char', + 'uuid', + 'time', + 'timetz', + 'interval', + 'bytea', +}; +const _jsonFormats = {'json', 'jsonb'}; + +/// Derives the [ColumnTypeKind] from the postgres-meta [format] of a column, +/// for example `int8`, `timestamptz` or `_text` for a `text[]` array. This is +/// the single place where type names are compared as strings; everything +/// downstream works with the enum. +ColumnTypeKind _typeKind(String format, {required bool isEnum}) { + if (format.startsWith('_')) return ColumnTypeKind.array; + if (isEnum) return ColumnTypeKind.enumType; + if (_integerFormats.contains(format)) return ColumnTypeKind.integer; + if (_floatingFormats.contains(format)) return ColumnTypeKind.floating; + if (format == 'numeric') return ColumnTypeKind.numeric; + if (format == 'bool') return ColumnTypeKind.boolean; + if (format == 'date') return ColumnTypeKind.date; + if (format == 'timestamp') return ColumnTypeKind.timestamp; + if (format == 'timestamptz') return ColumnTypeKind.timestampWithTimeZone; + if (_textFormats.contains(format)) return ColumnTypeKind.text; + if (_jsonFormats.contains(format)) return ColumnTypeKind.json; + return ColumnTypeKind.unknown; +} + +/// The kind of the elements of an array column, where enum elements are +/// carried as their wire strings. +ColumnTypeKind _elementTypeKind(String elementFormat, {required bool isEnum}) { + final kind = _typeKind(elementFormat, isEnum: isEnum); + return kind == ColumnTypeKind.enumType ? ColumnTypeKind.text : kind; +} + +/// Parses the generator metadata document that postgres-meta emits from its +/// `json` generator (`supabase gen types --lang json`, the +/// `/generators/json` endpoint, or `PG_META_GENERATE_TYPES=json`) into a +/// [SchemaDescription] for [schemaName]. +/// +/// Throws a [FormatException] when the document does not carry the supported +/// `version`. +SchemaDescription parsePostgresMetaDocument( + Map document, { + String schemaName = 'public', +}) { + final version = document['version']; + if (version != supportedPostgresMetaVersion) { + throw FormatException( + 'Unsupported postgres-meta document version $version; this version of ' + 'supabase_typegen supports version $supportedPostgresMetaVersion.', + ); + } + + final relations = [ + for (final key in ['tables', 'foreignTables', 'views', 'materializedViews']) + ...?(document[key] as List?)?.cast>(), + ].where((relation) => relation['schema'] == schemaName); + + final columnsByRelationId = >>{}; + for (final column + in (document['columns'] as List? ?? const []) + .cast>()) { + columnsByRelationId + .putIfAbsent(column['table_id'] as int, () => []) + .add(column); + } + for (final columns in columnsByRelationId.values) { + columns.sort( + (a, b) => (a['ordinal_position'] as int).compareTo( + b['ordinal_position'] as int, + ), + ); + } + + final foreignKeysByColumn = _foreignKeysByColumn(document, schemaName); + final enumTypes = _enumTypes(document, schemaName); + + final tables = []; + final enumsByQualifiedName = {}; + + for (final relation in relations) { + final relationName = relation['name'] as String; + final primaryKeyNames = { + for (final primaryKey + in (relation['primary_keys'] as List? ?? const []) + .cast>()) + primaryKey['name'] as String, + }; + + final columns = []; + for (final column + in columnsByRelationId[relation['id'] as int] ?? const []) { + final name = column['name'] as String; + final format = column['format'] as String; + final enumValues = (column['enums'] as List? ?? const []) + .cast(); + final isEnum = enumValues.isNotEmpty; + final typeKind = _typeKind(format, isEnum: isEnum); + final isArray = typeKind == ColumnTypeKind.array; + + var postgresFormat = format; + if (isEnum && !isArray) { + final enumDescription = _enumDescription(format, enumValues, enumTypes); + postgresFormat = enumDescription.qualifiedName; + enumsByQualifiedName.putIfAbsent( + enumDescription.qualifiedName, + () => enumDescription, + ); + } + + final hasDefault = + column['default_value'] != null || + column['is_identity'] as bool || + column['is_generated'] as bool; + final isNullable = column['is_nullable'] as bool; + + columns.add( + ColumnDescription( + name: name, + postgresFormat: postgresFormat, + typeKind: typeKind, + elementTypeKind: isArray + ? _elementTypeKind(format.substring(1), isEnum: isEnum) + : null, + enumValues: isEnum ? enumValues : null, + isRequired: !isNullable && !hasDefault, + isPrimaryKey: primaryKeyNames.contains(name), + hasDefault: hasDefault, + isNullable: isNullable, + isReadOnly: + column['identity_generation'] == 'ALWAYS' || + column['is_generated'] as bool, + comment: column['comment'] as String?, + foreignKey: foreignKeysByColumn[(relationName, name)], + ), + ); + } + + tables.add( + TableDescription( + name: relationName, + comment: relation['comment'] as String?, + columns: columns, + ), + ); + } + + tables.sort((a, b) => a.name.compareTo(b.name)); + final enums = enumsByQualifiedName.values.toList() + ..sort((a, b) => a.qualifiedName.compareTo(b.qualifiedName)); + + return SchemaDescription( + schemaName: schemaName, + tables: tables, + enums: enums, + ); +} + +/// Maps `(table, column)` pairs of [schemaName] to their foreign key targets, +/// pairing the source and referenced columns of each relationship by index. +Map<(String, String), ForeignKeyDescription> _foreignKeysByColumn( + Map document, + String schemaName, +) { + final foreignKeys = <(String, String), ForeignKeyDescription>{}; + for (final relationship + in (document['relationships'] as List? ?? const []) + .cast>()) { + if (relationship['schema'] != schemaName) continue; + final table = relationship['relation'] as String; + final columns = (relationship['columns'] as List).cast(); + final referencedColumns = + (relationship['referenced_columns'] as List).cast(); + for (var i = 0; i < columns.length; i++) { + foreignKeys.putIfAbsent( + (table, columns[i]), + () => ForeignKeyDescription( + table: relationship['referenced_relation'] as String, + column: referencedColumns[i], + ), + ); + } + } + return foreignKeys; +} + +/// Maps enum type names to `(schema, values)`, preferring types of +/// [schemaName] when the same name exists in several schemas. +Map)> _enumTypes( + Map document, + String schemaName, +) { + final enumTypes = )>{}; + for (final type + in (document['types'] as List? ?? const []) + .cast>()) { + final values = (type['enums'] as List? ?? const []).cast(); + if (values.isEmpty) continue; + final name = type['name'] as String; + final schema = type['schema'] as String; + if (schema == schemaName || !enumTypes.containsKey(name)) { + enumTypes[name] = (schema, values); + } + } + return enumTypes; +} + +EnumDescription _enumDescription( + String format, + List columnEnumValues, + Map)> enumTypes, +) { + final type = enumTypes[format]; + return EnumDescription( + qualifiedName: type == null ? format : '${type.$1}.$format', + values: type == null ? columnEnumValues : type.$2, + ); +} diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart index 6ab5a59b4..657a8b176 100644 --- a/packages/supabase_typegen/lib/src/schema_description.dart +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -88,6 +88,8 @@ class ColumnDescription { required this.isRequired, required this.isPrimaryKey, required this.hasDefault, + required this.isNullable, + this.isReadOnly = false, this.elementTypeKind, this.enumValues, this.foreignKey, @@ -97,7 +99,7 @@ class ColumnDescription { /// Name of the column in the database. final String name; - /// The Postgres type, for example `bigint`, `text[]` or `public.mood`. + /// The Postgres type, for example `int8`, `_text` or `public.mood`. final String postgresFormat; /// The kind of Dart type the column maps to. @@ -127,12 +129,12 @@ class ColumnDescription { final ForeignKeyDescription? foreignKey; /// Whether the column can be `null` in query results. - /// - /// Derived from the OpenAPI description: columns in the `required` list are - /// `NOT NULL`, and primary keys are always `NOT NULL`. Other columns are - /// treated as nullable, which is safe but over-approximates for `NOT NULL` - /// columns that have a database default. - bool get isNullable => !isRequired && !isPrimaryKey; + final bool isNullable; + + /// Whether the column can never be written, because it is a + /// `GENERATED ALWAYS` identity or a generated column. Read-only columns + /// appear in the row type but not in the insert and update value types. + final bool isReadOnly; } /// The target of a foreign key column. diff --git a/packages/supabase_typegen/lib/supabase_typegen.dart b/packages/supabase_typegen/lib/supabase_typegen.dart index 1373314f0..bb41ad3a1 100644 --- a/packages/supabase_typegen/lib/supabase_typegen.dart +++ b/packages/supabase_typegen/lib/supabase_typegen.dart @@ -4,5 +4,5 @@ library; export 'src/dart_generator.dart'; export 'src/identifiers.dart'; -export 'src/openapi_parser.dart'; +export 'src/postgres_meta_parser.dart'; export 'src/schema_description.dart'; diff --git a/packages/supabase_typegen/pubspec.yaml b/packages/supabase_typegen/pubspec.yaml index 657ceed93..6c5182af1 100644 --- a/packages/supabase_typegen/pubspec.yaml +++ b/packages/supabase_typegen/pubspec.yaml @@ -20,9 +20,9 @@ executables: dependencies: args: ^2.7.0 dart_style: ^3.1.0 - http: ^1.6.0 dev_dependencies: + http: ^1.6.0 postgrest: ^2.9.0 supabase_lints: ^0.1.1 test: ^1.25.0 diff --git a/packages/supabase_typegen/test/dart_generator_test.dart b/packages/supabase_typegen/test/dart_generator_test.dart index 82ddbc115..511ec4950 100644 --- a/packages/supabase_typegen/test/dart_generator_test.dart +++ b/packages/supabase_typegen/test/dart_generator_test.dart @@ -15,9 +15,13 @@ void main() { setUpAll(() { final document = - jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + jsonDecode( + File( + 'test/fixtures/postgres_meta_schema.json', + ).readAsStringSync(), + ) as Map; - schema = parseOpenApiDocument(document); + schema = parsePostgresMetaDocument(document); }); test('matches the golden output', () { @@ -50,4 +54,25 @@ void main() { expect(code, contains('required String title')); expect(code, contains('int? id')); }); + + test('not null columns with a default read non-nullable', () { + final code = generateDartCode(schema); + + expect(code, contains("bool get inPrint => _json['in_print'] as bool;")); + expect( + code, + contains( + "DateTime get createdAt => " + "DateTime.parse(_json['created_at'] as String);", + ), + ); + }); + + test('always generated columns are excluded from insert and update', () { + final code = generateDartCode(schema); + + expect(code, contains('AuthorsInsert({required String name})')); + expect(code, contains('AuthorsUpdate({String? name})')); + expect(code, contains("TableColumn('id')")); + }); } diff --git a/packages/supabase_typegen/test/fixtures/openapi.json b/packages/supabase_typegen/test/fixtures/openapi.json deleted file mode 100644 index 8b215ab99..000000000 --- a/packages/supabase_typegen/test/fixtures/openapi.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "PostgREST API", - "description": "standard public schema", - "version": "12.2.0" - }, - "definitions": { - "books": { - "description": "Books available in the library", - "required": [ - "title", - "author_id" - ], - "properties": { - "id": { - "description": "Note:\nThis is a Primary Key.", - "format": "bigint", - "type": "integer", - "default": "nextval('books_id_seq'::regclass)" - }, - "title": { - "format": "text", - "type": "string" - }, - "author_id": { - "description": "Note:\nThis is a Foreign Key to `authors.id`.", - "format": "bigint", - "type": "integer" - }, - "price": { - "format": "numeric", - "type": "number" - }, - "rating": { - "format": "double precision", - "type": "number" - }, - "in_print": { - "format": "boolean", - "type": "boolean", - "default": true - }, - "mood": { - "enum": [ - "happy", - "very happy", - "sad" - ], - "format": "public.mood", - "type": "string" - }, - "tags": { - "format": "text[]", - "type": "array", - "items": { - "type": "string" - } - }, - "page_counts": { - "format": "integer[]", - "type": "array", - "items": { - "type": "integer" - } - }, - "metadata": { - "format": "jsonb" - }, - "cover_uuid": { - "format": "uuid", - "type": "string" - }, - "published_on": { - "format": "date", - "type": "string" - }, - "created_at": { - "description": "When the row was created", - "format": "timestamp with time zone", - "type": "string", - "default": "now()" - }, - "updated_at": { - "format": "timestamp without time zone", - "type": "string" - } - }, - "type": "object" - }, - "authors": { - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "description": "Note:\nThis is a Primary Key.", - "format": "bigint", - "type": "integer" - }, - "name": { - "format": "text", - "type": "string" - } - }, - "type": "object" - }, - "author_stats": { - "description": "Aggregated statistics per author", - "properties": { - "author_id": { - "format": "bigint", - "type": "integer" - }, - "book_count": { - "format": "bigint", - "type": "integer" - } - }, - "type": "object" - } - }, - "paths": {} -} diff --git a/packages/supabase_typegen/test/fixtures/postgres_meta_schema.json b/packages/supabase_typegen/test/fixtures/postgres_meta_schema.json new file mode 100644 index 000000000..f0058015b --- /dev/null +++ b/packages/supabase_typegen/test/fixtures/postgres_meta_schema.json @@ -0,0 +1,440 @@ +{ + "version": 1, + "schemas": [ + { "id": 2200, "name": "public", "owner": "postgres" } + ], + "tables": [ + { + "id": 16385, + "schema": "public", + "name": "books", + "rls_enabled": true, + "rls_forced": false, + "replica_identity": "DEFAULT", + "bytes": 8192, + "size": "8192 bytes", + "live_rows_estimate": 0, + "dead_rows_estimate": 0, + "comment": "Books available in the library", + "primary_keys": [ + { "schema": "public", "table_name": "books", "name": "id", "table_id": 16385 } + ], + "relationships": [] + }, + { + "id": 16401, + "schema": "public", + "name": "authors", + "rls_enabled": true, + "rls_forced": false, + "replica_identity": "DEFAULT", + "bytes": 8192, + "size": "8192 bytes", + "live_rows_estimate": 0, + "dead_rows_estimate": 0, + "comment": null, + "primary_keys": [ + { "schema": "public", "table_name": "authors", "name": "id", "table_id": 16401 } + ], + "relationships": [] + } + ], + "foreignTables": [], + "views": [ + { + "id": 16420, + "schema": "public", + "name": "author_stats", + "is_updatable": false, + "comment": "Aggregated statistics per author" + } + ], + "materializedViews": [], + "columns": [ + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.1", + "ordinal_position": 1, + "name": "id", + "default_value": null, + "data_type": "bigint", + "format": "int8", + "is_identity": true, + "identity_generation": "BY DEFAULT", + "is_generated": false, + "is_nullable": false, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.2", + "ordinal_position": 2, + "name": "title", + "default_value": null, + "data_type": "text", + "format": "text", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": false, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.3", + "ordinal_position": 3, + "name": "author_id", + "default_value": null, + "data_type": "bigint", + "format": "int8", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": false, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.4", + "ordinal_position": 4, + "name": "price", + "default_value": null, + "data_type": "numeric", + "format": "numeric", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.5", + "ordinal_position": 5, + "name": "rating", + "default_value": null, + "data_type": "double precision", + "format": "float8", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.6", + "ordinal_position": 6, + "name": "in_print", + "default_value": "true", + "data_type": "boolean", + "format": "bool", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": false, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.7", + "ordinal_position": 7, + "name": "mood", + "default_value": null, + "data_type": "USER-DEFINED", + "format": "mood", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": ["happy", "very happy", "sad"], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.8", + "ordinal_position": 8, + "name": "tags", + "default_value": null, + "data_type": "ARRAY", + "format": "_text", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.9", + "ordinal_position": 9, + "name": "page_counts", + "default_value": null, + "data_type": "ARRAY", + "format": "_int4", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.10", + "ordinal_position": 10, + "name": "metadata", + "default_value": null, + "data_type": "jsonb", + "format": "jsonb", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.11", + "ordinal_position": 11, + "name": "cover_uuid", + "default_value": null, + "data_type": "uuid", + "format": "uuid", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.12", + "ordinal_position": 12, + "name": "published_on", + "default_value": null, + "data_type": "date", + "format": "date", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.13", + "ordinal_position": 13, + "name": "created_at", + "default_value": "now()", + "data_type": "timestamp with time zone", + "format": "timestamptz", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": false, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": "When the row was created" + }, + { + "table_id": 16385, + "schema": "public", + "table": "books", + "id": "16385.14", + "ordinal_position": 14, + "name": "updated_at", + "default_value": null, + "data_type": "timestamp without time zone", + "format": "timestamp", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16401, + "schema": "public", + "table": "authors", + "id": "16401.1", + "ordinal_position": 1, + "name": "id", + "default_value": null, + "data_type": "bigint", + "format": "int8", + "is_identity": true, + "identity_generation": "ALWAYS", + "is_generated": false, + "is_nullable": false, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16401, + "schema": "public", + "table": "authors", + "id": "16401.2", + "ordinal_position": 2, + "name": "name", + "default_value": null, + "data_type": "text", + "format": "text", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": false, + "is_updatable": true, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16420, + "schema": "public", + "table": "author_stats", + "id": "16420.1", + "ordinal_position": 1, + "name": "author_id", + "default_value": null, + "data_type": "bigint", + "format": "int8", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": false, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + }, + { + "table_id": 16420, + "schema": "public", + "table": "author_stats", + "id": "16420.2", + "ordinal_position": 2, + "name": "book_count", + "default_value": null, + "data_type": "bigint", + "format": "int8", + "is_identity": false, + "identity_generation": null, + "is_generated": false, + "is_nullable": true, + "is_updatable": false, + "is_unique": false, + "enums": [], + "check": null, + "comment": null + } + ], + "relationships": [ + { + "foreign_key_name": "books_author_id_fkey", + "schema": "public", + "relation": "books", + "columns": ["author_id"], + "is_one_to_one": false, + "referenced_schema": "public", + "referenced_relation": "authors", + "referenced_columns": ["id"] + } + ], + "functions": [], + "types": [ + { + "id": 16390, + "name": "mood", + "schema": "public", + "format": "mood", + "enums": ["happy", "very happy", "sad"], + "attributes": [], + "comment": null, + "type_relation_id": null + } + ] +} diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 2c749c1e2..31ada9d93 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -51,6 +51,7 @@ void main() { 'author_id': 7, 'price': 12.5, 'rating': 4, + 'in_print': true, 'mood': 'very happy', 'tags': ['dart', 'types'], 'metadata': {'reprint': true}, @@ -65,6 +66,7 @@ void main() { expect(book.id, 1); expect(book.title, 'A typed row'); expect(book.rating, 4.0); + expect(book.inPrint, isTrue); expect(book.mood, Mood.veryHappy); expect(book.tags, ['dart', 'types']); expect(book.metadata, {'reprint': true}); diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index e73e20e7f..bc2df41d5 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -40,7 +40,7 @@ extension type const AuthorStatsRow(Map _json) int? get bookCount => _json['book_count'] as int?; } -/// Values for inserting a row into `author_stats`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. +/// Values for inserting a row into `author_stats`. Columns that are nullable, identity, or covered by a database default are optional; passing `null` omits the column so the database default applies. Columns the database always generates itself are left out entirely. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const AuthorStatsInsert._(Map _json) implements Map { AuthorStatsInsert({int? authorId, int? bookCount}) @@ -88,17 +88,16 @@ extension type const AuthorsRow(Map _json) String get name => _json['name'] as String; } -/// Values for inserting a row into `authors`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. +/// Values for inserting a row into `authors`. Columns that are nullable, identity, or covered by a database default are optional; passing `null` omits the column so the database default applies. Columns the database always generates itself are left out entirely. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const AuthorsInsert._(Map _json) implements Map { - AuthorsInsert({required int id, required String name}) - : this._({'id': id, 'name': name}); + AuthorsInsert({required String name}) : this._({'name': name}); } /// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. extension type const AuthorsUpdate._(Map _json) implements Map { - AuthorsUpdate({int? id, String? name}) : this._({'id': ?id, 'name': ?name}); + AuthorsUpdate({String? name}) : this._({'name': ?name}); } /// Typed access to the `authors` table. @@ -121,7 +120,7 @@ extension type const BooksRow(Map _json) int get authorId => _json['author_id'] as int; num? get price => _json['price'] as num?; double? get rating => (_json['rating'] as num?)?.toDouble(); - bool? get inPrint => _json['in_print'] as bool?; + bool get inPrint => _json['in_print'] as bool; Mood? get mood => switch (_json['mood']) { null => null, final Object value => Mood.fromWire(value as String), @@ -136,17 +135,14 @@ extension type const BooksRow(Map _json) }; /// When the row was created - DateTime? get createdAt => switch (_json['created_at']) { - null => null, - final Object value => DateTime.parse(value as String), - }; + DateTime get createdAt => DateTime.parse(_json['created_at'] as String); DateTime? get updatedAt => switch (_json['updated_at']) { null => null, final Object value => DateTime.parse(value as String), }; } -/// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. +/// Values for inserting a row into `books`. Columns that are nullable, identity, or covered by a database default are optional; passing `null` omits the column so the database default applies. Columns the database always generates itself are left out entirely. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const BooksInsert._(Map _json) implements Map { BooksInsert({ @@ -190,9 +186,6 @@ extension type const BooksInsert._(Map _json) /// Returns a copy with `rating` set to SQL NULL, overriding any database default. BooksInsert setRatingToNull() => BooksInsert._({..._json, 'rating': null}); - /// Returns a copy with `in_print` set to SQL NULL, overriding any database default. - BooksInsert setInPrintToNull() => BooksInsert._({..._json, 'in_print': null}); - /// Returns a copy with `mood` set to SQL NULL, overriding any database default. BooksInsert setMoodToNull() => BooksInsert._({..._json, 'mood': null}); @@ -215,10 +208,6 @@ extension type const BooksInsert._(Map _json) BooksInsert setPublishedOnToNull() => BooksInsert._({..._json, 'published_on': null}); - /// Returns a copy with `created_at` set to SQL NULL, overriding any database default. - BooksInsert setCreatedAtToNull() => - BooksInsert._({..._json, 'created_at': null}); - /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. BooksInsert setUpdatedAtToNull() => BooksInsert._({..._json, 'updated_at': null}); @@ -268,9 +257,6 @@ extension type const BooksUpdate._(Map _json) /// Returns a copy with `rating` set to SQL NULL, overriding any database default. BooksUpdate setRatingToNull() => BooksUpdate._({..._json, 'rating': null}); - /// Returns a copy with `in_print` set to SQL NULL, overriding any database default. - BooksUpdate setInPrintToNull() => BooksUpdate._({..._json, 'in_print': null}); - /// Returns a copy with `mood` set to SQL NULL, overriding any database default. BooksUpdate setMoodToNull() => BooksUpdate._({..._json, 'mood': null}); @@ -293,10 +279,6 @@ extension type const BooksUpdate._(Map _json) BooksUpdate setPublishedOnToNull() => BooksUpdate._({..._json, 'published_on': null}); - /// Returns a copy with `created_at` set to SQL NULL, overriding any database default. - BooksUpdate setCreatedAtToNull() => - BooksUpdate._({..._json, 'created_at': null}); - /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. BooksUpdate setUpdatedAtToNull() => BooksUpdate._({..._json, 'updated_at': null}); diff --git a/packages/supabase_typegen/test/openapi_parser_test.dart b/packages/supabase_typegen/test/postgres_meta_parser_test.dart similarity index 52% rename from packages/supabase_typegen/test/openapi_parser_test.dart rename to packages/supabase_typegen/test/postgres_meta_parser_test.dart index 2c71355f7..043e51cab 100644 --- a/packages/supabase_typegen/test/openapi_parser_test.dart +++ b/packages/supabase_typegen/test/postgres_meta_parser_test.dart @@ -5,16 +5,34 @@ import 'package:supabase_typegen/supabase_typegen.dart'; import 'package:test/test.dart'; void main() { + late Map document; late SchemaDescription schema; setUpAll(() { - final document = - jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + document = + jsonDecode( + File( + 'test/fixtures/postgres_meta_schema.json', + ).readAsStringSync(), + ) as Map; - schema = parseOpenApiDocument(document); + schema = parsePostgresMetaDocument(document); }); - test('parses all tables sorted by name', () { + test('rejects unsupported document versions', () { + expect( + () => parsePostgresMetaDocument({...document, 'version': 2}), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('version 2'), + ), + ), + ); + }); + + test('parses tables and views sorted by name', () { expect(schema.tables.map((table) => table.name), [ 'author_stats', 'authors', @@ -22,18 +40,24 @@ void main() { ]); }); - test('parses table comments', () { + test('parses table and view comments', () { final books = schema.tables.singleWhere((table) => table.name == 'books'); expect(books.comment, 'Books available in the library'); + + final authorStats = schema.tables.singleWhere( + (table) => table.name == 'author_stats', + ); + expect(authorStats.comment, 'Aggregated statistics per author'); }); - test('parses primary keys, requiredness and defaults', () { + test('parses primary keys, requiredness, defaults and nullability', () { final books = schema.tables.singleWhere((table) => table.name == 'books'); final id = books.columns.singleWhere((column) => column.name == 'id'); expect(id.isPrimaryKey, isTrue); expect(id.isRequired, isFalse); expect(id.hasDefault, isTrue); expect(id.isNullable, isFalse); + expect(id.isReadOnly, isFalse); final title = books.columns.singleWhere((column) => column.name == 'title'); expect(title.isRequired, isTrue); @@ -44,7 +68,34 @@ void main() { expect(price.isNullable, isTrue); }); - test('parses foreign keys', () { + test('not null columns with a database default are non-nullable reads ' + 'but optional writes', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final inPrint = books.columns.singleWhere( + (column) => column.name == 'in_print', + ); + expect(inPrint.isNullable, isFalse); + expect(inPrint.isRequired, isFalse); + expect(inPrint.hasDefault, isTrue); + + final createdAt = books.columns.singleWhere( + (column) => column.name == 'created_at', + ); + expect(createdAt.isNullable, isFalse); + expect(createdAt.isRequired, isFalse); + }); + + test('always generated identity columns are read-only', () { + final authors = schema.tables.singleWhere( + (table) => table.name == 'authors', + ); + final id = authors.columns.singleWhere((column) => column.name == 'id'); + expect(id.isReadOnly, isTrue); + expect(id.isRequired, isFalse); + expect(id.isNullable, isFalse); + }); + + test('parses foreign keys from the relationships', () { final books = schema.tables.singleWhere((table) => table.name == 'books'); final authorId = books.columns.singleWhere( (column) => column.name == 'author_id', @@ -71,23 +122,34 @@ void main() { expect(kindOf('cover_uuid'), ColumnTypeKind.text); }); - test('collects Postgres enums', () { + test('collects Postgres enums with their schema qualification', () { expect(schema.enums, hasLength(1)); final mood = schema.enums.single; expect(mood.qualifiedName, 'public.mood'); expect(mood.name, 'mood'); expect(mood.values, ['happy', 'very happy', 'sad']); + + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final moodColumn = books.columns.singleWhere( + (column) => column.name == 'mood', + ); + expect(moodColumn.postgresFormat, 'public.mood'); }); test('parses array columns', () { final books = schema.tables.singleWhere((table) => table.name == 'books'); final tags = books.columns.singleWhere((column) => column.name == 'tags'); - expect(tags.postgresFormat, 'text[]'); + expect(tags.postgresFormat, '_text'); expect(tags.typeKind, ColumnTypeKind.array); expect(tags.elementTypeKind, ColumnTypeKind.text); + + final pageCounts = books.columns.singleWhere( + (column) => column.name == 'page_counts', + ); + expect(pageCounts.elementTypeKind, ColumnTypeKind.integer); }); - test('keeps human column comments without the key markers', () { + test('keeps column comments', () { final books = schema.tables.singleWhere((table) => table.name == 'books'); final id = books.columns.singleWhere((column) => column.name == 'id'); expect(id.comment, isNull); @@ -97,4 +159,15 @@ void main() { ); expect(createdAt.comment, 'When the row was created'); }); + + test('view columns come through like table columns', () { + final authorStats = schema.tables.singleWhere( + (table) => table.name == 'author_stats', + ); + expect(authorStats.columns.map((column) => column.name), [ + 'author_id', + 'book_count', + ]); + expect(authorStats.columns.first.isNullable, isTrue); + }); } diff --git a/packages/supabase_typegen/tool/regenerate_goldens.dart b/packages/supabase_typegen/tool/regenerate_goldens.dart index 9d453d5dd..0dd1e046f 100644 --- a/packages/supabase_typegen/tool/regenerate_goldens.dart +++ b/packages/supabase_typegen/tool/regenerate_goldens.dart @@ -8,9 +8,11 @@ import 'package:supabase_typegen/supabase_typegen.dart'; /// Run from the package root with `dart run tool/regenerate_goldens.dart`. void main() { final document = - jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + jsonDecode( + File('test/fixtures/postgres_meta_schema.json').readAsStringSync(), + ) as Map; - final schema = parseOpenApiDocument(document); + final schema = parsePostgresMetaDocument(document); File( 'test/goldens/supabase_schema.dart', ).writeAsStringSync(generateDartCode(schema)); diff --git a/pubspec.lock b/pubspec.lock index d9b31859a..3f4d56504 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: build_web_compilers - sha256: c8be4b48f09289d145c7eaa3240f1e7776c529ea1cecddf483218edd3129de3f + sha256: ef4bc35e0e335f8520692a333b872446697adce0618cf23c3084715ace641721 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.8.5" built_collection: dependency: transitive description: @@ -507,10 +507,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -531,10 +531,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -968,26 +968,26 @@ packages: dependency: transitive description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" timezone: dependency: transitive description: @@ -1088,10 +1088,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -1197,5 +1197,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.12.0 <3.13.0-z" + dart: ">=3.13.0-107.0.dev <3.14.0-z" flutter: ">=3.44.0" From d99442d1fa19b4fc50d27cfa817423e163ccf3ab Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 17 Aug 2026 15:28:45 +0200 Subject: [PATCH 17/25] fix(supabase_typegen): wrap generated doc comments at 80 characters --- .../lib/src/dart_generator.dart | 44 ++++++--- .../test/goldens/supabase_schema.dart | 96 +++++++++++++------ 2 files changed, 100 insertions(+), 40 deletions(-) diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index 6944dbbc2..468817e7d 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -223,8 +223,8 @@ void _writeValues( if (!column.isReadOnly) column, ]; + _writeDocComment(buffer, docLine); buffer - ..writeln('/// $docLine') ..writeln('extension type const $typeName._(Map _json)') ..writeln(' implements Map {') ..writeln(' $typeName({'); @@ -257,16 +257,17 @@ void _writeValues( if (!column.isNullable) continue; final name = memberNames[column.name]!; final methodName = 'set${name[0].toUpperCase()}${name.substring(1)}ToNull'; - buffer - ..writeln() - ..writeln( - ' /// Returns a copy with `${column.name}` set to SQL NULL, ' - 'overriding any database default.', - ) - ..writeln( - ' $typeName $methodName() => ' - '$typeName._({..._json, ${_stringLiteral(column.name)}: null});', - ); + buffer.writeln(); + _writeDocComment( + buffer, + 'Returns a copy with `${column.name}` set to SQL NULL, overriding any ' + 'database default.', + indent: ' ', + ); + buffer.writeln( + ' $typeName $methodName() => ' + '$typeName._({..._json, ${_stringLiteral(column.name)}: null});', + ); } buffer ..writeln('}') @@ -456,9 +457,28 @@ void _writeDocComment( String indent = '', }) { if (comment == null) return; + final width = 80 - indent.length - '/// '.length; for (final line in comment.trim().split('\n')) { - buffer.writeln('$indent/// ${line.trim()}'); + for (final wrapped in _wrap(line.trim(), width)) { + buffer.writeln('$indent/// $wrapped'); + } + } +} + +/// Greedily wraps [text] into lines of at most [width] characters, keeping +/// words longer than [width] on their own line. +Iterable _wrap(String text, int width) sync* { + final words = text.split(' ').where((word) => word.isNotEmpty); + final line = StringBuffer(); + for (final word in words) { + if (line.isNotEmpty && line.length + 1 + word.length > width) { + yield line.toString(); + line.clear(); + } + if (line.isNotEmpty) line.write(' '); + line.write(word); } + if (line.isNotEmpty) yield line.toString(); } String _stringLiteral(String value) { diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index bc2df41d5..de46fcc15 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -40,32 +40,42 @@ extension type const AuthorStatsRow(Map _json) int? get bookCount => _json['book_count'] as int?; } -/// Values for inserting a row into `author_stats`. Columns that are nullable, identity, or covered by a database default are optional; passing `null` omits the column so the database default applies. Columns the database always generates itself are left out entirely. Use the `set…ToNull` methods to insert SQL NULL explicitly. +/// Values for inserting a row into `author_stats`. Columns that are nullable, +/// identity, or covered by a database default are optional; passing `null` +/// omits the column so the database default applies. Columns the database +/// always generates itself are left out entirely. Use the `set…ToNull` methods +/// to insert SQL NULL explicitly. extension type const AuthorStatsInsert._(Map _json) implements Map { AuthorStatsInsert({int? authorId, int? bookCount}) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); - /// Returns a copy with `author_id` set to SQL NULL, overriding any database default. + /// Returns a copy with `author_id` set to SQL NULL, overriding any database + /// default. AuthorStatsInsert setAuthorIdToNull() => AuthorStatsInsert._({..._json, 'author_id': null}); - /// Returns a copy with `book_count` set to SQL NULL, overriding any database default. + /// Returns a copy with `book_count` set to SQL NULL, overriding any database + /// default. AuthorStatsInsert setBookCountToNull() => AuthorStatsInsert._({..._json, 'book_count': null}); } -/// Values for updating rows of `author_stats`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. +/// Values for updating rows of `author_stats`. All columns are optional; +/// passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` +/// methods to write SQL NULL explicitly. extension type const AuthorStatsUpdate._(Map _json) implements Map { AuthorStatsUpdate({int? authorId, int? bookCount}) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); - /// Returns a copy with `author_id` set to SQL NULL, overriding any database default. + /// Returns a copy with `author_id` set to SQL NULL, overriding any database + /// default. AuthorStatsUpdate setAuthorIdToNull() => AuthorStatsUpdate._({..._json, 'author_id': null}); - /// Returns a copy with `book_count` set to SQL NULL, overriding any database default. + /// Returns a copy with `book_count` set to SQL NULL, overriding any database + /// default. AuthorStatsUpdate setBookCountToNull() => AuthorStatsUpdate._({..._json, 'book_count': null}); } @@ -88,13 +98,19 @@ extension type const AuthorsRow(Map _json) String get name => _json['name'] as String; } -/// Values for inserting a row into `authors`. Columns that are nullable, identity, or covered by a database default are optional; passing `null` omits the column so the database default applies. Columns the database always generates itself are left out entirely. Use the `set…ToNull` methods to insert SQL NULL explicitly. +/// Values for inserting a row into `authors`. Columns that are nullable, +/// identity, or covered by a database default are optional; passing `null` +/// omits the column so the database default applies. Columns the database +/// always generates itself are left out entirely. Use the `set…ToNull` methods +/// to insert SQL NULL explicitly. extension type const AuthorsInsert._(Map _json) implements Map { AuthorsInsert({required String name}) : this._({'name': name}); } -/// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. +/// Values for updating rows of `authors`. All columns are optional; passing +/// `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods +/// to write SQL NULL explicitly. extension type const AuthorsUpdate._(Map _json) implements Map { AuthorsUpdate({String? name}) : this._({'name': ?name}); @@ -142,7 +158,11 @@ extension type const BooksRow(Map _json) }; } -/// Values for inserting a row into `books`. Columns that are nullable, identity, or covered by a database default are optional; passing `null` omits the column so the database default applies. Columns the database always generates itself are left out entirely. Use the `set…ToNull` methods to insert SQL NULL explicitly. +/// Values for inserting a row into `books`. Columns that are nullable, +/// identity, or covered by a database default are optional; passing `null` +/// omits the column so the database default applies. Columns the database +/// always generates itself are left out entirely. Use the `set…ToNull` methods +/// to insert SQL NULL explicitly. extension type const BooksInsert._(Map _json) implements Map { BooksInsert({ @@ -180,40 +200,51 @@ extension type const BooksInsert._(Map _json) 'updated_at': ?updatedAt?.toIso8601String(), }); - /// Returns a copy with `price` set to SQL NULL, overriding any database default. + /// Returns a copy with `price` set to SQL NULL, overriding any database + /// default. BooksInsert setPriceToNull() => BooksInsert._({..._json, 'price': null}); - /// Returns a copy with `rating` set to SQL NULL, overriding any database default. + /// Returns a copy with `rating` set to SQL NULL, overriding any database + /// default. BooksInsert setRatingToNull() => BooksInsert._({..._json, 'rating': null}); - /// Returns a copy with `mood` set to SQL NULL, overriding any database default. + /// Returns a copy with `mood` set to SQL NULL, overriding any database + /// default. BooksInsert setMoodToNull() => BooksInsert._({..._json, 'mood': null}); - /// Returns a copy with `tags` set to SQL NULL, overriding any database default. + /// Returns a copy with `tags` set to SQL NULL, overriding any database + /// default. BooksInsert setTagsToNull() => BooksInsert._({..._json, 'tags': null}); - /// Returns a copy with `page_counts` set to SQL NULL, overriding any database default. + /// Returns a copy with `page_counts` set to SQL NULL, overriding any database + /// default. BooksInsert setPageCountsToNull() => BooksInsert._({..._json, 'page_counts': null}); - /// Returns a copy with `metadata` set to SQL NULL, overriding any database default. + /// Returns a copy with `metadata` set to SQL NULL, overriding any database + /// default. BooksInsert setMetadataToNull() => BooksInsert._({..._json, 'metadata': null}); - /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database default. + /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database + /// default. BooksInsert setCoverUuidToNull() => BooksInsert._({..._json, 'cover_uuid': null}); - /// Returns a copy with `published_on` set to SQL NULL, overriding any database default. + /// Returns a copy with `published_on` set to SQL NULL, overriding any + /// database default. BooksInsert setPublishedOnToNull() => BooksInsert._({..._json, 'published_on': null}); - /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. + /// Returns a copy with `updated_at` set to SQL NULL, overriding any database + /// default. BooksInsert setUpdatedAtToNull() => BooksInsert._({..._json, 'updated_at': null}); } -/// Values for updating rows of `books`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. +/// Values for updating rows of `books`. All columns are optional; passing +/// `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods +/// to write SQL NULL explicitly. extension type const BooksUpdate._(Map _json) implements Map { BooksUpdate({ @@ -251,35 +282,44 @@ extension type const BooksUpdate._(Map _json) 'updated_at': ?updatedAt?.toIso8601String(), }); - /// Returns a copy with `price` set to SQL NULL, overriding any database default. + /// Returns a copy with `price` set to SQL NULL, overriding any database + /// default. BooksUpdate setPriceToNull() => BooksUpdate._({..._json, 'price': null}); - /// Returns a copy with `rating` set to SQL NULL, overriding any database default. + /// Returns a copy with `rating` set to SQL NULL, overriding any database + /// default. BooksUpdate setRatingToNull() => BooksUpdate._({..._json, 'rating': null}); - /// Returns a copy with `mood` set to SQL NULL, overriding any database default. + /// Returns a copy with `mood` set to SQL NULL, overriding any database + /// default. BooksUpdate setMoodToNull() => BooksUpdate._({..._json, 'mood': null}); - /// Returns a copy with `tags` set to SQL NULL, overriding any database default. + /// Returns a copy with `tags` set to SQL NULL, overriding any database + /// default. BooksUpdate setTagsToNull() => BooksUpdate._({..._json, 'tags': null}); - /// Returns a copy with `page_counts` set to SQL NULL, overriding any database default. + /// Returns a copy with `page_counts` set to SQL NULL, overriding any database + /// default. BooksUpdate setPageCountsToNull() => BooksUpdate._({..._json, 'page_counts': null}); - /// Returns a copy with `metadata` set to SQL NULL, overriding any database default. + /// Returns a copy with `metadata` set to SQL NULL, overriding any database + /// default. BooksUpdate setMetadataToNull() => BooksUpdate._({..._json, 'metadata': null}); - /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database default. + /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database + /// default. BooksUpdate setCoverUuidToNull() => BooksUpdate._({..._json, 'cover_uuid': null}); - /// Returns a copy with `published_on` set to SQL NULL, overriding any database default. + /// Returns a copy with `published_on` set to SQL NULL, overriding any + /// database default. BooksUpdate setPublishedOnToNull() => BooksUpdate._({..._json, 'published_on': null}); - /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. + /// Returns a copy with `updated_at` set to SQL NULL, overriding any database + /// default. BooksUpdate setUpdatedAtToNull() => BooksUpdate._({..._json, 'updated_at': null}); } From 2e6c294ee7bb5eff12620dfb293934c98515938a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 17 Aug 2026 16:13:37 +0200 Subject: [PATCH 18/25] feat(supabase_typegen): support writing the generated code to stdout with --output - --- .../bin/supabase_typegen.dart | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/supabase_typegen/bin/supabase_typegen.dart b/packages/supabase_typegen/bin/supabase_typegen.dart index 57af6626a..71cd1f5e7 100644 --- a/packages/supabase_typegen/bin/supabase_typegen.dart +++ b/packages/supabase_typegen/bin/supabase_typegen.dart @@ -22,7 +22,7 @@ final _argParser = ArgParser() 'output', abbr: 'o', defaultsTo: 'lib/supabase_schema.g.dart', - help: 'Path of the generated Dart file.', + help: 'Path of the generated Dart file, or - to write the code to stdout.', ) ..addOption( 'import', @@ -104,16 +104,25 @@ Future _run(List arguments) async { final code = generateDartCode(schema, importUri: options.option('import')!); - final outputFile = File(options.option('output')!); - outputFile.parent.createSync(recursive: true); - outputFile.writeAsStringSync(code); + final output = options.option('output')!; + final String generatedInto; + if (output == '-') { + stdout.write(code); + generatedInto = 'stdout'; + } else { + final outputFile = File(output); + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(code); + generatedInto = outputFile.path; + } final emittedTables = schema.tables .where((table) => table.columns.isNotEmpty) .length; final skippedTables = schema.tables.length - emittedTables; - stdout.writeln( - 'Generated ${outputFile.path} with $emittedTables tables and ' + final summarySink = output == '-' ? stderr : stdout; + summarySink.writeln( + 'Generated $generatedInto with $emittedTables tables and ' '${schema.enums.length} enums from schema "$schemaName".' '${skippedTables == 0 ? '' : ' Skipped $skippedTables tables ' 'without columns.'}', From 32f279c709969c6fba8564633f76b4916c256796 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 18 Aug 2026 09:39:30 +0200 Subject: [PATCH 19/25] fix(supabase_typegen): handle all-read-only tables and string-serialized Postgres types --- .../lib/src/dart_generator.dart | 54 ++++++++++--------- .../lib/src/postgres_meta_parser.dart | 15 +++++- .../test/dart_generator_test.dart | 27 ++++++++++ .../test/postgres_meta_parser_test.dart | 44 +++++++++++++++ 4 files changed, 115 insertions(+), 25 deletions(-) diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index 468817e7d..ddf769620 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -226,33 +226,39 @@ void _writeValues( _writeDocComment(buffer, docLine); buffer ..writeln('extension type const $typeName._(Map _json)') - ..writeln(' implements Map {') - ..writeln(' $typeName({'); - for (final column in writableColumns) { - final binding = bindings[column.name]!; - final name = memberNames[column.name]!; - if (isRequired(column)) { - buffer.writeln(' required ${binding.dartType} $name,'); - } else { - buffer.writeln(' ${binding.dartType}? $name,'); + ..writeln(' implements Map {'); + if (writableColumns.isEmpty) { + // A named parameter list cannot be empty, so a table whose columns are + // all read-only gets a parameterless constructor. + buffer.writeln(' $typeName() : this._({});'); + } else { + buffer.writeln(' $typeName({'); + for (final column in writableColumns) { + final binding = bindings[column.name]!; + final name = memberNames[column.name]!; + if (isRequired(column)) { + buffer.writeln(' required ${binding.dartType} $name,'); + } else { + buffer.writeln(' ${binding.dartType}? $name,'); + } } - } - buffer.writeln(' }) : this._({'); - for (final column in writableColumns) { - final binding = bindings[column.name]!; - final name = memberNames[column.name]!; - final key = _stringLiteral(column.name); - if (isRequired(column)) { - buffer.writeln( - ' $key: ${_writeExpression(name, binding, nullable: false)},', - ); - } else { - buffer.writeln( - ' $key: ?${_writeExpression(name, binding, nullable: true)},', - ); + buffer.writeln(' }) : this._({'); + for (final column in writableColumns) { + final binding = bindings[column.name]!; + final name = memberNames[column.name]!; + final key = _stringLiteral(column.name); + if (isRequired(column)) { + buffer.writeln( + ' $key: ${_writeExpression(name, binding, nullable: false)},', + ); + } else { + buffer.writeln( + ' $key: ?${_writeExpression(name, binding, nullable: true)},', + ); + } } + buffer.writeln(' });'); } - buffer.writeln(' });'); for (final column in writableColumns) { if (!column.isNullable) continue; final name = memberNames[column.name]!; diff --git a/packages/supabase_typegen/lib/src/postgres_meta_parser.dart b/packages/supabase_typegen/lib/src/postgres_meta_parser.dart index 9eff89af1..ca227cf1f 100644 --- a/packages/supabase_typegen/lib/src/postgres_meta_parser.dart +++ b/packages/supabase_typegen/lib/src/postgres_meta_parser.dart @@ -3,19 +3,32 @@ import 'schema_description.dart'; /// The metadata document version this parser understands. const supportedPostgresMetaVersion = 1; -const _integerFormats = {'int2', 'int4', 'int8'}; +const _integerFormats = {'int2', 'int4', 'int8', 'oid'}; const _floatingFormats = {'float4', 'float8'}; + +/// Types that PostgREST serializes as JSON strings. const _textFormats = { 'text', 'citext', 'varchar', 'bpchar', 'char', + 'name', 'uuid', 'time', 'timetz', 'interval', 'bytea', + 'inet', + 'cidr', + 'macaddr', + 'macaddr8', + 'money', + 'xml', + 'bit', + 'varbit', + 'tsvector', + 'tsquery', }; const _jsonFormats = {'json', 'jsonb'}; diff --git a/packages/supabase_typegen/test/dart_generator_test.dart b/packages/supabase_typegen/test/dart_generator_test.dart index 511ec4950..d34360261 100644 --- a/packages/supabase_typegen/test/dart_generator_test.dart +++ b/packages/supabase_typegen/test/dart_generator_test.dart @@ -75,4 +75,31 @@ void main() { expect(code, contains('AuthorsUpdate({String? name})')); expect(code, contains("TableColumn('id')")); }); + + test('tables whose columns are all read-only get parameterless ' + 'insert and update constructors', () { + final table = TableDescription( + name: 'counters', + comment: null, + columns: [ + ColumnDescription( + name: 'id', + postgresFormat: 'int8', + typeKind: ColumnTypeKind.integer, + isRequired: false, + isPrimaryKey: true, + hasDefault: true, + isNullable: false, + isReadOnly: true, + ), + ], + ); + final code = generateDartCode( + SchemaDescription(schemaName: 'public', tables: [table], enums: []), + ); + + expect(code, contains('CountersInsert() : this._({});')); + expect(code, contains('CountersUpdate() : this._({});')); + expect(code, contains("int get id => _json['id'] as int;")); + }); } diff --git a/packages/supabase_typegen/test/postgres_meta_parser_test.dart b/packages/supabase_typegen/test/postgres_meta_parser_test.dart index 043e51cab..d08e1e408 100644 --- a/packages/supabase_typegen/test/postgres_meta_parser_test.dart +++ b/packages/supabase_typegen/test/postgres_meta_parser_test.dart @@ -122,6 +122,50 @@ void main() { expect(kindOf('cover_uuid'), ColumnTypeKind.text); }); + test('types that PostgREST serializes as strings read as text', () { + Map columnOf(String format) => { + 'table_id': 1, + 'schema': 'public', + 'table': 'servers', + 'id': '1.1', + 'ordinal_position': 1, + 'name': 'value', + 'default_value': null, + 'data_type': format, + 'format': format, + 'is_identity': false, + 'identity_generation': null, + 'is_generated': false, + 'is_nullable': true, + 'is_updatable': true, + 'is_unique': false, + 'enums': [], + 'check': null, + 'comment': null, + }; + + for (final format in ['inet', 'cidr', 'macaddr', 'money', 'xml', 'name']) { + final parsed = parsePostgresMetaDocument({ + 'version': 1, + 'tables': [ + { + 'id': 1, + 'schema': 'public', + 'name': 'servers', + 'comment': null, + 'primary_keys': >[], + }, + ], + 'columns': [columnOf(format)], + }); + expect( + parsed.tables.single.columns.single.typeKind, + ColumnTypeKind.text, + reason: '$format should map to text', + ); + } + }); + test('collects Postgres enums with their schema qualification', () { expect(schema.enums, hasLength(1)); final mood = schema.enums.single; From 3ea9e56426e815d97538ffa796e08a86b61ab920 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 18 Aug 2026 09:41:20 +0200 Subject: [PATCH 20/25] docs(supabase_typegen): document the cross-schema enum name limitation --- packages/supabase_typegen/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index e70289ed4..c70424e09 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -68,5 +68,9 @@ await client.table(Books.table).insert( - `timestamptz` values are written back in UTC, naive `timestamp` values as local wall time, and `date` values date-only, so calendar dates never shift with the client timezone. +- The metadata identifies a column's enum type only by its bare name. When + two schemas define enums with the same name, columns using the enum from + the other schema resolve to the generated schema's enum, so keep enum + names unique across schemas. - Foreign key relationship getters and typed functions (rpc) are not generated yet. From 6455a3eee3047ae73bb05f8508cf1e4e90ea883e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 18 Aug 2026 10:09:35 +0200 Subject: [PATCH 21/25] docs(supabase_typegen): recommend the Supabase CLI as the easiest way to run the generator --- packages/supabase_typegen/README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index c70424e09..2f3cfc0c4 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -14,7 +14,19 @@ For every table the generator emits: ## Usage -First dump the schema metadata with the Supabase CLI, then generate: +The easiest way is through the Supabase CLI, which handles the database +connection and runs this package for you. Add `supabase_typegen` as a dev +dependency of your project, then: + +```sh +supabase gen types --lang dart --local > lib/supabase_schema.g.dart +``` + +Any of the CLI's connection flags work (`--local`, `--linked`, `--db-url`, +`--project-id`). + +To run the package yourself, dump the schema metadata first and pass it with +`--input` (a path, or `-` for stdin): ```sh supabase gen types --lang json --local > schema.json @@ -22,16 +34,10 @@ dart run supabase_typegen --input schema.json \ --output lib/supabase_schema.g.dart ``` -Any of the CLI's connection flags work (`--local`, `--linked`, -`--db-url`, `--project-id`). Until CLI support for `--lang json` ships, the -same document comes straight from +Until CLI support for `--lang dart` and `--lang json` ships, the same +metadata document comes straight from [postgres-meta](https://github.com/supabase/postgres-meta) with -`PG_META_GENERATE_TYPES=json` or its `/generators/json` endpoint. Pass -`--input -` to read the document from stdin: - -```sh -supabase gen types --lang json --local | dart run supabase_typegen --input - -``` +`PG_META_GENERATE_TYPES=json` or its `/generators/json` endpoint. Use `--schema` to generate for a schema other than `public`, and `--import` to change which library the generated file imports `PostgrestTable` and From bef322baf549f1104567d5955fc181eae50bb248 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 18 Aug 2026 10:15:17 +0200 Subject: [PATCH 22/25] docs(supabase_typegen): explain when committing schema.json makes sense --- packages/supabase_typegen/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 2f3cfc0c4..12bb4257d 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -39,6 +39,27 @@ metadata document comes straight from [postgres-meta](https://github.com/supabase/postgres-meta) with `PG_META_GENERATE_TYPES=json` or its `/generators/json` endpoint. +## Committing schema.json + +The SQL in your `supabase/` directory stays the single source of truth: the +CLI applies your migrations to the local database and the metadata document +is introspected from the result. `schema.json` is derived output, the same +category as the generated Dart file, so committing it is optional and the +recommended one-liner never writes it at all. + +Committing a snapshot can still be worthwhile: + +- it diffs nicely in review, so a migration's effect on the API surface is + visible next to the SQL that caused it, +- the generator can re-run from it offline, without Docker or a database, + which keeps CI checks and codegen fast and hermetic, +- a stale generated file is detectable by regenerating from the snapshot and + comparing. + +If you commit it, treat it like a lockfile: regenerate it in the same change +as every migration, and never edit it by hand. When the snapshot and the +migrations disagree, the migrations win; regenerate the snapshot. + Use `--schema` to generate for a schema other than `public`, and `--import` to change which library the generated file imports `PostgrestTable` and `TableColumn` from. From 29c584fc4fd19688d5d1c3bce069d21f8d1e8e84 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 18 Aug 2026 13:48:17 +0200 Subject: [PATCH 23/25] refactor(supabase_typegen): consume the postgrest-typegen GeneratorMetadata contract --- packages/supabase_typegen/README.md | 9 +++-- .../bin/supabase_typegen.dart | 8 ++-- ...er.dart => generator_metadata_parser.dart} | 34 ++++++---------- .../lib/src/schema_description.dart | 4 -- .../lib/supabase_typegen.dart | 2 +- .../test/dart_generator_test.dart | 5 +-- ...ta_schema.json => generator_metadata.json} | 39 +++++++++++-------- ...rt => generator_metadata_parser_test.dart} | 17 ++++---- .../tool/regenerate_goldens.dart | 4 +- 9 files changed, 56 insertions(+), 66 deletions(-) rename packages/supabase_typegen/lib/src/{postgres_meta_parser.dart => generator_metadata_parser.dart} (87%) rename packages/supabase_typegen/test/fixtures/{postgres_meta_schema.json => generator_metadata.json} (94%) rename packages/supabase_typegen/test/{postgres_meta_parser_test.dart => generator_metadata_parser_test.dart} (93%) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 12bb4257d..a88b2a6cc 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -34,10 +34,11 @@ dart run supabase_typegen --input schema.json \ --output lib/supabase_schema.g.dart ``` -Until CLI support for `--lang dart` and `--lang json` ships, the same -metadata document comes straight from -[postgres-meta](https://github.com/supabase/postgres-meta) with -`PG_META_GENERATE_TYPES=json` or its `/generators/json` endpoint. +The document is the `GeneratorMetadata` introspection contract of +[`@supabase/postgrest-typegen`](https://github.com/supabase/pg-toolbelt), +which is also what postgres-meta's own type generators consume. Until CLI +support for `--lang dart` and `--lang json` ships, the same document comes +from serializing that package's `introspect()` result. ## Committing schema.json diff --git a/packages/supabase_typegen/bin/supabase_typegen.dart b/packages/supabase_typegen/bin/supabase_typegen.dart index 71cd1f5e7..eeb740e11 100644 --- a/packages/supabase_typegen/bin/supabase_typegen.dart +++ b/packages/supabase_typegen/bin/supabase_typegen.dart @@ -9,7 +9,7 @@ final _argParser = ArgParser() 'input', abbr: 'i', help: - 'Path of the postgres-meta generator metadata document, or - to ' + 'Path of the GeneratorMetadata document, or - to ' 'read it from stdin. Produce it with ' '`supabase gen types --lang json`.', ) @@ -65,7 +65,7 @@ Future _run(List arguments) async { final input = options.option('input'); if (input == null) { stderr.writeln( - '--input is required: the path of a postgres-meta generator metadata ' + '--input is required: the path of a GeneratorMetadata ' 'document, or - to read it from stdin. Produce it with ' '`supabase gen types --lang json`.', ); @@ -87,7 +87,7 @@ Future _run(List arguments) async { final schemaName = options.option('schema')!; final SchemaDescription schema; try { - schema = parsePostgresMetaDocument( + schema = parseGeneratorMetadata( jsonDecode(contents) as Map, schemaName: schemaName, ); @@ -96,7 +96,7 @@ Future _run(List arguments) async { return 65; } on TypeError { stderr.writeln( - 'The document in $input is not postgres-meta generator metadata. ' + 'The document in $input is not a GeneratorMetadata document. ' 'Produce it with `supabase gen types --lang json`.', ); return 65; diff --git a/packages/supabase_typegen/lib/src/postgres_meta_parser.dart b/packages/supabase_typegen/lib/src/generator_metadata_parser.dart similarity index 87% rename from packages/supabase_typegen/lib/src/postgres_meta_parser.dart rename to packages/supabase_typegen/lib/src/generator_metadata_parser.dart index ca227cf1f..136c60a51 100644 --- a/packages/supabase_typegen/lib/src/postgres_meta_parser.dart +++ b/packages/supabase_typegen/lib/src/generator_metadata_parser.dart @@ -1,8 +1,5 @@ import 'schema_description.dart'; -/// The metadata document version this parser understands. -const supportedPostgresMetaVersion = 1; - const _integerFormats = {'int2', 'int4', 'int8', 'oid'}; const _floatingFormats = {'float4', 'float8'}; @@ -58,22 +55,22 @@ ColumnTypeKind _elementTypeKind(String elementFormat, {required bool isEnum}) { return kind == ColumnTypeKind.enumType ? ColumnTypeKind.text : kind; } -/// Parses the generator metadata document that postgres-meta emits from its -/// `json` generator (`supabase gen types --lang json`, the -/// `/generators/json` endpoint, or `PG_META_GENERATE_TYPES=json`) into a -/// [SchemaDescription] for [schemaName]. +/// Parses a `GeneratorMetadata` document, the introspection contract shared +/// by `@supabase/postgrest-typegen` and postgres-meta +/// (`supabase gen types --lang json`), into a [SchemaDescription] for +/// [schemaName]. /// -/// Throws a [FormatException] when the document does not carry the supported -/// `version`. -SchemaDescription parsePostgresMetaDocument( +/// Throws a [FormatException] when the document does not have the +/// `GeneratorMetadata` shape. +SchemaDescription parseGeneratorMetadata( Map document, { String schemaName = 'public', }) { - final version = document['version']; - if (version != supportedPostgresMetaVersion) { - throw FormatException( - 'Unsupported postgres-meta document version $version; this version of ' - 'supabase_typegen supports version $supportedPostgresMetaVersion.', + if (document['tables'] is! List || + document['columns'] is! List) { + throw const FormatException( + 'Not a GeneratorMetadata document: expected the introspection contract ' + 'of @supabase/postgrest-typegen, with "tables" and "columns" lists.', ); } @@ -106,12 +103,6 @@ SchemaDescription parsePostgresMetaDocument( for (final relation in relations) { final relationName = relation['name'] as String; - final primaryKeyNames = { - for (final primaryKey - in (relation['primary_keys'] as List? ?? const []) - .cast>()) - primaryKey['name'] as String, - }; final columns = []; for (final column @@ -150,7 +141,6 @@ SchemaDescription parsePostgresMetaDocument( : null, enumValues: isEnum ? enumValues : null, isRequired: !isNullable && !hasDefault, - isPrimaryKey: primaryKeyNames.contains(name), hasDefault: hasDefault, isNullable: isNullable, isReadOnly: diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart index 657a8b176..d8a3b5a77 100644 --- a/packages/supabase_typegen/lib/src/schema_description.dart +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -86,7 +86,6 @@ class ColumnDescription { required this.postgresFormat, required this.typeKind, required this.isRequired, - required this.isPrimaryKey, required this.hasDefault, required this.isNullable, this.isReadOnly = false, @@ -116,9 +115,6 @@ class ColumnDescription { /// it required on insert. final bool isRequired; - /// Whether the column is part of the primary key. - final bool isPrimaryKey; - /// Whether the column has a database default. final bool hasDefault; diff --git a/packages/supabase_typegen/lib/supabase_typegen.dart b/packages/supabase_typegen/lib/supabase_typegen.dart index bb41ad3a1..aeb9b9e2d 100644 --- a/packages/supabase_typegen/lib/supabase_typegen.dart +++ b/packages/supabase_typegen/lib/supabase_typegen.dart @@ -4,5 +4,5 @@ library; export 'src/dart_generator.dart'; export 'src/identifiers.dart'; -export 'src/postgres_meta_parser.dart'; +export 'src/generator_metadata_parser.dart'; export 'src/schema_description.dart'; diff --git a/packages/supabase_typegen/test/dart_generator_test.dart b/packages/supabase_typegen/test/dart_generator_test.dart index d34360261..7a9573780 100644 --- a/packages/supabase_typegen/test/dart_generator_test.dart +++ b/packages/supabase_typegen/test/dart_generator_test.dart @@ -17,11 +17,11 @@ void main() { final document = jsonDecode( File( - 'test/fixtures/postgres_meta_schema.json', + 'test/fixtures/generator_metadata.json', ).readAsStringSync(), ) as Map; - schema = parsePostgresMetaDocument(document); + schema = parseGeneratorMetadata(document); }); test('matches the golden output', () { @@ -87,7 +87,6 @@ void main() { postgresFormat: 'int8', typeKind: ColumnTypeKind.integer, isRequired: false, - isPrimaryKey: true, hasDefault: true, isNullable: false, isReadOnly: true, diff --git a/packages/supabase_typegen/test/fixtures/postgres_meta_schema.json b/packages/supabase_typegen/test/fixtures/generator_metadata.json similarity index 94% rename from packages/supabase_typegen/test/fixtures/postgres_meta_schema.json rename to packages/supabase_typegen/test/fixtures/generator_metadata.json index f0058015b..e4286148c 100644 --- a/packages/supabase_typegen/test/fixtures/postgres_meta_schema.json +++ b/packages/supabase_typegen/test/fixtures/generator_metadata.json @@ -1,7 +1,10 @@ { - "version": 1, "schemas": [ - { "id": 2200, "name": "public", "owner": "postgres" } + { + "id": 2200, + "name": "public", + "owner": "postgres" + } ], "tables": [ { @@ -15,11 +18,7 @@ "size": "8192 bytes", "live_rows_estimate": 0, "dead_rows_estimate": 0, - "comment": "Books available in the library", - "primary_keys": [ - { "schema": "public", "table_name": "books", "name": "id", "table_id": 16385 } - ], - "relationships": [] + "comment": "Books available in the library" }, { "id": 16401, @@ -32,11 +31,7 @@ "size": "8192 bytes", "live_rows_estimate": 0, "dead_rows_estimate": 0, - "comment": null, - "primary_keys": [ - { "schema": "public", "table_name": "authors", "name": "id", "table_id": 16401 } - ], - "relationships": [] + "comment": null } ], "foreignTables": [], @@ -187,7 +182,11 @@ "is_nullable": true, "is_updatable": true, "is_unique": false, - "enums": ["happy", "very happy", "sad"], + "enums": [ + "happy", + "very happy", + "sad" + ], "check": null, "comment": null }, @@ -417,11 +416,15 @@ "foreign_key_name": "books_author_id_fkey", "schema": "public", "relation": "books", - "columns": ["author_id"], + "columns": [ + "author_id" + ], "is_one_to_one": false, "referenced_schema": "public", "referenced_relation": "authors", - "referenced_columns": ["id"] + "referenced_columns": [ + "id" + ] } ], "functions": [], @@ -431,7 +434,11 @@ "name": "mood", "schema": "public", "format": "mood", - "enums": ["happy", "very happy", "sad"], + "enums": [ + "happy", + "very happy", + "sad" + ], "attributes": [], "comment": null, "type_relation_id": null diff --git a/packages/supabase_typegen/test/postgres_meta_parser_test.dart b/packages/supabase_typegen/test/generator_metadata_parser_test.dart similarity index 93% rename from packages/supabase_typegen/test/postgres_meta_parser_test.dart rename to packages/supabase_typegen/test/generator_metadata_parser_test.dart index d08e1e408..324d475c3 100644 --- a/packages/supabase_typegen/test/postgres_meta_parser_test.dart +++ b/packages/supabase_typegen/test/generator_metadata_parser_test.dart @@ -12,21 +12,21 @@ void main() { document = jsonDecode( File( - 'test/fixtures/postgres_meta_schema.json', + 'test/fixtures/generator_metadata.json', ).readAsStringSync(), ) as Map; - schema = parsePostgresMetaDocument(document); + schema = parseGeneratorMetadata(document); }); - test('rejects unsupported document versions', () { + test('rejects documents without the GeneratorMetadata shape', () { expect( - () => parsePostgresMetaDocument({...document, 'version': 2}), + () => parseGeneratorMetadata({'swagger': '2.0', 'definitions': {}}), throwsA( isA().having( (error) => error.message, 'message', - contains('version 2'), + contains('GeneratorMetadata'), ), ), ); @@ -50,10 +50,9 @@ void main() { expect(authorStats.comment, 'Aggregated statistics per author'); }); - test('parses primary keys, requiredness, defaults and nullability', () { + test('parses requiredness, defaults and nullability', () { final books = schema.tables.singleWhere((table) => table.name == 'books'); final id = books.columns.singleWhere((column) => column.name == 'id'); - expect(id.isPrimaryKey, isTrue); expect(id.isRequired, isFalse); expect(id.hasDefault, isTrue); expect(id.isNullable, isFalse); @@ -145,15 +144,13 @@ void main() { }; for (final format in ['inet', 'cidr', 'macaddr', 'money', 'xml', 'name']) { - final parsed = parsePostgresMetaDocument({ - 'version': 1, + final parsed = parseGeneratorMetadata({ 'tables': [ { 'id': 1, 'schema': 'public', 'name': 'servers', 'comment': null, - 'primary_keys': >[], }, ], 'columns': [columnOf(format)], diff --git a/packages/supabase_typegen/tool/regenerate_goldens.dart b/packages/supabase_typegen/tool/regenerate_goldens.dart index 0dd1e046f..aa1f77cbf 100644 --- a/packages/supabase_typegen/tool/regenerate_goldens.dart +++ b/packages/supabase_typegen/tool/regenerate_goldens.dart @@ -9,10 +9,10 @@ import 'package:supabase_typegen/supabase_typegen.dart'; void main() { final document = jsonDecode( - File('test/fixtures/postgres_meta_schema.json').readAsStringSync(), + File('test/fixtures/generator_metadata.json').readAsStringSync(), ) as Map; - final schema = parsePostgresMetaDocument(document); + final schema = parseGeneratorMetadata(document); File( 'test/goldens/supabase_schema.dart', ).writeAsStringSync(generateDartCode(schema)); From fb5b9155ff5f4e5085014ff6f005e4490938f588 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 26 Aug 2026 16:44:11 +0200 Subject: [PATCH 24/25] test(supabase_typegen): pin key-column write behaviors of the generated value types --- .../test/generated_schema_behavior_test.dart | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 31ada9d93..8403c47bd 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -146,6 +146,54 @@ void main() { ); }); + test('insert can carry an explicitly chosen key column', () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .insert(BooksInsert(id: 7, title: 'A typed row', authorId: 7)); + + expect(jsonDecode(httpClient.lastRequestBody!), { + 'id': 7, + 'title': 'A typed row', + 'author_id': 7, + }); + }); + + test('upsert merges on key columns carried by the insert values', () async { + httpClient.responseBody = ''; + + await client.table(Books.table).upsert([ + BooksInsert(id: 1, title: 'First', authorId: 7), + BooksInsert(id: 2, title: 'Second', authorId: 7), + ]); + + expect(jsonDecode(httpClient.lastRequestBody!), [ + {'id': 1, 'title': 'First', 'author_id': 7}, + {'id': 2, 'title': 'Second', 'author_id': 7}, + ]); + expect( + httpClient.lastRequest!.headers['Prefer'], + contains('resolution=merge-duplicates'), + ); + expect( + httpClient.lastRequest!.url.queryParameters['columns'], + '"id","title","author_id"', + ); + }); + + test('update can change a key column', () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .update(BooksUpdate(id: 2)) + .where(Books.id.eq(1)); + + expect(jsonDecode(httpClient.lastRequestBody!), {'id': 2}); + expect(httpClient.lastRequest!.url.queryParameters['id'], 'eq.1'); + }); + test('update sends only the provided columns', () async { httpClient.responseBody = ''; From 9779c139f8c464c18bd64c8c12dbb69dd53be61f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 26 Aug 2026 16:56:12 +0200 Subject: [PATCH 25/25] fix(supabase_typegen): match the workspace postgrest version --- packages/supabase_typegen/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/supabase_typegen/pubspec.yaml b/packages/supabase_typegen/pubspec.yaml index 228705f95..3958d0899 100644 --- a/packages/supabase_typegen/pubspec.yaml +++ b/packages/supabase_typegen/pubspec.yaml @@ -23,6 +23,6 @@ dependencies: dev_dependencies: http: ^1.6.0 - postgrest: ^2.9.0 + postgrest: 3.0.0-dev.1 supabase_lints: ^0.1.1 test: ^1.25.0