diff --git a/MIGRATION.md b/MIGRATION.md index 060026138..b16a42171 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -796,6 +796,25 @@ await supabase.from('countries'); await supabase.from('countries').select(); ``` +### `rpc` is a getter + +`PostgrestClient.rpc`, `SupabaseClient.rpc` and `SupabaseQuerySchema.rpc` are getters returning a +`PostgrestRpc` instead of methods. Calling the result makes the same untyped call as before, so +`supabase.rpc('fn', params: {...})` and `supabase.rpc('fn')` are unchanged. The extension +methods `supabase_typegen` generates on `PostgrestRpc` are what make +`supabase.rpc.searchBooks(query: 'dart')` typed. + +Code that passes `rpc` itself around as a function no longer compiles, since the getter returns an +object rather than a function. Wrap it in a closure instead. + +```dart +// Before +final call = supabase.rpc; + +// After +PostgrestFilterBuilder call(String fn) => supabase.rpc(fn); +``` + ### `insert()` and `upsert()` no longer take filters `insert()` and `upsert()` return a `PostgrestTransformBuilder` instead of a diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index 742054e33..011a612ce 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -190,31 +190,37 @@ class PostgrestClient { ); } - /// {@template postgrest_rpc} - /// Performs a stored procedure call. + /// Calls Postgres functions in the schema of this client. /// - /// [fn] is the name of the function to call. - /// - /// [params] is an optional object to pass as arguments to the function call. - /// - /// When [get] is set to `true`, the function will be called with read-only - /// access mode. - /// - /// {@endtemplate} + /// Calling the result performs an untyped call, see [PostgrestRpc.call]: /// /// ```dart - /// supabase.rpc('get_status', params: {'name_param': 'supabot'}) + /// client.rpc('get_status', params: {'name_param': 'supabot'}) /// ``` - PostgrestFilterBuilder rpc( - String fn, { - Map? params, + /// + /// The extension methods `package:supabase_typegen` generates on + /// [PostgrestRpc] call the functions of the database with typed arguments + /// and results, `client.rpc.searchBooks(query: 'dart')`. + PostgrestRpc get rpc => PostgrestRpc( + this, + schema: _schema, + schemaIsExplicit: _schemaIsExplicit, + ); + + /// Like [rpc], but with the call scoped to [schema] instead of the schema + /// this client was created with; `null` keeps the client's schema. + @internal + PostgrestFilterBuilder rpcInSchema( + String fn, + String? schema, { + Object? params, bool get = false, }) { final requestUrl = '$url/rpc/$fn'; return PostgrestRpcBuilder( requestUrl, headers: headers, - schema: _schema, + schema: schema ?? _schema, httpClient: httpClient, jsonCodec: _jsonCodec, retryOptions: retryOptions, diff --git a/packages/postgrest/lib/src/postgrest_rpc.dart b/packages/postgrest/lib/src/postgrest_rpc.dart new file mode 100644 index 000000000..bcc406191 --- /dev/null +++ b/packages/postgrest/lib/src/postgrest_rpc.dart @@ -0,0 +1,144 @@ +part of 'postgrest_typed_builder.dart'; + +/// Calls Postgres functions, returned by [PostgrestClient.rpc]. +/// +/// Calling it performs an untyped call, whose result is the decoded response +/// body: +/// +/// ```dart +/// final status = await client.rpc('get_status', params: {'name': 'supabot'}); +/// ``` +/// +/// `package:supabase_typegen` generates an extension method on this class for +/// every function of the database, which takes the arguments as typed +/// parameters and resolves to typed results: +/// +/// ```dart +/// final List books = await client.rpc +/// .searchBooks(query: 'dart') +/// .where(Books.publishedOn.isNull().not()) +/// .order(Books.title.asc()); +/// ``` +/// +/// The generated methods are built on [rows] and [value]. +final class PostgrestRpc { + /// Calls functions through [client], in [schema] or the schema of the + /// client when `null`. When [schemaIsExplicit] is set, [schema] also wins + /// over the schema a generated method passes along. + @internal + const PostgrestRpc( + this._client, { + String? schema, + bool schemaIsExplicit = false, + }) : _schema = schema, + _schemaIsExplicit = schemaIsExplicit; + + final PostgrestClient _client; + final String? _schema; + final bool _schemaIsExplicit; + + /// {@template postgrest_rpc} + /// Performs a stored procedure call. + /// + /// [fn] is the name of the function to call. + /// + /// [params] is an optional object to pass as arguments to the function call. + /// + /// When [get] is set to `true`, the function will be called with read-only + /// access mode. + /// {@endtemplate} + /// + /// ```dart + /// client.rpc('get_status', params: {'name_param': 'supabot'}) + /// ``` + PostgrestFilterBuilder call( + String fn, { + Map? params, + bool get = false, + }) => + // ignore: invalid_use_of_internal_member + _client.rpcInSchema(fn, _schema, params: params, get: get); + + /// Calls [function] with [arguments] and reads the rows it returns through + /// [rowFromJson]. + /// + /// The result can be filtered, ordered and paged like the rows of a table, + /// with the column tokens of the row type: + /// + /// ```dart + /// final List books = await client.rpc + /// .rows( + /// 'search_books', + /// arguments: {'query': 'dart'}, + /// rowFromJson: BooksRow.new, + /// ) + /// .where(Books.id.gt(10)); + /// ``` + /// + /// [arguments] is sent as the request body: the JSON object of named + /// arguments, or the value of a function's sole unnamed `json` or `jsonb` + /// parameter. The call addresses [schema] unless this object was scoped + /// with [PostgrestClient.schema]. + @experimental + PostgrestTypedFilterBuilder> rows( + String function, { + Object? arguments, + required RowConverter rowFromJson, + String? schema, + }) => PostgrestTypedFilterBuilder._( + _request(function, arguments, rowFromJson, schema), + PostgrestHttpTableExecutor(_client), + (result) => [ + for (final row in result.data! as List) + rowFromJson(row as Map), + ], + rowFromJson, + ); + + /// Calls [function] with [arguments] and converts the response body, a + /// scalar, a single row or a list of scalars, through [fromJson]. + /// + /// ```dart + /// final int? count = await client.rpc.value( + /// 'count_books', + /// fromJson: (json) => json as int?, + /// ); + /// ``` + /// + /// See [rows] for [arguments] and [schema]. + @experimental + PostgrestTypedBuilder value( + String function, { + Object? arguments, + required T Function(Object? json) fromJson, + String? schema, + }) => PostgrestTypedBuilder._( + _request( + function, + arguments, + _noRows, + schema, + ).copyWith(shape: PostgrestResultShape.value), + PostgrestHttpTableExecutor(_client), + (result) => fromJson(result.data), + ); + + PostgrestTableRequest _request( + String function, + Object? arguments, + RowConverter rowFromJson, + String? schema, + ) => PostgrestTableRequest( + table: PostgrestTable( + function, + rowFromJson, + primaryKey: const [], + ), + operation: PostgrestTableOperation.rpc, + schema: _schemaIsExplicit ? _schema : (schema ?? _schema), + payload: arguments, + ); +} + +Never _noRows(Map json) => + throw StateError('A function call read as a value has no rows'); diff --git a/packages/postgrest/lib/src/postgrest_table_executor.dart b/packages/postgrest/lib/src/postgrest_table_executor.dart index a8d0990e9..d5a95483f 100644 --- a/packages/postgrest/lib/src/postgrest_table_executor.dart +++ b/packages/postgrest/lib/src/postgrest_table_executor.dart @@ -38,13 +38,10 @@ final class PostgrestHttpTableExecutor implements PostgrestTableExecutor { @override Future execute(PostgrestTableRequest request) async { - // ignore: invalid_use_of_internal_member - final query = client.fromInSchema(request.table.name, request.schema); - if (request.operation == PostgrestTableOperation.count) { final counted = _applyOptions( _applyFilter( - query.count(request.countOption ?? CountOption.exact), + _query(request).count(request.countOption ?? CountOption.exact), request, ), request.options, @@ -53,7 +50,7 @@ final class PostgrestHttpTableExecutor implements PostgrestTableExecutor { } final terminal = _applyOptions( - _terminal(_transforms(_operation(query, request), request), request), + _terminal(_transforms(_operation(request), request), request), request.options, ); @@ -97,30 +94,27 @@ final class PostgrestHttpTableExecutor implements PostgrestTableExecutor { /// An insert or upsert takes no filter, the same way the untyped builders /// offer none after them; a hand-built request that carries one is /// rejected rather than sent with the filter silently dropped. - static PostgrestTransformBuilder _operation( - PostgrestQueryBuilder query, - PostgrestTableRequest request, - ) { + PostgrestTransformBuilder _operation(PostgrestTableRequest request) { final filter = request.filter; switch (request.operation) { case PostgrestTableOperation.select: return _applyFilter( - query.select(_selectList(request.columns)), + _query(request).select(_selectList(request.columns)), request, ); case PostgrestTableOperation.update: - return _applyFilter(query.update(request.payload!), request); + return _applyFilter(_query(request).update(request.payload!), request); case PostgrestTableOperation.delete: - return _applyFilter(query.delete(), request); + return _applyFilter(_query(request).delete(), request); case PostgrestTableOperation.insert: _rejectFilter(filter, request.operation); - return query.insert( + return _query(request).insert( request.payload!, defaultToNull: request.defaultToNull, ); case PostgrestTableOperation.upsert: _rejectFilter(filter, request.operation); - return query.upsert( + return _query(request).upsert( request.payload!, onConflict: request.onConflict ?.map((column) => column.name) @@ -128,11 +122,46 @@ final class PostgrestHttpTableExecutor implements PostgrestTableExecutor { ignoreDuplicates: request.ignoreDuplicates, defaultToNull: request.defaultToNull, ); + case PostgrestTableOperation.rpc: + return _applyFilter(_call(request), request); case PostgrestTableOperation.count: throw StateError('count is rendered before the other operations'); } } + /// The table builder of [request], in its schema. + PostgrestQueryBuilder _query(PostgrestTableRequest request) => + // ignore: invalid_use_of_internal_member + client.fromInSchema(request.table.name, request.schema); + + /// The function call of [request], decoding a response read as rows into + /// a [PostgrestList] the way a select does. + /// + /// A HEAD request has no body, so a call read with + /// [PostgrestResultShape.head] sends its arguments as query parameters. + PostgrestFilterBuilder _call(PostgrestTableRequest request) { + final name = request.table.name; + final head = request.shape == PostgrestResultShape.head; + final arguments = head + ? (request.payload ?? const {}) + : request.payload; + if (request.shape == PostgrestResultShape.value) { + // ignore: invalid_use_of_internal_member + return client.rpcInSchema( + name, + request.schema, + params: arguments, + ); + } + // ignore: invalid_use_of_internal_member + return client.rpcInSchema( + name, + request.schema, + params: arguments, + get: head, + ); + } + static void _rejectFilter( PostgrestFilter? filter, PostgrestTableOperation operation, @@ -218,7 +247,9 @@ final class PostgrestHttpTableExecutor implements PostgrestTableExecutor { PostgrestTransformBuilder transformed, PostgrestTableRequest request, ) => switch (request.shape) { - PostgrestResultShape.rows || PostgrestResultShape.none => transformed, + PostgrestResultShape.rows || + PostgrestResultShape.none || + PostgrestResultShape.value => transformed, PostgrestResultShape.single => transformed.single(), PostgrestResultShape.maybeSingle => transformed.maybeSingle(), PostgrestResultShape.head => transformed.head(), diff --git a/packages/postgrest/lib/src/postgrest_table_request.dart b/packages/postgrest/lib/src/postgrest_table_request.dart index b9a3ce7a6..77e05cd86 100644 --- a/packages/postgrest/lib/src/postgrest_table_request.dart +++ b/packages/postgrest/lib/src/postgrest_table_request.dart @@ -19,12 +19,17 @@ enum PostgrestTableOperation { delete, /// Counts the matching rows without reading them. - count; + count, - /// Whether the operation writes to the table. + /// Calls the Postgres function the request's table is named after, with + /// the payload as its arguments. + rpc; + + /// Whether the operation may write to the database. A function call + /// counts as one, since nothing on the client tells whether it does. bool get isMutation => switch (this) { select || count => false, - insert || upsert || update || delete => true, + insert || upsert || update || delete || rpc => true, }; } @@ -54,6 +59,9 @@ enum PostgrestResultShape { /// The query plan instead of the rows. explain, + + /// The response body of a function call as decoded, whatever its shape. + value, } /// A `limit` or `range` applied to an embedded relation rather than to the @@ -200,6 +208,9 @@ final class PostgrestTableRequest { onConflict = onConflict == null ? null : List.unmodifiable(onConflict); /// The table the request addresses. + /// + /// For [PostgrestTableOperation.rpc] it describes the function instead: + /// its name, and the row type of the rows it returns. final PostgrestTable table; /// What the request does to [table]. @@ -239,7 +250,8 @@ final class PostgrestTableRequest { /// `null` means exact. final CountOption? countOption; - /// The rows a mutation sends: one JSON object, or a list of them. + /// The rows a mutation sends: one JSON object, or a list of them. For + /// [PostgrestTableOperation.rpc] the arguments of the function call. /// /// Kept as the caller passed it, not copied: the builders never modify it /// and an executor must not either. An executor that stores a request for @@ -335,7 +347,8 @@ final class PostgrestTableRequest { /// `List>` for rows, a `Map` (or /// `null`) for a single row, a `String` for csv and explain, a /// `Map` for GeoJSON, and `null` when nothing was asked -/// back. The typed builder converts it into the table's row type. +/// back. A [PostgrestResultShape.value] request gets the decoded response body +/// as it is. The typed builder converts it into the table's row type. @experimental final class PostgrestTableResult { const PostgrestTableResult({this.data, this.count}); diff --git a/packages/postgrest/lib/src/postgrest_typed_builder.dart b/packages/postgrest/lib/src/postgrest_typed_builder.dart index 7763083e8..310eda001 100644 --- a/packages/postgrest/lib/src/postgrest_typed_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_builder.dart @@ -19,6 +19,7 @@ part 'postgrest_interval.dart'; part 'postgrest_ordering.dart'; part 'postgrest_partial_row.dart'; part 'postgrest_range.dart'; +part 'postgrest_rpc.dart'; part 'postgrest_table.dart'; part 'postgrest_table_executor.dart'; part 'postgrest_table_request.dart'; diff --git a/packages/postgrest/test/typed_rpc_integration_test.dart b/packages/postgrest/test/typed_rpc_integration_test.dart new file mode 100644 index 000000000..f3880360a --- /dev/null +++ b/packages/postgrest/test/typed_rpc_integration_test.dart @@ -0,0 +1,107 @@ +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +extension type const UserStatus(Map _json) + implements Map { + String? get username => _json['username'] as String?; + String? get status => _json['status'] as String?; +} + +class UserStatuses { + static const username = PostgrestNullableColumn( + 'username', + ); + static const status = PostgrestNullableColumn('status'); +} + +void main() { + late PostgrestClient postgrest; + + setUp(() { + postgrest = PostgrestClient(localStackRestUrl, headers: apiHeaders); + }); + + tearDown(() async { + await postgrest.dispose(); + }); + + test('reads RETURNS TABLE rows and filters them', () async { + final rows = await postgrest.rpc + .rows( + 'get_username_and_status', + arguments: {'name_param': 'supabot'}, + rowFromJson: UserStatus.new, + ) + .where(UserStatuses.status.eq('ONLINE')) + .order(UserStatuses.username.asc()); + + expect(rows.single.username, 'supabot'); + expect(rows.single.status, 'ONLINE'); + }); + + test('selects columns of the returned rows and reads a single one', () async { + final row = await postgrest.rpc + .rows( + 'get_username_and_status', + arguments: {'name_param': 'supabot'}, + rowFromJson: UserStatus.new, + ) + .selectOnly([UserStatuses.status]) + .single(); + + expect(row.read(UserStatuses.status), 'ONLINE'); + }); + + test('counts the returned rows', () async { + final response = await postgrest.rpc + .rows( + 'get_username_and_status', + arguments: {'name_param': 'supabot'}, + rowFromJson: UserStatus.new, + ) + .count(); + + expect(response.count, 1); + }); + + test('reads a scalar, a missing result and no result', () async { + final status = await postgrest.rpc.value( + 'get_status', + arguments: {'name_param': 'supabot'}, + fromJson: (json) => json as String?, + ); + expect(status, 'ONLINE'); + + final missing = await postgrest.rpc.value( + 'get_status', + arguments: {'name_param': 'nobody'}, + fromJson: (json) => json as String?, + ); + expect(missing, isNull); + + final element = await postgrest.rpc.value( + 'get_array_element', + arguments: { + 'arr': [37, 420, 64], + 'index': 2, + }, + fromJson: (json) => json as int?, + ); + expect(element, 420); + + await postgrest.rpc.value('void_func', fromJson: (json) {}); + }); + + test('calls a function of the schema it is given', () async { + final status = await postgrest.rpc.value( + 'get_status', + arguments: {'name_param': 'supabot'}, + fromJson: (json) => json as String?, + schema: 'personal', + ); + + expect(status, 'ONLINE'); + }); +} diff --git a/packages/postgrest/test/typed_rpc_test.dart b/packages/postgrest/test/typed_rpc_test.dart new file mode 100644 index 000000000..e883f14e5 --- /dev/null +++ b/packages/postgrest/test/typed_rpc_test.dart @@ -0,0 +1,235 @@ +import 'package:postgrest/postgrest.dart'; +import 'package:supabase_test/supabase_test.dart'; +import 'package:test/test.dart'; + +extension type const Book(Map _json) implements Object { + int get id => _json['id'] as int; + String get title => _json['title'] as String; +} + +class Books { + static const id = PostgrestColumn('id'); + static const title = PostgrestColumn('title'); +} + +const bookRows = [ + {'id': 1, 'title': 'a'}, + {'id': 2, 'title': 'b'}, +]; + +void main() { + late MockSupabaseHttpClient httpClient; + late PostgrestClient client; + + setUp(() { + httpClient = MockSupabaseHttpClient(); + client = PostgrestClient( + 'http://localhost/rest/v1', + httpClient: httpClient, + ); + }); + + tearDown(() async { + await client.dispose(); + }); + + RecordedRequest lastRequest() => httpClient.requests.last; + + group('call', () { + test('performs an untyped call', () async { + httpClient.stub('ok', path: '/rest/v1/rpc/get_status'); + + final status = await client.rpc( + 'get_status', + params: {'name': 'supabot'}, + ); + + expect(status, 'ok'); + expect(lastRequest().method, HttpMethod.post.value); + expect(lastRequest().jsonBody, {'name': 'supabot'}); + }); + + test('passes the type argument and the read-only flag on', () async { + httpClient.stub(bookRows); + + final PostgrestList rows = await client.rpc( + 'list_books', + params: {'limit': 2}, + get: true, + ); + + expect(rows, bookRows); + expect(lastRequest().method, HttpMethod.get.value); + expect(lastRequest().url.queryParameters, {'limit': '2'}); + }); + + test('calls in the schema the client was scoped to', () async { + httpClient.stub(null); + + await client.schema('inventory').rpc('restock'); + + expect(lastRequest().headers['content-profile'], 'inventory'); + }); + }); + + group('rows', () { + test('converts the returned rows into the row type', () async { + httpClient.stub(bookRows, path: '/rest/v1/rpc/search_books'); + + final List books = await client.rpc.rows( + 'search_books', + arguments: {'query': 'dart'}, + rowFromJson: Book.new, + ); + + expect(books.map((book) => book.title), ['a', 'b']); + expect(lastRequest().method, HttpMethod.post.value); + expect(lastRequest().jsonBody, {'query': 'dart'}); + expect(lastRequest().url.queryParameters, isEmpty); + }); + + test('applies filters, orderings and limits to the result', () async { + httpClient.stub(bookRows); + + await client.rpc + .rows('search_books', rowFromJson: Book.new) + .where(Books.id.gt(1)) + .order(Books.title.desc()) + .limit(5); + + expect(lastRequest().url.queryParameters, { + 'id': 'gt.1', + 'order': 'title.desc', + 'limit': '5', + }); + }); + + test('selects columns of the result as partial rows', () async { + httpClient.stub([ + {'title': 'a'}, + ]); + + final rows = await client.rpc + .rows('search_books', rowFromJson: Book.new) + .selectOnly([Books.title]); + + expect(lastRequest().url.queryParameters, {'select': 'title'}); + expect(rows.single.read(Books.title), 'a'); + }); + + test('reads a single row', () async { + httpClient.stub(bookRows.first); + + final Book book = await client.rpc + .rows('search_books', rowFromJson: Book.new) + .single(); + + expect(book.id, 1); + expect( + lastRequest().headers['accept'], + 'application/vnd.pgrst.object+json', + ); + }); + + test('counts the rows next to the result', () async { + httpClient.stub(bookRows, headers: {'content-range': '0-1/2'}); + + final response = await client.rpc + .rows('search_books', rowFromJson: Book.new) + .count(); + + expect(response.data, hasLength(2)); + expect(response.count, 2); + }); + + test('sends the arguments of a head request as query parameters', () async { + httpClient.stub(null); + + await client.rpc + .rows( + 'search_books', + arguments: {'query': 'dart'}, + rowFromJson: Book.new, + ) + .where(Books.id.gt(1)) + .head(); + + expect(lastRequest().method, HttpMethod.head.value); + expect(lastRequest().url.queryParameters, { + 'query': 'dart', + 'id': 'gt.1', + }); + }); + + test('calls in the schema it is given', () async { + httpClient.stub(bookRows); + + await client.rpc.rows( + 'search_books', + rowFromJson: Book.new, + schema: 'inventory', + ); + + expect(lastRequest().headers['content-profile'], 'inventory'); + }); + + test('calls in the schema the client was scoped to over the one it is ' + 'given', () async { + httpClient.stub(bookRows); + + await client + .schema('archive') + .rpc + .rows('search_books', rowFromJson: Book.new, schema: 'inventory'); + + expect(lastRequest().headers['content-profile'], 'archive'); + }); + }); + + group('value', () { + test('converts the response body', () async { + httpClient.stub(3, path: '/rest/v1/rpc/count_books'); + + final int? count = await client.rpc.value( + 'count_books', + fromJson: (json) => json as int?, + ); + + expect(count, 3); + expect(lastRequest().url.queryParameters, isEmpty); + }); + + test('sends the arguments as the request body', () async { + httpClient.stub(null); + + await client.rpc.value( + 'log_event', + arguments: {'kind': 'click'}, + fromJson: (json) {}, + ); + + expect(lastRequest().jsonBody, {'kind': 'click'}); + }); + + test('converts a list of scalars', () async { + httpClient.stub([1, 2, null]); + + final List ids = await client.rpc.value( + 'book_ids', + fromJson: (json) => (json! as List).cast(), + ); + + expect(ids, [1, 2, null]); + }); + + test('applies the request options', () async { + httpClient.stub(3); + + await client.rpc + .value('count_books', fromJson: (json) => json) + .setHeader('x-trace', 'abc'); + + expect(lastRequest().headers['x-trace'], 'abc'); + }); + }); +} diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 337a19216..9fcd14965 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -289,14 +289,12 @@ class SupabaseClient { /// The schema needs to be on the list of exposed schemas inside Supabase. SupabaseQuerySchema schema(String schema) => _defaultSchema.schema(schema); - /// {@macro postgrest_rpc} - PostgrestFilterBuilder rpc( - String fn, { - Map? params, - get = false, - }) { - return _defaultSchema.rpc(fn, params: params, get: get); - } + /// Calls Postgres functions, see [PostgrestClient.rpc]. + /// + /// ```dart + /// await supabase.rpc('get_status', params: {'name_param': 'supabot'}); + /// ``` + PostgrestRpc get rpc => _defaultSchema.rpc; /// Creates a Realtime channel with Broadcast, Presence, and Postgres Changes. RealtimeChannel channel( diff --git a/packages/supabase/lib/src/supabase_query_schema.dart b/packages/supabase/lib/src/supabase_query_schema.dart index 3d24f48dc..404e122dc 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -62,18 +62,11 @@ class SupabaseQuerySchema { ); } - /// {@macro postgrest_rpc} - PostgrestFilterBuilder rpc( - String fn, { - Map? params, - bool get = false, - }) { - return _rest.rpc( - fn, - params: params, - get: get, - ); - } + /// Calls Postgres functions in this schema, see [PostgrestClient.rpc]. + /// + /// The schema of this object wins over the schema a generated function + /// method addresses. + PostgrestRpc get rpc => _rest.rpc; /// Returns a copy of this scoped to [schema] instead. SupabaseQuerySchema schema(String schema) { diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index a3b2abd53..5afcd0b10 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -43,7 +43,22 @@ For every table the generator emits: column, and a computed relationship named like the table of a foreign key embed replaces that embed, since PostgREST resolves the name to the function, -- Dart enums for Postgres enums, with wire-name mapping. +- Dart enums for Postgres enums, with wire-name mapping, +- an extension method on `PostgrestRpc` for every function PostgREST exposes + under `/rpc`, so `client.rpc.searchBooks(query: 'dart')` takes the + arguments as typed parameters, optional where the function declares a + default, and resolves to a typed result. A function returning rows of a + generated table resolves to those rows and can be filtered, ordered and + paged with the table's column tokens, and one returning a single row to + that row, or `null` when it finds none; a `RETURNS TABLE (…)` function gets a + row type and column tokens of its own; a scalar comes back converted and + nullable, since a function can always return `NULL`. Functions of a schema + other than `public` carry the schema as a prefix, `inventoryRestock`. Of + overloaded functions, the overload with the fewest arguments is named after + the function and every other one spells out its arguments, + `booksOfWithAuthorNameAndMaxPrice`; an overload PostgREST cannot resolve + gets no method, only a comment saying why. `client.rpc('fn', params: …)` + still makes an untyped call. The schemas are generated into one file. Objects of the `public` schema are named after themselves, `books` becomes `Books`, `BooksRow`, `BooksInsert` @@ -189,4 +204,11 @@ final archived = await client - Foreign keys into another schema get no relation member, since PostgREST only embeds tables of the schema a request addresses. The foreign key itself is still described, so the column is typed like any other. -- Typed functions (rpc) are not generated yet. +- A function taking an unnamed parameter gets a method only when that is its + sole parameter and of type `json` or `jsonb`, which PostgREST passes as the + whole request body. Call one taking a sole unnamed `text` parameter through + `client.rpc('fn', params: …)`; one taking a sole unnamed row parameter is a + computed field or relationship, reached through its table. +- The parameters of a function are typed non-nullable unless they have a + default, matching the supabase-js type generator; pass SQL NULL to a + function without a default through `client.rpc`. diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index d7d072888..872f91760 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -28,7 +28,8 @@ final minimumLanguageVersion = Version(3, 8, 0); /// Generates a Dart source file with typed table definitions, row extension /// types, insert and update value types, column tokens, computed fields, -/// relations and Postgres enums for the schemas of [database]. +/// relations, Postgres enums and typed function calls for the schemas of +/// [database]. /// /// Objects of the `public` schema are named after themselves, `books` becomes /// `Books` and `BooksRow`; objects of any other schema carry the schema as a @@ -40,8 +41,9 @@ final minimumLanguageVersion = Version(3, 8, 0); /// `PostgrestColumn`, `PostgrestNullableColumn`, `PostgrestVectorColumn`, /// `PostgrestNullableVectorColumn`, `PostgrestComputedField`, /// `PostgrestRange`, `PostgrestDate`, `PostgrestTime`, `PostgrestInterval`, -/// `PostgrestToOneRelation`, `PostgrestToManyRelation`, `postgrestBytea` and -/// `postgrestVector`); pass the library [packageImportUri] finds for the +/// `PostgrestToOneRelation`, `PostgrestToManyRelation`, `PostgrestRpc`, +/// `PostgrestTypedBuilder`, `PostgrestTypedFilterBuilder`, `postgrestBytea` +/// and `postgrestVector`); pass the library [packageImportUri] finds for the /// project the file is written into. /// /// The code is formatted for [languageVersion], so it uses no syntax a @@ -59,17 +61,18 @@ String generateDartCode( languageVersion == null || languageVersion < minimumLanguageVersion ? minimumLanguageVersion : languageVersion; - final usesBinaryColumns = database.tables.any( - (table) => - [ + final usesBinaryColumns = + [ + for (final table in database.tables) ...[ for (final column in table.columns) _columnShape(column), for (final field in table.computedFields) _computedFieldShape(field), - ].any( - (shape) => - shape.typeKind == ColumnTypeKind.binary || - shape.elementTypeKind == ColumnTypeKind.binary, - ), - ); + ], + for (final function in database.functions) ..._functionShapes(function), + ].any( + (shape) => + shape.typeKind == ColumnTypeKind.binary || + shape.elementTypeKind == ColumnTypeKind.binary, + ); // Caller-provided values are encoded before they are written into source: // a line terminator in a schema name would escape the comment, and a // quote in the import URI would escape the import string. @@ -136,6 +139,14 @@ String generateDartCode( decoders, ); } + _writeFunctions( + buffer, + database.functions, + tableNames, + typeNames, + enumTypeNames, + decoders, + ); decoders.write(buffer); return DartFormatter( @@ -187,6 +198,9 @@ class _TypeNameRegistry { 'PostgrestRelation', 'PostgrestToOneRelation', 'PostgrestToManyRelation', + 'PostgrestRpc', + 'PostgrestTypedBuilder', + 'PostgrestTypedFilterBuilder', }; String claim(String name) => _claimName(_taken, name); @@ -582,7 +596,16 @@ void _writeTable( final relationNames = _relationNames(typeNames, members, relations); final relationKeys = _embedKeys(relations, relationNames); - _writeRow(buffer, table, names, memberNames, bindings); + _writeRow( + buffer, + table, + names.rowType, + memberNames, + bindings, + docLine: + 'A row of the `${names.displayName}` table, as `select()` reads it ' + 'with every column.', + ); if (insertType != null) { _writeValues( buffer, @@ -697,16 +720,12 @@ String _claimAlias(Set used, String candidate) { void _writeRow( StringBuffer buffer, TableDescription table, - _TableNames names, + String rowType, Map memberNames, - Map bindings, -) { - final rowType = names.rowType; - _writeDocComment( - buffer, - 'A row of the `${names.displayName}` table, as `select()` reads it with ' - 'every column.', - ); + Map bindings, { + required String docLine, +}) { + _writeDocComment(buffer, docLine); _writeDocComment(buffer, table.comment); buffer ..writeln('extension type const $rowType(Map _json)') @@ -848,25 +867,14 @@ void _writeNamespace( buffer ..writeln(' );') ..writeln(); - for (final column in table.columns) { - final binding = bindings[column.name]!; - final columnType = switch ((binding.kind, column.isNullable)) { - (ColumnTypeKind.vector, true) => - 'PostgrestNullableVectorColumn<$rowType>', - (ColumnTypeKind.vector, false) => 'PostgrestVectorColumn<$rowType>', - (_, true) => 'PostgrestNullableColumn<$rowType, ${binding.dartType}>', - (_, false) => 'PostgrestColumn<$rowType, ${binding.dartType}>', - }; - // A vector column decodes its literal itself. - final decoder = binding.kind == ColumnTypeKind.vector - ? null - : decoders.claim(binding); - final arguments = decoder == null ? '' : ', fromJson: $decoder'; - buffer.writeln( - ' static const ${columnMembers[column.name]} = ' - '$columnType(${_stringLiteral(column.name)}$arguments);', - ); - } + _writeColumnTokens( + buffer, + table.columns, + rowType, + columnMembers, + bindings, + decoders, + ); for (final field in table.computedFields) { final binding = computedFieldBindings[field.name]!; final decoder = decoders.claim(binding); @@ -913,6 +921,37 @@ void _writeNamespace( ..writeln(); } +/// Writes a column token constant of the row type [rowType] for each of +/// [columns]. +void _writeColumnTokens( + StringBuffer buffer, + List columns, + String rowType, + Map columnMembers, + Map bindings, + _DecoderRegistry decoders, +) { + for (final column in columns) { + final binding = bindings[column.name]!; + final columnType = switch ((binding.kind, column.isNullable)) { + (ColumnTypeKind.vector, true) => + 'PostgrestNullableVectorColumn<$rowType>', + (ColumnTypeKind.vector, false) => 'PostgrestVectorColumn<$rowType>', + (_, true) => 'PostgrestNullableColumn<$rowType, ${binding.dartType}>', + (_, false) => 'PostgrestColumn<$rowType, ${binding.dartType}>', + }; + // A vector column decodes its literal itself. + final decoder = binding.kind == ColumnTypeKind.vector + ? null + : decoders.claim(binding); + final arguments = decoder == null ? '' : ', fromJson: $decoder'; + buffer.writeln( + ' static const ${columnMembers[column.name]} = ' + '$columnType(${_stringLiteral(column.name)}$arguments);', + ); + } +} + /// The private top-level functions the column tokens decode their values /// with, one per Dart type that differs from its JSON representation, shared /// by every column of that type. @@ -1370,3 +1409,462 @@ String _stringLiteral(String value) { .replaceAll('\u2029', r'\u{2029}'); return "'$escaped'"; } + +_TypeShape _valueShape(String name, ValueTypeDescription type) => ( + name: name, + typeKind: type.typeKind, + elementTypeKind: type.elementTypeKind, + boundTypeKind: type.boundTypeKind, + enumType: type.enumType, +); + +/// The shapes of every argument and result type of [function]. +Iterable<_TypeShape> _functionShapes(FunctionDescription function) sync* { + for (final overload in function.overloads) { + for (final argument in overload.arguments) { + yield _valueShape(argument.name, argument.type); + } + switch (overload.result) { + case FunctionValueResult(:final type): + yield _valueShape(function.name, type); + case FunctionColumnsResult(:final columns): + yield* columns.map(_columnShape); + case FunctionNoResult() || FunctionTableRowsResult(): + break; + } + } +} + +/// The members of [PostgrestRpc] a generated method may not be named like, +/// since an instance member wins over an extension member, and the names the +/// generated method bodies refer to. +const _rpcMembers = {'call', 'rows', 'value'}; + +/// The top-level codecs the generated conversions call unqualified. +const _codecs = {'postgrestBytea', 'postgrestVector'}; + +/// One overload of a function that gets a generated method. +class _FunctionMethod { + const _FunctionMethod(this.function, this.overload, this.name); + + final FunctionDescription function; + final FunctionOverloadDescription overload; + final String name; +} + +/// Why the generated code cannot call [overload], or `null` when it can. +/// +/// PostgREST passes an unnamed parameter only when it is the sole parameter +/// and of type `json` or `jsonb`, as the whole request body; a sole unnamed +/// `text` one needs a body of its own content type, and a sole unnamed row +/// parameter is not callable under `/rpc` at all. +String? _unsupportedReason(FunctionOverloadDescription overload) { + if (overload.error case final error?) return error; + final unnamed = overload.arguments.where((argument) => argument.name == ''); + if (unnamed.isEmpty) return null; + if (overload.arguments case [final argument] + when argument.rowOf == null && + argument.type.typeKind == ColumnTypeKind.json) { + return null; + } + return 'PostgREST only passes an unnamed parameter that is the sole one and ' + 'of type json or jsonb; call it through `client.rpc` instead'; +} + +/// The method names of the callable overloads of [functions], unique among +/// each other and apart from the members of `PostgrestRpc` and the [reserved] +/// names the generated method bodies refer to. +/// +/// A function with one callable overload is named after the function, with +/// the schema as a prefix outside `public`. Of several overloads, the first +/// one with the fewest arguments keeps that name and every other one spells +/// out its arguments, `booksOfWithAuthorNameAndMaxPrice`, or `body` for the +/// unnamed one. +List<_FunctionMethod> _functionMethods( + List functions, { + required Set reserved, +}) { + final used = {..._rpcMembers, ...reserved}; + final methods = <_FunctionMethod>[]; + for (final function in functions) { + final callable = [ + for (final overload in function.overloads) + if (_unsupportedReason(overload) == null) overload, + ]; + if (callable.isEmpty) continue; + final baseName = memberIdentifier( + function.schema == 'public' + ? function.name + : '${function.schema}_${function.name}', + ); + final plain = callable.reduce( + (fewest, overload) => overload.arguments.length < fewest.arguments.length + ? overload + : fewest, + ); + for (final overload in callable) { + final argumentNames = overload.arguments.map( + (argument) => argument.name.isEmpty ? 'body' : argument.name, + ); + final name = identical(overload, plain) + ? baseName + : '${baseName}With${pascalCase(argumentNames.join('_and_'))}'; + methods.add(_FunctionMethod(function, overload, _claimName(used, name))); + } + } + return methods; +} + +/// The generated names of the rows a `RETURNS TABLE` function returns. +class _ResultRowNames { + const _ResultRowNames({ + required this.rowType, + required this.namespaceType, + required this.rowMembers, + required this.columnMembers, + }); + + final String rowType; + final String namespaceType; + final Map rowMembers; + final Map columnMembers; +} + +void _writeFunctions( + StringBuffer buffer, + List functions, + Map<(String, String), _TableNames> tableNames, + _TypeNameRegistry typeNames, + Map<(String, String), String> enumTypeNames, + _DecoderRegistry decoders, +) { + final methods = _functionMethods( + functions, + reserved: {...typeNames.taken, ..._codecs}, + ); + // A computed field or relationship the generated code cannot call is + // reached through the members of its table instead, so it goes + // unmentioned. + final unsupported = [ + for (final function in functions) + for (final overload in function.overloads) + if (!overload.isComputedField) + if (_unsupportedReason(overload) case final reason?) + (function, overload, reason), + ]; + if (methods.isEmpty && unsupported.isEmpty) return; + + final resultRows = <_FunctionMethod, _ResultRowNames>{}; + for (final method in methods) { + if (method.overload.result case FunctionColumnsResult(:final columns)) { + final baseName = pascalCase(method.name); + final rowType = typeNames.claim('${baseName}Row'); + final namespaceType = typeNames.claim(baseName); + final names = [for (final column in columns) column.name]; + final rowMembers = _uniqueMemberNames( + names, + reserved: {...typeNames.taken, 'toJson', ..._codecs}, + ); + resultRows[method] = _ResultRowNames( + rowType: rowType, + namespaceType: namespaceType, + rowMembers: rowMembers, + columnMembers: _uniqueMemberNames( + names, + reserved: typeNames.taken, + existing: rowMembers, + ), + ); + } + } + for (final MapEntry(key: method, value: names) in resultRows.entries) { + _writeResultRows( + buffer, + method, + names, + enumTypeNames, + decoders, + ); + } + + final extensionName = typeNames.claim('DatabaseFunctions'); + _writeDocComment( + buffer, + 'Typed calls of the database functions, `client.rpc.functionName(…)`.', + ); + for (final (function, overload, reason) in unsupported) { + final signature = overload.arguments + .map( + (argument) => argument.name.isEmpty + ? argument.type.postgresFormat + : '${argument.name} ${argument.type.postgresFormat}', + ) + .join(', '); + buffer.writeln('///'); + _writeDocComment( + buffer, + '`${_displayName(function.schema, function.name)}($signature)` has no ' + 'method: $reason' + .replaceAll(_lineTerminators, ' '), + ); + } + buffer.writeln('extension $extensionName on PostgrestRpc {'); + for (final (index, method) in methods.indexed) { + if (index > 0) buffer.writeln(); + _writeFunctionMethod( + buffer, + method, + tableNames, + typeNames.taken, + resultRows[method], + enumTypeNames, + decoders, + ); + } + buffer + ..writeln('}') + ..writeln(); +} + +/// Writes the row type and the column tokens of the rows the +/// `RETURNS TABLE` function of [method] returns. +void _writeResultRows( + StringBuffer buffer, + _FunctionMethod method, + _ResultRowNames names, + Map<(String, String), String> enumTypeNames, + _DecoderRegistry decoders, +) { + final function = method.function; + final columns = (method.overload.result as FunctionColumnsResult).columns; + final bindings = { + for (final column in columns) + column.name: _bindingFor(_columnShape(column), enumTypeNames), + }; + final displayName = _displayName(function.schema, function.name); + _writeRow( + buffer, + TableDescription( + schema: function.schema, + name: function.name, + columns: columns, + ), + names.rowType, + names.rowMembers, + bindings, + docLine: 'A row returned by the `$displayName` function.', + ); + _writeDocComment( + buffer, + 'Column tokens of the rows the `$displayName` function returns, for ' + 'filtering and ordering them.', + ); + buffer + ..writeln('class ${names.namespaceType} {') + ..writeln(' const ${names.namespaceType}._();') + ..writeln(); + _writeColumnTokens( + buffer, + columns, + names.rowType, + names.columnMembers, + bindings, + decoders, + ); + buffer + ..writeln('}') + ..writeln(); +} + +void _writeFunctionMethod( + StringBuffer buffer, + _FunctionMethod method, + Map<(String, String), _TableNames> tableNames, + Set typeNames, + _ResultRowNames? resultRows, + Map<(String, String), String> enumTypeNames, + _DecoderRegistry decoders, +) { + final _FunctionMethod(:function, :overload) = method; + final arguments = overload.arguments; + final parameterNames = _uniqueMemberNames( + [for (final argument in arguments) argument.name], + reserved: {...typeNames, ..._rpcMembers, ..._codecs}, + ); + + String? rowTypeOf(FunctionArgumentDescription argument) => + switch (argument.rowOf) { + final relation? => tableNames[relation]?.rowType, + null => null, + }; + + final requiredParameters = []; + final optionalParameters = []; + final entries = []; + String? body; + for (final argument in arguments) { + final rowType = rowTypeOf(argument); + final binding = _bindingFor( + _valueShape(argument.name, argument.type), + enumTypeNames, + ); + final dartType = + rowType ?? + (binding.kind == ColumnTypeKind.json ? 'Object' : binding.dartType); + if (argument.name == '') { + requiredParameters.add('Object? body'); + body = 'body'; + continue; + } + final name = parameterNames[argument.name]!; + final key = _stringLiteral(argument.name); + if (argument.hasDefault) { + optionalParameters.add('$dartType? $name'); + final value = rowType == null + ? _writeExpression(name, binding, nullable: true) + : '$name?.toJson()'; + entries.add('$key: ?$value'); + } else { + requiredParameters.add('required $dartType $name'); + final value = rowType == null + ? _writeExpression(name, binding, nullable: false) + : '$name.toJson()'; + entries.add('$key: $value'); + } + } + final parameters = [...requiredParameters, ...optionalParameters]; + final unnamed = arguments.isNotEmpty && arguments.first.name == ''; + final parameterList = parameters.isEmpty + ? '' + : unnamed + ? parameters.single + : '{${parameters.join(', ')}}'; + final argumentsValue = + body ?? (entries.isEmpty ? null : '{${entries.join(', ')}}'); + + final call = _functionCall( + method, + tableNames, + resultRows, + enumTypeNames, + decoders, + ); + final displayName = _displayName(function.schema, function.name); + _writeDocComment( + buffer, + 'Calls the `$displayName` function' + '${call.description.isEmpty ? '' : ', ${call.description}'}.', + indent: ' ', + ); + buffer + ..writeln(' ${call.returnType} ${method.name}($parameterList) =>') + ..writeln(' ${call.method}(') + ..writeln(' ${_stringLiteral(function.name)},'); + if (argumentsValue != null) { + buffer.writeln(' arguments: $argumentsValue,'); + } + buffer + ..writeln(' ${call.converter},') + ..writeln(' schema: ${_stringLiteral(function.schema)},') + ..writeln(' );'); +} + +/// How the generated method of a function calls `PostgrestRpc`: the type it +/// returns, the `rows` or `value` method it calls with the converter argument +/// for it, and what the result is in its documentation, empty when the +/// return type says it all. +typedef _FunctionCall = ({ + String returnType, + String method, + String converter, + String description, +}); + +/// The [_FunctionCall] of the generated method of [method]. +_FunctionCall _functionCall( + _FunctionMethod method, + Map<(String, String), _TableNames> tableNames, + _ResultRowNames? resultRows, + Map<(String, String), String> enumTypeNames, + _DecoderRegistry decoders, +) { + _FunctionCall rows(String rowType, String description) => ( + returnType: 'PostgrestTypedFilterBuilder<$rowType, List<$rowType>>', + method: 'rows', + converter: 'rowFromJson: $rowType.new', + description: description, + ); + + _FunctionCall value( + String type, + String fromJson, [ + String description = '', + ]) => ( + returnType: 'PostgrestTypedBuilder<$type>', + method: 'value', + converter: 'fromJson: $fromJson', + description: description, + ); + + _FunctionCall json(bool isSetReturning) => isSetReturning + ? value('List', '(json) => (json! as List).cast()') + : value('Object?', '(json) => json'); + + switch (method.overload.result) { + case FunctionNoResult(): + return value('void', '(_) {}'); + case FunctionColumnsResult(): + return rows( + resultRows!.rowType, + 'which returns rows filterable through `${resultRows.namespaceType}`', + ); + case FunctionTableRowsResult( + :final schema, + :final table, + :final isSetReturning, + ): + final names = tableNames[(schema, table)]; + if (names == null) return json(isSetReturning); + final rowType = names.rowType; + if (isSetReturning) { + return rows( + rowType, + 'which returns rows of `${names.displayName}` filterable through ' + '`${names.namespaceType}`', + ); + } + // PostgREST answers a function that finds no row with an object whose + // every column is null. + return value( + '$rowType?', + '(json) => switch (json) { final Map row ' + 'when row.values.any((value) => value != null) => $rowType(row), ' + '_ => null }', + 'which returns a row of `${names.displayName}`, or `null` when it ' + 'finds none', + ); + case FunctionValueResult(:final type, :final isSetReturning): + final binding = _bindingFor( + _valueShape(method.function.name, type), + enumTypeNames, + ); + if (binding.kind == ColumnTypeKind.json) return json(isSetReturning); + final dartType = '${binding.dartType}?'; + final decoder = decoders.claim(binding); + if (isSetReturning) { + return value( + 'List<$dartType>', + decoder == null + ? '(json) => (json! as List).cast()' + : '(json) => [for (final element in json! as List) ' + 'element == null ? null : $decoder(element)]', + ); + } + return value( + dartType, + decoder == null + ? '(json) => json as $dartType' + : '(json) => json == null ? null : $decoder(json)', + ); + } +} diff --git a/packages/supabase_typegen/lib/src/generator_metadata_parser.dart b/packages/supabase_typegen/lib/src/generator_metadata_parser.dart index e6a15b477..8124425e7 100644 --- a/packages/supabase_typegen/lib/src/generator_metadata_parser.dart +++ b/packages/supabase_typegen/lib/src/generator_metadata_parser.dart @@ -1,3 +1,4 @@ +import 'rpc_functions.dart'; import 'schema_description.dart'; const _integerFormats = {'int2', 'int4', 'int8', 'oid'}; @@ -73,7 +74,10 @@ ColumnTypeKind _typeKind(String format, {required bool isEnum}) { /// `primaryKeys`, `relationships`, `functions` and `types`. Of `functions`, /// only the computed fields and computed relationships are read: functions /// whose single input argument is the row type of a described relation of -/// the same schema. Fields the generator does not need are ignored. +/// the same schema. The functions callable through `/rpc` come from the +/// optional `rpcFunctions` collection, resolved with [resolveRpcFunctions] +/// when the document predates it. Fields the generator does not need are +/// ignored. /// /// Tables and foreign tables are always insertable and updatable. Views use /// the `is_insert_enabled` and `is_update_enabled` flags, falling back to @@ -310,6 +314,16 @@ DatabaseDescription _parseGeneratorMetadata( ); } + final functions = _functions( + document, + schemas, + typesById: typesById, + enumTypes: enumTypes, + enumsByType: enumsByType, + relationsById: relationsById, + generatedRelations: generatedRelations, + ); + tables.sort( (a, b) => a.schema == b.schema ? a.name.compareTo(b.name) @@ -330,6 +344,7 @@ DatabaseDescription _parseGeneratorMetadata( relationships: _relationships(document, { for (final table in tables) (table.schema, table.name), }), + functions: functions, ); } @@ -546,22 +561,40 @@ ComputedFieldDescription _computedField( Map<(String, String), List> enumTypes, Map<(String, String), EnumDescription> enumsByType, ) { - if (returnType == null) { - return ComputedFieldDescription( - name: name, + final type = _valueType(returnType, enumTypes, enumsByType); + return ComputedFieldDescription( + name: name, + postgresFormat: type.postgresFormat, + typeKind: type.typeKind, + elementTypeKind: type.elementTypeKind, + boundTypeKind: type.boundTypeKind, + enumType: type.enumType, + ); +} + +/// The value type of [type], a record of the `types` collection, or of an +/// unknown kind for `null`, a type the document does not list. An enum it +/// names is registered in [enumsByType]. +ValueTypeDescription _valueType( + Map? type, + Map<(String, String), List> enumTypes, + Map<(String, String), EnumDescription> enumsByType, +) { + if (type == null) { + return const ValueTypeDescription( postgresFormat: 'unknown', typeKind: ColumnTypeKind.unknown, ); } - final format = returnType['name'] as String; - final typeSchema = returnType['schema'] as String; + final format = type['name'] as String; + final typeSchema = type['schema'] as String; final isArray = format.startsWith('_'); final elementFormat = isArray ? format.substring(1) : format; // The array type of an enum carries no values itself; its element type // does. final enumValues = isArray ? (enumTypes[(typeSchema, elementFormat)] ?? const []) - : (returnType['enums'] as List? ?? const []).cast(); + : (type['enums'] as List? ?? const []).cast(); final isEnum = enumValues.isNotEmpty; EnumDescription? enumDescription; if (isEnum) { @@ -576,8 +609,7 @@ ComputedFieldDescription _computedField( () => described, ); } - return ComputedFieldDescription( - name: name, + return ValueTypeDescription( postgresFormat: isEnum && !isArray ? enumDescription!.qualifiedName : format, @@ -587,3 +619,115 @@ ComputedFieldDescription _computedField( enumType: enumDescription, ); } + +/// The functions of [schemas] callable through `/rpc`, from the +/// `rpcFunctions` collection of [document], or resolved from its `functions` +/// when the document predates the collection. +List _functions( + Map document, + Set schemas, { + required Map> typesById, + required Map<(String, String), List> enumTypes, + required Map<(String, String), EnumDescription> enumsByType, + required Map relationsById, + required Set<(String, String)> generatedRelations, +}) { + final rpcFunctions = + (document['rpcFunctions'] as List?) + ?.cast>() ?? + resolveRpcFunctions(document); + + ValueTypeDescription valueType(Map reference) => _valueType( + typesById[reference['type_id'] as int], + enumTypes, + enumsByType, + ); + + (String, String)? generatedRelation(int? relationId) => + switch (relationsById[relationId]) { + final relation? when generatedRelations.contains(relation) => relation, + _ => null, + }; + + FunctionResultDescription result(Map overload) { + final returns = overload['returns'] as Map; + final isSetReturning = overload['is_set_returning'] as bool; + final columns = (returns['columns'] as List?) + ?.cast>(); + if (columns != null) { + return FunctionColumnsResult([ + for (final column in columns) _resultColumn(column, valueType(column)), + ]); + } + if (generatedRelation(returns['relation_id'] as int?) case ( + final schema, + final table, + )) { + return FunctionTableRowsResult( + schema: schema, + table: table, + isSetReturning: isSetReturning, + ); + } + if (returns['format'] == 'void') return const FunctionNoResult(); + // Rows of a relation that is not generated, a composite type or a + // record read as JSON. + final type = returns['relation_id'] != null || returns['format'] == 'record' + ? const ValueTypeDescription( + postgresFormat: 'json', + typeKind: ColumnTypeKind.json, + ) + : valueType(returns); + return FunctionValueResult(type, isSetReturning: isSetReturning); + } + + return [ + for (final function in rpcFunctions) + if (schemas.contains(function['schema'])) + FunctionDescription( + schema: function['schema'] as String, + name: function['name'] as String, + overloads: [ + for (final overload + in (function['overloads'] as List) + .cast>()) + FunctionOverloadDescription( + arguments: [ + for (final argument + in (overload['args'] as List) + .cast>()) + FunctionArgumentDescription( + name: argument['name'] as String, + type: valueType(argument), + hasDefault: argument['has_default'] as bool, + rowOf: generatedRelation( + argument['relation_id'] as int?, + ), + ), + ], + result: result(overload), + error: overload['error'] as String?, + isComputedField: overload['computed_field_of'] != null, + ), + ], + ), + ]; +} + +/// A column of a `RETURNS TABLE` result, which can always be `NULL`. +ColumnDescription _resultColumn( + Map column, + ValueTypeDescription type, +) => ColumnDescription( + name: column['name'] as String, + postgresFormat: type.postgresFormat, + typeKind: type.typeKind, + elementTypeKind: type.elementTypeKind, + boundTypeKind: type.boundTypeKind, + enumType: type.enumType, + enumValues: type.enumType?.values, + isRequired: false, + hasDefault: false, + isNullable: true, + isReadOnly: true, +); diff --git a/packages/supabase_typegen/lib/src/introspection/introspect.dart b/packages/supabase_typegen/lib/src/introspection/introspect.dart index c519e4b3e..6c59d74b6 100644 --- a/packages/supabase_typegen/lib/src/introspection/introspect.dart +++ b/packages/supabase_typegen/lib/src/introspection/introspect.dart @@ -1,3 +1,4 @@ +import '../rpc_functions.dart'; import 'queryable.dart'; import 'relationships.dart'; import 'sort.dart'; @@ -16,10 +17,10 @@ import 'sql/views_sql.dart'; import 'supabase_cli_io.dart'; /// The supabase/sdk revision of `@supabase/postgrest-typegen` whose -/// introspection this library ports, a commit or a `postgrest-typegen-v*` -/// tag. `tool/check_introspection_drift.ts` compares the SQL of the port -/// against it and `tool/regenerate_fixture.ts` expects a checkout of it. -const postgrestTypegenRevision = 'postgrest-typegen-v0.2.2'; +/// introspection this library ports, a commit or a release tag. +/// `tool/check_introspection_drift.ts` compares the SQL of the port against +/// it and `tool/regenerate_fixture.ts` expects a checkout of it. +const postgrestTypegenRevision = 'postgrest-typegen/v0.5.0'; /// The `version` field of the emitted `GeneratorMetadata` document. const generatorMetadataVersion = 1; @@ -99,7 +100,7 @@ Future> introspect( ), ]; - return { + final document = { 'version': generatorMetadataVersion, 'schemas': rows('schemas'), 'tables': rows('tables'), @@ -119,4 +120,5 @@ Future> introspect( ], 'types': rows('types'), }; + return {...document, 'rpcFunctions': resolveRpcFunctions(document)}; } diff --git a/packages/supabase_typegen/lib/src/introspection/sort.dart b/packages/supabase_typegen/lib/src/introspection/sort.dart index 847325ba6..eeda57832 100644 --- a/packages/supabase_typegen/lib/src/introspection/sort.dart +++ b/packages/supabase_typegen/lib/src/introspection/sort.dart @@ -11,8 +11,10 @@ import 'collation.dart'; /// their declared column order kept, relationships by foreign key name, /// referenced relation and referenced columns and then by referenced schema, /// schema, relation and columns, functions by schema, name and signature with -/// their arguments by name. Names compare with [localeCompare]. The sort is -/// stable, so the primary keys of one table keep their declared order. +/// their arguments by name, and the `rpcFunctions` entries by schema and name +/// with the arguments and `RETURNS TABLE` columns of each overload by name. +/// Names compare with [localeCompare]. The sort is stable, so the primary keys +/// of one table keep their declared order. Map sortGeneratorMetadata(Map document) { int bySchemaName(Map a, Map b) => _firstNonZero([ @@ -101,9 +103,45 @@ Map sortGeneratorMetadata(Map document) { }, ], 'types': _sorted(document['types'], bySchemaName), + // The resolver orders the overloads of a function by signature, and that + // order is kept; the entries, their args and their table columns are + // addressed by name. + if (document['rpcFunctions'] case final List rpcFunctions) + 'rpcFunctions': [ + for (final function in _sorted( + rpcFunctions, + (a, b) => _firstNonZero([ + () => localeCompare(a['schema'] as String, b['schema'] as String), + () => localeCompare(a['name'] as String, b['name'] as String), + ]), + )) + { + ...function, + 'overloads': [ + for (final overload + in (function['overloads'] as List) + .cast>()) + { + ...overload, + 'args': _sorted(overload['args'], _byName), + 'returns': { + ...overload['returns'] as Map, + 'columns': switch ((overload['returns'] + as Map)['columns']) { + null => null, + final Object columns => _sorted(columns, _byName), + }, + }, + }, + ], + }, + ], }; } +int _byName(Map a, Map b) => + localeCompare(a['name'] as String, b['name'] as String); + List> _sorted( Object? collection, int Function(Map a, Map b) compare, diff --git a/packages/supabase_typegen/lib/src/rpc_functions.dart b/packages/supabase_typegen/lib/src/rpc_functions.dart new file mode 100644 index 000000000..b89a5d8ea --- /dev/null +++ b/packages/supabase_typegen/lib/src/rpc_functions.dart @@ -0,0 +1,340 @@ +import 'package:collection/collection.dart'; + +import 'introspection/collation.dart'; + +/// json, jsonb and text: the types PostgREST accepts for a single unnamed +/// parameter. +const _unnamedArgumentTypeIds = {114, 3802, 25}; +const _inputArgumentModes = {'in', 'inout', 'variadic'}; + +typedef _Candidate = ({ + Map function, + List> inputArguments, +}); + +/// Resolves the PostgREST view of the `functions` collection of the +/// `GeneratorMetadata` [document]: which functions are callable through +/// `/rpc`, how their overloads group and what a call returns. +/// +/// Port of `resolveRpcFunctions` of `@supabase/postgrest-typegen`, which +/// `introspect` attaches to the document as `rpcFunctions`. The result has +/// the shape of that collection and its canonical order: entries by schema +/// and name, overloads by argument list and return type, arguments and +/// `RETURNS TABLE` columns by name. +List> resolveRpcFunctions(Map document) { + List> records(String collection) => + (document[collection] as List? ?? const []) + .cast>(); + + final schemaNames = { + for (final schema in records('schemas')) schema['name'] as String, + }; + final typesById = { + for (final type in records('types')) type['id'] as int: type, + }; + final relationTypesById = { + for (final type in records('types')) + if (type['type_relation_id'] != null) type['id'] as int: type, + }; + final relations = [ + for (final collection in const [ + 'tables', + 'foreignTables', + 'views', + 'materializedViews', + ]) + ...records(collection), + ]; + final relationsById = { + for (final relation in relations) relation['id'] as int: relation, + }; + final relationsBySchemaName = { + for (final relation in relations) + (relation['schema'] as String, relation['name'] as String): relation, + }; + final columnNamesByRelationId = >{}; + for (final column in records('columns')) { + columnNamesByRelationId + .putIfAbsent(column['table_id'] as int, () => {}) + .add(column['name'] as String); + } + + Map typeReference(int typeId) { + final type = typesById[typeId]; + return { + 'type_id': typeId, + 'format': type?['name'], + 'type_schema': type?['schema'], + }; + } + + String typeName(int typeId) => + typesById[typeId]?['name'] as String? ?? 'unknown'; + + // The relation a return type stands for, or a composite type's name when + // the return type is one, since both are relations in `pg_class`. + String? returnRelationName(Map function) { + final relationId = function['return_type_relation_id'] as int?; + if (relationId == null) return null; + return relationsById[relationId]?['name'] as String? ?? + relationTypesById[function['return_type_id'] as int]?['name'] + as String?; + } + + // The relation whose row type the sole input parameter has, provided it + // lives in the function's schema and has no column of the function's name. + // When the parameter's type is not in `types`, the rendered argument list + // may still name the relation. + int? computedFieldOf(_Candidate candidate) { + final (:function, :inputArguments) = candidate; + if (inputArguments case [final argument]) { + final schema = function['schema'] as String; + final argumentType = relationTypesById[argument['type_id'] as int]; + final relation = argumentType != null + ? relationsById[argumentType['type_relation_id'] as int] + : typesById.containsKey(argument['type_id']) + ? null + : relationsBySchemaName[( + schema, + function['argument_types'] as String, + )]; + if (relation == null || relation['schema'] != schema) return null; + final relationId = relation['id'] as int; + if (columnNamesByRelationId[relationId]?.contains(function['name']) ?? + false) { + return null; + } + return relationId; + } + return null; + } + + bool isUnnamed(Map argument) => argument['name'] == ''; + + bool isCallable(_Candidate candidate) { + final arguments = candidate.inputArguments; + if (arguments.isEmpty || !arguments.any(isUnnamed)) return true; + if (arguments.every( + (argument) => + !isUnnamed(argument) || + (argument['has_default'] == true && + _unnamedArgumentTypeIds.contains(argument['type_id'])), + )) { + return true; + } + if (arguments case [final argument] when isUnnamed(argument)) { + return _unnamedArgumentTypeIds.contains(argument['type_id']) || + relationTypesById.containsKey(argument['type_id']); + } + return false; + } + + String? overloadError(_Candidate candidate, List<_Candidate> group) { + final (:function, :inputArguments) = candidate; + final qualifiedName = '${function['schema']}.${function['name']}'; + final soleArgument = inputArguments.length == 1 + ? inputArguments.single + : null; + if (group.length > 1) { + // A no-argument overload next to one whose sole unnamed parameter has + // a default: PostgREST cannot pick one for an empty call. + if (inputArguments.isEmpty) { + for (final other in group) { + if (identical(other, candidate)) continue; + if (other.inputArguments case [ + final argument, + ] when isUnnamed(argument) && argument['has_default'] == true) { + return 'Could not choose the best candidate function between: ' + '$qualifiedName(), $qualifiedName( => ' + '${typeName(other.function['return_type_id'] as int)}). Try ' + 'renaming the parameters or the function itself in the ' + 'database so function overloading can be resolved'; + } + } + } + // Overloads whose sole parameter shares a name but not a type: a call + // carries no type information, so PostgREST cannot pick one. + if (soleArgument != null && !isUnnamed(soleArgument)) { + final conflicting = [ + for (final other in group) + if (!identical(other, candidate)) + if (other.inputArguments case [final argument] + when argument['name'] == soleArgument['name'] && + argument['type_id'] != soleArgument['type_id']) + other, + ]; + if (conflicting.isNotEmpty) { + int firstTypeId(_Candidate other) => + other.inputArguments.firstOrNull?['type_id'] as int? ?? 0; + final ordered = [candidate, ...conflicting]; + mergeSort( + ordered, + compare: (a, b) => firstTypeId(a) - firstTypeId(b), + ); + String signature(_Candidate other) => other.inputArguments + .map( + (argument) => + '${argument['name']} => ' + '${typeName(argument['type_id'] as int)}', + ) + .join(', '); + final conflictList = ordered + .map((other) => '$qualifiedName(${signature(other)})') + .join(', '); + return 'Could not choose the best candidate function between: ' + '$conflictList. Try renaming the parameters or the function ' + 'itself in the database so function overloading can be resolved'; + } + } + } + // A sole unnamed row parameter makes the function a computed field, + // which PostgREST only exposes under `/rpc` when it also returns rows. + if (soleArgument != null && + isUnnamed(soleArgument) && + relationTypesById.containsKey(soleArgument['type_id']) && + returnRelationName(function) == null) { + return 'the function $qualifiedName with parameter or with a single ' + 'unnamed json/jsonb parameter, but no matches were found in the ' + 'schema cache'; + } + return null; + } + + Map resolveOverload( + _Candidate candidate, + List<_Candidate> group, + ) { + final (:function, :inputArguments) = candidate; + final arguments = [ + for (final argument in inputArguments) + { + 'name': argument['name'], + 'mode': argument['mode'], + ...typeReference(argument['type_id'] as int), + 'has_default': argument['has_default'] == true, + 'relation_id': switch (relationTypesById[argument['type_id'] + as int]) { + {'type_relation_id': final int relationId} + when relationsById.containsKey(relationId) => + relationId, + _ => null, + }, + }, + ]; + mergeSort(arguments, compare: _byName); + + final allArguments = (function['args'] as List? ?? const []) + .cast>(); + final tableColumns = [ + for (final argument in allArguments) + if (argument['mode'] == 'table') + { + 'name': argument['name'], + ...typeReference(argument['type_id'] as int), + }, + ]; + mergeSort(tableColumns, compare: _byName); + + final relationName = returnRelationName(function); + final rowsEstimate = function['prorows'] as num?; + final returnsMultipleRows = rowsEstimate != null && rowsEstimate > 1; + final isSetReturning = function['is_set_returning_function'] as bool; + // A function whose sole parameter (of any mode) is a row type is embedded + // from that relation; any other row-returning function embeds from `*`. + final sourceType = allArguments.length == 1 + ? relationTypesById[allArguments.single['type_id'] as int] + : null; + final returnRelationId = function['return_type_relation_id'] as int?; + + return { + 'function_id': function['id'], + 'args': arguments, + 'returns': { + ...typeReference(function['return_type_id'] as int), + 'relation_id': + returnRelationId != null && + relationsById.containsKey(returnRelationId) + ? returnRelationId + : null, + 'columns': tableColumns.isEmpty ? null : tableColumns, + }, + 'is_set_returning': isSetReturning, + 'rows_estimate': rowsEstimate, + 'setof_options': relationName == null + ? null + : { + 'from': sourceType == null ? '*' : sourceType['format'], + 'to': relationName, + 'is_one_to_one': sourceType != null && !isSetReturning + ? true + : !returnsMultipleRows, + 'is_setof_return': isSetReturning, + }, + 'computed_field_of': computedFieldOf(candidate), + 'error': overloadError(candidate, group), + }; + } + + final groups = <(String, String), List<_Candidate>>{}; + for (final function in records('functions')) { + final schema = function['schema'] as String; + if (!schemaNames.contains(schema)) continue; + final candidate = ( + function: function, + inputArguments: [ + for (final argument + in (function['args'] as List? ?? const []) + .cast>()) + if (_inputArgumentModes.contains(argument['mode'])) argument, + ], + ); + if (!isCallable(candidate)) continue; + groups + .putIfAbsent((schema, function['name'] as String), () => []) + .add(candidate); + } + + final entries = [ + for (final MapEntry(key: (schema, name), value: group) in groups.entries) + () { + final sorted = [...group] + ..sort( + (a, b) => _firstNonZero([ + () => localeCompare( + a.function['argument_types'] as String, + b.function['argument_types'] as String, + ), + () => localeCompare( + a.function['return_type'] as String, + b.function['return_type'] as String, + ), + () => (a.function['id'] as int) - (b.function['id'] as int), + ]), + ); + return { + 'schema': schema, + 'name': name, + 'overloads': [ + for (final candidate in sorted) resolveOverload(candidate, sorted), + ], + }; + }(), + ]; + return entries..sort( + (a, b) => _firstNonZero([ + () => localeCompare(a['schema'] as String, b['schema'] as String), + () => localeCompare(a['name'] as String, b['name'] as String), + ]), + ); +} + +int _byName(Map a, Map b) => + localeCompare(a['name'] as String, b['name'] as String); + +int _firstNonZero(List comparisons) { + for (final comparison in comparisons) { + final result = comparison(); + if (result != 0) return result; + } + return 0; +} diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart index 2f931ce82..697d03943 100644 --- a/packages/supabase_typegen/lib/src/schema_description.dart +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -69,6 +69,7 @@ class DatabaseDescription { required this.tables, required this.enums, this.relationships = const [], + this.functions = const [], this.metadataVersion = 1, }); @@ -86,6 +87,10 @@ class DatabaseDescription { /// Foreign keys between the tables, in database order. final List relationships; + + /// The functions of the schemas PostgREST exposes under `/rpc`, sorted by + /// schema and name. + final List functions; } /// A foreign key from [sourceTable] to [targetTable]. @@ -349,3 +354,151 @@ class ComputedRelationshipDescription { /// a list. A single row and a set declared `ROWS 1` embed as one object. final bool isToMany; } + +/// The Dart-relevant type of a function argument, a function result or an +/// element of a set-returning function. +class ValueTypeDescription { + const ValueTypeDescription({ + required this.postgresFormat, + required this.typeKind, + this.elementTypeKind, + this.boundTypeKind, + this.enumType, + }); + + /// The Postgres type, for example `int8`, `_text` or `public.mood`. + final String postgresFormat; + + /// The kind of Dart type the value maps to. + final ColumnTypeKind typeKind; + + /// The kind of Dart type of the array elements for [ColumnTypeKind.array] + /// types. + final ColumnTypeKind? elementTypeKind; + + /// The kind of Dart type of the bounds for [ColumnTypeKind.range] types and + /// for arrays of a range type. + final ColumnTypeKind? boundTypeKind; + + /// The Postgres enum of an enum type or an array of one; `null` otherwise. + final EnumDescription? enumType; +} + +/// A function PostgREST exposes under `/rpc`, with its overloads. +class FunctionDescription { + const FunctionDescription({ + required this.schema, + required this.name, + required this.overloads, + }); + + /// The schema of the function, for example `public`. + final String schema; + + /// The name of the function, which it is called by. + final String name; + + /// The definitions of the function, ordered by argument list and then by + /// return type. Never empty. + final List overloads; +} + +/// One definition of a [FunctionDescription]. +class FunctionOverloadDescription { + const FunctionOverloadDescription({ + required this.arguments, + required this.result, + this.error, + this.isComputedField = false, + }); + + /// The input parameters, in name order. + final List arguments; + + /// What a call returns. + final FunctionResultDescription result; + + /// Why PostgREST cannot resolve a call to this overload, when it cannot: + /// it conflicts with another overload, or it is a computed field that + /// returns no rows. Such an overload gets no method. + final String? error; + + /// Whether the function is a computed field of a relation of its schema, + /// selectable through the relation's computed fields. + final bool isComputedField; +} + +/// An input parameter of a [FunctionOverloadDescription]. +class FunctionArgumentDescription { + const FunctionArgumentDescription({ + required this.name, + required this.type, + required this.hasDefault, + this.rowOf, + }); + + /// The parameter name, empty for an unnamed parameter. + final String name; + + /// The type of the parameter. + final ValueTypeDescription type; + + /// Whether the parameter has a default, which makes it optional. + final bool hasDefault; + + /// The described table whose row type the parameter has, as schema and + /// name, or `null` for any other type. + final (String, String)? rowOf; +} + +/// What a call of a [FunctionOverloadDescription] returns. +sealed class FunctionResultDescription { + const FunctionResultDescription(); +} + +/// The function returns nothing (`void`). +class FunctionNoResult extends FunctionResultDescription { + const FunctionNoResult(); +} + +/// The function returns a value of [type], or a set of them when +/// [isSetReturning]. A function can always return `NULL`, so the value is +/// nullable whatever the type. +class FunctionValueResult extends FunctionResultDescription { + const FunctionValueResult(this.type, {required this.isSetReturning}); + + /// The type of the value, or of each element of the set. + final ValueTypeDescription type; + + /// Whether the response is a list of values. + final bool isSetReturning; +} + +/// The function returns rows of the described table [schema].[table], a +/// list of them when [isSetReturning] and one row otherwise. +class FunctionTableRowsResult extends FunctionResultDescription { + const FunctionTableRowsResult({ + required this.schema, + required this.table, + required this.isSetReturning, + }); + + /// The schema of [table]. + final String schema; + + /// The table whose rows the function returns. + final String table; + + /// Whether the response is a list of rows. + final bool isSetReturning; +} + +/// The function returns rows of its own shape, `RETURNS TABLE (…)`. Nothing +/// on the catalog says whether a column can be `NULL`, so every column is +/// nullable. +class FunctionColumnsResult extends FunctionResultDescription { + const FunctionColumnsResult(this.columns); + + /// The columns of the rows, in name order. + final List columns; +} diff --git a/packages/supabase_typegen/test/dart_generator_test.dart b/packages/supabase_typegen/test/dart_generator_test.dart index cba8afe5f..72057f369 100644 --- a/packages/supabase_typegen/test/dart_generator_test.dart +++ b/packages/supabase_typegen/test/dart_generator_test.dart @@ -181,7 +181,7 @@ void main() { expect(hostile, contains('class EvilMultilineSchemaNamePostgrestColumn {')); expect( hostile, - isNot(contains('EvilMultilineSchemaNamePostgrestColumnRow>')), + isNot(contains('EvilMultilineSchemaNamePostgrestColumnRow>(')), ); }); @@ -1629,4 +1629,227 @@ void main() { expect(code, contains("import 'dart:typed_data';")); expect(code, contains('PostgrestComputedField')); }); + + group('functions', () { + const integer = ValueTypeDescription( + postgresFormat: 'int4', + typeKind: ColumnTypeKind.integer, + ); + + FunctionArgumentDescription argument(String name) => + FunctionArgumentDescription( + name: name, + type: integer, + hasDefault: false, + ); + + DatabaseDescription withFunctions(List functions) => + DatabaseDescription( + schemaNames: const ['public'], + tables: const [], + enums: const [], + functions: functions, + ); + + test('no extension is generated without functions', () { + expect(generateDartCode(schema), contains('extension DatabaseFunctions')); + expect( + generateDartCode(withFunctions(const [])), + isNot(contains('extension DatabaseFunctions')), + ); + }); + + test('the overload with the fewest arguments keeps the plain name', () { + final code = _normalize( + generateDartCode( + withFunctions([ + FunctionDescription( + schema: 'public', + name: 'find_books', + overloads: [ + FunctionOverloadDescription( + arguments: [argument('author_id'), argument('limit')], + result: const FunctionNoResult(), + ), + FunctionOverloadDescription( + arguments: [argument('title')], + result: const FunctionNoResult(), + ), + ], + ), + ]), + ), + ); + + expect( + code, + contains( + 'findBooksWithAuthorIdAndLimit({ required int authorId, ' + 'required int limit, })', + ), + ); + expect(code, contains('findBooks({required int title})')); + }); + + test('an overload that cannot be called is noted, a computed field ' + 'reached through its table is not', () { + final code = generateDartCode( + withFunctions([ + FunctionDescription( + schema: 'public', + name: 'label', + overloads: [ + FunctionOverloadDescription( + arguments: [argument('amount')], + result: const FunctionNoResult(), + error: 'Could not choose the best candidate function', + ), + ], + ), + FunctionDescription( + schema: 'public', + name: 'title_upper', + overloads: [ + FunctionOverloadDescription( + arguments: [argument('')], + result: const FunctionNoResult(), + error: 'no matches were found in the schema cache', + isComputedField: true, + ), + ], + ), + ]), + ); + + expect( + code, + contains( + '/// `label(amount int4)` has no method: Could not choose the best ' + 'candidate\n/// function\nextension DatabaseFunctions', + ), + ); + expect(code, isNot(contains('title_upper'))); + expect(code, isNot(contains('label()'))); + }); + + test('a bytea argument alone brings the typed_data import', () { + final code = generateDartCode( + withFunctions([ + const FunctionDescription( + schema: 'public', + name: 'store', + overloads: [ + FunctionOverloadDescription( + arguments: [ + FunctionArgumentDescription( + name: 'data', + type: ValueTypeDescription( + postgresFormat: 'bytea', + typeKind: ColumnTypeKind.binary, + ), + hasDefault: false, + ), + ], + result: FunctionNoResult(), + ), + ], + ), + ]), + ); + + expect(code, contains("import 'dart:typed_data';")); + expect(code, contains("'data': postgrestBytea.encode(data)")); + }); + + test('method names keep clear of the types and codecs the bodies use', () { + final code = generateDartCode( + withFunctions([ + const FunctionDescription( + schema: 'public', + name: 'int', + overloads: [ + FunctionOverloadDescription( + arguments: [], + result: FunctionValueResult(integer, isSetReturning: false), + ), + ], + ), + const FunctionDescription( + schema: 'public', + name: 'postgrest_bytea', + overloads: [ + FunctionOverloadDescription( + arguments: [], + result: FunctionNoResult(), + ), + ], + ), + ]), + ); + + expect(code, contains('PostgrestTypedBuilder int\$()')); + expect(code, contains('PostgrestTypedBuilder postgrestBytea\$()')); + }); + + test('result row getters keep clear of the codecs', () { + final code = generateDartCode( + withFunctions([ + const FunctionDescription( + schema: 'public', + name: 'neighbours', + overloads: [ + FunctionOverloadDescription( + arguments: [], + result: FunctionColumnsResult([ + ColumnDescription( + name: 'postgrest_vector', + postgresFormat: 'text', + typeKind: ColumnTypeKind.text, + isRequired: false, + hasDefault: false, + isNullable: true, + isReadOnly: true, + ), + ]), + ), + ], + ), + ]), + ); + + expect(code, contains('String? get postgrestVector\$ =>')); + }); + + test('an overload taking the unnamed body is named after it', () { + final code = generateDartCode( + withFunctions([ + FunctionDescription( + schema: 'public', + name: 'echo', + overloads: [ + const FunctionOverloadDescription( + arguments: [], + result: FunctionNoResult(), + ), + FunctionOverloadDescription( + arguments: [ + FunctionArgumentDescription( + name: '', + type: const ValueTypeDescription( + postgresFormat: 'jsonb', + typeKind: ColumnTypeKind.json, + ), + hasDefault: false, + ), + ], + result: const FunctionNoResult(), + ), + ], + ), + ]), + ); + + expect(code, contains('echoWithBody(Object? body)')); + }); + }); } diff --git a/packages/supabase_typegen/test/fixtures/generator_metadata.json b/packages/supabase_typegen/test/fixtures/generator_metadata.json index aa50d1094..2ff66067c 100644 --- a/packages/supabase_typegen/test/fixtures/generator_metadata.json +++ b/packages/supabase_typegen/test/fixtures/generator_metadata.json @@ -962,6 +962,205 @@ } ], "functions": [ + { + "id": 16485, + "schema": "inventory", + "name": "restock", + "language": "sql", + "definition": "\n UPDATE inventory.stock SET quantity = quantity + amount\n WHERE id = stock_id\n RETURNING quantity;\n", + "complete_statement": "CREATE OR REPLACE FUNCTION inventory.restock(stock_id bigint, amount integer)\n RETURNS integer\n LANGUAGE sql\nAS $function$\n UPDATE inventory.stock SET quantity = quantity + amount\n WHERE id = stock_id\n RETURNING quantity;\n$function$\n", + "args": [ + { + "mode": "in", + "name": "amount", + "type_id": 23, + "has_default": false + }, + { + "mode": "in", + "name": "stock_id", + "type_id": 20, + "has_default": false + } + ], + "argument_types": "stock_id bigint, amount integer", + "identity_argument_types": "stock_id bigint, amount integer", + "return_type_id": 23, + "return_type": "integer", + "return_type_relation_id": null, + "is_set_returning_function": false, + "prorows": null, + "behavior": "VOLATILE", + "security_definer": false, + "config_params": null + }, + { + "id": 16475, + "schema": "public", + "name": "author_book_counts", + "language": "sql", + "definition": "\n SELECT authors.name, count(books.id)\n FROM public.authors\n LEFT JOIN public.books ON books.author_id = authors.id\n GROUP BY authors.name\n HAVING count(books.id) >= min_books;\n", + "complete_statement": "CREATE OR REPLACE FUNCTION public.author_book_counts(min_books integer DEFAULT 0)\n RETURNS TABLE(author_name text, book_count bigint)\n LANGUAGE sql\n STABLE\nAS $function$\n SELECT authors.name, count(books.id)\n FROM public.authors\n LEFT JOIN public.books ON books.author_id = authors.id\n GROUP BY authors.name\n HAVING count(books.id) >= min_books;\n$function$\n", + "args": [ + { + "mode": "table", + "name": "author_name", + "type_id": 25, + "has_default": null + }, + { + "mode": "table", + "name": "book_count", + "type_id": 20, + "has_default": null + }, + { + "mode": "in", + "name": "min_books", + "type_id": 23, + "has_default": true + } + ], + "argument_types": "min_books integer DEFAULT 0", + "identity_argument_types": "min_books integer", + "return_type_id": 2249, + "return_type": "TABLE(author_name text, book_count bigint)", + "return_type_relation_id": null, + "is_set_returning_function": true, + "prorows": 1000, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, + { + "id": 16474, + "schema": "public", + "name": "book_by_id", + "language": "sql", + "definition": " SELECT * FROM public.books WHERE id = book_id; ", + "complete_statement": "CREATE OR REPLACE FUNCTION public.book_by_id(book_id bigint)\n RETURNS books\n LANGUAGE sql\n STABLE\nAS $function$ SELECT * FROM public.books WHERE id = book_id; $function$\n", + "args": [ + { + "mode": "in", + "name": "book_id", + "type_id": 20, + "has_default": false + } + ], + "argument_types": "book_id bigint", + "identity_argument_types": "book_id bigint", + "return_type_id": 16402, + "return_type": "books", + "return_type_relation_id": 16400, + "is_set_returning_function": false, + "prorows": null, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, + { + "id": 16480, + "schema": "public", + "name": "books_of", + "language": "sql", + "definition": "\n SELECT * FROM public.books WHERE books.author_id = books_of.author_id;\n", + "complete_statement": "CREATE OR REPLACE FUNCTION public.books_of(author_id bigint)\n RETURNS SETOF books\n LANGUAGE sql\n STABLE\nAS $function$\n SELECT * FROM public.books WHERE books.author_id = books_of.author_id;\n$function$\n", + "args": [ + { + "mode": "in", + "name": "author_id", + "type_id": 20, + "has_default": false + } + ], + "argument_types": "author_id bigint", + "identity_argument_types": "author_id bigint", + "return_type_id": 16402, + "return_type": "SETOF books", + "return_type_relation_id": 16400, + "is_set_returning_function": true, + "prorows": 1000, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, + { + "id": 16481, + "schema": "public", + "name": "books_of", + "language": "sql", + "definition": "\n SELECT books.* FROM public.books\n JOIN public.authors ON authors.id = books.author_id\n WHERE authors.name = author_name AND books.price <= max_price;\n", + "complete_statement": "CREATE OR REPLACE FUNCTION public.books_of(author_name text, max_price numeric)\n RETURNS SETOF books\n LANGUAGE sql\n STABLE\nAS $function$\n SELECT books.* FROM public.books\n JOIN public.authors ON authors.id = books.author_id\n WHERE authors.name = author_name AND books.price <= max_price;\n$function$\n", + "args": [ + { + "mode": "in", + "name": "author_name", + "type_id": 25, + "has_default": false + }, + { + "mode": "in", + "name": "max_price", + "type_id": 1700, + "has_default": false + } + ], + "argument_types": "author_name text, max_price numeric", + "identity_argument_types": "author_name text, max_price numeric", + "return_type_id": 16402, + "return_type": "SETOF books", + "return_type_relation_id": 16400, + "is_set_returning_function": true, + "prorows": 1000, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, + { + "id": 16479, + "schema": "public", + "name": "books_with_tags", + "language": "sql", + "definition": "\n SELECT * FROM public.books WHERE books.tags @> books_with_tags.tags;\n", + "complete_statement": "CREATE OR REPLACE FUNCTION public.books_with_tags(tags text[])\n RETURNS SETOF books\n LANGUAGE sql\n STABLE\nAS $function$\n SELECT * FROM public.books WHERE books.tags @> books_with_tags.tags;\n$function$\n", + "args": [ + { + "mode": "in", + "name": "tags", + "type_id": 1009, + "has_default": false + } + ], + "argument_types": "tags text[]", + "identity_argument_types": "tags text[]", + "return_type_id": 16402, + "return_type": "SETOF books", + "return_type_relation_id": 16400, + "is_set_returning_function": true, + "prorows": 1000, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, + { + "id": 16476, + "schema": "public", + "name": "count_books", + "language": "sql", + "definition": " SELECT count(*) FROM public.books; ", + "complete_statement": "CREATE OR REPLACE FUNCTION public.count_books()\n RETURNS bigint\n LANGUAGE sql\n STABLE\nAS $function$ SELECT count(*) FROM public.books; $function$\n", + "args": [], + "argument_types": "", + "identity_argument_types": "", + "return_type_id": 20, + "return_type": "bigint", + "return_type_relation_id": null, + "is_set_returning_function": false, + "prorows": null, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, { "id": 16472, "schema": "public", @@ -988,6 +1187,32 @@ "security_definer": false, "config_params": null }, + { + "id": 16484, + "schema": "public", + "name": "echo", + "language": "sql", + "definition": " SELECT $1; ", + "complete_statement": "CREATE OR REPLACE FUNCTION public.echo(json)\n RETURNS json\n LANGUAGE sql\n IMMUTABLE\nAS $function$ SELECT $1; $function$\n", + "args": [ + { + "mode": "in", + "name": "", + "type_id": 114, + "has_default": false + } + ], + "argument_types": "json", + "identity_argument_types": "json", + "return_type_id": 114, + "return_type": "json", + "return_type_relation_id": null, + "is_set_returning_function": false, + "prorows": null, + "behavior": "IMMUTABLE", + "security_definer": false, + "config_params": null + }, { "id": 16470, "schema": "public", @@ -1040,6 +1265,84 @@ "security_definer": false, "config_params": null }, + { + "id": 16483, + "schema": "public", + "name": "price_label", + "language": "sql", + "definition": " SELECT amount::text; ", + "complete_statement": "CREATE OR REPLACE FUNCTION public.price_label(amount integer)\n RETURNS text\n LANGUAGE sql\n IMMUTABLE\nAS $function$ SELECT amount::text; $function$\n", + "args": [ + { + "mode": "in", + "name": "amount", + "type_id": 23, + "has_default": false + } + ], + "argument_types": "amount integer", + "identity_argument_types": "amount integer", + "return_type_id": 25, + "return_type": "text", + "return_type_relation_id": null, + "is_set_returning_function": false, + "prorows": null, + "behavior": "IMMUTABLE", + "security_definer": false, + "config_params": null + }, + { + "id": 16482, + "schema": "public", + "name": "price_label", + "language": "sql", + "definition": " SELECT amount::text; ", + "complete_statement": "CREATE OR REPLACE FUNCTION public.price_label(amount numeric)\n RETURNS text\n LANGUAGE sql\n IMMUTABLE\nAS $function$ SELECT amount::text; $function$\n", + "args": [ + { + "mode": "in", + "name": "amount", + "type_id": 1700, + "has_default": false + } + ], + "argument_types": "amount numeric", + "identity_argument_types": "amount numeric", + "return_type_id": 25, + "return_type": "text", + "return_type_relation_id": null, + "is_set_returning_function": false, + "prorows": null, + "behavior": "IMMUTABLE", + "security_definer": false, + "config_params": null + }, + { + "id": 16477, + "schema": "public", + "name": "publication_dates", + "language": "sql", + "definition": "\n SELECT published_on FROM public.books\n WHERE books.author_id = publication_dates.author_id;\n", + "complete_statement": "CREATE OR REPLACE FUNCTION public.publication_dates(author_id bigint)\n RETURNS SETOF date\n LANGUAGE sql\n STABLE\nAS $function$\n SELECT published_on FROM public.books\n WHERE books.author_id = publication_dates.author_id;\n$function$\n", + "args": [ + { + "mode": "in", + "name": "author_id", + "type_id": 20, + "has_default": false + } + ], + "argument_types": "author_id bigint", + "identity_argument_types": "author_id bigint", + "return_type_id": 1082, + "return_type": "SETOF date", + "return_type_relation_id": null, + "is_set_returning_function": true, + "prorows": 1000, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, { "id": 16469, "schema": "public", @@ -1066,6 +1369,44 @@ "security_definer": false, "config_params": null }, + { + "id": 16473, + "schema": "public", + "name": "search_books", + "language": "sql", + "definition": "\n SELECT * FROM public.books\n WHERE books.title ILIKE '%' || query || '%'\n AND (search_books.mood IS NULL OR books.mood = search_books.mood)\n AND (max_price IS NULL OR books.price <= max_price);\n", + "complete_statement": "CREATE OR REPLACE FUNCTION public.search_books(query text, mood mood DEFAULT NULL::mood, max_price numeric DEFAULT NULL::numeric)\n RETURNS SETOF books\n LANGUAGE sql\n STABLE\nAS $function$\n SELECT * FROM public.books\n WHERE books.title ILIKE '%' || query || '%'\n AND (search_books.mood IS NULL OR books.mood = search_books.mood)\n AND (max_price IS NULL OR books.price <= max_price);\n$function$\n", + "args": [ + { + "mode": "in", + "name": "max_price", + "type_id": 1700, + "has_default": true + }, + { + "mode": "in", + "name": "mood", + "type_id": 16385, + "has_default": true + }, + { + "mode": "in", + "name": "query", + "type_id": 25, + "has_default": false + } + ], + "argument_types": "query text, mood mood DEFAULT NULL::mood, max_price numeric DEFAULT NULL::numeric", + "identity_argument_types": "query text, mood mood, max_price numeric", + "return_type_id": 16402, + "return_type": "SETOF books", + "return_type_relation_id": 16400, + "is_set_returning_function": true, + "prorows": 1000, + "behavior": "STABLE", + "security_definer": false, + "config_params": null + }, { "id": 16471, "schema": "public", @@ -1117,6 +1458,38 @@ "behavior": "STABLE", "security_definer": false, "config_params": null + }, + { + "id": 16478, + "schema": "public", + "name": "touch_book", + "language": "sql", + "definition": "\n UPDATE public.books SET updated_at = touched_at WHERE id = book_id;\n", + "complete_statement": "CREATE OR REPLACE FUNCTION public.touch_book(book_id bigint, touched_at timestamp with time zone DEFAULT now())\n RETURNS void\n LANGUAGE sql\nAS $function$\n UPDATE public.books SET updated_at = touched_at WHERE id = book_id;\n$function$\n", + "args": [ + { + "mode": "in", + "name": "book_id", + "type_id": 20, + "has_default": false + }, + { + "mode": "in", + "name": "touched_at", + "type_id": 1184, + "has_default": true + } + ], + "argument_types": "book_id bigint, touched_at timestamp with time zone DEFAULT now()", + "identity_argument_types": "book_id bigint, touched_at timestamp with time zone", + "return_type_id": 2278, + "return_type": "void", + "return_type_relation_id": null, + "is_set_returning_function": false, + "prorows": null, + "behavior": "VOLATILE", + "security_definer": false, + "config_params": null } ], "types": [ @@ -7437,5 +7810,686 @@ "comment": null, "type_relation_id": null } + ], + "rpcFunctions": [ + { + "schema": "inventory", + "name": "restock", + "overloads": [ + { + "function_id": 16485, + "args": [ + { + "name": "amount", + "mode": "in", + "type_id": 23, + "format": "int4", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + }, + { + "name": "stock_id", + "mode": "in", + "type_id": 20, + "format": "int8", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 23, + "format": "int4", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "author_book_counts", + "overloads": [ + { + "function_id": 16475, + "args": [ + { + "name": "min_books", + "mode": "in", + "type_id": 23, + "format": "int4", + "type_schema": "pg_catalog", + "has_default": true, + "relation_id": null + } + ], + "returns": { + "type_id": 2249, + "format": "record", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": [ + { + "name": "author_name", + "type_id": 25, + "format": "text", + "type_schema": "pg_catalog" + }, + { + "name": "book_count", + "type_id": 20, + "format": "int8", + "type_schema": "pg_catalog" + } + ] + }, + "is_set_returning": true, + "rows_estimate": 1000, + "setof_options": null, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "book_by_id", + "overloads": [ + { + "function_id": 16474, + "args": [ + { + "name": "book_id", + "mode": "in", + "type_id": 20, + "format": "int8", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 16402, + "format": "books", + "type_schema": "public", + "relation_id": 16400, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": { + "from": "*", + "to": "books", + "is_one_to_one": true, + "is_setof_return": false + }, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "books_of", + "overloads": [ + { + "function_id": 16480, + "args": [ + { + "name": "author_id", + "mode": "in", + "type_id": 20, + "format": "int8", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 16402, + "format": "books", + "type_schema": "public", + "relation_id": 16400, + "columns": null + }, + "is_set_returning": true, + "rows_estimate": 1000, + "setof_options": { + "from": "*", + "to": "books", + "is_one_to_one": false, + "is_setof_return": true + }, + "computed_field_of": null, + "error": null + }, + { + "function_id": 16481, + "args": [ + { + "name": "author_name", + "mode": "in", + "type_id": 25, + "format": "text", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + }, + { + "name": "max_price", + "mode": "in", + "type_id": 1700, + "format": "numeric", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 16402, + "format": "books", + "type_schema": "public", + "relation_id": 16400, + "columns": null + }, + "is_set_returning": true, + "rows_estimate": 1000, + "setof_options": { + "from": "*", + "to": "books", + "is_one_to_one": false, + "is_setof_return": true + }, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "books_with_tags", + "overloads": [ + { + "function_id": 16479, + "args": [ + { + "name": "tags", + "mode": "in", + "type_id": 1009, + "format": "_text", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 16402, + "format": "books", + "type_schema": "public", + "relation_id": 16400, + "columns": null + }, + "is_set_returning": true, + "rows_estimate": 1000, + "setof_options": { + "from": "*", + "to": "books", + "is_one_to_one": false, + "is_setof_return": true + }, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "count_books", + "overloads": [ + { + "function_id": 16476, + "args": [], + "returns": { + "type_id": 20, + "format": "int8", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "discount", + "overloads": [ + { + "function_id": 16472, + "args": [ + { + "name": "", + "mode": "in", + "type_id": 16420, + "format": "book_prices", + "type_schema": "public", + "has_default": false, + "relation_id": 16418 + } + ], + "returns": { + "type_id": 1700, + "format": "numeric", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": 16418, + "error": "the function public.discount with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + ] + }, + { + "schema": "public", + "name": "echo", + "overloads": [ + { + "function_id": 16484, + "args": [ + { + "name": "", + "mode": "in", + "type_id": 114, + "format": "json", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 114, + "format": "json", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "latest_book", + "overloads": [ + { + "function_id": 16470, + "args": [ + { + "name": "", + "mode": "in", + "type_id": 16394, + "format": "authors", + "type_schema": "public", + "has_default": false, + "relation_id": 16392 + } + ], + "returns": { + "type_id": 16402, + "format": "books", + "type_schema": "public", + "relation_id": 16400, + "columns": null + }, + "is_set_returning": true, + "rows_estimate": 1, + "setof_options": { + "from": "authors", + "to": "books", + "is_one_to_one": true, + "is_setof_return": true + }, + "computed_field_of": 16392, + "error": null + } + ] + }, + { + "schema": "public", + "name": "mood_or_default", + "overloads": [ + { + "function_id": 16468, + "args": [ + { + "name": "book", + "mode": "in", + "type_id": 16402, + "format": "books", + "type_schema": "public", + "has_default": false, + "relation_id": 16400 + } + ], + "returns": { + "type_id": 16385, + "format": "mood", + "type_schema": "public", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": 16400, + "error": null + } + ] + }, + { + "schema": "public", + "name": "price_label", + "overloads": [ + { + "function_id": 16483, + "args": [ + { + "name": "amount", + "mode": "in", + "type_id": 23, + "format": "int4", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 25, + "format": "text", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": null, + "error": "Could not choose the best candidate function between: public.price_label(amount => int4), public.price_label(amount => numeric). Try renaming the parameters or the function itself in the database so function overloading can be resolved" + }, + { + "function_id": 16482, + "args": [ + { + "name": "amount", + "mode": "in", + "type_id": 1700, + "format": "numeric", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 25, + "format": "text", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": null, + "error": "Could not choose the best candidate function between: public.price_label(amount => int4), public.price_label(amount => numeric). Try renaming the parameters or the function itself in the database so function overloading can be resolved" + } + ] + }, + { + "schema": "public", + "name": "publication_dates", + "overloads": [ + { + "function_id": 16477, + "args": [ + { + "name": "author_id", + "mode": "in", + "type_id": 20, + "format": "int8", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 1082, + "format": "date", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": true, + "rows_estimate": 1000, + "setof_options": null, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "recent_books", + "overloads": [ + { + "function_id": 16469, + "args": [ + { + "name": "", + "mode": "in", + "type_id": 16394, + "format": "authors", + "type_schema": "public", + "has_default": false, + "relation_id": 16392 + } + ], + "returns": { + "type_id": 16402, + "format": "books", + "type_schema": "public", + "relation_id": 16400, + "columns": null + }, + "is_set_returning": true, + "rows_estimate": 1000, + "setof_options": { + "from": "authors", + "to": "books", + "is_one_to_one": false, + "is_setof_return": true + }, + "computed_field_of": 16392, + "error": null + } + ] + }, + { + "schema": "public", + "name": "search_books", + "overloads": [ + { + "function_id": 16473, + "args": [ + { + "name": "max_price", + "mode": "in", + "type_id": 1700, + "format": "numeric", + "type_schema": "pg_catalog", + "has_default": true, + "relation_id": null + }, + { + "name": "mood", + "mode": "in", + "type_id": 16385, + "format": "mood", + "type_schema": "public", + "has_default": true, + "relation_id": null + }, + { + "name": "query", + "mode": "in", + "type_id": 25, + "format": "text", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + } + ], + "returns": { + "type_id": 16402, + "format": "books", + "type_schema": "public", + "relation_id": 16400, + "columns": null + }, + "is_set_returning": true, + "rows_estimate": 1000, + "setof_options": { + "from": "*", + "to": "books", + "is_one_to_one": false, + "is_setof_return": true + }, + "computed_field_of": null, + "error": null + } + ] + }, + { + "schema": "public", + "name": "title", + "overloads": [ + { + "function_id": 16471, + "args": [ + { + "name": "", + "mode": "in", + "type_id": 16402, + "format": "books", + "type_schema": "public", + "has_default": false, + "relation_id": 16400 + } + ], + "returns": { + "type_id": 25, + "format": "text", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": null, + "error": "the function public.title with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + ] + }, + { + "schema": "public", + "name": "title_upper", + "overloads": [ + { + "function_id": 16467, + "args": [ + { + "name": "", + "mode": "in", + "type_id": 16402, + "format": "books", + "type_schema": "public", + "has_default": false, + "relation_id": 16400 + } + ], + "returns": { + "type_id": 25, + "format": "text", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": 16400, + "error": "the function public.title_upper with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache" + } + ] + }, + { + "schema": "public", + "name": "touch_book", + "overloads": [ + { + "function_id": 16478, + "args": [ + { + "name": "book_id", + "mode": "in", + "type_id": 20, + "format": "int8", + "type_schema": "pg_catalog", + "has_default": false, + "relation_id": null + }, + { + "name": "touched_at", + "mode": "in", + "type_id": 1184, + "format": "timestamptz", + "type_schema": "pg_catalog", + "has_default": true, + "relation_id": null + } + ], + "returns": { + "type_id": 2278, + "format": "void", + "type_schema": "pg_catalog", + "relation_id": null, + "columns": null + }, + "is_set_returning": false, + "rows_estimate": null, + "setof_options": null, + "computed_field_of": null, + "error": null + } + ] + } ] } diff --git a/packages/supabase_typegen/test/fixtures/seed.sql b/packages/supabase_typegen/test/fixtures/seed.sql index cbdaaf99c..6ea3a4b33 100644 --- a/packages/supabase_typegen/test/fixtures/seed.sql +++ b/packages/supabase_typegen/test/fixtures/seed.sql @@ -147,3 +147,92 @@ LANGUAGE sql STABLE AS $$ SELECT 'shadowed'; $$; -- A computed field on a view. CREATE FUNCTION public.discount(public.book_prices) RETURNS numeric LANGUAGE sql STABLE AS $$ SELECT $1.price - $1.discounted_price; $$; + +-- Functions called through /rpc. A set of rows of a table, with a required +-- argument and defaulted ones, one of them an enum. +CREATE FUNCTION public.search_books( + query text, + mood public.mood DEFAULT NULL, + max_price numeric DEFAULT NULL +) RETURNS SETOF public.books +LANGUAGE sql STABLE AS $$ + SELECT * FROM public.books + WHERE books.title ILIKE '%' || query || '%' + AND (search_books.mood IS NULL OR books.mood = search_books.mood) + AND (max_price IS NULL OR books.price <= max_price); +$$; + +-- A single row of a table. +CREATE FUNCTION public.book_by_id(book_id bigint) RETURNS public.books +LANGUAGE sql STABLE AS $$ SELECT * FROM public.books WHERE id = book_id; $$; + +-- Rows of their own shape. +CREATE FUNCTION public.author_book_counts(min_books integer DEFAULT 0) +RETURNS TABLE (author_name text, book_count bigint) +LANGUAGE sql STABLE AS $$ + SELECT authors.name, count(books.id) + FROM public.authors + LEFT JOIN public.books ON books.author_id = authors.id + GROUP BY authors.name + HAVING count(books.id) >= min_books; +$$; + +-- A scalar without arguments, and a set of scalars that need a conversion. +CREATE FUNCTION public.count_books() RETURNS bigint +LANGUAGE sql STABLE AS $$ SELECT count(*) FROM public.books; $$; + +CREATE FUNCTION public.publication_dates(author_id bigint) RETURNS SETOF date +LANGUAGE sql STABLE AS $$ + SELECT published_on FROM public.books + WHERE books.author_id = publication_dates.author_id; +$$; + +-- No result, and a timestamp argument with a default. +CREATE FUNCTION public.touch_book( + book_id bigint, + touched_at timestamptz DEFAULT now() +) RETURNS void +LANGUAGE sql AS $$ + UPDATE public.books SET updated_at = touched_at WHERE id = book_id; +$$; + +-- An array argument. +CREATE FUNCTION public.books_with_tags(tags text[]) RETURNS SETOF public.books +LANGUAGE sql STABLE AS $$ + SELECT * FROM public.books WHERE books.tags @> books_with_tags.tags; +$$; + +-- Overloads PostgREST tells apart by their argument names. +CREATE FUNCTION public.books_of(author_id bigint) RETURNS SETOF public.books +LANGUAGE sql STABLE AS $$ + SELECT * FROM public.books WHERE books.author_id = books_of.author_id; +$$; + +CREATE FUNCTION public.books_of(author_name text, max_price numeric) +RETURNS SETOF public.books +LANGUAGE sql STABLE AS $$ + SELECT books.* FROM public.books + JOIN public.authors ON authors.id = books.author_id + WHERE authors.name = author_name AND books.price <= max_price; +$$; + +-- Overloads PostgREST cannot tell apart: the same argument name with +-- different types. +CREATE FUNCTION public.price_label(amount numeric) RETURNS text +LANGUAGE sql IMMUTABLE AS $$ SELECT amount::text; $$; + +CREATE FUNCTION public.price_label(amount integer) RETURNS text +LANGUAGE sql IMMUTABLE AS $$ SELECT amount::text; $$; + +-- A sole unnamed json parameter takes the whole request body. +CREATE FUNCTION public.echo(json) RETURNS json +LANGUAGE sql IMMUTABLE AS $$ SELECT $1; $$; + +-- A function of the second schema. +CREATE FUNCTION inventory.restock(stock_id bigint, amount integer) +RETURNS integer +LANGUAGE sql AS $$ + UPDATE inventory.stock SET quantity = quantity + amount + WHERE id = stock_id + RETURNING quantity; +$$; diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index a95dbd733..5f10eb5cf 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -623,4 +623,147 @@ void main() { Authors.recentBooks, ]); }); + + group('functions', () { + RecordedRequest lastRequest() => httpClient.requests.last; + + test('a set of rows takes typed arguments and reads typed rows', () async { + httpClient.stub([ + {'id': 1, 'title': 'A typed row', 'mood': 'very happy'}, + ], path: '/rest/v1/rpc/search_books'); + + final List books = await client.rpc + .searchBooks(query: 'typed', mood: Mood.veryHappy) + .where(Books.id.gt(0)) + .order(Books.title.asc()); + + expect(books.single.mood, Mood.veryHappy); + expect(lastRequest().method, HttpMethod.post.value); + expect(lastRequest().jsonBody, {'query': 'typed', 'mood': 'very happy'}); + expect(lastRequest().url.queryParameters, { + 'id': 'gt.0', + 'order': 'title.asc', + }); + expect(lastRequest().headers['content-profile'], 'public'); + }); + + test('a function of another schema is called in its schema', () async { + httpClient.stub(5); + + final int? quantity = await client.rpc.inventoryRestock( + stockId: 1, + amount: 4, + ); + + expect(quantity, 5); + expect(lastRequest().url.path, '/rest/v1/rpc/restock'); + expect(lastRequest().headers['content-profile'], 'inventory'); + expect(lastRequest().jsonBody, {'stock_id': 1, 'amount': 4}); + }); + + test('a single row reads the row', () async { + httpClient.stub({'id': 3, 'title': 'One'}); + + final BooksRow? book = await client.rpc.bookById(bookId: 3); + + expect(book?.title, 'One'); + }); + + test('a single row that is not found reads as null', () async { + httpClient.stub({'id': null, 'title': null}); + final BooksRow? allNull = await client.rpc.bookById(bookId: 999); + expect(allNull == null, isTrue); + + httpClient.stub(null); + final BooksRow? missing = await client.rpc.bookById(bookId: 999); + expect(missing == null, isTrue); + }); + + test('RETURNS TABLE rows are read and filtered through their own ' + 'column tokens', () async { + httpClient.stub([ + {'author_name': 'Ada', 'book_count': 2}, + ]); + + final rows = await client.rpc + .authorBookCounts() + .where(AuthorBookCounts.bookCount.gte(2)) + .order(AuthorBookCounts.authorName.asc()); + + expect(rows.single.authorName, 'Ada'); + expect(rows.single.bookCount, 2); + expect(lastRequest().url.queryParameters, { + 'book_count': 'gte.2', + 'order': 'author_name.asc', + }); + }); + + test('scalars and sets of scalars are converted', () async { + httpClient.stub(['2026-07-23', null]); + final List dates = await client.rpc.publicationDates( + authorId: 1, + ); + expect(dates, [PostgrestDate(2026, 7, 23), null]); + + httpClient.stub('sad'); + final Mood? mood = await client.rpc.moodOrDefault( + book: BooksRow({'id': 1, 'title': 'A typed row'}), + ); + expect(mood, Mood.sad); + expect(lastRequest().jsonBody, { + 'book': {'id': 1, 'title': 'A typed row'}, + }); + + httpClient.stub(null); + final int? count = await client.rpc.countBooks(); + expect(count, isNull); + }); + + test('optional arguments left out are not sent', () async { + httpClient.stub(null); + + await client.rpc.touchBook(bookId: 2); + expect(lastRequest().jsonBody, {'book_id': 2}); + + await client.rpc.touchBook( + bookId: 2, + touchedAt: DateTime.utc(2026, 7, 23, 10), + ); + expect(lastRequest().jsonBody, { + 'book_id': 2, + 'touched_at': '2026-07-23T10:00:00.000Z', + }); + }); + + test('overloads get a method each', () async { + httpClient.stub([]); + + await client.rpc.booksOf(authorId: 1); + expect(lastRequest().jsonBody, {'author_id': 1}); + + await client.rpc.booksOfWithAuthorNameAndMaxPrice( + authorName: 'Ada', + maxPrice: 10, + ); + expect(lastRequest().jsonBody, {'author_name': 'Ada', 'max_price': 10}); + }); + + test('a sole unnamed json parameter is sent as the whole body', () async { + httpClient.stub({'echoed': true}); + final echoed = await client.rpc.echo({'echoed': true}); + expect(echoed, {'echoed': true}); + expect(lastRequest().jsonBody, {'echoed': true}); + }); + + test( + 'the untyped call still works next to the generated methods', + () async { + httpClient.stub(3); + + final count = await client.rpc('count_books'); + + expect(count, 3); + }, + ); + }); } diff --git a/packages/supabase_typegen/test/generator_metadata_parser_test.dart b/packages/supabase_typegen/test/generator_metadata_parser_test.dart index 2a075a958..8b1642857 100644 --- a/packages/supabase_typegen/test/generator_metadata_parser_test.dart +++ b/packages/supabase_typegen/test/generator_metadata_parser_test.dart @@ -1220,6 +1220,195 @@ void main() { expect(publicTable(publicOnly, 'todos').computedRelationships, isEmpty); }); }); + + group('functions', () { + FunctionDescription function(String name) => + schema.functions.singleWhere((function) => function.name == name); + + FunctionOverloadDescription overload(String name) => + function(name).overloads.single; + + test('lists the functions callable through /rpc by schema and name', () { + expect( + schema.functions.map( + (function) => '${function.schema}.${function.name}', + ), + [ + 'inventory.restock', + 'public.author_book_counts', + 'public.book_by_id', + 'public.books_of', + 'public.books_with_tags', + 'public.count_books', + 'public.discount', + 'public.echo', + 'public.latest_book', + 'public.mood_or_default', + 'public.price_label', + 'public.publication_dates', + 'public.recent_books', + 'public.search_books', + 'public.title', + 'public.title_upper', + 'public.touch_book', + ], + ); + expect(function('books_of').overloads, hasLength(2)); + }); + + test('reads the arguments with their types and defaults', () { + final arguments = overload('search_books').arguments; + + expect(arguments.map((argument) => argument.name), [ + 'max_price', + 'mood', + 'query', + ]); + expect(arguments.map((argument) => argument.hasDefault), [ + true, + true, + false, + ]); + expect(arguments[1].type.typeKind, ColumnTypeKind.enumType); + expect(arguments[1].type.enumType?.qualifiedName, 'public.mood'); + expect( + overload('books_with_tags').arguments.single.type.elementTypeKind, + ColumnTypeKind.text, + ); + expect(overload('mood_or_default').arguments.single.rowOf, ( + 'public', + 'books', + )); + }); + + test('reads every result shape', () { + expect( + overload('search_books').result, + isA() + .having((result) => result.table, 'table', 'books') + .having((result) => result.isSetReturning, 'isSetReturning', true), + ); + expect( + overload('book_by_id').result, + isA().having( + (result) => result.isSetReturning, + 'isSetReturning', + false, + ), + ); + expect( + overload('author_book_counts').result, + isA().having( + (result) => [ + for (final column in result.columns) + (column.name, column.typeKind, column.isNullable), + ], + 'columns', + [ + ('author_name', ColumnTypeKind.text, true), + ('book_count', ColumnTypeKind.integer, true), + ], + ), + ); + expect( + overload('publication_dates').result, + isA() + .having( + (result) => result.type.typeKind, + 'kind', + ColumnTypeKind.date, + ) + .having((result) => result.isSetReturning, 'isSetReturning', true), + ); + expect(overload('touch_book').result, isA()); + expect( + overload('echo').result, + isA().having( + (result) => result.type.typeKind, + 'kind', + ColumnTypeKind.json, + ), + ); + }); + + test('keeps the error of an overload PostgREST cannot resolve', () { + expect( + function('price_label').overloads.map((overload) => overload.error), + everyElement(startsWith('Could not choose the best candidate')), + ); + expect(overload('title_upper').error, isNotNull); + expect(overload('title_upper').isComputedField, isTrue); + expect(overload('search_books').error, isNull); + }); + + test('resolves a document that predates rpcFunctions the same way', () { + final legacy = parseGeneratorMetadata( + {...document}..remove('rpcFunctions'), + ); + + expect( + legacy.functions.map((function) => function.name), + schema.functions.map((function) => function.name), + ); + }); + + test('lists the functions of the named schemas only', () { + final public = parseGeneratorMetadata(document, schemaNames: ['public']); + + expect( + public.functions.map((function) => function.schema).toSet(), + {'public'}, + ); + }); + + test('reads rows of a relation that is not generated as JSON', () { + final inventoryOnly = parseGeneratorMetadata( + document, + schemaNames: ['inventory'], + ); + final publicRows = parseGeneratorMetadata( + { + ...document, + 'rpcFunctions': [ + for (final entry + in (document['rpcFunctions'] as List) + .cast>()) + if (entry['name'] == 'search_books') + {...entry, 'schema': 'inventory'}, + ], + }, + schemaNames: ['inventory'], + ); + + expect(inventoryOnly.functions.single.name, 'restock'); + expect( + publicRows.functions.single.overloads.single.result, + isA() + .having( + (result) => result.type.typeKind, + 'kind', + ColumnTypeKind.json, + ) + .having((result) => result.isSetReturning, 'isSetReturning', true), + ); + }); + + test('collects an enum only a function refers to', () { + final parsed = parseGeneratorMetadata( + { + ...document, + 'tables': [], + 'views': [], + 'materializedViews': [], + }, + schemaNames: ['public'], + ); + + expect(parsed.enums.map((enumType) => enumType.qualifiedName), [ + 'public.mood', + ]); + }); + }); } /// The table [name] of the `public` schema. diff --git a/packages/supabase_typegen/test/goldens/hostile_fixture.dart b/packages/supabase_typegen/test/goldens/hostile_fixture.dart index b4eb8979b..2c950abb5 100644 --- a/packages/supabase_typegen/test/goldens/hostile_fixture.dart +++ b/packages/supabase_typegen/test/goldens/hostile_fixture.dart @@ -3,8 +3,9 @@ import 'package:supabase_typegen/supabase_typegen.dart'; /// A schema built from valid but pathological database names: identifiers /// that shadow core and imported types, comments and values carrying every /// line terminator and Dart string metacharacter, array element kinds with -/// dedicated conversions, and pgvector columns, which the fixture database -/// cannot load. +/// dedicated conversions, pgvector columns, which the fixture database +/// cannot load, and functions named like the members their generated +/// methods sit next to. /// /// The golden generated from it, `hostile_schema.dart`, is checked in so the /// package's own `dart analyze` run proves the generated code stays valid, @@ -474,6 +475,216 @@ const DatabaseDescription hostileSchema = DatabaseDescription( isOneToOne: true, ), ], + functions: [ + // Named like the method a call of `client.rpc` goes through. + FunctionDescription( + schema: 'public', + name: 'call', + overloads: [ + FunctionOverloadDescription( + arguments: [], + result: FunctionValueResult( + ValueTypeDescription( + postgresFormat: 'int8', + typeKind: ColumnTypeKind.integer, + ), + isSetReturning: false, + ), + ), + ], + ), + // Named like the generated method body's callee, with parameters named + // like it and like the codecs the body refers to. + FunctionDescription( + schema: 'public', + name: 'rows', + overloads: [ + FunctionOverloadDescription( + arguments: [ + FunctionArgumentDescription( + name: 'postgrest_bytea', + type: ValueTypeDescription( + postgresFormat: 'bytea', + typeKind: ColumnTypeKind.binary, + ), + hasDefault: true, + ), + FunctionArgumentDescription( + name: 'rows', + type: ValueTypeDescription( + postgresFormat: 'int4range', + typeKind: ColumnTypeKind.range, + boundTypeKind: ColumnTypeKind.integer, + ), + hasDefault: false, + ), + FunctionArgumentDescription( + name: 'value', + type: ValueTypeDescription( + postgresFormat: 'public.string', + typeKind: ColumnTypeKind.enumType, + enumType: EnumDescription( + schema: 'public', + name: 'string', + values: ["it's \$a\u2028trap", 'plain'], + ), + ), + hasDefault: false, + ), + ], + result: FunctionValueResult( + ValueTypeDescription( + postgresFormat: 'vector', + typeKind: ColumnTypeKind.vector, + ), + isSetReturning: true, + ), + ), + ], + ), + // A reserved word returning rows of its own shape, one column of a + // shadowing enum. + FunctionDescription( + schema: 'public', + name: 'class', + overloads: [ + FunctionOverloadDescription( + arguments: [], + result: FunctionColumnsResult([ + ColumnDescription( + name: 'string', + postgresFormat: 'public.string', + typeKind: ColumnTypeKind.enumType, + enumType: EnumDescription( + schema: 'public', + name: 'string', + values: ["it's \$a\u2028trap", 'plain'], + ), + isRequired: false, + hasDefault: false, + isNullable: true, + isReadOnly: true, + ), + ]), + ), + ], + ), + // Returning rows of its own shape, named like a core type and a + // generated table at once, and taking a row of that table. + FunctionDescription( + schema: 'public', + name: 'map', + overloads: [ + FunctionOverloadDescription( + arguments: [ + FunctionArgumentDescription( + name: 'map', + type: ValueTypeDescription( + postgresFormat: 'map', + typeKind: ColumnTypeKind.unknown, + ), + hasDefault: false, + rowOf: ('public', 'map'), + ), + ], + result: FunctionColumnsResult([ + ColumnDescription( + name: 'list', + postgresFormat: '_float8', + typeKind: ColumnTypeKind.array, + elementTypeKind: ColumnTypeKind.floating, + isRequired: false, + hasDefault: false, + isNullable: true, + isReadOnly: true, + ), + ]), + ), + ], + ), + // Named like the schema-prefixed name of `private.restock`. + FunctionDescription( + schema: 'public', + name: 'private_restock', + overloads: [ + FunctionOverloadDescription(arguments: [], result: FunctionNoResult()), + ], + ), + FunctionDescription( + schema: 'private', + name: 'restock', + overloads: [ + FunctionOverloadDescription(arguments: [], result: FunctionNoResult()), + // An overload spelling its arguments out, with a row of a table of + // another schema and an unknown type. + FunctionOverloadDescription( + arguments: [ + FunctionArgumentDescription( + name: 'item', + type: ValueTypeDescription( + postgresFormat: 'achievement_item_progress_tbl', + typeKind: ColumnTypeKind.unknown, + ), + hasDefault: false, + rowOf: ('private', 'achievement_item_progress_tbl'), + ), + FunctionArgumentDescription( + name: 'note', + type: ValueTypeDescription( + postgresFormat: 'tsquery_custom', + typeKind: ColumnTypeKind.unknown, + ), + hasDefault: true, + ), + ], + result: FunctionTableRowsResult( + schema: 'public', + table: 'orders', + isSetReturning: false, + ), + ), + ], + ), + // A schema name with line terminators and quotes, in a method name, a + // string literal and the note of an overload the generated code cannot + // call. + FunctionDescription( + schema: 'evil\nmultiline "schema" name', + name: 'it\'s', + overloads: [ + FunctionOverloadDescription( + arguments: [ + FunctionArgumentDescription( + name: '', + type: ValueTypeDescription( + postgresFormat: 'text', + typeKind: ColumnTypeKind.text, + ), + hasDefault: false, + ), + ], + result: FunctionNoResult(), + ), + FunctionOverloadDescription( + arguments: [ + FunctionArgumentDescription( + name: 'at', + type: ValueTypeDescription( + postgresFormat: 'timestamp', + typeKind: ColumnTypeKind.timestamp, + ), + hasDefault: false, + ), + ], + result: FunctionTableRowsResult( + schema: 'evil\nmultiline "schema" name', + table: 'postgrest_column', + isSetReturning: true, + ), + ), + ], + ), + ], enums: [ EnumDescription( schema: 'public', diff --git a/packages/supabase_typegen/test/goldens/hostile_schema.dart b/packages/supabase_typegen/test/goldens/hostile_schema.dart index 51d508dc1..fe2915084 100644 --- a/packages/supabase_typegen/test/goldens/hostile_schema.dart +++ b/packages/supabase_typegen/test/goldens/hostile_schema.dart @@ -945,6 +945,136 @@ class PrivateAchievementItemProgressTbl { PostgrestColumn('name'); } +/// A row returned by the `class` function. +extension type const ClassRow(Map _json) implements Object { + String$? get string => switch (_json['string']) { + null => null, + final Object value => String$.fromWire(value as String), + }; + + /// The row as decoded from the response. + Map toJson() => _json; +} + +/// Column tokens of the rows the `class` function returns, for filtering and +/// ordering them. +class Class { + const Class._(); + + static const string = PostgrestNullableColumn( + 'string', + fromJson: _stringFromJson, + ); +} + +/// A row returned by the `map` function. +extension type const MapRow$(Map _json) implements Object { + List? get list => (_json['list'] as List?) + ?.map((element) => (element as num).toDouble()) + .toList(); + + /// The row as decoded from the response. + Map toJson() => _json; +} + +/// Column tokens of the rows the `map` function returns, for filtering and +/// ordering them. +class Map$$ { + const Map$$._(); + + static const list = PostgrestNullableColumn>( + 'list', + fromJson: _doubleListFromJson, + ); +} + +/// Typed calls of the database functions, `client.rpc.functionName(…)`. +/// +/// `evil multiline "schema" name.it's(text)` has no method: PostgREST only +/// passes an unnamed parameter that is the sole one and of type json or jsonb; +/// call it through `client.rpc` instead +extension DatabaseFunctions on PostgrestRpc { + /// Calls the `call` function. + PostgrestTypedBuilder call$() => + value('call', fromJson: (json) => json as int?, schema: 'public'); + + /// Calls the `rows` function. + PostgrestTypedBuilder?>> rows$({ + required PostgrestRange rows$, + required String$ value$, + Uint8List? postgrestBytea$, + }) => value( + 'rows', + arguments: { + 'postgrest_bytea': ?switch (postgrestBytea$) { + null => null, + final value => postgrestBytea.encode(value), + }, + 'rows': rows$.literal, + 'value': value$.wireName, + }, + fromJson: (json) => [ + for (final element in json! as List) + element == null ? null : _doubleListFromJson$(element), + ], + schema: 'public', + ); + + /// Calls the `class` function, which returns rows filterable through `Class`. + PostgrestTypedFilterBuilder> class$() => + rows('class', rowFromJson: ClassRow.new, schema: 'public'); + + /// Calls the `map` function, which returns rows filterable through `Map$$`. + PostgrestTypedFilterBuilder> map({ + required MapRow map, + }) => rows( + 'map', + arguments: {'map': map.toJson()}, + rowFromJson: MapRow$.new, + schema: 'public', + ); + + /// Calls the `private_restock` function. + PostgrestTypedBuilder privateRestock() => + value('private_restock', fromJson: (_) {}, schema: 'public'); + + /// Calls the `private.restock` function. + PostgrestTypedBuilder privateRestock$() => + value('restock', fromJson: (_) {}, schema: 'private'); + + /// Calls the `private.restock` function, which returns a row of `orders`, or + /// `null` when it finds none. + PostgrestTypedBuilder privateRestockWithItemAndNote({ + required PrivateAchievementItemProgressTblRow item, + Object? note, + }) => value( + 'restock', + arguments: {'item': item.toJson(), 'note': ?note}, + fromJson: (json) => switch (json) { + final Map row + when row.values.any((value) => value != null) => + OrdersRow(row), + _ => null, + }, + schema: 'private', + ); + + /// Calls the `evil + /// multiline "schema" name.it's` function, which returns rows of `evil + /// multiline "schema" name.postgrest_column` filterable through + /// `EvilMultilineSchemaNamePostgrestColumn`. + PostgrestTypedFilterBuilder< + EvilMultilineSchemaNamePostgrestColumnRow, + List + > + evilMultilineSchemaNameItS({required DateTime at}) => rows( + 'it\'s', + arguments: {'at': at.toIso8601String()}, + rowFromJson: EvilMultilineSchemaNamePostgrestColumnRow.new, + schema: 'evil\nmultiline "schema" name', + ); +} + DateTime _dateTimeFromJson(Object json) => DateTime.parse(json as String); List _dateTimeListFromJson(Object json) => (json as List) @@ -957,6 +1087,9 @@ List _doubleListFromJson(Object json) => (json as List) .map((element) => (element as num).toDouble()) .toList(); +List _doubleListFromJson$(Object json) => + postgrestVector.decode(json as String); + $ _fromJson(Object json) => $.fromWire(json as String); PostgrestDate _postgrestDateFromJson(Object json) => diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index afbf3582b..4c26425c5 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -794,6 +794,179 @@ class Books { ); } +/// A row returned by the `author_book_counts` function. +extension type const AuthorBookCountsRow(Map _json) + implements Object { + String? get authorName => _json['author_name'] as String?; + int? get bookCount => _json['book_count'] as int?; + + /// The row as decoded from the response. + Map toJson() => _json; +} + +/// Column tokens of the rows the `author_book_counts` function returns, for +/// filtering and ordering them. +class AuthorBookCounts { + const AuthorBookCounts._(); + + static const authorName = + PostgrestNullableColumn('author_name'); + static const bookCount = PostgrestNullableColumn( + 'book_count', + ); +} + +/// Typed calls of the database functions, `client.rpc.functionName(…)`. +/// +/// `price_label(amount int4)` has no method: Could not choose the best +/// candidate function between: public.price_label(amount => int4), +/// public.price_label(amount => numeric). Try renaming the parameters or the +/// function itself in the database so function overloading can be resolved +/// +/// `price_label(amount numeric)` has no method: Could not choose the best +/// candidate function between: public.price_label(amount => int4), +/// public.price_label(amount => numeric). Try renaming the parameters or the +/// function itself in the database so function overloading can be resolved +/// +/// `title(books)` has no method: the function public.title with parameter or +/// with a single unnamed json/jsonb parameter, but no matches were found in the +/// schema cache +extension DatabaseFunctions on PostgrestRpc { + /// Calls the `inventory.restock` function. + PostgrestTypedBuilder inventoryRestock({ + required int amount, + required int stockId, + }) => value( + 'restock', + arguments: {'amount': amount, 'stock_id': stockId}, + fromJson: (json) => json as int?, + schema: 'inventory', + ); + + /// Calls the `author_book_counts` function, which returns rows filterable + /// through `AuthorBookCounts`. + PostgrestTypedFilterBuilder> + authorBookCounts({int? minBooks}) => rows( + 'author_book_counts', + arguments: {'min_books': ?minBooks}, + rowFromJson: AuthorBookCountsRow.new, + schema: 'public', + ); + + /// Calls the `book_by_id` function, which returns a row of `books`, or `null` + /// when it finds none. + PostgrestTypedBuilder bookById({required int bookId}) => value( + 'book_by_id', + arguments: {'book_id': bookId}, + fromJson: (json) => switch (json) { + final Map row + when row.values.any((value) => value != null) => + BooksRow(row), + _ => null, + }, + schema: 'public', + ); + + /// Calls the `books_of` function, which returns rows of `books` filterable + /// through `Books`. + PostgrestTypedFilterBuilder> booksOf({ + required int authorId, + }) => rows( + 'books_of', + arguments: {'author_id': authorId}, + rowFromJson: BooksRow.new, + schema: 'public', + ); + + /// Calls the `books_of` function, which returns rows of `books` filterable + /// through `Books`. + PostgrestTypedFilterBuilder> + booksOfWithAuthorNameAndMaxPrice({ + required String authorName, + required num maxPrice, + }) => rows( + 'books_of', + arguments: {'author_name': authorName, 'max_price': maxPrice}, + rowFromJson: BooksRow.new, + schema: 'public', + ); + + /// Calls the `books_with_tags` function, which returns rows of `books` + /// filterable through `Books`. + PostgrestTypedFilterBuilder> booksWithTags({ + required List tags, + }) => rows( + 'books_with_tags', + arguments: {'tags': tags}, + rowFromJson: BooksRow.new, + schema: 'public', + ); + + /// Calls the `count_books` function. + PostgrestTypedBuilder countBooks() => + value('count_books', fromJson: (json) => json as int?, schema: 'public'); + + /// Calls the `echo` function. + PostgrestTypedBuilder echo(Object? body) => value( + 'echo', + arguments: body, + fromJson: (json) => json, + schema: 'public', + ); + + /// Calls the `mood_or_default` function. + PostgrestTypedBuilder moodOrDefault({required BooksRow book}) => value( + 'mood_or_default', + arguments: {'book': book.toJson()}, + fromJson: (json) => json == null ? null : _moodFromJson(json), + schema: 'public', + ); + + /// Calls the `publication_dates` function. + PostgrestTypedBuilder> publicationDates({ + required int authorId, + }) => value( + 'publication_dates', + arguments: {'author_id': authorId}, + fromJson: (json) => [ + for (final element in json! as List) + element == null ? null : _postgrestDateFromJson(element), + ], + schema: 'public', + ); + + /// Calls the `search_books` function, which returns rows of `books` + /// filterable through `Books`. + PostgrestTypedFilterBuilder> searchBooks({ + required String query, + num? maxPrice, + Mood? mood, + }) => rows( + 'search_books', + arguments: { + 'max_price': ?maxPrice, + 'mood': ?mood?.wireName, + 'query': query, + }, + rowFromJson: BooksRow.new, + schema: 'public', + ); + + /// Calls the `touch_book` function. + PostgrestTypedBuilder touchBook({ + required int bookId, + DateTime? touchedAt, + }) => value( + 'touch_book', + arguments: { + 'book_id': bookId, + 'touched_at': ?touchedAt?.toUtc().toIso8601String(), + }, + fromJson: (_) {}, + schema: 'public', + ); +} + DateTime _dateTimeFromJson(Object json) => DateTime.parse(json as String); double _doubleFromJson(Object json) => (json as num).toDouble(); diff --git a/packages/supabase_typegen/test/introspection/introspect_test.dart b/packages/supabase_typegen/test/introspection/introspect_test.dart index 3f75b4793..2018032af 100644 --- a/packages/supabase_typegen/test/introspection/introspect_test.dart +++ b/packages/supabase_typegen/test/introspection/introspect_test.dart @@ -37,7 +37,13 @@ Map _function(String name, String returnType) => { 'id': 1, 'schema': 'public', 'name': name, + 'args': >[], + 'argument_types': '', + 'return_type_id': 2249, 'return_type': returnType, + 'return_type_relation_id': null, + 'is_set_returning_function': false, + 'prorows': null, }; void main() { @@ -93,9 +99,21 @@ void main() { 'relationships', 'functions', 'types', + 'rpcFunctions', ]); }); + test('resolves the functions callable through /rpc', () async { + final document = await introspect(database); + + expect( + (document['rpcFunctions'] as List).map( + (function) => function['name'], + ), + ['search_books'], + ); + }); + test('appends the view derived relationships to the foreign keys', () async { final document = await introspect(database); diff --git a/packages/supabase_typegen/test/rpc_functions_test.dart b/packages/supabase_typegen/test/rpc_functions_test.dart new file mode 100644 index 000000000..d2e77a219 --- /dev/null +++ b/packages/supabase_typegen/test/rpc_functions_test.dart @@ -0,0 +1,346 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase_typegen/introspection.dart'; +import 'package:supabase_typegen/src/rpc_functions.dart'; +import 'package:test/test.dart'; + +const _int4 = 23; +const _text = 25; +const _jsonb = 3802; +const _todosRowType = 500; + +Map _argument( + String name, + int typeId, { + String mode = 'in', + bool hasDefault = false, +}) => { + 'mode': mode, + 'name': name, + 'type_id': typeId, + 'has_default': hasDefault, +}; + +Map _function( + String name, { + int id = 1, + String schema = 'public', + List> arguments = const [], + String argumentTypes = '', + int returnTypeId = _int4, + String returnType = 'integer', + int? returnTypeRelationId, + bool isSetReturning = false, + int? prorows, +}) => { + 'id': id, + 'schema': schema, + 'name': name, + 'args': arguments, + 'argument_types': argumentTypes, + 'return_type_id': returnTypeId, + 'return_type': returnType, + 'return_type_relation_id': returnTypeRelationId, + 'is_set_returning_function': isSetReturning, + 'prorows': prorows, +}; + +Map _type( + int id, + String name, { + String schema = 'pg_catalog', + int? relationId, +}) => { + 'id': id, + 'name': name, + 'schema': schema, + 'format': name, + 'enums': [], + 'type_relation_id': relationId, +}; + +Map _document( + List> functions, { + List schemas = const ['public'], +}) => { + 'schemas': [ + for (final (index, schema) in schemas.indexed) + {'id': index, 'name': schema}, + ], + 'tables': [ + {'id': 1, 'schema': 'public', 'name': 'todos'}, + ], + 'columns': [ + {'table_id': 1, 'name': 'id'}, + {'table_id': 1, 'name': 'title'}, + ], + 'functions': functions, + 'types': [ + _type(_int4, 'int4'), + _type(_text, 'text'), + _type(_jsonb, 'jsonb'), + _type(_todosRowType, 'todos', schema: 'public', relationId: 1), + ], +}; + +List> _overloads(Map function) => + (function['overloads'] as List).cast(); + +void main() { + test('resolves the fixture database like @supabase/postgrest-typegen', () { + final fixture = + jsonDecode( + File('test/fixtures/generator_metadata.json').readAsStringSync(), + ) + as Map; + final withoutRpcFunctions = {...fixture}..remove('rpcFunctions'); + + final resolved = sortGeneratorMetadata({ + ...withoutRpcFunctions, + 'rpcFunctions': resolveRpcFunctions(withoutRpcFunctions), + }); + + expect(resolved['rpcFunctions'], fixture['rpcFunctions']); + }); + + test('leaves out functions with an unnamed parameter PostgREST cannot ' + 'pass', () { + final resolved = resolveRpcFunctions( + _document([ + _function('by_text', arguments: [_argument('', _text)]), + _function( + 'mixed', + id: 2, + arguments: [_argument('', _int4), _argument('b', _int4)], + ), + _function( + 'defaulted', + id: 3, + arguments: [ + _argument('', _jsonb, hasDefault: true), + _argument('b', _int4), + ], + ), + ]), + ); + + expect(resolved.map((function) => function['name']), [ + 'by_text', + 'defaulted', + ]); + }); + + test('reads only the input parameters and the RETURNS TABLE columns', () { + final [function] = resolveRpcFunctions( + _document([ + _function( + 'counts', + arguments: [ + _argument('since', _int4), + _argument('total', _int4, mode: 'out'), + _argument('name', _text, mode: 'table'), + ], + returnType: 'TABLE(name text)', + isSetReturning: true, + ), + ]), + ); + final [overload] = _overloads(function); + + expect( + (overload['args'] as List).map((argument) => argument['name']), + ['since'], + ); + expect(overload['returns'], { + 'type_id': _int4, + 'format': 'int4', + 'type_schema': 'pg_catalog', + 'relation_id': null, + 'columns': [ + { + 'name': 'name', + 'type_id': _text, + 'format': 'text', + 'type_schema': 'pg_catalog', + }, + ], + }); + }); + + test('marks rows of a relation as embeddable from the row parameter', () { + final [function] = resolveRpcFunctions( + _document([ + _function( + 'similar', + arguments: [_argument('todo', _todosRowType)], + argumentTypes: 'todo todos', + returnTypeId: _todosRowType, + returnType: 'SETOF todos', + returnTypeRelationId: 1, + isSetReturning: true, + prorows: 1000, + ), + ]), + ); + final [overload] = _overloads(function); + + expect((overload['args'] as List).single['relation_id'], 1); + expect(overload['setof_options'], { + 'from': 'todos', + 'to': 'todos', + 'is_one_to_one': false, + 'is_setof_return': true, + }); + expect(overload['computed_field_of'], 1); + expect(overload['error'], isNull); + }); + + test('a computed field named like a column is none', () { + final [function] = resolveRpcFunctions( + _document([ + _function( + 'title', + arguments: [_argument('todo', _todosRowType)], + argumentTypes: 'todo todos', + returnTypeId: _text, + returnType: 'text', + ), + ]), + ); + + expect(_overloads(function).single['computed_field_of'], isNull); + }); + + test('a scalar computed field with an unnamed row parameter is an ' + 'error', () { + final [function] = resolveRpcFunctions( + _document([ + _function( + 'title_upper', + arguments: [_argument('', _todosRowType)], + argumentTypes: 'todos', + returnTypeId: _text, + returnType: 'text', + ), + ]), + ); + + expect( + _overloads(function).single['error'], + 'the function public.title_upper with parameter or with a single ' + 'unnamed json/jsonb parameter, but no matches were found in the schema ' + 'cache', + ); + }); + + test('a parameter of a type the document lacks falls back to the argument ' + 'list, a known scalar never does', () { + final resolved = resolveRpcFunctions( + _document([ + _function( + 'unknown_type', + arguments: [_argument('todo', 9999)], + argumentTypes: 'todos', + ), + _function( + 'known_type', + id: 2, + arguments: [_argument('todo', _int4)], + argumentTypes: 'todos', + ), + ]), + ); + + expect( + { + for (final function in resolved) + function['name']: _overloads(function).single['computed_field_of'], + }, + {'known_type': null, 'unknown_type': 1}, + ); + }); + + test('overloads PostgREST cannot tell apart carry an error', () { + final [emptyCall, singleParameter] = resolveRpcFunctions( + _document([ + _function('ping', returnTypeId: _int4, returnType: 'integer'), + _function( + 'ping', + id: 2, + arguments: [_argument('', _text, hasDefault: true)], + argumentTypes: 'text DEFAULT NULL', + returnTypeId: _text, + returnType: 'text', + ), + _function( + 'label', + id: 3, + arguments: [_argument('amount', _int4)], + argumentTypes: 'amount integer', + ), + _function( + 'label', + id: 4, + arguments: [_argument('amount', _text)], + argumentTypes: 'amount text', + ), + ]), + ); + + expect(emptyCall['name'], 'label'); + expect( + _overloads(emptyCall).map((overload) => overload['error']), + everyElement( + 'Could not choose the best candidate function between: ' + 'public.label(amount => int4), public.label(amount => text). Try ' + 'renaming the parameters or the function itself in the database so ' + 'function overloading can be resolved', + ), + ); + expect( + _overloads(singleParameter).first['error'], + 'Could not choose the best candidate function between: public.ping(), ' + 'public.ping( => text). Try renaming the parameters or the function ' + 'itself in the database so function overloading can be resolved', + ); + expect(_overloads(singleParameter).last['error'], isNull); + }); + + test('orders overloads by argument list and keeps schemas apart', () { + final resolved = resolveRpcFunctions( + _document( + [ + _function( + 'find', + arguments: [_argument('b', _int4)], + argumentTypes: 'b integer', + ), + _function( + 'find', + id: 2, + arguments: [_argument('a', _int4)], + argumentTypes: 'a integer', + ), + _function('c', id: 3, schema: 'a.b'), + _function('b.c', id: 4, schema: 'a'), + _function('hidden', id: 5, schema: 'private'), + ], + schemas: ['public', 'a', 'a.b'], + ), + ); + + String describe(Map function) { + final ids = _overloads(function).map( + (overload) => overload['function_id'], + ); + return '${function['schema']}|${function['name']}|${ids.join(',')}'; + } + + expect(resolved.map(describe), [ + 'a|b.c|4', + 'a.b|c|3', + 'public|find|2,1', + ]); + }); +} diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 6aac98d25..314f47277 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -976,6 +976,10 @@ features: - SupabaseClient.rpc supporting_symbols: - PostgrestQueryBuilder.count + - PostgrestRpc + - PostgrestRpc.call + - PostgrestRpc.rows + - PostgrestRpc.value - PostgrestRpcBuilder.rpc - PostgrestBuilder.count - PostgrestTransformBuilder.head