From b3f1b97509f6e335fcc14e9a780047ba8a472fdb Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 11 Aug 2026 11:19:43 +0530 Subject: [PATCH 1/3] Send filter array operators as a single form field in URLFormEncoder in/not_in/between carry the whole array in one field (updated_at[between]=[a,b]). Only ListParamEncoder did this, and it keys off nesting level, so filters on export operations were index-encoded as [between][0]/[between][1] and silently dropped by the API. Co-authored-by: Cursor --- src/ValueObjects/Encoders/URLFormEncoder.php | 10 +++++ .../Encoder/URLFormEncoderTest.php | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/ValueObjects/Encoders/URLFormEncoder.php b/src/ValueObjects/Encoders/URLFormEncoder.php index 733c5510..6f34a911 100644 --- a/src/ValueObjects/Encoders/URLFormEncoder.php +++ b/src/ValueObjects/Encoders/URLFormEncoder.php @@ -6,6 +6,12 @@ class URLFormEncoder implements ParamEncoderInterface { + /** + * Filter operators whose value is the whole array, sent as a single field + * (e.g. updated_at[between]=[1704067200,1717199999]). + */ + private const ARRAY_OPERATORS = ['in', 'not_in', 'between']; + /** * @param array $params. * @param array $jsonKeys. @@ -33,6 +39,10 @@ private static function serialize($value, $prefix = null, $idx = null, $jsonKeys (!is_null($prefix) ? '[' . $usK . ']' : $usK) . (!is_null($idx) ? '[' . $idx . ']' : ''); $serialized[$key] = is_string($v) ? $v : json_encode((is_array($v) && $v === []) ? (object)[] : $v); + } else if (is_array($v) && !is_int($k) && in_array(Util::toUnderscoreFromCamelCase($k), self::ARRAY_OPERATORS, true)) { + $usK = Util::toUnderscoreFromCamelCase($k); + $key = (!is_null($prefix) ? $prefix . '[' . $usK . ']' : $usK) . (!is_null($idx) ? '[' . $idx . ']' : ''); + $serialized[$key] = json_encode($v); } else if (is_array($v) && !is_int($k)) { $tempPrefix = (!is_null($prefix)) ? $prefix . '[' . Util::toUnderscoreFromCamelCase($k) . ']' : Util::toUnderscoreFromCamelCase($k); $serialized = array_merge($serialized, self::serialize($v, $tempPrefix, null, $jsonKeys, $level + 1)); diff --git a/tests/ValueObjects/Encoder/URLFormEncoderTest.php b/tests/ValueObjects/Encoder/URLFormEncoderTest.php index 5f4b3178..af2bad59 100644 --- a/tests/ValueObjects/Encoder/URLFormEncoderTest.php +++ b/tests/ValueObjects/Encoder/URLFormEncoderTest.php @@ -217,4 +217,41 @@ public function testEncodeParamsShouldNotUseArrayBasedIndexingForJsonArray(): vo $this->assertIsString($encoded); $this->assertSame("id=foo&name=foo&discount_percentage=10&apply_on=each_specified_item&item_constraints%5Bconstraint%5D%5B0%5D=specific&item_constraints%5Bitem_type%5D%5B0%5D=plan&item_constraints%5Bitem_price_ids%5D%5B0%5D=%5B%22some_price_id%22%5D", $encoded); } + + /** Filter operators carry the whole array in one field, at any nesting depth. */ + /** ramp[effective_from][between]=[1704067200,1717199999]&ramp[status][in]=["scheduled","draft"] */ + public function testEncodeParamsWithFilterArrayOperators(): void + { + $params = [ + 'export_type' => 'import_friendly_data', + 'ramp' => [ + 'effective_from' => [ + 'between' => [1704067200, 1717199999], + ], + 'status' => [ + 'in' => ['scheduled', 'draft'], + ], + ], + ]; + $encoded = URLFormEncoder::encode($params); + $this->assertIsString($encoded); + $this->assertSame( + "export_type=import_friendly_data&ramp%5Beffective_from%5D%5Bbetween%5D=%5B1704067200%2C1717199999%5D&ramp%5Bstatus%5D%5Bin%5D=%5B%22scheduled%22%2C%22draft%22%5D", + $encoded + ); + } + + /** Top-level filters on non-list requests get the same treatment. */ + /** updated_at[between]=[1704067200,1717199999] */ + public function testEncodeParamsWithTopLevelFilterArrayOperator(): void + { + $params = [ + 'updated_at' => [ + 'between' => [1704067200, 1717199999], + ], + ]; + $encoded = URLFormEncoder::encode($params); + $this->assertIsString($encoded); + $this->assertSame("updated_at%5Bbetween%5D=%5B1704067200%2C1717199999%5D", $encoded); + } } From 23ecd06f5b9ec3764b0a1b8979950873179983f1 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 11 Aug 2026 11:26:42 +0530 Subject: [PATCH 2/3] Releasing v4.25.1 Co-authored-by: Cursor --- CHANGELOG.md | 5 +++++ VERSION | 2 +- src/Version.php | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 940e9885..f39b091e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### v4.25.1 (2026-08-11) +* * * +### Bug Fixes: +- Filter operators `in`, `not_in` and `between` are now sent as a single form field by `URLFormEncoder`, at any nesting depth. Filters on export operations, such as `ramp[effective_from][between]`, were index-encoded and therefore ignored by the API. + ### v4.25.0 (2026-07-30) * * * ### New Resources: diff --git a/VERSION b/VERSION index 4d9fbcf2..72e19f2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.25.0 +4.25.1 diff --git a/src/Version.php b/src/Version.php index d50c6817..aa6a6013 100644 --- a/src/Version.php +++ b/src/Version.php @@ -4,7 +4,7 @@ final class Version { - const VERSION = '4.25.0'; + const VERSION = '4.25.1'; } ?> \ No newline at end of file From 5a6b0a2d3559bf3cf6f8168cdcd66b12e4520b90 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 11 Aug 2026 12:37:51 +0530 Subject: [PATCH 3/3] Skip empty filter operator arrays in URLFormEncoder An empty in/not_in/between array is now omitted instead of being sent as an empty JSON array. Co-authored-by: Cursor --- src/ValueObjects/Encoders/URLFormEncoder.php | 4 +++- tests/ValueObjects/Encoder/URLFormEncoderTest.php | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/ValueObjects/Encoders/URLFormEncoder.php b/src/ValueObjects/Encoders/URLFormEncoder.php index 6f34a911..ff4a2b4e 100644 --- a/src/ValueObjects/Encoders/URLFormEncoder.php +++ b/src/ValueObjects/Encoders/URLFormEncoder.php @@ -39,7 +39,9 @@ private static function serialize($value, $prefix = null, $idx = null, $jsonKeys (!is_null($prefix) ? '[' . $usK . ']' : $usK) . (!is_null($idx) ? '[' . $idx . ']' : ''); $serialized[$key] = is_string($v) ? $v : json_encode((is_array($v) && $v === []) ? (object)[] : $v); - } else if (is_array($v) && !is_int($k) && in_array(Util::toUnderscoreFromCamelCase($k), self::ARRAY_OPERATORS, true)) { + } else if (is_array($v) && $v !== [] && !is_int($k) && in_array(Util::toUnderscoreFromCamelCase($k), self::ARRAY_OPERATORS, true)) { + // An empty filter is not a filter, so it falls through to the + // recursion below and leaves nothing in the request. $usK = Util::toUnderscoreFromCamelCase($k); $key = (!is_null($prefix) ? $prefix . '[' . $usK . ']' : $usK) . (!is_null($idx) ? '[' . $idx . ']' : ''); $serialized[$key] = json_encode($v); diff --git a/tests/ValueObjects/Encoder/URLFormEncoderTest.php b/tests/ValueObjects/Encoder/URLFormEncoderTest.php index af2bad59..880876f0 100644 --- a/tests/ValueObjects/Encoder/URLFormEncoderTest.php +++ b/tests/ValueObjects/Encoder/URLFormEncoderTest.php @@ -254,4 +254,19 @@ public function testEncodeParamsWithTopLevelFilterArrayOperator(): void $this->assertIsString($encoded); $this->assertSame("updated_at%5Bbetween%5D=%5B1704067200%2C1717199999%5D", $encoded); } + + /** An empty filter operator is left out of the request entirely. */ + /** limit=5 */ + public function testEncodeParamsWithEmptyFilterArrayOperator(): void + { + $params = [ + 'limit' => 5, + 'updated_at' => [ + 'between' => [], + ], + ]; + $encoded = URLFormEncoder::encode($params); + $this->assertIsString($encoded); + $this->assertSame("limit=5", $encoded); + } }