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..20a91ac32 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 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. +/// /// 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..2effd74f8 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,124 @@ 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'), + /// ]); + /// ``` + /// + /// [rows] needs at least one row. + /// + /// See [insert] and [PostgrestQueryBuilder.insert] for [defaultToNull]. + PostgrestTypedFilterBuilder insertAll( + List rows, { + bool defaultToNull = true, + }) => PostgrestTypedFilterBuilder._( + _queryBuilder.insert(_nonEmpty(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], + /// ); + /// ``` + /// + /// [rows] needs at least one row. + /// + /// See [upsert] for [onConflict] and [PostgrestQueryBuilder.upsert] for + /// [ignoreDuplicates] and [defaultToNull]. + PostgrestTypedFilterBuilder upsertAll( + List rows, { List>? onConflict, bool ignoreDuplicates = false, bool defaultToNull = true, + }) => _upsert( + _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, + required bool ignoreDuplicates, + required bool defaultToNull, }) { if (onConflict != null && onConflict.isEmpty) { throw ArgumentError.value( @@ -108,7 +178,7 @@ class PostgrestTypedQueryBuilder { ignoreDuplicates: ignoreDuplicates, defaultToNull: defaultToNull, ), - table, + table.rowFromJson, ); } @@ -120,11 +190,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 +208,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 +217,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..b2c643924 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,49 @@ 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('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'}); final Book book = await client .table(Books.table) - .insert({'title': 'foo'}) + .insert(BookInsert(title: 'foo')) .select() .single(); @@ -541,7 +589,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 +599,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 +613,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 +656,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/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/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..8f16496f8 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', @@ -128,19 +136,18 @@ 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', ); } /// 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/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', () { 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: