From 73406f33370c9890bc468f8f79c2de405199571c Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 14:05:17 +0200 Subject: [PATCH] feat(postgrest): add csv, explain, head, dryRun, stripNulls, maxAffected and geojson to the typed transform builder --- .../postgrest_typed_transform_builder.dart | 93 ++++++++++++ packages/postgrest/test/typed_query_test.dart | 138 ++++++++++++++++++ sdk-compliance.yaml | 7 + 3 files changed, 238 insertions(+) diff --git a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart index 8c620e6f7..269cd68a6 100644 --- a/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_typed_transform_builder.dart @@ -103,6 +103,99 @@ class PostgrestTypedTransformBuilder extends PostgrestTypedBuilder { _table, ); + /// Omits `null`-valued properties from the response objects. + /// + /// This uses the `nulls=stripped` variant of the `Accept` header and + /// requires PostgREST 11.2 or higher. + PostgrestTypedTransformBuilder stripNulls() => + PostgrestTypedTransformBuilder._(_transformBuilder.stripNulls(), _table); + + /// Runs the query but rolls back the transaction, so no changes are + /// persisted. + /// + /// The data that would have resulted from the query is still returned, + /// which is useful for previewing the effect of a mutation. + /// + /// ```dart + /// await client.table(Books.table).insert({'title': 'foo'}).dryRun(); + /// ``` + PostgrestTypedTransformBuilder dryRun() => + PostgrestTypedTransformBuilder._(_transformBuilder.dryRun(), _table); + + /// Sets the maximum number of rows that can be affected by the query. + /// + /// Only available with PATCH and DELETE operations. Requires PostgREST v13 or + /// higher. When the limit is exceeded, the query will fail with an error. + /// + /// ```dart + /// await client + /// .table(Books.table) + /// .delete() + /// .where(Books.isDone.eq(true)) + /// .maxAffected(10); + /// ``` + PostgrestTypedTransformBuilder maxAffected(int value) => + PostgrestTypedTransformBuilder._( + _transformBuilder.maxAffected(value), + _table, + ); + + /// Retrieves the response as CSV. + /// + /// This will skip object parsing. + /// + /// ```dart + /// final String csv = await client.table(Books.table).select().csv(); + /// ``` + PostgrestTypedTransformBuilder csv() => + PostgrestTypedTransformBuilder._(_transformBuilder.csv(), _table); + + /// Performs a head request. + /// + /// This will not return any data. + /// + /// ```dart + /// await client.table(Books.table).select().head(); + /// ``` + PostgrestTypedBuilder head() => + PostgrestTypedBuilder._(_transformBuilder.head()); + + /// Enables support for GeoJSON for use with PostGIS data types. + /// + /// Used when you need the complete response to be in GeoJSON format. You + /// will need to enable the PostGIS extension for this to work. + /// + /// https://supabase.com/docs/guides/database/extensions/postgis + PostgrestTypedBuilder> geojson() => + PostgrestTypedBuilder._(_transformBuilder.geojson()); + + /// Obtains the EXPLAIN plan for this request. + /// + /// Before using this method, you need to enable `explain()` on your + /// Supabase instance by following the guide below. Note that `explain()` + /// should only be enabled on a development environment. + /// + /// https://supabase.com/docs/guides/api/rest/debugging-performance#enabling-explain + /// + /// See [PostgrestTransformBuilder.explain] for the options. + PostgrestTypedBuilder explain({ + bool analyze = false, + bool verbose = false, + bool settings = false, + bool buffers = false, + bool wal = false, + ExplainFormat format = ExplainFormat.text, + }) => PostgrestTypedBuilder._( + _transformBuilder.explain( + analyze: analyze, + verbose: verbose, + settings: settings, + buffers: buffers, + wal: wal, + format: format, + ), + ); + /// Performs additionally to the query a count query. /// /// This changes the awaited type to a [PostgrestResponse] carrying both the diff --git a/packages/postgrest/test/typed_query_test.dart b/packages/postgrest/test/typed_query_test.dart index d7049197e..3e57b1223 100644 --- a/packages/postgrest/test/typed_query_test.dart +++ b/packages/postgrest/test/typed_query_test.dart @@ -472,6 +472,144 @@ void main() { }); }); + group('result modifiers', () { + test('csv returns the body as text', () async { + httpClient.stubText('id,title\n1,a\n', contentType: 'text/csv'); + + final String csv = await client.table(Books.table).select().csv(); + + expect(httpClient.requests.last.headers['Accept'], 'text/csv'); + expect(csv, 'id,title\n1,a\n'); + }); + + test('csv keeps the other transforms', () async { + httpClient.stubText('id,title\n1,a\n', contentType: 'text/csv'); + + final String csv = await client + .table(Books.table) + .select() + .where(Books.id.gt(0)) + .csv() + .order(Books.title) + .limit(1); + + expect(requestParameters()['id'], 'gt.0'); + expect(requestParameters()['order'], 'title'); + expect(requestParameters()['limit'], '1'); + expect(csv, 'id,title\n1,a\n'); + }); + + test('explain returns the plan as text', () async { + httpClient.stubText('Aggregate (cost=0.00..0.00)'); + + final String plan = await client + .table(Books.table) + .select() + .explain(analyze: true, format: ExplainFormat.json); + + expect( + httpClient.requests.last.headers['Accept'], + 'application/vnd.pgrst.plan+json; for="application/json"; ' + 'options=analyze;', + ); + expect(plan, 'Aggregate (cost=0.00..0.00)'); + }); + + test('head sends a HEAD request and resolves to nothing', () async { + httpClient.stub(null); + + await client.table(Books.table).select().where(Books.id.eq(1)).head(); + + expect(httpClient.requests.last.method, 'HEAD'); + expect(requestParameters()['id'], 'eq.1'); + }); + + test('geojson returns the feature collection', () async { + const collection = {'type': 'FeatureCollection', 'features': []}; + httpClient.stub(collection); + + final Map geojson = await client + .table(Books.table) + .select() + .geojson(); + + expect( + httpClient.requests.last.headers['Accept'], + startsWith('application/geo+json'), + ); + expect(geojson, collection); + }); + + test('dryRun rolls back and keeps the row type', () async { + httpClient.stub({'id': 3, 'title': 'foo'}); + + final Book book = await client + .table(Books.table) + .insert({'title': 'foo'}) + .select() + .single() + .dryRun(); + + expect(httpClient.requests.last.method, 'POST'); + expect( + httpClient.requests.last.headers['Prefer'], + 'return=representation,tx=rollback', + ); + expect(book.id, 3); + }); + + test('stripNulls asks for stripped nulls and keeps the row type', () async { + httpClient.stub(bookRows); + + final List books = await client + .table(Books.table) + .select() + .stripNulls(); + + expect( + httpClient.requests.last.headers['Accept'], + 'application/json;nulls=stripped', + ); + expect(books, hasLength(2)); + }); + + test('maxAffected limits a delete', () async { + httpClient.stub(null); + + await client + .table(Books.table) + .delete() + .where(Books.id.gt(0)) + .maxAffected(10); + + expect(httpClient.requests.last.method, 'DELETE'); + expect( + httpClient.requests.last.headers['Prefer'], + 'handling=strict,max-affected=10', + ); + }); + + test('maxAffected keeps the row type of an update', () async { + httpClient.stub([ + {'id': 1, 'title': 'bar'}, + ]); + + final List books = await client + .table(Books.table) + .update({'title': 'bar'}) + .where(Books.id.eq(1)) + .maxAffected(1) + .select(); + + expect(httpClient.requests.last.method, 'PATCH'); + expect( + httpClient.requests.last.headers['Prefer'], + 'handling=strict,max-affected=1,return=representation', + ); + expect(books.single.title, 'bar'); + }); + }); + group('errors', () { setUp(() { httpClient.stub({'message': 'boom', 'code': '42501'}, statusCode: 403); diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 02f119a1c..a2a820fd4 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -865,6 +865,7 @@ features: - PostgrestRpcBuilder.rpc - PostgrestBuilder.count - PostgrestTransformBuilder.head + - PostgrestTypedTransformBuilder.head database.query.schema_selection: status: implemented symbols: @@ -1149,14 +1150,17 @@ features: status: implemented symbols: - PostgrestTransformBuilder.maxAffected + - PostgrestTypedTransformBuilder.maxAffected database.using_modifiers.format_csv: status: implemented symbols: - PostgrestTransformBuilder.csv + - PostgrestTypedTransformBuilder.csv database.using_modifiers.format_geojson: status: implemented symbols: - PostgrestTransformBuilder.geojson + - PostgrestTypedTransformBuilder.geojson database.using_modifiers.relationship_embed: status: implemented note: "Two spellings. The string builder takes query-string syntax in select(), for example `author(*)`. The column-expression surface declares a relation once on the table's namespace class as a PostgrestToOneRelation or PostgrestToManyRelation and projects a column through it, `Books.author(Authors.name)`, which is compile-time checked in select position and, for the to-one direction only, in order position (a to-many order key is PGRST118 on the server). Filtering inside an embed is not on the typed surface; embeddedFilterName is the form it will use." @@ -1182,14 +1186,17 @@ features: symbols: - ExplainFormat - PostgrestTransformBuilder.explain + - PostgrestTypedTransformBuilder.explain database.using_modifiers.dry_run: status: implemented symbols: - PostgrestTransformBuilder.dryRun + - PostgrestTypedTransformBuilder.dryRun database.using_modifiers.strip_nulls: status: implemented symbols: - PostgrestTransformBuilder.stripNulls + - PostgrestTypedTransformBuilder.stripNulls database.using_modifiers.request_cancellation: status: implemented symbols: