Skip to content

Commit 01a6d98

Browse files
richard457tshedor
andauthored
feat: add support for Where().isIn query (#602)
Co-authored-by: Tim Shedor <[email protected]>
1 parent ea1a95d commit 01a6d98

File tree

8 files changed

+172
-29
lines changed

8 files changed

+172
-29
lines changed

packages/brick_core/lib/src/query/where.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ class Where extends WhereCondition {
191191
Where isNot(dynamic value) =>
192192
Where(evaluatedField, value: value, compare: Compare.notEqual, isRequired: isRequired);
193193

194+
/// Convenience function to create a [Where] with [Compare.inIterable].
195+
Where isIn(Iterable<dynamic> values) =>
196+
Where(evaluatedField, value: values, compare: Compare.inIterable, isRequired: isRequired);
197+
194198
/// Recursively find conditions that evaluate a specific field. A field is a member on a model,
195199
/// such as `myUserId` in `final String myUserId`.
196200
/// If the use case for the field only requires one result, say `id` or `primaryKey`,
@@ -329,4 +333,7 @@ enum Compare {
329333

330334
/// The query value does not match the field value.
331335
notEqual,
336+
337+
/// The field value is in the query value iterable.
338+
inIterable,
332339
}

packages/brick_core/test/query/where_test.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ void main() {
6565
const Where('id', value: 1, compare: Compare.notEqual, isRequired: true),
6666
);
6767
});
68+
69+
test('#isIn', () {
70+
expect(
71+
const Where('id').isIn([1, 2, 3]),
72+
const Where('id', value: [1, 2, 3], compare: Compare.inIterable, isRequired: true),
73+
);
74+
});
75+
76+
test('#isIn with String', () {
77+
expect(
78+
const Where('name').isIn(['Alice', 'Bob']),
79+
const Where('name', value: ['Alice', 'Bob'], compare: Compare.inIterable, isRequired: true),
80+
);
81+
});
6882
});
6983

7084
group('.byField', () {

packages/brick_sqlite/lib/src/helpers/query_sql_transformer.dart

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,9 @@ class WhereColumnFragment {
274274
if (condition.compare == Compare.between) {
275275
return _generateBetween();
276276
}
277-
277+
if (condition.compare == Compare.inIterable) {
278+
return _generateInList();
279+
}
278280
return _generateIterable();
279281
}
280282

@@ -323,6 +325,8 @@ class WhereColumnFragment {
323325
return 'BETWEEN';
324326
case Compare.notEqual:
325327
return '!=';
328+
case Compare.inIterable:
329+
return 'IN';
326330
}
327331
}
328332

@@ -347,6 +351,16 @@ class WhereColumnFragment {
347351

348352
return ' $matcher ${wherePrepared.join(' $matcher ')}';
349353
}
354+
355+
String _generateInList() {
356+
final value = condition.value;
357+
if (value is! Iterable || value.isEmpty) {
358+
return '';
359+
}
360+
values.addAll(value.map((v) => sqlifiedValue(v, condition.compare)));
361+
final placeholders = List.filled(value.length, '?').join(', ');
362+
return ' $matcher $column IN ($placeholders)';
363+
}
350364
}
351365

352366
/// Query modifiers such as `LIMIT`, `OFFSET`, etc. that require minimal logic.

packages/brick_sqlite/test/query_sql_transformer_test.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,23 @@ void main() {
171171
await db.rawQuery(sqliteQuery.statement, sqliteQuery.values);
172172
sqliteStatementExpectation(statement, ['%Thomas%']);
173173
});
174+
175+
test('.inIterable', () async {
176+
const statement =
177+
'SELECT DISTINCT `DemoModel`.* FROM `DemoModel` WHERE full_name IN (?, ?)';
178+
final sqliteQuery = QuerySqlTransformer<DemoModel>(
179+
modelDictionary: dictionary,
180+
query: Query(
181+
where: [
182+
const Where('name').isIn(['Thomas', 'Guy']),
183+
],
184+
),
185+
);
186+
187+
expect(sqliteQuery.statement, statement);
188+
await db.rawQuery(sqliteQuery.statement, sqliteQuery.values);
189+
sqliteStatementExpectation(statement, ['Thomas', 'Guy']);
190+
});
174191
});
175192

176193
group('SELECT COUNT', () {

packages/brick_supabase/lib/src/query_supabase_transformer.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,9 @@ class QuerySupabaseTransformer<_Model extends SupabaseModel> {
188188

189189
return [
190190
{
191-
queryKey: '${_compareToSearchParam(condition.compare)}.${condition.value}',
191+
queryKey: condition.compare == Compare.inIterable && condition.value is Iterable
192+
? 'in.(${(condition.value as Iterable).join(',')})'
193+
: '${_compareToSearchParam(condition.compare)}.${condition.value}',
192194
},
193195
...associationConditions,
194196
];
@@ -259,6 +261,8 @@ class QuerySupabaseTransformer<_Model extends SupabaseModel> {
259261
return 'adj';
260262
case Compare.notEqual:
261263
return 'neq';
264+
case Compare.inIterable:
265+
throw ArgumentError('Compare.inIterable is not supported by _compareToSearchParam.');
262266
}
263267
}
264268
}

packages/brick_supabase/lib/src/supabase_provider.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ class SupabaseProvider implements Provider<SupabaseModel> {
6161
return null;
6262
case Compare.doesNotContain:
6363
return null;
64+
case Compare.inIterable:
65+
return null;
6466
}
6567
}
6668

packages/brick_supabase/lib/src/testing/supabase_mock_server.dart

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ class SupabaseMockServer {
4141
/// The stubbed websocket that can be listed to for streams
4242
WebSocket? webSocket;
4343

44+
/// Whether the server request loop has been started
45+
var _serverLoopStarted = false;
46+
47+
/// Active responses used by the running server loop
48+
Map<SupabaseRequest, SupabaseResponse> _activeResponses = const {};
49+
4450
/// An all-in-one mock for Supabase repsonses in unit tests.
4551
SupabaseMockServer({this.apiKey = 'supabaseKey', required this.modelDictionary});
4652

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

6066
await server.close(force: true);
67+
68+
_serverLoopStarted = false;
69+
_activeResponses = const {};
6170
}
6271

6372
/// Invoke within a test block before any calls are made to a Supabase server
6473
// https://github.com/supabase/supabase-flutter/blob/main/packages/supabase/test/mock_test.dart#L21
6574
Future<void> handle(Map<SupabaseRequest, SupabaseResponse> responses) async {
75+
_activeResponses = responses;
76+
77+
if (_serverLoopStarted) return;
78+
_serverLoopStarted = true;
79+
6680
await for (final request in server) {
6781
final url = request.uri.toString();
6882
if (url.startsWith('/rest')) {
69-
final resp = handleRest(request, responses);
83+
final resp = handleRest(request, _activeResponses);
7084
await resp.close();
7185
// Borrowed from
7286
// https://github.com/supabase/supabase-flutter/blob/main/packages/supabase/test/mock_test.dart#L101-L202
7387
} else if (url.startsWith('/realtime')) {
74-
await handleRealtime(request, responses);
88+
await handleRealtime(request, _activeResponses);
7589
}
7690
}
7791
}
@@ -108,32 +122,33 @@ class SupabaseMockServer {
108122
final matching = responses.entries
109123
.firstWhereOrNull((r) => r.key.realtime && realtimeFilter == r.key.filter);
110124

111-
if (matching == null) return;
112-
113-
if (requestJson['payload']['config']['postgres_changes'].first['event'] != '*') {
114-
final replyString = jsonEncode({
115-
'event': 'phx_reply',
116-
'payload': {
117-
'response': {
118-
'postgres_changes': matching.value.flattenedResponses.map((r) {
119-
final data = Map<String, dynamic>.from(r.data as Map);
120-
121-
return {
122-
'id': data['payload']['ids'][0],
123-
'event': data['payload']['data']['type'],
124-
'schema': data['payload']['data']['schema'],
125-
'table': data['payload']['data']['table'],
126-
if (realtimeFilter != null) 'filter': realtimeFilter,
127-
};
128-
}).toList(),
129-
},
130-
'status': 'ok',
125+
// Always acknowledge the join with a phx_reply, regardless of event filter
126+
final replyString = jsonEncode({
127+
'event': 'phx_reply',
128+
'payload': {
129+
'response': {
130+
'postgres_changes': matching == null
131+
? []
132+
: matching.value.flattenedResponses.map((r) {
133+
final data = Map<String, dynamic>.from(r.data as Map);
134+
135+
return {
136+
'id': data['payload']['ids'][0],
137+
'event': data['payload']['data']['type'],
138+
'schema': data['payload']['data']['schema'],
139+
'table': data['payload']['data']['table'],
140+
if (realtimeFilter != null) 'filter': realtimeFilter,
141+
};
142+
}).toList(),
131143
},
132-
'ref': ref,
133-
'topic': topic,
134-
});
135-
webSocket!.add(replyString);
136-
}
144+
'status': 'ok',
145+
},
146+
'ref': ref,
147+
'topic': topic,
148+
});
149+
webSocket!.add(replyString);
150+
151+
if (matching == null) return;
137152

138153
for (final realtimeResponses in matching.value.flattenedResponses) {
139154
await Future.delayed(matching.value.realtimeSubsequentReplyDelay);
@@ -254,5 +269,11 @@ class SupabaseMockServer {
254269
Future<void> setUp() async {
255270
server = await HttpServer.bind('localhost', 0);
256271
client = SupabaseClient(serverUrl, apiKey);
272+
// Ensure the server loop is running so realtime subscriptions can join even if
273+
// tests don't explicitly register responses for realtime.
274+
// This call updates the active responses map and starts the loop once.
275+
// Intentionally not awaited.
276+
// ignore: discarded_futures
277+
handle(const {});
257278
}
258279
}

packages/brick_supabase/test/query_supabase_transformer_test.dart

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ void main() {
7878
expect(select.query, 'select=id,name,custom_age');
7979
});
8080

81+
test(
82+
'inIterable',
83+
() {
84+
final query = Query(
85+
where: [
86+
const Where('name').isIn(['Jens', 'Thomas']),
87+
],
88+
);
89+
final select = _buildTransformer<Demo>(query)
90+
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));
91+
92+
expect(select.query, 'select=id,name,custom_age&name=in.(Jens,Thomas)');
93+
},
94+
);
95+
8196
group('with query', () {
8297
group('eq', () {
8398
test('by field', () {
@@ -149,6 +164,55 @@ void main() {
149164

150165
expect(select.query, 'select=id,name,custom_age&name=not.like.search');
151166
});
167+
168+
test('with non-string values', () {
169+
const query = Query(
170+
where: [
171+
Where('age', value: 30, compare: Compare.lessThan),
172+
Where('id', value: 42, compare: Compare.exact),
173+
],
174+
);
175+
final select = _buildTransformer<Demo>(query)
176+
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));
177+
178+
expect(select.query, 'select=id,name,custom_age&age=lt.30&id=eq.42');
179+
});
180+
181+
test('inIterable with non-string values', () {
182+
final query = Query(
183+
where: [
184+
const Where('id').isIn([1, 2, 3]),
185+
],
186+
);
187+
final select = _buildTransformer<Demo>(query)
188+
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));
189+
190+
expect(select.query, 'select=id,name,custom_age&id=in.(1,2,3)');
191+
});
192+
193+
test('inIterable with string values that might need quoting', () {
194+
final query = Query(
195+
where: [
196+
const Where('name').isIn(['John Doe', 'Jane Smith']),
197+
],
198+
);
199+
final select = _buildTransformer<Demo>(query)
200+
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));
201+
202+
expect(select.query, 'select=id,name,custom_age&name=in.(John Doe,Jane Smith)');
203+
});
204+
205+
test('inIterable with empty list', () {
206+
final query = Query(
207+
where: [
208+
const Where('id').isIn([]),
209+
],
210+
);
211+
final select = _buildTransformer<Demo>(query)
212+
.select(_supabaseClient.from(DemoAdapter().supabaseTableName));
213+
214+
expect(select.query, 'select=id,name,custom_age&id=in.()');
215+
});
152216
});
153217
});
154218

0 commit comments

Comments
 (0)