From f5cc6b9c40dc55c9193f9a6fc4b9742b826c7445 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 14:39:42 +0200 Subject: [PATCH 1/2] feat(postgrest)!: only accept the table's Insert and Update types on the typed builder PostgrestTable carries Insert and Update type parameters next to Row, and the typed query builder's insert, insertAll, upsert, upsertAll and update methods accept only those types. Read-only relations use Never for the write types they do not support. supabase_typegen emits the type arguments, drops the Map interface from the generated extension types and adds toJson to row types. The untyped update accepts Object like insert. --- packages/postgrest/lib/src/postgrest.dart | 4 +- .../lib/src/postgrest_query_builder.dart | 2 +- .../postgrest/lib/src/postgrest_table.dart | 37 ++++-- .../lib/src/postgrest_typed_builder.dart | 7 +- .../src/postgrest_typed_filter_builder.dart | 6 +- .../src/postgrest_typed_query_builder.dart | 113 ++++++++++++++---- .../postgrest_typed_transform_builder.dart | 32 ++--- packages/postgrest/test/typed_query_test.dart | 79 ++++++++++-- .../supabase/lib/src/supabase_client.dart | 4 +- .../lib/src/supabase_query_schema.dart | 4 +- .../lib/src/supabase_typed_query_builder.dart | 7 +- .../src/supabase_typed_stream_builder.dart | 8 +- packages/supabase/test/mock_test.dart | 2 +- .../supabase/test/stream_filter_test.dart | 5 +- .../supabase_test/test/typed_api_test.dart | 19 ++- packages/supabase_typegen/README.md | 4 +- .../lib/src/dart_generator.dart | 22 ++-- .../test/generated_schema_behavior_test.dart | 5 +- .../test/goldens/hostile_schema.dart | 33 +++-- .../test/goldens/supabase_schema.dart | 81 +++++++++---- sdk-compliance.yaml | 2 + 21 files changed, 354 insertions(+), 122 deletions(-) diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index 95df2e4a3..cfbe72ef8 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -135,7 +135,9 @@ class PostgrestClient { /// .where(Books.id.gt(10)); /// ``` @experimental - PostgrestTypedQueryBuilder table(PostgrestTable table) { + PostgrestTypedQueryBuilder table( + PostgrestTable table, + ) { return PostgrestTypedQueryBuilder(from(table.name), table); } diff --git a/packages/postgrest/lib/src/postgrest_query_builder.dart b/packages/postgrest/lib/src/postgrest_query_builder.dart index 68cb90762..ad448bf4c 100644 --- a/packages/postgrest/lib/src/postgrest_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_query_builder.dart @@ -220,7 +220,7 @@ class PostgrestQueryBuilder { /// .eq('message', 'foo') /// .select(); /// ``` - PostgrestFilterBuilder update(Map values) { + PostgrestFilterBuilder update(Object values) { final newHeaders = {..._config.headers}..remove('Prefer'); return _filterBuilder( diff --git a/packages/postgrest/lib/src/postgrest_table.dart b/packages/postgrest/lib/src/postgrest_table.dart index 7080226c9..b739ceaa1 100644 --- a/packages/postgrest/lib/src/postgrest_table.dart +++ b/packages/postgrest/lib/src/postgrest_table.dart @@ -4,20 +4,32 @@ part of 'postgrest_typed_builder.dart'; @experimental typedef RowConverter = Row Function(Map json); -/// Describes a database table (or view) together with the Dart type its rows -/// are converted into. +/// Describes a database table (or view) together with the Dart types its rows +/// are read as and written with. /// /// Passing a [PostgrestTable] to [PostgrestClient.table] gives fully typed -/// query results, so no raw `Map` needs to be handled: +/// query results, so no raw `Map` needs to be handled, and +/// only accepts [Insert] and [Update] values on the write methods: /// /// ```dart -/// extension type Book(Map json) { +/// extension type Book(Map json) implements Object { /// int get id => json['id'] as int; /// String get title => json['title'] as String; /// } /// +/// extension type BookInsert._(Map json) implements Object { +/// BookInsert({required String title}) : this._({'title': title}); +/// } +/// +/// extension type BookUpdate._(Map json) implements Object { +/// BookUpdate({String? title}) : this._({'title': ?title}); +/// } +/// /// class Books { -/// static const table = PostgrestTable('books', Book.new); +/// static const table = PostgrestTable( +/// 'books', +/// Book.new, +/// ); /// static const id = PostgrestColumn('id'); /// static const title = PostgrestColumn('title'); /// } @@ -27,14 +39,25 @@ typedef RowConverter = Row Function(Map json); /// .select() /// .where(Books.title.like('%Dart%')) /// .order(Books.id.desc()); +/// +/// await client.table(Books.table).insert(BookInsert(title: 'Dart')); /// ``` /// +/// [Insert] and [Update] are sent as the request body, so they have to encode +/// to a JSON object: an extension type over the map to send (as above), or a +/// class with a `toJson` method. Both have to be spelled out, since nothing in +/// the constructor arguments can infer them. A read-only relation, such as a +/// materialized view, uses `Never` for the write types it does not support, +/// which makes the corresponding methods uncallable. +/// /// 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. +/// regular data class. `package:supabase_typegen` generates all three types +/// and the table definition from the database schema. @experimental -class PostgrestTable { +// ignore: avoid-unused-generics +class PostgrestTable { const PostgrestTable(this.name, this.rowFromJson); /// Name of the table in the database. diff --git a/packages/postgrest/lib/src/postgrest_typed_builder.dart b/packages/postgrest/lib/src/postgrest_typed_builder.dart index 8a1b3b4ba..4030ecf8a 100644 --- a/packages/postgrest/lib/src/postgrest_typed_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_builder.dart @@ -17,9 +17,10 @@ part 'postgrest_typed_query_builder.dart'; part 'postgrest_typed_transform_builder.dart'; part 'postgrest_typed_filter_builder.dart'; -List _rowsFromJson(PostgrestTable table, PostgrestList rows) => [ - for (final row in rows) table.rowFromJson(row), -]; +List _rowsFromJson( + RowConverter rowFromJson, + PostgrestList rows, +) => [for (final row in rows) rowFromJson(row)]; /// The `select` parameter for [columns], or `*` when none are given. String _selectList(List>? columns) { diff --git a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart index 47a3f87a4..58b96dad9 100644 --- a/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_filter_builder.dart @@ -9,8 +9,8 @@ class PostgrestTypedFilterBuilder extends PostgrestTypedTransformBuilder { const PostgrestTypedFilterBuilder._( this._filterBuilder, - PostgrestTable table, - ) : super._(_filterBuilder, table); + RowConverter rowFromJson, + ) : super._(_filterBuilder, rowFromJson); final PostgrestFilterBuilder _filterBuilder; @@ -42,6 +42,6 @@ class PostgrestTypedFilterBuilder for (final parameter in filter.queryParameters) { builder = builder.appendSearchParameter(parameter.key, parameter.value); } - return PostgrestTypedFilterBuilder._(builder, _table); + return PostgrestTypedFilterBuilder._(builder, _rowFromJson); } } diff --git a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart index fd8f8bded..420e238ed 100644 --- a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart @@ -5,10 +5,11 @@ part of 'postgrest_typed_builder.dart'; /// [PostgrestClient.table]. /// /// Query results are converted into [Row] through -/// [PostgrestTable.rowFromJson], so no raw `Map` is exposed. +/// [PostgrestTable.rowFromJson], so no raw `Map` is exposed, +/// and writes only accept the table's [Insert] and [Update] types. /// {@endtemplate} @experimental -class PostgrestTypedQueryBuilder { +class PostgrestTypedQueryBuilder { /// {@macro postgrest_typed_query_builder} const PostgrestTypedQueryBuilder( PostgrestQueryBuilder queryBuilder, @@ -18,7 +19,7 @@ class PostgrestTypedQueryBuilder { final PostgrestQueryBuilder _queryBuilder; /// The table this builder queries. - final PostgrestTable table; + final PostgrestTable table; /// Perform a SELECT query on the table or view. /// @@ -44,55 +45,113 @@ class PostgrestTypedQueryBuilder { PostgrestFilterBuilder( _queryBuilder .select(_selectList(columns)) - .withConverter((rows) => _rowsFromJson(table, rows)), + .withConverter((rows) => _rowsFromJson(table.rowFromJson, rows)), ), - table, + table.rowFromJson, ); - /// Perform an INSERT into the table or view. + /// Perform an INSERT of a single [row] 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]. + /// inserted row typed as [Row]. /// /// ```dart /// final Book book = await client /// .table(Books.table) - /// .insert({'title': 'foo'}) + /// .insert(BookInsert(title: 'foo')) /// .select() /// .single(); /// ``` + /// + /// See [insertAll] to insert several rows in one request and + /// [PostgrestQueryBuilder.insert] for [defaultToNull]. PostgrestTypedFilterBuilder insert( - Object values, { + Insert row, { bool defaultToNull = true, }) => PostgrestTypedFilterBuilder._( - _queryBuilder.insert(values, defaultToNull: defaultToNull), - table, + _queryBuilder.insert(row as Object, defaultToNull: defaultToNull), + table.rowFromJson, ); - /// Perform an UPSERT on the table or view. + /// Perform an INSERT of every row in [rows] into the table or view. + /// + /// ```dart + /// await client.table(Books.table).insertAll([ + /// BookInsert(title: 'foo'), + /// BookInsert(title: 'bar'), + /// ]); + /// ``` + /// + /// See [insert] and [PostgrestQueryBuilder.insert] for [defaultToNull]. + PostgrestTypedFilterBuilder insertAll( + List rows, { + bool defaultToNull = true, + }) => PostgrestTypedFilterBuilder._( + _queryBuilder.insert(rows, defaultToNull: defaultToNull), + table.rowFromJson, + ); + + /// Perform an UPSERT of a single [row] on the table or view. /// /// By default no data is returned. Use a trailing [select] to return the - /// upserted rows typed as [Row]. + /// upserted row typed as [Row]. /// /// [onConflict] names the columns of the unique constraint to merge on; /// left out, the primary key is the target. /// /// ```dart /// await client.table(Users.table).upsert( - /// {'email': 'a@example.com', 'name': 'Ada'}, + /// UserInsert(email: 'a@example.com', name: 'Ada'), /// onConflict: [Users.email], /// ); /// ``` /// - /// See [PostgrestQueryBuilder.upsert] for [values], [ignoreDuplicates] and + /// See [upsertAll] to upsert several rows in one request and + /// [PostgrestQueryBuilder.upsert] for [ignoreDuplicates] and /// [defaultToNull]. PostgrestTypedFilterBuilder upsert( - Object values, { + Insert row, { List>? onConflict, bool ignoreDuplicates = false, bool defaultToNull = true, + }) => _upsert( + row as Object, + onConflict: onConflict, + ignoreDuplicates: ignoreDuplicates, + defaultToNull: defaultToNull, + ); + + /// Perform an UPSERT of every row in [rows] on the table or view. + /// + /// ```dart + /// await client.table(Users.table).upsertAll( + /// [ + /// UserInsert(email: 'a@example.com', name: 'Ada'), + /// UserInsert(email: 'b@example.com', name: 'Bob'), + /// ], + /// onConflict: [Users.email], + /// ); + /// ``` + /// + /// See [upsert] for [onConflict] and [PostgrestQueryBuilder.upsert] for + /// [ignoreDuplicates] and [defaultToNull]. + PostgrestTypedFilterBuilder upsertAll( + List rows, { + List>? onConflict, + bool ignoreDuplicates = false, + bool defaultToNull = true, + }) => _upsert( + rows, + onConflict: onConflict, + ignoreDuplicates: ignoreDuplicates, + defaultToNull: defaultToNull, + ); + + PostgrestTypedFilterBuilder _upsert( + Object values, { + required List>? onConflict, + required bool ignoreDuplicates, + required bool defaultToNull, }) { if (onConflict != null && onConflict.isEmpty) { throw ArgumentError.value( @@ -108,7 +167,7 @@ class PostgrestTypedQueryBuilder { ignoreDuplicates: ignoreDuplicates, defaultToNull: defaultToNull, ), - table, + table.rowFromJson, ); } @@ -120,11 +179,14 @@ class PostgrestTypedQueryBuilder { /// ```dart /// await client /// .table(Books.table) - /// .update({'title': 'bar'}) + /// .update(BookUpdate(title: 'bar')) /// .where(Books.id.eq(1)); /// ``` - PostgrestTypedFilterBuilder update(Map values) => - PostgrestTypedFilterBuilder._(_queryBuilder.update(values), table); + PostgrestTypedFilterBuilder update(Update values) => + PostgrestTypedFilterBuilder._( + _queryBuilder.update(values as Object), + table.rowFromJson, + ); /// Perform a DELETE on the table or view. /// @@ -135,7 +197,7 @@ class PostgrestTypedQueryBuilder { /// await client.table(Books.table).delete().where(Books.id.eq(1)); /// ``` PostgrestTypedFilterBuilder delete() => - PostgrestTypedFilterBuilder._(_queryBuilder.delete(), table); + PostgrestTypedFilterBuilder._(_queryBuilder.delete(), table.rowFromJson); /// Only performs a count query on the table or view. /// @@ -144,5 +206,8 @@ class PostgrestTypedQueryBuilder { /// ``` PostgrestTypedFilterBuilder count([ CountOption option = CountOption.exact, - ]) => PostgrestTypedFilterBuilder._(_queryBuilder.count(option), table); + ]) => PostgrestTypedFilterBuilder._( + _queryBuilder.count(option), + table.rowFromJson, + ); } diff --git a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart index 8c620e6f7..587bfd78d 100644 --- a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart @@ -6,19 +6,23 @@ part of 'postgrest_typed_builder.dart'; /// request resolves to when awaited. @experimental class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { - const PostgrestTypedTransformBuilder._(this._transformBuilder, this._table) - : super._(_transformBuilder); + const PostgrestTypedTransformBuilder._( + this._transformBuilder, + this._rowFromJson, + ) : super._(_transformBuilder); final PostgrestTransformBuilder _transformBuilder; - final PostgrestTable _table; + final RowConverter _rowFromJson; /// 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(); + /// final List books = await client + /// .table(Books.table) + /// .insert(BookInsert(title: 'foo')) + /// .select(); /// ``` /// /// See [PostgrestTypedQueryBuilder.select] for [columns]. @@ -28,9 +32,9 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestTransformBuilder( _transformBuilder .select(_selectList(columns)) - .withConverter((rows) => _rowsFromJson(_table, rows)), + .withConverter((rows) => _rowsFromJson(_rowFromJson, rows)), ), - _table, + _rowFromJson, ); /// Sorts the result by [ordering]. @@ -49,7 +53,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestOrdering ordering, ) => PostgrestTypedTransformBuilder._( _transformBuilder.appendOrderKey(ordering.orderKey), - _table, + _rowFromJson, ); /// Limits the result with the specified [count]. @@ -58,7 +62,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { String? referencedTable, }) => PostgrestTypedTransformBuilder._( _transformBuilder.limit(count, referencedTable: referencedTable), - _table, + _rowFromJson, ); /// Limits the result to rows within the specified range, inclusive. @@ -68,7 +72,7 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { String? referencedTable, }) => PostgrestTypedTransformBuilder._( _transformBuilder.range(from, to, referencedTable: referencedTable), - _table, + _rowFromJson, ); /// Retrieves only one row from the result as [Row]. @@ -86,9 +90,9 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestTypedTransformBuilder single() => PostgrestTypedTransformBuilder._( PostgrestTransformBuilder( - _transformBuilder.single().withConverter(_table.rowFromJson), + _transformBuilder.single().withConverter(_rowFromJson), ), - _table, + _rowFromJson, ); /// Retrieves at most one row from the result as [Row], or `null` when the @@ -97,10 +101,10 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { PostgrestTypedTransformBuilder._( PostgrestTransformBuilder( _transformBuilder.maybeSingle().withConverter( - (row) => row == null ? null : _table.rowFromJson(row), + (row) => row == null ? null : _rowFromJson(row), ), ), - _table, + _rowFromJson, ); /// Performs additionally to the query a count query. diff --git a/packages/postgrest/test/typed_query_test.dart b/packages/postgrest/test/typed_query_test.dart index d7049197e..78176310c 100644 --- a/packages/postgrest/test/typed_query_test.dart +++ b/packages/postgrest/test/typed_query_test.dart @@ -8,6 +8,17 @@ extension type const Book(Map _json) String get title => _json['title'] as String; } +extension type const BookInsert._(Map _json) + implements Object { + BookInsert({required String title, int? id}) + : this._({'title': title, 'id': ?id}); +} + +extension type const BookUpdate._(Map _json) + implements Object { + BookUpdate({String? title}) : this._({'title': ?title}); +} + extension type const Author(Map _json) implements Map {} @@ -17,7 +28,10 @@ const bookRows = [ ]; class Books { - static const table = PostgrestTable('books', Book.new); + static const table = PostgrestTable( + 'books', + Book.new, + ); static const id = PostgrestColumn('id'); static const title = PostgrestColumn('title'); static const tags = PostgrestColumn>('tags'); @@ -34,7 +48,10 @@ class Books { } class Authors { - static const table = PostgrestTable('authors', Author.new); + static const table = PostgrestTable( + 'authors', + Author.new, + ); static const id = PostgrestColumn('id'); static const name = PostgrestColumn('name'); static const books = PostgrestToManyRelation('books'); @@ -511,18 +528,38 @@ void main() { test('insert posts the values', () async { httpClient.stub(null); - await client.table(Books.table).insert({'title': 'foo'}); + await client.table(Books.table).insert(BookInsert(title: 'foo')); expect(httpClient.requests.last.method, 'POST'); expect(httpClient.requests.last.body, '{"title":"foo"}'); }); + test('insertAll posts every row and names the columns', () async { + httpClient.stub(null); + + await client.table(Books.table).insertAll([ + BookInsert(title: 'foo'), + BookInsert(id: 2, title: 'bar'), + ], defaultToNull: false); + + expect(httpClient.requests.last.method, 'POST'); + expect( + httpClient.requests.last.body, + '[{"title":"foo"},{"title":"bar","id":2}]', + ); + expect(requestParameters()['columns'], '"title","id"'); + expect( + httpClient.requests.last.headers['Prefer'], + contains('missing=default'), + ); + }); + test('insert with a trailing select returns the typed row', () async { httpClient.stub({'id': 3, 'title': 'foo'}); final Book book = await client .table(Books.table) - .insert({'title': 'foo'}) + .insert(BookInsert(title: 'foo')) .select() .single(); @@ -541,7 +578,7 @@ void main() { final List books = await client .table(Books.table) - .insert({'title': 'foo'}) + .insert(BookInsert(title: 'foo')) .select([Books.id]); expect(requestParameters()['select'], 'id'); @@ -551,7 +588,7 @@ void main() { test('upsert sets the resolution header', () async { httpClient.stub(null); - await client.table(Books.table).upsert({'id': 1, 'title': 'foo'}); + await client.table(Books.table).upsert(BookInsert(id: 1, title: 'foo')); expect( httpClient.requests.last.headers['Prefer'], @@ -565,16 +602,40 @@ void main() { await client .table(Books.table) .upsert( - {'id': 1, 'title': 'foo'}, + BookInsert(id: 1, title: 'foo'), onConflict: [Books.id, Books.title], ); expect(requestParameters()['on_conflict'], 'id,title'); }); + test('upsertAll posts every row with the resolution header', () async { + httpClient.stub(null); + + await client + .table(Books.table) + .upsertAll( + [BookInsert(id: 1, title: 'foo'), BookInsert(id: 2, title: 'bar')], + onConflict: [Books.id], + ignoreDuplicates: true, + ); + + expect( + httpClient.requests.last.body, + '[{"title":"foo","id":1},{"title":"bar","id":2}]', + ); + expect(requestParameters()['on_conflict'], 'id'); + expect( + httpClient.requests.last.headers['Prefer'], + contains('resolution=ignore-duplicates'), + ); + }); + test('an empty conflict target throws', () { expect( - () => client.table(Books.table).upsert({'id': 1}, onConflict: []), + () => client + .table(Books.table) + .upsert(BookInsert(id: 1, title: 'foo'), onConflict: []), throwsArgumentError, ); }); @@ -584,7 +645,7 @@ void main() { await client .table(Books.table) - .update({'title': 'bar'}) + .update(BookUpdate(title: 'bar')) .where(Books.id.eq(1)); expect(httpClient.requests.last.method, 'PATCH'); diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 53e3c8fdc..880e9e160 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -256,7 +256,9 @@ class SupabaseClient { /// .where(Books.id.gt(10)); /// ``` @experimental - SupabaseTypedQueryBuilder table(PostgrestTable table) { + 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 4c2dc653e..37b126887 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -49,7 +49,9 @@ class SupabaseQuerySchema { /// Perform a typed table operation, see [SupabaseClient.table]. @experimental - SupabaseTypedQueryBuilder table(PostgrestTable table) { + 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 13e4a756f..032946b31 100644 --- a/packages/supabase/lib/src/supabase_typed_query_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_query_builder.dart @@ -8,13 +8,14 @@ import 'package:supabase/supabase.dart'; /// [PostgrestTypedQueryBuilder], this builder exposes a typed realtime /// [stream]. @experimental -class SupabaseTypedQueryBuilder extends PostgrestTypedQueryBuilder { +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 const SupabaseTypedQueryBuilder( SupabaseQueryBuilder queryBuilder, - PostgrestTable table, + PostgrestTable table, ) : _queryBuilder = queryBuilder, super(queryBuilder, table); @@ -43,7 +44,7 @@ class SupabaseTypedQueryBuilder extends PostgrestTypedQueryBuilder { primaryKey: [for (final column in primaryKey) column.name], private: private, ), - table, + table.rowFromJson, ); } } diff --git a/packages/supabase/lib/src/supabase_typed_stream_builder.dart b/packages/supabase/lib/src/supabase_typed_stream_builder.dart index c486ceed6..dcea16ed3 100644 --- a/packages/supabase/lib/src/supabase_typed_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_typed_stream_builder.dart @@ -9,11 +9,11 @@ import 'package:supabase/supabase.dart'; class SupabaseTypedStreamBuilder extends Stream> { const SupabaseTypedStreamBuilder( SupabaseStreamBuilder streamBuilder, - this._table, + this._rowFromJson, ) : _streamBuilder = streamBuilder; final SupabaseStreamBuilder _streamBuilder; - final PostgrestTable _table; + final RowConverter _rowFromJson; /// Orders the result with the specified [column]. /// @@ -52,7 +52,7 @@ class SupabaseTypedStreamBuilder extends Stream> { }) { return _streamBuilder .map( - (rows) => [for (final row in rows) _table.rowFromJson(row)], + (rows) => [for (final row in rows) _rowFromJson(row)], ) .listen( onData, @@ -69,7 +69,7 @@ class SupabaseTypedStreamFilterBuilder extends SupabaseTypedStreamBuilder { const SupabaseTypedStreamFilterBuilder( SupabaseStreamFilterBuilder super.streamBuilder, - super.table, + super.rowFromJson, ); SupabaseStreamFilterBuilder get _streamFilterBuilder => diff --git a/packages/supabase/test/mock_test.dart b/packages/supabase/test/mock_test.dart index 556e965f3..07b4dcba8 100644 --- a/packages/supabase/test/mock_test.dart +++ b/packages/supabase/test/mock_test.dart @@ -18,7 +18,7 @@ extension type const Todo(Map _json) } class Todos { - static const table = PostgrestTable('todos', Todo.new); + static const table = PostgrestTable('todos', Todo.new); static const id = PostgrestColumn('id'); static const task = PostgrestColumn('task'); static const status = PostgrestColumn('status'); diff --git a/packages/supabase/test/stream_filter_test.dart b/packages/supabase/test/stream_filter_test.dart index df3641c92..cf2fc48ff 100644 --- a/packages/supabase/test/stream_filter_test.dart +++ b/packages/supabase/test/stream_filter_test.dart @@ -279,7 +279,10 @@ extension type const _User(Map _json) } class _Users { - static const table = PostgrestTable('users', _User.new); + static const table = PostgrestTable<_User, Never, Never>( + 'users', + _User.new, + ); static const username = PostgrestColumn<_User, String>('username'); static const status = PostgrestColumn<_User, String>('status'); static const age = PostgrestColumn<_User, int>('age'); diff --git a/packages/supabase_test/test/typed_api_test.dart b/packages/supabase_test/test/typed_api_test.dart index d3e5ae9ca..23157b899 100644 --- a/packages/supabase_test/test/typed_api_test.dart +++ b/packages/supabase_test/test/typed_api_test.dart @@ -12,8 +12,23 @@ extension type const Todo(Map _json) bool get status => _json['status'] as bool; } +extension type const TodoInsert._(Map _json) + implements Object { + TodoInsert({required String task, bool? status}) + : this._({'task': task, 'status': ?status}); +} + +extension type const TodoUpdate._(Map _json) + implements Object { + TodoUpdate({String? task, bool? status}) + : this._({'task': ?task, 'status': ?status}); +} + class Todos { - static const table = PostgrestTable('todos', Todo.new); + static const table = PostgrestTable( + 'todos', + Todo.new, + ); static const id = PostgrestColumn('id'); static const task = PostgrestColumn('task'); static const status = PostgrestColumn('status'); @@ -130,7 +145,7 @@ void main() { final Todo inserted = await supabase .table(Todos.table) - .insert({'task': 'Write tests', 'status': false}) + .insert(TodoInsert(task: 'Write tests', status: false)) .select() .single(); diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 4946ff05a..b4b93b53c 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -7,7 +7,9 @@ For every table the generator emits: - 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, + construction site and are the only values the typed `insert`, `upsert` and + `update` methods accept. Read-only relations such as materialized views get + neither, so those methods cannot be called on them, - a `PostgrestTable` definition and `PostgrestColumn` tokens for compile-time checked filters and orderings, with nullable columns as `PostgrestNullableColumn` so `isNull()` only exists where it can match, and diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index b4dc7b992..778b5dd8d 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -108,6 +108,7 @@ class _TypeNameRegistry { 'double', 'num', 'bool', + 'Never', 'PostgrestTable', 'PostgrestColumn', 'PostgrestNullableColumn', @@ -319,11 +320,11 @@ void _writeTable( List<_RelationMember> relations, Map enumTypeNames, ) { - final _TableNames(:rowType, :insertType, :updateType, :namespaceType) = names; + final _TableNames(:rowType, :insertType, :updateType) = names; final memberNames = _uniqueMemberNames( [for (final column in table.columns) column.name], - reserved: {rowType, ?insertType, ?updateType}, + reserved: {rowType, ?insertType, ?updateType, 'toJson'}, ); final bindings = { for (final column in table.columns) @@ -365,8 +366,7 @@ void _writeTable( _writeNamespace( buffer, table, - namespaceType, - rowType, + names, memberNames, bindings, relations, @@ -384,7 +384,7 @@ void _writeRow( _writeDocComment(buffer, table.comment); buffer ..writeln('extension type const $rowType(Map _json)') - ..writeln(' implements Map {'); + ..writeln(' implements Object {'); for (final column in table.columns) { final binding = bindings[column.name]!; _writeDocComment(buffer, column.comment, indent: ' '); @@ -394,6 +394,9 @@ void _writeRow( ); } buffer + ..writeln() + ..writeln(' /// The row as decoded from the response.') + ..writeln(' Map toJson() => _json;') ..writeln('}') ..writeln(); } @@ -417,7 +420,7 @@ void _writeValues( _writeDocComment(buffer, docLine); buffer ..writeln('extension type const $typeName._(Map _json)') - ..writeln(' implements Map {'); + ..writeln(' implements Object {'); if (writableColumns.isEmpty) { // A named parameter list cannot be empty, so a table whose columns are // all read-only gets a parameterless constructor. @@ -474,12 +477,12 @@ void _writeValues( void _writeNamespace( StringBuffer buffer, TableDescription table, - String namespaceType, - String rowType, + _TableNames names, Map memberNames, Map bindings, List<_RelationMember> relations, ) { + final _TableNames(:rowType, :insertType, :updateType, :namespaceType) = names; final columnNames = _uniqueMemberNames( [for (final column in table.columns) column.name], reserved: {'table', namespaceType}, @@ -512,7 +515,8 @@ void _writeNamespace( ..writeln() ..writeln(' /// Table definition for [PostgrestClient.table].') ..writeln( - ' static const table = PostgrestTable' + ' static const table = PostgrestTable<$rowType, ' + '${insertType ?? 'Never'}, ${updateType ?? 'Never'}>' '(${_stringLiteral(table.name)}, $rowType.new);', ) ..writeln(); diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index a1c920e4a..16fbe0bc4 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -172,9 +172,10 @@ void main() { 'price': null, 'mood': null, }); + await client.table(Books.table).update(update).where(Books.id.eq(1)); expect( - update.containsKey('price'), - isFalse, + httpClient.requests.last.jsonBody, + {'in_print': false}, reason: 'setPriceToNull returns a copy and must not mutate', ); diff --git a/packages/supabase_typegen/test/goldens/hostile_schema.dart b/packages/supabase_typegen/test/goldens/hostile_schema.dart index fac9c0fb5..730a17aff 100644 --- a/packages/supabase_typegen/test/goldens/hostile_schema.dart +++ b/packages/supabase_typegen/test/goldens/hostile_schema.dart @@ -36,7 +36,7 @@ enum String$ { /// second /// third $interpolation "quoted" extension type const PostgrestTableRow(Map _json) - implements Map { + implements Object { /// says "hi" \ and $more String get quoteNameTail => _json['quote\'name\u{2029}tail'] as String; String$? get mood => switch (_json['mood']) { @@ -47,6 +47,9 @@ extension type const PostgrestTableRow(Map _json) .map((element) => (element as num).toDouble()) .toList(); List? get days => (_json['days'] as List?)?.cast(); + + /// The row as decoded from the response. + Map toJson() => _json; } /// Values for inserting a row into `postgrest_table`. Columns that are @@ -55,7 +58,7 @@ extension type const PostgrestTableRow(Map _json) /// database always generates itself are left out entirely. Use the `set…ToNull` /// methods to insert SQL NULL explicitly. extension type const PostgrestTableInsert._(Map _json) - implements Map { + implements Object { PostgrestTableInsert({ required String quoteNameTail, String$? mood, @@ -83,7 +86,7 @@ extension type const PostgrestTableInsert._(Map _json) /// passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` /// methods to write SQL NULL explicitly. extension type const PostgrestTableUpdate._(Map _json) - implements Map { + implements Object { PostgrestTableUpdate({ String? quoteNameTail, String$? mood, @@ -112,7 +115,12 @@ class PostgrestTable$ { const PostgrestTable$._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable('postgrest_table', PostgrestTableRow.new); + static const table = + PostgrestTable< + PostgrestTableRow, + PostgrestTableInsert, + PostgrestTableUpdate + >('postgrest_table', PostgrestTableRow.new); static const quoteNameTail = PostgrestColumn( 'quote\'name\u{2029}tail', @@ -139,8 +147,7 @@ class PostgrestTable$ { } /// A row of the `map` table. -extension type const MapRow(Map _json) - implements Map { +extension type const MapRow(Map _json) implements Object { int get list => _json['list'] as int; DateTime? get dateTime => switch (_json['date_time']) { null => null, @@ -160,6 +167,9 @@ extension type const MapRow(Map _json) null => null, final Object value => PostgrestRange.parse(value as String, DateTime.parse), }; + + /// The row as decoded from the response. + Map toJson() => _json; } /// Values for inserting a row into `map`. Columns that are nullable, identity, @@ -167,8 +177,7 @@ extension type const MapRow(Map _json) /// 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 MapInsert._(Map _json) - implements Map { +extension type const MapInsert._(Map _json) implements Object { MapInsert({ required int list, DateTime? dateTime, @@ -203,8 +212,7 @@ extension type const MapInsert._(Map _json) /// Values for updating rows of `map`. 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 MapUpdate._(Map _json) - implements Map { +extension type const MapUpdate._(Map _json) implements Object { MapUpdate({ int? list, DateTime? dateTime, @@ -241,7 +249,10 @@ class Map$ { const Map$._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable('map', MapRow.new); + static const table = PostgrestTable( + 'map', + MapRow.new, + ); static const list = PostgrestColumn('list'); static const dateTime = PostgrestNullableColumn( diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index a7c716e86..e9643ded2 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -35,9 +35,12 @@ enum Mood { /// A row of the `author_stats` table. /// Aggregated statistics per author extension type const AuthorStatsRow(Map _json) - implements Map { + implements Object { int? get authorId => _json['author_id'] as int?; int? get bookCount => _json['book_count'] as int?; + + /// The row as decoded from the response. + Map toJson() => _json; } /// Typed access to the `author_stats` table. @@ -45,7 +48,10 @@ class AuthorStats { const AuthorStats._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable('author_stats', AuthorStatsRow.new); + static const table = PostgrestTable( + 'author_stats', + AuthorStatsRow.new, + ); static const authorId = PostgrestNullableColumn( 'author_id', @@ -61,10 +67,12 @@ class AuthorStats { } /// A row of the `authors` table. -extension type const AuthorsRow(Map _json) - implements Map { +extension type const AuthorsRow(Map _json) implements Object { int get id => _json['id'] as int; String get name => _json['name'] as String; + + /// The row as decoded from the response. + Map toJson() => _json; } /// Values for inserting a row into `authors`. Columns that are nullable, @@ -73,7 +81,7 @@ extension type const AuthorsRow(Map _json) /// 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 { + implements Object { AuthorsInsert({required String name}) : this._({'name': name}); } @@ -81,7 +89,7 @@ extension type const AuthorsInsert._(Map _json) /// `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 { + implements Object { AuthorsUpdate({String? name}) : this._({'name': ?name}); } @@ -90,7 +98,10 @@ class Authors { const Authors._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable('authors', AuthorsRow.new); + static const table = PostgrestTable( + 'authors', + AuthorsRow.new, + ); static const id = PostgrestColumn('id'); static const name = PostgrestColumn('name'); @@ -106,11 +117,14 @@ class Authors { /// A row of the `book_prices` table. /// Prices per book, with the standard discount precomputed extension type const BookPricesRow(Map _json) - implements Map { + implements Object { num? get discountedPrice => _json['discounted_price'] as num?; int? get id => _json['id'] as int?; num? get price => _json['price'] as num?; String? get title => _json['title'] as String?; + + /// The row as decoded from the response. + Map toJson() => _json; } /// Values for inserting a row into `book_prices`. Columns that are nullable, @@ -119,7 +133,7 @@ extension type const BookPricesRow(Map _json) /// always generates itself are left out entirely. Use the `set…ToNull` methods /// to insert SQL NULL explicitly. extension type const BookPricesInsert._(Map _json) - implements Map { + implements Object { BookPricesInsert({int? id, num? price, String? title}) : this._({'id': ?id, 'price': ?price, 'title': ?title}); @@ -141,7 +155,7 @@ extension type const BookPricesInsert._(Map _json) /// `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods /// to write SQL NULL explicitly. extension type const BookPricesUpdate._(Map _json) - implements Map { + implements Object { BookPricesUpdate({int? id, num? price, String? title}) : this._({'id': ?id, 'price': ?price, 'title': ?title}); @@ -164,7 +178,11 @@ class BookPrices { const BookPrices._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable('book_prices', BookPricesRow.new); + static const table = + PostgrestTable( + 'book_prices', + BookPricesRow.new, + ); static const discountedPrice = PostgrestNullableColumn( 'discounted_price', @@ -176,9 +194,12 @@ class BookPrices { /// A row of the `book_submissions` table. extension type const BookSubmissionsRow(Map _json) - implements Map { + implements Object { String? get authorName => _json['author_name'] as String?; String? get title => _json['title'] as String?; + + /// The row as decoded from the response. + Map toJson() => _json; } /// Values for inserting a row into `book_submissions`. Columns that are @@ -187,7 +208,7 @@ extension type const BookSubmissionsRow(Map _json) /// database always generates itself are left out entirely. Use the `set…ToNull` /// methods to insert SQL NULL explicitly. extension type const BookSubmissionsInsert._(Map _json) - implements Map { + implements Object { BookSubmissionsInsert({String? authorName, String? title}) : this._({'author_name': ?authorName, 'title': ?title}); @@ -207,10 +228,11 @@ class BookSubmissions { const BookSubmissions._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable( - 'book_submissions', - BookSubmissionsRow.new, - ); + static const table = + PostgrestTable( + 'book_submissions', + BookSubmissionsRow.new, + ); static const authorName = PostgrestNullableColumn( 'author_name', @@ -223,10 +245,13 @@ class BookSubmissions { /// A row of the `book_summaries` table. /// Denormalized book and author names extension type const BookSummariesRow(Map _json) - implements Map { + implements Object { String? get authorName => _json['author_name'] as String?; int? get id => _json['id'] as int?; String? get title => _json['title'] as String?; + + /// The row as decoded from the response. + Map toJson() => _json; } /// Typed access to the `book_summaries` table. @@ -234,7 +259,10 @@ class BookSummaries { const BookSummaries._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable('book_summaries', BookSummariesRow.new); + static const table = PostgrestTable( + 'book_summaries', + BookSummariesRow.new, + ); static const authorName = PostgrestNullableColumn( 'author_name', @@ -247,8 +275,7 @@ class BookSummaries { /// A row of the `books` table. /// Books available in the library -extension type const BooksRow(Map _json) - implements Map { +extension type const BooksRow(Map _json) implements Object { int get authorId => _json['author_id'] as int; String? get coverUuid => _json['cover_uuid'] as String?; @@ -274,6 +301,9 @@ extension type const BooksRow(Map _json) null => null, final Object value => DateTime.parse(value as String), }; + + /// The row as decoded from the response. + Map toJson() => _json; } /// Values for inserting a row into `books`. Columns that are nullable, @@ -282,7 +312,7 @@ extension type const BooksRow(Map _json) /// 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 { + implements Object { BooksInsert({ required int authorId, String? coverUuid, @@ -364,7 +394,7 @@ extension type const BooksInsert._(Map _json) /// `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 { + implements Object { BooksUpdate({ int? authorId, String? coverUuid, @@ -447,7 +477,10 @@ class Books { const Books._(); /// Table definition for [PostgrestClient.table]. - static const table = PostgrestTable('books', BooksRow.new); + static const table = PostgrestTable( + 'books', + BooksRow.new, + ); static const authorId = PostgrestColumn('author_id'); static const coverUuid = PostgrestNullableColumn( diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 02f119a1c..588d62e7f 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -888,6 +888,7 @@ features: symbols: - PostgrestQueryBuilder.insert - PostgrestTypedQueryBuilder.insert + - PostgrestTypedQueryBuilder.insertAll database.mutate.update: status: implemented symbols: @@ -898,6 +899,7 @@ features: symbols: - PostgrestQueryBuilder.upsert - PostgrestTypedQueryBuilder.upsert + - PostgrestTypedQueryBuilder.upsertAll database.mutate.delete: status: implemented symbols: From d6693e93f3e21691a131a182c088c6981d3d7906 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 14:49:59 +0200 Subject: [PATCH 2/2] fix(postgrest): reject empty bulk writes and stop reserving Map member names in typegen insertAll and upsertAll throw on an empty list, matching the other argument checks on the typed builder. Generated extension types no longer implement Map, so only the Object members need a suffix in generated member names. --- .../postgrest/lib/src/postgrest_table.dart | 8 ++--- .../src/postgrest_typed_query_builder.dart | 15 +++++++-- packages/postgrest/test/typed_query_test.dart | 11 +++++++ .../supabase_typegen/lib/src/identifiers.dart | 32 ++++--------------- .../test/dart_generator_test.dart | 7 ++-- .../test/goldens/hostile_schema.dart | 4 +-- .../test/identifiers_test.dart | 11 +++++-- 7 files changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/postgrest/lib/src/postgrest_table.dart b/packages/postgrest/lib/src/postgrest_table.dart index b739ceaa1..20a91ac32 100644 --- a/packages/postgrest/lib/src/postgrest_table.dart +++ b/packages/postgrest/lib/src/postgrest_table.dart @@ -43,10 +43,10 @@ typedef RowConverter = Row Function(Map json); /// await client.table(Books.table).insert(BookInsert(title: 'Dart')); /// ``` /// -/// [Insert] and [Update] are sent as the request body, so they have to encode -/// to a JSON object: an extension type over the map to send (as above), or a -/// class with a `toJson` method. Both have to be spelled out, since nothing in -/// the constructor arguments can infer them. A read-only relation, such as a +/// [Insert] and [Update] are sent as the request body, so a value has to be +/// the JSON object to send, in practice an extension type over the +/// `Map` as above. Both have to be spelled out, since nothing +/// in the constructor arguments can infer them. A read-only relation, such as a /// materialized view, uses `Never` for the write types it does not support, /// which makes the corresponding methods uncallable. /// diff --git a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart index 420e238ed..2effd74f8 100644 --- a/packages/postgrest/lib/src/postgrest_typed_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_query_builder.dart @@ -82,12 +82,14 @@ class PostgrestTypedQueryBuilder { /// ]); /// ``` /// + /// [rows] needs at least one row. + /// /// See [insert] and [PostgrestQueryBuilder.insert] for [defaultToNull]. PostgrestTypedFilterBuilder insertAll( List rows, { bool defaultToNull = true, }) => PostgrestTypedFilterBuilder._( - _queryBuilder.insert(rows, defaultToNull: defaultToNull), + _queryBuilder.insert(_nonEmpty(rows), defaultToNull: defaultToNull), table.rowFromJson, ); @@ -133,6 +135,8 @@ class PostgrestTypedQueryBuilder { /// ); /// ``` /// + /// [rows] needs at least one row. + /// /// See [upsert] for [onConflict] and [PostgrestQueryBuilder.upsert] for /// [ignoreDuplicates] and [defaultToNull]. PostgrestTypedFilterBuilder upsertAll( @@ -141,12 +145,19 @@ class PostgrestTypedQueryBuilder { bool ignoreDuplicates = false, bool defaultToNull = true, }) => _upsert( - rows, + _nonEmpty(rows), onConflict: onConflict, ignoreDuplicates: ignoreDuplicates, defaultToNull: defaultToNull, ); + List _nonEmpty(List rows) { + if (rows.isEmpty) { + throw ArgumentError.value(rows, 'rows', 'rows needs at least one row'); + } + return rows; + } + PostgrestTypedFilterBuilder _upsert( Object values, { required List>? onConflict, diff --git a/packages/postgrest/test/typed_query_test.dart b/packages/postgrest/test/typed_query_test.dart index 78176310c..b2c643924 100644 --- a/packages/postgrest/test/typed_query_test.dart +++ b/packages/postgrest/test/typed_query_test.dart @@ -554,6 +554,17 @@ void main() { ); }); + test('insertAll and upsertAll without rows throw', () { + expect( + () => client.table(Books.table).insertAll([]), + throwsArgumentError, + ); + expect( + () => client.table(Books.table).upsertAll([]), + throwsArgumentError, + ); + }); + test('insert with a trailing select returns the typed row', () async { httpClient.stub({'id': 3, 'title': 'foo'}); diff --git a/packages/supabase_typegen/lib/src/identifiers.dart b/packages/supabase_typegen/lib/src/identifiers.dart index 0c8dcb8b9..eedb550ad 100644 --- a/packages/supabase_typegen/lib/src/identifiers.dart +++ b/packages/supabase_typegen/lib/src/identifiers.dart @@ -68,32 +68,13 @@ const _reservedWords = { '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', +/// Members every extension type inherits from `Object`, so column getters +/// cannot use these names. +const _objectMembers = { 'hashCode', - 'isEmpty', - 'isNotEmpty', - 'keys', - 'length', - 'map', 'noSuchMethod', - 'putIfAbsent', - 'remove', - 'removeWhere', 'runtimeType', 'toString', - 'update', - 'updateAll', - 'values', }; final _wordSeparator = RegExp('[^a-zA-Z0-9]+'); @@ -124,11 +105,12 @@ String camelCase(String name) { /// 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$`. +/// Reserved words and members that would collide with `Object` get a `$` +/// suffix, for example `class` becomes `class$`. String memberIdentifier(String name) { final identifier = camelCase(name); - if (_reservedWords.contains(identifier) || _mapMembers.contains(identifier)) { + if (_reservedWords.contains(identifier) || + _objectMembers.contains(identifier)) { return '$identifier\$'; } return identifier; diff --git a/packages/supabase_typegen/test/dart_generator_test.dart b/packages/supabase_typegen/test/dart_generator_test.dart index ecb89d4f0..34b7dd03f 100644 --- a/packages/supabase_typegen/test/dart_generator_test.dart +++ b/packages/supabase_typegen/test/dart_generator_test.dart @@ -114,12 +114,11 @@ void main() { generateDartCode(hostileSchema), ).replaceAll(' ', ''); - // Two keys from postgrest_table to map: both carry the constraint hint, - // and `map` itself is renamed like any member that shadows a core name. + // Two keys from postgrest_table to map: both carry the constraint hint. expect( compact, contains( - r"staticconstmap$ByMood=PostgrestToOneRelation('map!postgrest_table_mood_fkey'", ), ); @@ -139,7 +138,7 @@ void main() { ).replaceAll(' ', ''); expect(compact, isNot(contains(''))); - expect(compact, isNot(contains(r'map$ByList'))); + expect(compact, isNot(contains('mapByList'))); }); test('respects a custom import', () { diff --git a/packages/supabase_typegen/test/goldens/hostile_schema.dart b/packages/supabase_typegen/test/goldens/hostile_schema.dart index 730a17aff..8f16496f8 100644 --- a/packages/supabase_typegen/test/goldens/hostile_schema.dart +++ b/packages/supabase_typegen/test/goldens/hostile_schema.dart @@ -136,12 +136,12 @@ class PostgrestTable$ { ); /// The `map` row referenced by `mood`. - static const map$ByMood = PostgrestToOneRelation( + static const mapByMood = PostgrestToOneRelation( 'map!postgrest_table_mood_fkey', ); /// The `map` row referenced by `days`. - static const map$ByDays = PostgrestToOneRelation( + static const mapByDays = PostgrestToOneRelation( 'map!postgrest_table_days_fkey', ); } diff --git a/packages/supabase_typegen/test/identifiers_test.dart b/packages/supabase_typegen/test/identifiers_test.dart index 5755922e9..b4e36f0a6 100644 --- a/packages/supabase_typegen/test/identifiers_test.dart +++ b/packages/supabase_typegen/test/identifiers_test.dart @@ -33,9 +33,14 @@ void main() { expect(memberIdentifier('in'), r'in$'); }); - test('suffixes Map member names', () { - expect(memberIdentifier('length'), r'length$'); - expect(memberIdentifier('keys'), r'keys$'); + test('suffixes Object member names', () { + expect(memberIdentifier('hash_code'), r'hashCode$'); + expect(memberIdentifier('to_string'), r'toString$'); + }); + + test('keeps Map member names now that rows do not implement Map', () { + expect(memberIdentifier('length'), 'length'); + expect(memberIdentifier('keys'), 'keys'); }); test('keeps regular names untouched', () {