Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f6620e0
add support for Where().isIn query
richard457 Jun 20, 2025
a525b6a
support isIn for supabase and sqlite
richard457 Jun 20, 2025
9919814
...
richard457 Jun 20, 2025
eb2b7a0
address empty list query
richard457 Jun 20, 2025
d666ea6
address empty list query
richard457 Jun 20, 2025
4508776
address empty list query for supabase
richard457 Jun 20, 2025
5df6b00
fix quote issue
richard457 Jun 20, 2025
70472a9
address non-iterable on supabase query
richard457 Jun 20, 2025
9a26b54
fix but in sql transformer
richard457 Jun 20, 2025
ad90bff
fix but in sql transformer
richard457 Jun 20, 2025
1de9369
...
richard457 Jun 20, 2025
87e743d
remove unncessary code used for debug
richard457 Jun 20, 2025
3e41db6
remove unncessary code used for debug
richard457 Jun 20, 2025
15380d9
address the feedback and more tests
richard457 Jun 22, 2025
60f6676
more cleanups
richard457 Jun 22, 2025
cc07027
more cleanups
richard457 Jun 22, 2025
504ea6c
remove example for supabse build, it should be in separatePR
richard457 Jul 1, 2025
00744ba
remove // import 'package:analyzer/dart/element/element.dart';, shoul…
richard457 Jul 1, 2025
ff3d8ce
Update packages/brick_sqlite/lib/src/helpers/query_sql_transformer.dart
richard457 Jul 1, 2025
ff56609
Merge branch 'main' into main
richard457 Jul 10, 2025
40a9147
address coments from review
richard457 Jul 10, 2025
88e38a9
remove unintended changes done while experiment
richard457 Jul 10, 2025
8031ed1
remove unintended changes done while experiment
richard457 Jul 10, 2025
962c96b
avoid generating wrong supabase query, 501 postgress error
richard457 Jul 10, 2025
0f7a7cf
Compare.inIterable is not supported by _compareToSearchParam.
richard457 Jul 10, 2025
cf82bd6
revert @Query transformer the error reported by supabase might not be…
richard457 Jul 10, 2025
1cb5733
addressing comments
richard457 Jul 11, 2025
b470ded
chore: accept suggestion
richard457 Aug 15, 2025
653a086
fix(supabase_mock_server): always ack realtime join; start server loo…
richard457 Aug 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/brick_core/lib/src/query/where.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ class Where extends WhereCondition {
Where isNot(dynamic value) =>
Where(evaluatedField, value: value, compare: Compare.notEqual, isRequired: isRequired);

/// Convenience function to create a [Where] with [Compare.inIterable].
Where isIn(Iterable<dynamic> values) =>
Where(evaluatedField, value: values, compare: Compare.inIterable, isRequired: isRequired);

/// Recursively find conditions that evaluate a specific field. A field is a member on a model,
/// such as `myUserId` in `final String myUserId`.
/// If the use case for the field only requires one result, say `id` or `primaryKey`,
Expand Down Expand Up @@ -329,4 +333,7 @@ enum Compare {

/// The query value does not match the field value.
notEqual,

/// The field value is in the query value iterable.
inIterable,
}
14 changes: 14 additions & 0 deletions packages/brick_core/test/query/where_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ void main() {
const Where('id', value: 1, compare: Compare.notEqual, isRequired: true),
);
});

test('#isIn', () {
expect(
const Where('id').isIn([1, 2, 3]),
const Where('id', value: [1, 2, 3], compare: Compare.inIterable, isRequired: true),
);
});

test('#isIn with String', () {
expect(
const Where('name').isIn(['Alice', 'Bob']),
const Where('name', value: ['Alice', 'Bob'], compare: Compare.inIterable, isRequired: true),
);
});
});

group('.byField', () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ class WhereColumnFragment {
if (condition.compare == Compare.between) {
return _generateBetween();
}

if (condition.compare == Compare.inIterable) {
return _generateInList();
}
return _generateIterable();
}

Expand Down Expand Up @@ -323,6 +325,8 @@ class WhereColumnFragment {
return 'BETWEEN';
case Compare.notEqual:
return '!=';
case Compare.inIterable:
return 'IN';
}
}

Expand All @@ -347,6 +351,16 @@ class WhereColumnFragment {

return ' $matcher ${wherePrepared.join(' $matcher ')}';
}

String _generateInList() {
final value = condition.value;
if (value is! Iterable || value.isEmpty) {
return '';
}
values.addAll(value.map((v) => sqlifiedValue(v, condition.compare)));
final placeholders = List.filled(value.length, '?').join(', ');
return ' $matcher $column IN ($placeholders)';
}
}

/// Query modifiers such as `LIMIT`, `OFFSET`, etc. that require minimal logic.
Expand Down
17 changes: 17 additions & 0 deletions packages/brick_sqlite/test/query_sql_transformer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,23 @@ void main() {
await db.rawQuery(sqliteQuery.statement, sqliteQuery.values);
sqliteStatementExpectation(statement, ['%Thomas%']);
});

test('.inIterable', () async {
const statement =
'SELECT DISTINCT `DemoModel`.* FROM `DemoModel` WHERE full_name IN (?, ?)';
final sqliteQuery = QuerySqlTransformer<DemoModel>(
modelDictionary: dictionary,
query: Query(
where: [
const Where('name').isIn(['Thomas', 'Guy']),
],
),
);

expect(sqliteQuery.statement, statement);
await db.rawQuery(sqliteQuery.statement, sqliteQuery.values);
sqliteStatementExpectation(statement, ['Thomas', 'Guy']);
});
});

group('SELECT COUNT', () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ class QuerySupabaseTransformer<_Model extends SupabaseModel> {

return [
{
queryKey: '${_compareToSearchParam(condition.compare)}.${condition.value}',
queryKey: condition.compare == Compare.inIterable && condition.value is Iterable
? 'in.(${(condition.value as Iterable).join(',')})'
: '${_compareToSearchParam(condition.compare)}.${condition.value}',
},
...associationConditions,
];
Expand Down Expand Up @@ -259,6 +261,8 @@ class QuerySupabaseTransformer<_Model extends SupabaseModel> {
return 'adj';
case Compare.notEqual:
return 'neq';
case Compare.inIterable:
throw ArgumentError('Compare.inIterable is not supported by _compareToSearchParam.');
}
}
}
2 changes: 2 additions & 0 deletions packages/brick_supabase/lib/src/supabase_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class SupabaseProvider implements Provider<SupabaseModel> {
return null;
case Compare.doesNotContain:
return null;
case Compare.inIterable:
return null;
}
}

Expand Down
75 changes: 48 additions & 27 deletions packages/brick_supabase/lib/src/testing/supabase_mock_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ class SupabaseMockServer {
/// The stubbed websocket that can be listed to for streams
WebSocket? webSocket;

/// Whether the server request loop has been started
var _serverLoopStarted = false;

/// Active responses used by the running server loop
Map<SupabaseRequest, SupabaseResponse> _activeResponses = const {};

/// An all-in-one mock for Supabase repsonses in unit tests.
SupabaseMockServer({this.apiKey = 'supabaseKey', required this.modelDictionary});

Expand All @@ -58,20 +64,28 @@ class SupabaseMockServer {
await webSocket?.close();

await server.close(force: true);

_serverLoopStarted = false;
_activeResponses = const {};
}

/// Invoke within a test block before any calls are made to a Supabase server
// https://github.com/supabase/supabase-flutter/blob/main/packages/supabase/test/mock_test.dart#L21
Future<void> handle(Map<SupabaseRequest, SupabaseResponse> responses) async {
_activeResponses = responses;

if (_serverLoopStarted) return;
_serverLoopStarted = true;

await for (final request in server) {
final url = request.uri.toString();
if (url.startsWith('/rest')) {
final resp = handleRest(request, responses);
final resp = handleRest(request, _activeResponses);
await resp.close();
// Borrowed from
// https://github.com/supabase/supabase-flutter/blob/main/packages/supabase/test/mock_test.dart#L101-L202
} else if (url.startsWith('/realtime')) {
await handleRealtime(request, responses);
await handleRealtime(request, _activeResponses);
}
}
}
Expand Down Expand Up @@ -108,32 +122,33 @@ class SupabaseMockServer {
final matching = responses.entries
.firstWhereOrNull((r) => r.key.realtime && realtimeFilter == r.key.filter);

if (matching == null) return;

if (requestJson['payload']['config']['postgres_changes'].first['event'] != '*') {
final replyString = jsonEncode({
'event': 'phx_reply',
'payload': {
'response': {
'postgres_changes': matching.value.flattenedResponses.map((r) {
final data = Map<String, dynamic>.from(r.data as Map);

return {
'id': data['payload']['ids'][0],
'event': data['payload']['data']['type'],
'schema': data['payload']['data']['schema'],
'table': data['payload']['data']['table'],
if (realtimeFilter != null) 'filter': realtimeFilter,
};
}).toList(),
},
'status': 'ok',
// Always acknowledge the join with a phx_reply, regardless of event filter
final replyString = jsonEncode({
'event': 'phx_reply',
'payload': {
'response': {
'postgres_changes': matching == null
? []
: matching.value.flattenedResponses.map((r) {
final data = Map<String, dynamic>.from(r.data as Map);

return {
'id': data['payload']['ids'][0],
'event': data['payload']['data']['type'],
'schema': data['payload']['data']['schema'],
'table': data['payload']['data']['table'],
if (realtimeFilter != null) 'filter': realtimeFilter,
};
}).toList(),
},
'ref': ref,
'topic': topic,
});
webSocket!.add(replyString);
}
'status': 'ok',
},
'ref': ref,
'topic': topic,
});
webSocket!.add(replyString);

if (matching == null) return;

for (final realtimeResponses in matching.value.flattenedResponses) {
await Future.delayed(matching.value.realtimeSubsequentReplyDelay);
Expand Down Expand Up @@ -254,5 +269,11 @@ class SupabaseMockServer {
Future<void> setUp() async {
server = await HttpServer.bind('localhost', 0);
client = SupabaseClient(serverUrl, apiKey);
// Ensure the server loop is running so realtime subscriptions can join even if
// tests don't explicitly register responses for realtime.
// This call updates the active responses map and starts the loop once.
// Intentionally not awaited.
// ignore: discarded_futures
handle(const {});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ void main() {
expect(select.query, 'select=id,name,custom_age');
});

test(
'inIterable',
() {
final query = Query(
where: [
const Where('name').isIn(['Jens', 'Thomas']),
],
);
final select = _buildTransformer<Demo>(query)
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));

expect(select.query, 'select=id,name,custom_age&name=in.(Jens,Thomas)');
},
);

group('with query', () {
group('eq', () {
test('by field', () {
Expand Down Expand Up @@ -149,6 +164,55 @@ void main() {

expect(select.query, 'select=id,name,custom_age&name=not.like.search');
});

test('with non-string values', () {
const query = Query(
where: [
Where('age', value: 30, compare: Compare.lessThan),
Where('id', value: 42, compare: Compare.exact),
],
);
final select = _buildTransformer<Demo>(query)
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));

expect(select.query, 'select=id,name,custom_age&age=lt.30&id=eq.42');
});

test('inIterable with non-string values', () {
final query = Query(
where: [
const Where('id').isIn([1, 2, 3]),
],
);
final select = _buildTransformer<Demo>(query)
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));

expect(select.query, 'select=id,name,custom_age&id=in.(1,2,3)');
});

test('inIterable with string values that might need quoting', () {
final query = Query(
where: [
const Where('name').isIn(['John Doe', 'Jane Smith']),
],
);
final select = _buildTransformer<Demo>(query)
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));

expect(select.query, 'select=id,name,custom_age&name=in.(John Doe,Jane Smith)');
});

test('inIterable with empty list', () {
final query = Query(
where: [
const Where('id').isIn([]),
],
);
final select = _buildTransformer<Demo>(query)
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));

expect(select.query, 'select=id,name,custom_age&id=in.()');
});
});
});

Expand Down