Skip to content

Commit 7442f76

Browse files
authored
Merge pull request #6 from supabase-community/feat/count
feat: Add support for count and select with count.
2 parents 4fe0fb8 + e9bd77f commit 7442f76

File tree

2 files changed

+171
-8
lines changed

2 files changed

+171
-8
lines changed

lib/src/mock_supabase_http_client.dart

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ class MockSupabaseHttpClient extends BaseClient {
2222
);
2323

2424
// Decode the request body if it's not a GET request
25-
final body = (request.method != 'GET' && request.method != 'DELETE') &&
25+
final body = (request.method != 'GET' &&
26+
request.method != 'DELETE' &&
27+
request.method != 'HEAD') &&
2628
request is Request
2729
? jsonDecode(await request.finalize().transform(utf8.decoder).join())
2830
: null;
@@ -43,6 +45,8 @@ class MockSupabaseHttpClient extends BaseClient {
4345
return _handleDelete(tableName, body, request);
4446
case 'GET':
4547
return _handleSelect(tableName, request.url.queryParameters, request);
48+
case 'HEAD':
49+
return _handleHead(tableName, request.url.queryParameters, request);
4650
default:
4751
return _createResponse({'error': 'Method not allowed'},
4852
statusCode: 405, request: request);
@@ -219,7 +223,10 @@ class MockSupabaseHttpClient extends BaseClient {
219223
}
220224

221225
StreamedResponse _handleSelect(
222-
String tableName, Map<String, String> queryParams, BaseRequest request) {
226+
String tableName,
227+
Map<String, String> queryParams,
228+
BaseRequest request,
229+
) {
223230
// Handle selecting data from the mock database
224231
if (!_database.containsKey(tableName)) {
225232
return _createResponse([], request: request);
@@ -283,6 +290,9 @@ class MockSupabaseHttpClient extends BaseClient {
283290
}
284291
});
285292

293+
// Get the count value before any limiting
294+
final countValue = returningRows.length;
295+
286296
// Handle top level table ordering
287297
if (queryParams.containsKey('order')) {
288298
final orderParams = queryParams['order']!.split('.');
@@ -316,9 +326,12 @@ class MockSupabaseHttpClient extends BaseClient {
316326
}).toList();
317327
});
318328

329+
final offset = queryParams.containsKey('offset')
330+
? int.parse(queryParams['offset']!)
331+
: 0;
332+
319333
// Handle top level table offset
320-
if (queryParams.containsKey('offset')) {
321-
final offset = int.parse(queryParams['offset']!);
334+
if (offset > 0) {
322335
returningRows = returningRows.skip(offset).toList();
323336
}
324337

@@ -395,13 +408,28 @@ class MockSupabaseHttpClient extends BaseClient {
395408
// Handle top level column selection
396409
if (!selectedColumns.contains('*')) {
397410
returningRows = returningRows.map((row) {
398-
print(row);
399411
return Map<String, dynamic>.fromEntries(row.entries
400412
.where((entry) => selectedColumns.contains(entry.key)));
401413
}).toList();
402414
}
403415
}
404416

417+
// Handle count
418+
final preferHeader = request.headers['Prefer'];
419+
final isCountRequest =
420+
preferHeader != null && preferHeader.contains('count=');
421+
422+
if (isCountRequest) {
423+
final countType =
424+
preferHeader.contains('count=exact') ? 'exact' : 'planned';
425+
426+
return _createResponse(returningRows, request: request, headers: {
427+
'content-range': '$offset-${offset + returningRows.length}/$countValue',
428+
'content-profile': tableName,
429+
'preference-applied': 'count=$countType'
430+
});
431+
}
432+
405433
// Handle single
406434
if (request.headers['Accept'] == 'application/vnd.pgrst.object+json') {
407435
if (returningRows.length == 1) {
@@ -430,6 +458,61 @@ class MockSupabaseHttpClient extends BaseClient {
430458
return _createResponse(returningRows, request: request);
431459
}
432460

461+
StreamedResponse _handleHead(
462+
String tableName, Map<String, String> queryParams, BaseRequest request) {
463+
// Perform the same filtering as in _handleSelect
464+
var returningRows =
465+
List<Map<String, dynamic>>.from(_database[tableName] ?? []);
466+
467+
// Apply filters (you may want to extract this to a separate method)
468+
queryParams.forEach((key, value) {
469+
if (key != 'select' &&
470+
key != 'order' &&
471+
key != 'limit' &&
472+
key != 'range') {
473+
final filter = _parseFilter(
474+
columnName: key,
475+
postrestFilter: value,
476+
targetRow: returningRows.isNotEmpty ? returningRows.first : {},
477+
);
478+
returningRows = returningRows.where((item) => filter(item)).toList();
479+
}
480+
});
481+
482+
// Handle count
483+
final preferHeader = request.headers['Prefer'];
484+
final isCountRequest =
485+
preferHeader != null && preferHeader.contains('count=');
486+
487+
if (isCountRequest) {
488+
final count = returningRows.length;
489+
final countType =
490+
preferHeader.contains('count=exact') ? 'exact' : 'planned';
491+
492+
// Return only headers for HEAD request
493+
return StreamedResponse(
494+
Stream.value([]), // Empty body for HEAD request
495+
200,
496+
headers: {
497+
'content-range': '0-$count/$count',
498+
'content-profile': tableName,
499+
'preference-applied': 'count=$countType'
500+
},
501+
request: request,
502+
);
503+
}
504+
505+
// If it's not a count request, return basic headers
506+
return StreamedResponse(
507+
Stream.value([]), // Empty body for HEAD request
508+
200,
509+
headers: {
510+
'content-profile': tableName,
511+
},
512+
request: request,
513+
);
514+
}
515+
433516
bool Function(Map<String, dynamic> row) _parseFilter({
434517
required String columnName,
435518
required String postrestFilter,
@@ -530,12 +613,18 @@ class MockSupabaseHttpClient extends BaseClient {
530613
}
531614

532615
StreamedResponse _createResponse(dynamic data,
533-
{int statusCode = 200, required BaseRequest request}) {
534-
// Create a response for the mock client
616+
{int statusCode = 200,
617+
required BaseRequest request,
618+
Map<String, String>? headers}) {
619+
final responseHeaders = {
620+
'content-type': 'application/json; charset=utf-8',
621+
...?headers,
622+
};
623+
535624
return StreamedResponse(
536625
Stream.value(utf8.encode(jsonEncode(data))),
537626
statusCode,
538-
headers: {'content-type': 'application/json; charset=utf-8'},
627+
headers: responseHeaders,
539628
request: request,
540629
);
541630
}

test/mock_supabase_test.dart

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,17 @@ void main() {
503503
expect(posts.length, 1);
504504
});
505505

506+
test('limit with a filter', () async {
507+
await mockSupabase.from('posts').insert([
508+
{'id': 1, 'title': 'First post', 'author_id': 1},
509+
{'id': 2, 'title': 'Second post', 'author_id': 2},
510+
{'id': 3, 'title': 'Third post', 'author_id': 1}
511+
]);
512+
final posts =
513+
await mockSupabase.from('posts').select().eq('author_id', 1).limit(1);
514+
expect(posts.length, 1);
515+
});
516+
506517
test('Order', () async {
507518
await mockSupabase.from('posts').insert([
508519
{'id': 1, 'title': 'First post'},
@@ -648,6 +659,69 @@ void main() {
648659
});
649660
});
650661

662+
group('count', () {
663+
test('count', () async {
664+
await mockSupabase.from('posts').insert([
665+
{'title': 'First post'},
666+
{'title': 'Second post'}
667+
]);
668+
669+
final count = await mockSupabase.from('posts').count();
670+
expect(count, 2);
671+
});
672+
673+
test('count with data', () async {
674+
await mockSupabase.from('posts').insert([
675+
{'title': 'First post'},
676+
{'title': 'Second post'}
677+
]);
678+
final response = await mockSupabase.from('posts').select().count();
679+
expect(response.data.length, 2);
680+
expect(response.data.first['title'], 'First post');
681+
expect(response.count, 2);
682+
});
683+
684+
test('count with filter', () async {
685+
await mockSupabase.from('posts').insert([
686+
{'title': 'First post', 'author_id': 1},
687+
{'title': 'Second post', 'author_id': 2},
688+
{'title': 'Third post', 'author_id': 1}
689+
]);
690+
final count = await mockSupabase.from('posts').count().eq('author_id', 1);
691+
expect(count, 2);
692+
});
693+
694+
test('count with data and filter', () async {
695+
await mockSupabase.from('posts').insert([
696+
{'title': 'First post', 'author_id': 1},
697+
{'title': 'Second post', 'author_id': 2},
698+
{'title': 'Third post', 'author_id': 1}
699+
]);
700+
final response =
701+
await mockSupabase.from('posts').select().eq('author_id', 1).count();
702+
expect(response.data.length, 2);
703+
expect(response.data.first['title'], 'First post');
704+
expect(response.count, 2);
705+
});
706+
707+
test('count with filter and modifier', () async {
708+
await mockSupabase.from('posts').insert([
709+
{'title': 'First post', 'author_id': 1},
710+
{'title': 'Second post', 'author_id': 2},
711+
{'title': 'Third post', 'author_id': 1}
712+
]);
713+
final response = await mockSupabase
714+
.from('posts')
715+
.select()
716+
.eq('author_id', 1)
717+
.limit(1)
718+
.count();
719+
expect(response.data.length, 1);
720+
expect(response.data.first['title'], 'First post');
721+
expect(response.count, 2);
722+
});
723+
});
724+
651725
group('non-ASCII characters tests', () {
652726
test('Insert Japanese text', () async {
653727
await mockSupabase.from('posts').insert({'title': 'こんにちは'});

0 commit comments

Comments
 (0)