From e900901dcbedc53d8f68a1f277c9fdf579400a33 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 9 Sep 2026 15:06:38 +0200 Subject: [PATCH] fix: shape .single() responses after insert, update, upsert and delete The single-row content negotiation was only applied to GET requests, so `insert(...).select().single()` got a list back and the client failed with a type error while casting it to a map. Response shaping now happens in one place for every request that returns rows, and a result that does not contain exactly one row answers with the same 406 PGRST116 error PostgREST returns instead of a 200 carrying an `error` key. --- lib/src/mock_supabase_http_client.dart | 78 +++++++++-------- test/mock_supabase_http_client_test.dart | 107 +++++++++++++++++++++++ 2 files changed, 150 insertions(+), 35 deletions(-) diff --git a/lib/src/mock_supabase_http_client.dart b/lib/src/mock_supabase_http_client.dart index b96ea97..22278ac 100644 --- a/lib/src/mock_supabase_http_client.dart +++ b/lib/src/mock_supabase_http_client.dart @@ -378,12 +378,12 @@ class MockSupabaseHttpClient extends BaseClient { if (data is Map) { _database[tableKey]!.add(data); - return _createResponse([data], request: request); + return _createRowsResponse([data], request: request); } else if (data is List) { final List> items = List>.from(data); _database[tableKey]!.addAll(items); - return _createResponse(items, request: request); + return _createRowsResponse(items, request: request); } else { return _createResponse({'error': 'Invalid data format'}, statusCode: 400, request: request); @@ -412,7 +412,7 @@ class MockSupabaseHttpClient extends BaseClient { var updated = false; // Track updated rows - final updatedRows = []; + final updatedRows = >[]; // Update items that match the filters if (_database.containsKey(tableKey)) { @@ -426,7 +426,7 @@ class MockSupabaseHttpClient extends BaseClient { } if (updated) { - return _createResponse(updatedRows, request: request); + return _createRowsResponse(updatedRows, request: request); } else { return _createResponse({'error': 'Not found'}, statusCode: 404, request: request); @@ -503,7 +503,7 @@ class MockSupabaseHttpClient extends BaseClient { return item; }).toList(); - return _createResponse(results, request: request); + return _createRowsResponse(results, request: request); } StreamedResponse _handleDelete( @@ -520,7 +520,7 @@ class MockSupabaseHttpClient extends BaseClient { statusCode: 400, request: request); } - List removedItems = []; + final removedItems = >[]; if (_database.containsKey(tableKey)) { _database[tableKey]!.removeWhere((row) { final matched = _matchesFilters(row: row, filters: queryParams); @@ -531,7 +531,7 @@ class MockSupabaseHttpClient extends BaseClient { }); } - return _createResponse(removedItems, request: request); + return _createRowsResponse(removedItems, request: request); } StreamedResponse _handleSelect( @@ -543,7 +543,7 @@ class MockSupabaseHttpClient extends BaseClient { final tableKey = '$schema.$table'; // Handle selecting data from the mock database if (!_database.containsKey(tableKey)) { - return _createResponse([], request: request); + return _createRowsResponse([], request: request); } var returningRows = List>.from(_database[tableKey]!); @@ -740,39 +740,14 @@ class MockSupabaseHttpClient extends BaseClient { final countType = preferHeader.contains('count=exact') ? 'exact' : 'planned'; - return _createResponse(returningRows, request: request, headers: { + return _createRowsResponse(returningRows, request: request, headers: { 'content-range': '$offset-${offset + returningRows.length}/$countValue', 'content-profile': tableKey, 'preference-applied': 'count=$countType' }); } - // Handle single - if (request.headers['Accept'] == 'application/vnd.pgrst.object+json') { - if (returningRows.length == 1) { - return _createResponse(returningRows.first, request: request); - } else { - return _createResponse({ - 'error': '${returningRows.length} rows were found for single query' - }, request: request); - } - } - - // Handle maybeSingle - if (request.headers['Accept'] == 'application/json') { - if (returningRows.isEmpty) { - return _createResponse(null, request: request); - } else if (returningRows.length == 1) { - return _createResponse(returningRows.first, request: request); - } else { - return _createResponse({ - 'error': - '${returningRows.length} rows were found for maybeSingle query' - }, statusCode: 405, request: request); - } - } - - return _createResponse(returningRows, request: request); + return _createRowsResponse(returningRows, request: request); } StreamedResponse _handleHead( @@ -835,6 +810,39 @@ class MockSupabaseHttpClient extends BaseClient { ); } + /// Creates a response body shaped the way PostgREST would shape it for the + /// request's `Accept` header. + /// + /// A request made through `.single()` asks for + /// `application/vnd.pgrst.object+json`, which returns a single object rather + /// than a list, and fails with a 406 when the result does not contain + /// exactly one row. + StreamedResponse _createRowsResponse( + List> rows, { + required BaseRequest request, + Map? headers, + }) { + final accept = request.headers['Accept'] ?? ''; + if (!accept.startsWith('application/vnd.pgrst.object+json')) { + return _createResponse(rows, request: request, headers: headers); + } + if (rows.length == 1) { + return _createResponse(rows.first, request: request, headers: headers); + } + return _createResponse( + { + 'code': 'PGRST116', + 'details': 'Results contain ${rows.length} rows, ' + 'application/vnd.pgrst.object+json requires 1 row', + 'hint': null, + 'message': 'JSON object requested, multiple (or no) rows returned', + }, + statusCode: 406, + request: request, + headers: headers, + ); + } + StreamedResponse _createResponse( dynamic data, { int statusCode = 200, diff --git a/test/mock_supabase_http_client_test.dart b/test/mock_supabase_http_client_test.dart index b2234e2..2367a2b 100644 --- a/test/mock_supabase_http_client_test.dart +++ b/test/mock_supabase_http_client_test.dart @@ -729,6 +729,113 @@ void main() { expect(() => mockSupabase.from('posts').select().maybeSingle(), throwsException); }); + + test('Insert then single', () async { + final post = await mockSupabase + .from('posts') + .insert({'id': 1, 'title': 'First post'}) + .select() + .single(); + expect(post, {'id': 1, 'title': 'First post'}); + }); + + test('Insert then maybeSingle', () async { + final post = await mockSupabase + .from('posts') + .insert({'id': 1, 'title': 'First post'}) + .select() + .maybeSingle(); + expect(post, {'id': 1, 'title': 'First post'}); + }); + + test('Insert multiple rows then single throws', () async { + expect( + () => mockSupabase + .from('posts') + .insert([ + {'id': 1, 'title': 'First post'}, + {'id': 2, 'title': 'Second post'}, + ]) + .select() + .single(), + throwsA(isA() + .having((error) => error.code, 'code', 'PGRST116')), + ); + }); + + test('Upsert then single', () async { + await mockSupabase.from('posts').insert({'id': 1, 'title': 'First post'}); + final post = await mockSupabase + .from('posts') + .upsert({'id': 1, 'title': 'Updated post'}) + .select() + .single(); + expect(post, {'id': 1, 'title': 'Updated post'}); + }); + + test('Update then single', () async { + await mockSupabase.from('posts').insert({'id': 1, 'title': 'First post'}); + final post = await mockSupabase + .from('posts') + .update({'title': 'Updated post'}) + .eq('id', 1) + .select() + .single(); + expect(post, {'id': 1, 'title': 'Updated post'}); + }); + + test('Delete then single', () async { + await mockSupabase.from('posts').insert({'id': 1, 'title': 'First post'}); + final post = await mockSupabase + .from('posts') + .delete() + .eq('id', 1) + .select() + .single(); + expect(post, {'id': 1, 'title': 'First post'}); + }); + + test('Select single with no rows throws', () async { + expect( + () => mockSupabase.from('posts').select().single(), + throwsA(isA() + .having((error) => error.code, 'code', 'PGRST116')), + ); + }); + + test('Delete with no match then maybeSingle returns null', () async { + await mockSupabase.from('posts').insert({'id': 1, 'title': 'First post'}); + final post = await mockSupabase + .from('posts') + .delete() + .eq('id', 2) + .select() + .maybeSingle(); + expect(post, null); + }); + + test('Single with count', () async { + await mockSupabase.from('posts').insert({'id': 1, 'title': 'First post'}); + final response = await mockSupabase + .from('posts') + .select() + .single() + .count(CountOption.exact); + expect(response.data, {'id': 1, 'title': 'First post'}); + expect(response.count, 1); + }); + + test('Select single with multiple rows throws', () async { + await mockSupabase.from('posts').insert([ + {'id': 1, 'title': 'First post'}, + {'id': 2, 'title': 'Second post'}, + ]); + expect( + () => mockSupabase.from('posts').select().single(), + throwsA(isA() + .having((error) => error.code, 'code', 'PGRST116')), + ); + }); }); group('Referenced table queries', () {