From 6ddc1e27eb6d820afd415729c88096f6ce58e348 Mon Sep 17 00:00:00 2001 From: ibrahim-iqbal Date: Sun, 26 Jul 2026 17:08:06 +0530 Subject: [PATCH 1/2] feat: add Dynamic Search Rules endpoints (#495) Add SDK support for the experimental Dynamic Search Rules API introduced in Meilisearch v1.50.0. The endpoints require the `dynamicSearchRules` experimental feature flag to be enabled on the server. New client methods on `MeiliSearchClient`: - `listDynamicSearchRules({params})` -> Result - `getDynamicSearchRule(uid)` -> DynamicSearchRule - `updateOrCreateDynamicSearchRule(uid, rule)` -> Task (async) - `deleteDynamicSearchRule(uid)` -> Task (async) Design notes: - `DynamicSearchRule.conditions` and `.actions` are exposed as raw maps/lists so the SDK keeps working when the server adds sub-fields without a coordinated SDK release. Consumers wanting strongly typed access can wrap them at their layer. - `toUpsertBody()` is sparse (nulls omitted) so PATCH acts as a real partial upsert. - `DynamicSearchRulesQuery` mirrors the same sparse-body pattern for `POST /dynamic-search-rules`. Tests: 9 serialization / query-body unit tests covering full-response parsing, sparse responses, unparseable timestamps, and body-shape round-tripping. Integration tests are intentionally not added: the endpoints are experimental and require a server-side feature flag toggle that isn't in the current test setup. Code samples `list_dynamic_search_rules_1`, `get_dynamic_search_rule_1`, `patch_dynamic_search_rule_1`, `delete_dynamic_search_rule_1` added to `.code-samples.meilisearch.yaml` matching the keys used by the Meilisearch documentation site. --- .code-samples.meilisearch.yaml | 4 + lib/src/client.dart | 64 ++++++++ lib/src/query_parameters/_exports.dart | 1 + .../dynamic_search_rules_query.dart | 31 ++++ lib/src/results/_exports.dart | 1 + lib/src/results/dynamic_search_rule.dart | 86 ++++++++++ ...namic_search_rules_serialization_test.dart | 154 ++++++++++++++++++ 7 files changed, 341 insertions(+) create mode 100644 lib/src/query_parameters/dynamic_search_rules_query.dart create mode 100644 lib/src/results/dynamic_search_rule.dart create mode 100644 test/dynamic_search_rules_serialization_test.dart diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 453199a..8cef79b 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -249,3 +249,7 @@ get_non_separator_tokens_1: await client.index('articles').getNonSeparatorTokens update_non_separator_tokens_1: "await client.index('articles').updateNonSeparatorTokens([\"@\", \"#\"]);" reset_non_separator_tokens_1: await client.index('articles').resetNonSeparatorTokens(); export_post_1: "await client.export(\n ExportQuery(\n url: exportSinkUrl,\n apiKey: 'new_instance_api_key',\n payloadSize: \"100 MiB\",\n ),\n);" +list_dynamic_search_rules_1: "await client.listDynamicSearchRules();" +get_dynamic_search_rule_1: "await client.getDynamicSearchRule('black-friday');" +patch_dynamic_search_rule_1: "await client.updateOrCreateDynamicSearchRule(\n 'black-friday',\n DynamicSearchRule(\n uid: 'black-friday',\n description: 'Black Friday 2025 rules',\n priority: 10,\n active: true,\n conditions: {\n 'query': {'isEmpty': true},\n 'time': {\n 'start': '2025-11-28T00:00:00Z',\n 'end': '2025-11-28T23:59:59Z',\n },\n },\n actions: >[\n {\n 'selector': {'indexUid': 'products', 'id': '123'},\n 'action': {'type': 'pin', 'position': 1},\n },\n ],\n ),\n);" +delete_dynamic_search_rule_1: "await client.deleteDynamicSearchRule('black-friday');" diff --git a/lib/src/client.dart b/lib/src/client.dart index e2d430a..cbf68a2 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -366,4 +366,68 @@ class MeiliSearchClient { ), ); } + + // + // Dynamic Search Rules endpoints (experimental, Meilisearch v1.50.0+) + // + // These endpoints require the `dynamicSearchRules` experimental feature + // flag to be enabled on the server, e.g.: + // + // await client.http.updateExperimentalFeatures( + // const UpdateExperimentalFeatures(dynamicSearchRules: true), + // ); + // + + /// Lists Dynamic Search Rules. The server exposes this as a POST so the + /// filter expression can travel in the body; pass [params] to paginate + /// or filter, or omit it to list rules with the server's default paging. + @RequiredMeiliServerVersion('1.50.0') + Future> listDynamicSearchRules({ + DynamicSearchRulesQuery? params, + }) async { + final response = await http.postMethod>( + '/dynamic-search-rules', + data: params?.toBody() ?? const {}, + ); + + return Result.fromMapWithType( + response.data!, + (model) => DynamicSearchRule.fromJson(model), + ); + } + + /// Retrieves a single Dynamic Search Rule by its [uid]. + @RequiredMeiliServerVersion('1.50.0') + Future getDynamicSearchRule(String uid) async { + final response = await http.getMethod>( + '/dynamic-search-rules/$uid', + ); + + return DynamicSearchRule.fromJson(response.data!); + } + + /// Creates a new Dynamic Search Rule with the given [uid], or updates + /// the existing one (upsert). Returns the async [Task] the server + /// enqueues to apply the change. + @RequiredMeiliServerVersion('1.50.0') + Future updateOrCreateDynamicSearchRule( + String uid, + DynamicSearchRule rule, + ) { + return _update( + http.patchMethod>( + '/dynamic-search-rules/$uid', + data: rule.toUpsertBody(), + ), + ); + } + + /// Deletes the Dynamic Search Rule identified by [uid]. Returns the + /// async [Task] the server enqueues to apply the deletion. + @RequiredMeiliServerVersion('1.50.0') + Future deleteDynamicSearchRule(String uid) { + return _update( + http.deleteMethod>('/dynamic-search-rules/$uid'), + ); + } } diff --git a/lib/src/query_parameters/_exports.dart b/lib/src/query_parameters/_exports.dart index 13cf739..752aece 100644 --- a/lib/src/query_parameters/_exports.dart +++ b/lib/src/query_parameters/_exports.dart @@ -13,3 +13,4 @@ export 'facet_search_query.dart'; export 'swap_index.dart'; export 'hybrid_search.dart'; export 'export_query.dart'; +export 'dynamic_search_rules_query.dart'; diff --git a/lib/src/query_parameters/dynamic_search_rules_query.dart b/lib/src/query_parameters/dynamic_search_rules_query.dart new file mode 100644 index 0000000..4f252d7 --- /dev/null +++ b/lib/src/query_parameters/dynamic_search_rules_query.dart @@ -0,0 +1,31 @@ +/// Body for `POST /dynamic-search-rules`. +/// +/// The list endpoint is a POST rather than a GET so callers can send an +/// arbitrary filter expression in the body. All fields are optional; an +/// empty body lists every rule with the server's default pagination. +class DynamicSearchRulesQuery { + /// Zero-based offset into the rule list. + final int? offset; + + /// Maximum number of rules to return in a single response. + final int? limit; + + /// Optional filter expression matching the same grammar as Meilisearch's + /// task and document filters (e.g. `active = true AND priority > 5`). + final String? filter; + + const DynamicSearchRulesQuery({ + this.offset, + this.limit, + this.filter, + }); + + /// Serializes this query to the JSON body sent to + /// `POST /dynamic-search-rules`. Nulls are omitted so callers only send + /// what they set. + Map toBody() => { + if (offset != null) 'offset': offset, + if (limit != null) 'limit': limit, + if (filter != null) 'filter': filter, + }; +} diff --git a/lib/src/results/_exports.dart b/lib/src/results/_exports.dart index 9503c21..9d1455c 100644 --- a/lib/src/results/_exports.dart +++ b/lib/src/results/_exports.dart @@ -14,4 +14,5 @@ export 'all_stats.dart'; export 'network.dart'; export 'facet_stat.dart'; export 'document_container.dart'; +export 'dynamic_search_rule.dart'; export 'ranking_rules/_exports.dart'; diff --git a/lib/src/results/dynamic_search_rule.dart b/lib/src/results/dynamic_search_rule.dart new file mode 100644 index 0000000..08f2811 --- /dev/null +++ b/lib/src/results/dynamic_search_rule.dart @@ -0,0 +1,86 @@ +/// A Dynamic Search Rule as returned by the +/// `GET /dynamic-search-rules/{uid}` and `POST /dynamic-search-rules` +/// endpoints introduced in Meilisearch v1.50.0 (experimental). +/// +/// The rule shape follows the [Meilisearch reference](https://www.meilisearch.com/docs/capabilities/search_rules/overview). +/// `conditions` and `actions` are exposed as raw maps/lists so the SDK +/// keeps working when new sub-fields are added on the server side without a +/// coordinated release. Callers that want strongly typed access can wrap +/// them at their layer. +class DynamicSearchRule { + /// The rule's unique identifier (path parameter for the single-rule + /// endpoints). + final String uid; + + /// Human-readable description. + final String? description; + + /// Higher-priority rules apply first when several rules match. + final int? priority; + + /// Whether the rule is currently active. + final bool? active; + + /// Condition tree that decides when the rule fires (query patterns, + /// time windows, etc.). Exposed as a raw map so consumers see every + /// field the server sends, including ones added after this SDK ships. + final Map? conditions; + + /// Ordered list of actions the rule performs when it fires (typically + /// document pinning). Same raw-map rationale as [conditions]. + final List>? actions; + + /// ISO-8601 timestamp; present when the server returns it. + final DateTime? createdAt; + + /// ISO-8601 timestamp; present when the server returns it. + final DateTime? updatedAt; + + const DynamicSearchRule({ + required this.uid, + this.description, + this.priority, + this.active, + this.conditions, + this.actions, + this.createdAt, + this.updatedAt, + }); + + factory DynamicSearchRule.fromJson(Map json) { + final actionsRaw = json['actions']; + final createdAtRaw = json['createdAt']; + final updatedAtRaw = json['updatedAt']; + final conditionsRaw = json['conditions']; + return DynamicSearchRule( + uid: json['uid'] as String? ?? '', + description: json['description'] as String?, + priority: json['priority'] as int?, + active: json['active'] as bool?, + conditions: conditionsRaw is Map + ? Map.from(conditionsRaw) + : null, + actions: actionsRaw is Iterable + ? actionsRaw + .whereType>() + .map(Map.from) + .toList() + : null, + createdAt: + createdAtRaw is String ? DateTime.tryParse(createdAtRaw) : null, + updatedAt: + updatedAtRaw is String ? DateTime.tryParse(updatedAtRaw) : null, + ); + } + + /// Body used when upserting a rule via + /// `PATCH /dynamic-search-rules/{uid}`. Fields that are null on this + /// instance are omitted so callers can send sparse updates. + Map toUpsertBody() => { + if (description != null) 'description': description, + if (priority != null) 'priority': priority, + if (active != null) 'active': active, + if (conditions != null) 'conditions': conditions, + if (actions != null) 'actions': actions, + }; +} diff --git a/test/dynamic_search_rules_serialization_test.dart b/test/dynamic_search_rules_serialization_test.dart new file mode 100644 index 0000000..3d7fda3 --- /dev/null +++ b/test/dynamic_search_rules_serialization_test.dart @@ -0,0 +1,154 @@ +import 'package:meilisearch/meilisearch.dart'; +import 'package:test/test.dart'; + +// Serialization / query-body coverage for the Dynamic Search Rules +// endpoints introduced in Meilisearch v1.50.0. These tests intentionally +// don't touch the network — an experimental server-side flag is required +// to exercise the endpoints against a live Meilisearch, and the API shape +// is what breaks first when the server evolves. + +void main() { + group('DynamicSearchRule.fromJson', () { + test('parses every documented field including nested conditions/actions', + () { + final json = { + 'uid': 'black-friday', + 'description': 'Black Friday 2025 rules', + 'priority': 10, + 'active': true, + 'conditions': { + 'query': {'isEmpty': true}, + 'time': { + 'start': '2025-11-28T00:00:00Z', + 'end': '2025-11-28T23:59:59Z', + }, + }, + 'actions': [ + { + 'selector': { + 'indexUid': 'products', + 'id': '123', + }, + 'action': { + 'type': 'pin', + 'position': 1, + }, + }, + ], + 'createdAt': '2025-11-01T12:34:56Z', + 'updatedAt': '2025-11-02T10:00:00Z', + }; + + final rule = DynamicSearchRule.fromJson(json); + + expect(rule.uid, 'black-friday'); + expect(rule.description, 'Black Friday 2025 rules'); + expect(rule.priority, 10); + expect(rule.active, isTrue); + + expect(rule.conditions, isNotNull); + final query = rule.conditions!['query'] as Map; + expect(query['isEmpty'], isTrue); + + expect(rule.actions, isNotNull); + expect(rule.actions!.length, 1); + final firstAction = rule.actions!.first; + final selector = firstAction['selector'] as Map; + expect(selector['indexUid'], 'products'); + final action = firstAction['action'] as Map; + expect(action['type'], 'pin'); + expect(action['position'], 1); + + expect(rule.createdAt, DateTime.parse('2025-11-01T12:34:56Z')); + expect(rule.updatedAt, DateTime.parse('2025-11-02T10:00:00Z')); + }); + + test('handles a sparse response without exploding', () { + final rule = DynamicSearchRule.fromJson( + const {'uid': 'minimal'}, + ); + + expect(rule.uid, 'minimal'); + expect(rule.description, isNull); + expect(rule.priority, isNull); + expect(rule.active, isNull); + expect(rule.conditions, isNull); + expect(rule.actions, isNull); + expect(rule.createdAt, isNull); + expect(rule.updatedAt, isNull); + }); + + test('accepts an unparseable timestamp by leaving it null (not throwing)', + () { + final rule = DynamicSearchRule.fromJson({ + 'uid': 'x', + 'createdAt': 'not-a-date', + }); + + expect(rule.createdAt, isNull); + }); + }); + + group('DynamicSearchRule.toUpsertBody', () { + test('emits only the fields the caller set (sparse PATCH)', () { + final rule = DynamicSearchRule( + uid: 'ignored-when-upserting', + priority: 5, + ); + + final body = rule.toUpsertBody(); + + expect(body.keys, unorderedEquals(['priority'])); + expect(body['priority'], 5); + }); + + test('serializes conditions and actions verbatim', () { + final rule = DynamicSearchRule( + uid: 'x', + description: 'test', + active: true, + conditions: const { + 'query': {'isEmpty': true}, + }, + actions: const >[ + { + 'selector': {'indexUid': 'i', 'id': '1'}, + 'action': {'type': 'pin', 'position': 1}, + }, + ], + ); + + final body = rule.toUpsertBody(); + + expect(body['description'], 'test'); + expect(body['active'], isTrue); + expect(body['conditions'], isA>()); + expect((body['actions'] as List).length, 1); + }); + }); + + group('DynamicSearchRulesQuery.toBody', () { + test('empty query serializes to an empty body', () { + expect(const DynamicSearchRulesQuery().toBody(), isEmpty); + }); + + test('offset/limit/filter round-trip', () { + const q = DynamicSearchRulesQuery( + offset: 20, + limit: 5, + filter: 'active = true AND priority > 5', + ); + + expect(q.toBody(), { + 'offset': 20, + 'limit': 5, + 'filter': 'active = true AND priority > 5', + }); + }); + + test('nulls are omitted so sparse queries stay sparse', () { + const q = DynamicSearchRulesQuery(limit: 10); + expect(q.toBody().keys, ['limit']); + }); + }); +} From abff8940cdb32f011b3494fd387af27075de65ea Mon Sep 17 00:00:00 2001 From: ibrahim-iqbal Date: Sun, 26 Jul 2026 18:08:13 +0530 Subject: [PATCH 2/2] fix(dynamic-search-rules): match v1.50.0 API shape (CodeRabbit review) Two review findings from CodeRabbit on #497: 1. Rename `priority` to `precedence` on DynamicSearchRule. Meilisearch v1.50.0 renamed the ordering field and rejects the old `priority` key on the wire. Lower numeric `precedence` values are applied first (rule with precedence: 1 wins over precedence: 5). Updates the field name, KDoc, fromJson lookup, and toUpsertBody emission accordingly. 2. Change `DynamicSearchRulesQuery.filter` from a bare filter-string to a structured DynamicSearchRulesFilter object. In v1.50.0 the list endpoint filter is an object with `query` (free-text search across description + conditions.query.words) and `active` (bool status filter), not a filter-expression string. toBody() drops an entirely empty filter object so unconfigured queries don't send {} on the wire. Tests updated: precedence round-trip, structured filter round-trip, empty-filter drop, sparse-filter emission. `.code-samples.meilisearch.yaml` patch sample uses `precedence`. --- .code-samples.meilisearch.yaml | 2 +- .../dynamic_search_rules_query.dart | 42 ++++++++++++---- lib/src/results/dynamic_search_rule.dart | 13 +++-- ...namic_search_rules_serialization_test.dart | 50 ++++++++++++------- 4 files changed, 74 insertions(+), 33 deletions(-) diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 8cef79b..8552b0e 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -251,5 +251,5 @@ reset_non_separator_tokens_1: await client.index('articles').resetNonSeparatorTo export_post_1: "await client.export(\n ExportQuery(\n url: exportSinkUrl,\n apiKey: 'new_instance_api_key',\n payloadSize: \"100 MiB\",\n ),\n);" list_dynamic_search_rules_1: "await client.listDynamicSearchRules();" get_dynamic_search_rule_1: "await client.getDynamicSearchRule('black-friday');" -patch_dynamic_search_rule_1: "await client.updateOrCreateDynamicSearchRule(\n 'black-friday',\n DynamicSearchRule(\n uid: 'black-friday',\n description: 'Black Friday 2025 rules',\n priority: 10,\n active: true,\n conditions: {\n 'query': {'isEmpty': true},\n 'time': {\n 'start': '2025-11-28T00:00:00Z',\n 'end': '2025-11-28T23:59:59Z',\n },\n },\n actions: >[\n {\n 'selector': {'indexUid': 'products', 'id': '123'},\n 'action': {'type': 'pin', 'position': 1},\n },\n ],\n ),\n);" +patch_dynamic_search_rule_1: "await client.updateOrCreateDynamicSearchRule(\n 'black-friday',\n DynamicSearchRule(\n uid: 'black-friday',\n description: 'Black Friday 2025 rules',\n precedence: 10,\n active: true,\n conditions: {\n 'query': {'isEmpty': true},\n 'time': {\n 'start': '2025-11-28T00:00:00Z',\n 'end': '2025-11-28T23:59:59Z',\n },\n },\n actions: >[\n {\n 'selector': {'indexUid': 'products', 'id': '123'},\n 'action': {'type': 'pin', 'position': 1},\n },\n ],\n ),\n);" delete_dynamic_search_rule_1: "await client.deleteDynamicSearchRule('black-friday');" diff --git a/lib/src/query_parameters/dynamic_search_rules_query.dart b/lib/src/query_parameters/dynamic_search_rules_query.dart index 4f252d7..934332b 100644 --- a/lib/src/query_parameters/dynamic_search_rules_query.dart +++ b/lib/src/query_parameters/dynamic_search_rules_query.dart @@ -1,7 +1,26 @@ +/// The filter object accepted inside a +/// `POST /dynamic-search-rules` body (Meilisearch v1.50.0+). +/// +/// In v1.50.0 the list-endpoint filter changed shape: instead of a bare +/// filter-expression string it takes an object. [query] searches across +/// the rule's description and its `conditions.query.words`; [active] +/// narrows the result to active/paused rules. +class DynamicSearchRulesFilter { + final String? query; + final bool? active; + + const DynamicSearchRulesFilter({this.query, this.active}); + + Map toJson() => { + if (query != null) 'query': query, + if (active != null) 'active': active, + }; +} + /// Body for `POST /dynamic-search-rules`. /// /// The list endpoint is a POST rather than a GET so callers can send an -/// arbitrary filter expression in the body. All fields are optional; an +/// arbitrary filter object in the body. All fields are optional; an /// empty body lists every rule with the server's default pagination. class DynamicSearchRulesQuery { /// Zero-based offset into the rule list. @@ -10,9 +29,8 @@ class DynamicSearchRulesQuery { /// Maximum number of rules to return in a single response. final int? limit; - /// Optional filter expression matching the same grammar as Meilisearch's - /// task and document filters (e.g. `active = true AND priority > 5`). - final String? filter; + /// Optional filter object. See [DynamicSearchRulesFilter]. + final DynamicSearchRulesFilter? filter; const DynamicSearchRulesQuery({ this.offset, @@ -22,10 +40,14 @@ class DynamicSearchRulesQuery { /// Serializes this query to the JSON body sent to /// `POST /dynamic-search-rules`. Nulls are omitted so callers only send - /// what they set. - Map toBody() => { - if (offset != null) 'offset': offset, - if (limit != null) 'limit': limit, - if (filter != null) 'filter': filter, - }; + /// what they set. An empty filter object is also omitted so an + /// unconfigured [filter] doesn't send `{}` on the wire. + Map toBody() { + final filterMap = filter?.toJson(); + return { + if (offset != null) 'offset': offset, + if (limit != null) 'limit': limit, + if (filterMap != null && filterMap.isNotEmpty) 'filter': filterMap, + }; + } } diff --git a/lib/src/results/dynamic_search_rule.dart b/lib/src/results/dynamic_search_rule.dart index 08f2811..5b1f382 100644 --- a/lib/src/results/dynamic_search_rule.dart +++ b/lib/src/results/dynamic_search_rule.dart @@ -15,8 +15,11 @@ class DynamicSearchRule { /// Human-readable description. final String? description; - /// Higher-priority rules apply first when several rules match. - final int? priority; + /// Ordering key when several rules match a given query. In v1.50.0 this + /// field was renamed from `priority` to `precedence`, and **lower** + /// numeric values apply first: a rule with `precedence: 1` is picked + /// before a rule with `precedence: 5`. + final int? precedence; /// Whether the rule is currently active. final bool? active; @@ -39,7 +42,7 @@ class DynamicSearchRule { const DynamicSearchRule({ required this.uid, this.description, - this.priority, + this.precedence, this.active, this.conditions, this.actions, @@ -55,7 +58,7 @@ class DynamicSearchRule { return DynamicSearchRule( uid: json['uid'] as String? ?? '', description: json['description'] as String?, - priority: json['priority'] as int?, + precedence: json['precedence'] as int?, active: json['active'] as bool?, conditions: conditionsRaw is Map ? Map.from(conditionsRaw) @@ -78,7 +81,7 @@ class DynamicSearchRule { /// instance are omitted so callers can send sparse updates. Map toUpsertBody() => { if (description != null) 'description': description, - if (priority != null) 'priority': priority, + if (precedence != null) 'precedence': precedence, if (active != null) 'active': active, if (conditions != null) 'conditions': conditions, if (actions != null) 'actions': actions, diff --git a/test/dynamic_search_rules_serialization_test.dart b/test/dynamic_search_rules_serialization_test.dart index 3d7fda3..cd6c878 100644 --- a/test/dynamic_search_rules_serialization_test.dart +++ b/test/dynamic_search_rules_serialization_test.dart @@ -14,7 +14,7 @@ void main() { final json = { 'uid': 'black-friday', 'description': 'Black Friday 2025 rules', - 'priority': 10, + 'precedence': 10, 'active': true, 'conditions': { 'query': {'isEmpty': true}, @@ -43,7 +43,7 @@ void main() { expect(rule.uid, 'black-friday'); expect(rule.description, 'Black Friday 2025 rules'); - expect(rule.priority, 10); + expect(rule.precedence, 10); expect(rule.active, isTrue); expect(rule.conditions, isNotNull); @@ -70,7 +70,7 @@ void main() { expect(rule.uid, 'minimal'); expect(rule.description, isNull); - expect(rule.priority, isNull); + expect(rule.precedence, isNull); expect(rule.active, isNull); expect(rule.conditions, isNull); expect(rule.actions, isNull); @@ -93,13 +93,13 @@ void main() { test('emits only the fields the caller set (sparse PATCH)', () { final rule = DynamicSearchRule( uid: 'ignored-when-upserting', - priority: 5, + precedence: 5, ); final body = rule.toUpsertBody(); - expect(body.keys, unorderedEquals(['priority'])); - expect(body['priority'], 5); + expect(body.keys, unorderedEquals(['precedence'])); + expect(body['precedence'], 5); }); test('serializes conditions and actions verbatim', () { @@ -132,23 +132,39 @@ void main() { expect(const DynamicSearchRulesQuery().toBody(), isEmpty); }); - test('offset/limit/filter round-trip', () { + test('offset/limit round-trip', () { + const q = DynamicSearchRulesQuery(offset: 20, limit: 5); + expect(q.toBody(), {'offset': 20, 'limit': 5}); + }); + + test('filter object round-trip carries query + active', () { const q = DynamicSearchRulesQuery( - offset: 20, - limit: 5, - filter: 'active = true AND priority > 5', + limit: 10, + filter: DynamicSearchRulesFilter(query: 'black friday', active: true), ); - expect(q.toBody(), { - 'offset': 20, - 'limit': 5, - 'filter': 'active = true AND priority > 5', + 'limit': 10, + 'filter': { + 'query': 'black friday', + 'active': true, + }, }); }); - test('nulls are omitted so sparse queries stay sparse', () { - const q = DynamicSearchRulesQuery(limit: 10); - expect(q.toBody().keys, ['limit']); + test('an entirely empty filter object is dropped from the body', () { + const q = DynamicSearchRulesQuery( + filter: DynamicSearchRulesFilter(), + ); + expect(q.toBody(), isEmpty); + }); + + test('sparse filter with only one field only emits that field', () { + const q = DynamicSearchRulesQuery( + filter: DynamicSearchRulesFilter(active: false), + ); + expect(q.toBody(), { + 'filter': {'active': false}, + }); }); }); }