From 8ba636b3ebb23d1de36f9fb664063b9be09c2873 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 May 2026 09:22:03 +0100 Subject: [PATCH 1/2] feat: implement strict merge option and update related tests --- README.md | 30 ++++++++++++ src/decode/accumulate/insert.rs | 34 ++++++++++---- src/decode/accumulate/insert/tests.rs | 35 +++++++++----- src/decode/accumulate/process.rs | 38 ++++++++++----- src/decode/tests/duplicates.rs | 47 ++++++++++++++++++- src/merge.rs | 33 ++++++++++++++ src/merge/tests.rs | 50 ++++++++++++++++++++ src/options/decode.rs | 17 +++++++ tests/comparison/js/case.js | 1 + tests/porting_ledger.md | 2 +- tests/regressions.rs | 7 +++ tests/support/cases/node_parse.rs | 66 +++++++++++++++++++++++++++ tests/support/mod.rs | 1 + 13 files changed, 325 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 81d593c..b9089aa 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,36 @@ let first = decode( .unwrap(); assert_eq!(first.get("foo"), Some(&Value::String("bar".to_owned()))); +let bracketed = decode( + "a=1&a=2&b[]=1&b[]=2", + &DecodeOptions::new().with_duplicates(Duplicates::Last), +) +.unwrap(); +assert_eq!(bracketed.get("a"), Some(&Value::String("2".to_owned()))); +assert_eq!( + bracketed.get("b"), + Some(&Value::Array(vec![ + Value::String("1".to_owned()), + Value::String("2".to_owned()), + ])), +); + +let legacy_merge = decode( + "a[b]=c&a=d", + &DecodeOptions::new().with_strict_merge(false), +) +.unwrap(); +assert_eq!( + legacy_merge.get("a"), + Some(&Value::Object( + [ + ("b".to_owned(), Value::String("c".to_owned())), + ("d".to_owned(), Value::Bool(true)), + ] + .into(), + )), +); + let comma = decode("a=b,c", &DecodeOptions::new().with_comma(true)).unwrap(); assert_eq!( comma.get("a"), diff --git a/src/decode/accumulate/insert.rs b/src/decode/accumulate/insert.rs index c36749e..2350851 100644 --- a/src/decode/accumulate/insert.rs +++ b/src/decode/accumulate/insert.rs @@ -13,9 +13,20 @@ pub(in crate::decode) fn insert_value( entry: Entry<'_, String, ParsedFlatValue>, value: ParsedFlatValue, options: &DecodeOptions, +) -> Result<(), DecodeError> { + insert_value_with_duplicates(entry, value, options, options.duplicates) +} + +pub(in crate::decode) fn insert_value_with_duplicates( + entry: Entry<'_, String, ParsedFlatValue>, + value: ParsedFlatValue, + options: &DecodeOptions, + duplicates: Duplicates, ) -> Result<(), DecodeError> { match entry { - Entry::Occupied(mut entry) => insert_occupied_value(&mut entry, value, options), + Entry::Occupied(mut entry) => { + insert_occupied_value_with_duplicates(&mut entry, value, options, duplicates) + } Entry::Vacant(entry) => { entry.insert(value); Ok(()) @@ -23,12 +34,13 @@ pub(in crate::decode) fn insert_value( } } -pub(super) fn insert_occupied_value( +pub(super) fn insert_occupied_value_with_duplicates( entry: &mut OccupiedEntry<'_, String, ParsedFlatValue>, value: ParsedFlatValue, options: &DecodeOptions, + duplicates: Duplicates, ) -> Result<(), DecodeError> { - match options.duplicates { + match duplicates { Duplicates::Combine => { let current = std::mem::replace( entry.get_mut(), @@ -46,16 +58,17 @@ pub(super) fn insert_occupied_value( Ok(()) } -pub(super) fn insert_default_value( +pub(super) fn insert_default_value_with_duplicates( values: &mut FlatValues, key: String, value: ParsedFlatValue, options: &DecodeOptions, + duplicates: Duplicates, ) -> Result<(), DecodeError> { match values { FlatValues::Concrete(entries) => { if let ParsedFlatValue::Concrete(value) = value { - match options.duplicates { + match duplicates { Duplicates::First => { entries.entry(key).or_insert(value); return Ok(()); @@ -70,23 +83,26 @@ pub(super) fn insert_default_value( return Ok(()); } let values = values.ensure_parsed(); - return insert_value( + return insert_value_with_duplicates( values.entry(key), ParsedFlatValue::concrete(value), options, + duplicates, ); } } } - if matches!(options.duplicates, Duplicates::First) && entries.contains_key(&key) { + if matches!(duplicates, Duplicates::First) && entries.contains_key(&key) { return Ok(()); } let values = values.ensure_parsed(); - insert_value(values.entry(key), value, options) + insert_value_with_duplicates(values.entry(key), value, options, duplicates) + } + FlatValues::Parsed(entries) => { + insert_value_with_duplicates(entries.entry(key), value, options, duplicates) } - FlatValues::Parsed(entries) => insert_value(entries.entry(key), value, options), } } diff --git a/src/decode/accumulate/insert/tests.rs b/src/decode/accumulate/insert/tests.rs index d4202ad..3c674b0 100644 --- a/src/decode/accumulate/insert/tests.rs +++ b/src/decode/accumulate/insert/tests.rs @@ -1,4 +1,4 @@ -use super::{insert_default_value, insert_occupied_value}; +use super::{insert_default_value_with_duplicates, insert_occupied_value_with_duplicates}; use crate::decode::flat::{FlatValues, ParsedFlatValue}; use crate::internal::node::Node; use crate::options::{DecodeOptions, Duplicates}; @@ -39,10 +39,11 @@ fn occupied_insert_respects_duplicate_strategies() { IndexMap::from([("a".to_owned(), ParsedFlatValue::concrete(scalar("1")))]); match combine_entries.entry("a".to_owned()) { Entry::Occupied(mut entry) => { - insert_occupied_value( + insert_occupied_value_with_duplicates( &mut entry, ParsedFlatValue::concrete(scalar("2")), &combine_options, + Duplicates::Combine, ) .unwrap(); } @@ -58,10 +59,11 @@ fn occupied_insert_respects_duplicate_strategies() { IndexMap::from([("a".to_owned(), ParsedFlatValue::concrete(scalar("1")))]); match last_entries.entry("a".to_owned()) { Entry::Occupied(mut entry) => { - insert_occupied_value( + insert_occupied_value_with_duplicates( &mut entry, ParsedFlatValue::concrete(scalar("2")), &last_options, + Duplicates::Last, ) .unwrap(); } @@ -77,10 +79,11 @@ fn occupied_insert_respects_duplicate_strategies() { IndexMap::from([("a".to_owned(), ParsedFlatValue::concrete(scalar("1")))]); match first_entries.entry("a".to_owned()) { Entry::Occupied(mut entry) => { - insert_occupied_value( + insert_occupied_value_with_duplicates( &mut entry, ParsedFlatValue::concrete(scalar("2")), &first_options, + Duplicates::First, ) .unwrap(); } @@ -96,18 +99,20 @@ fn occupied_insert_respects_duplicate_strategies() { fn default_insert_keeps_concrete_storage_until_parsing_is_required() { let mut first_values = FlatValues::Concrete(Default::default()); let first_options = DecodeOptions::new().with_duplicates(Duplicates::First); - insert_default_value( + insert_default_value_with_duplicates( &mut first_values, "a".to_owned(), ParsedFlatValue::concrete(scalar("1")), &first_options, + Duplicates::First, ) .unwrap(); - insert_default_value( + insert_default_value_with_duplicates( &mut first_values, "a".to_owned(), ParsedFlatValue::concrete(scalar("2")), &first_options, + Duplicates::First, ) .unwrap(); assert!(stores_concrete_value(&first_values, "a")); @@ -118,18 +123,20 @@ fn default_insert_keeps_concrete_storage_until_parsing_is_required() { let mut last_values = FlatValues::Concrete(Default::default()); let last_options = DecodeOptions::new().with_duplicates(Duplicates::Last); - insert_default_value( + insert_default_value_with_duplicates( &mut last_values, "a".to_owned(), ParsedFlatValue::concrete(scalar("1")), &last_options, + Duplicates::Last, ) .unwrap(); - insert_default_value( + insert_default_value_with_duplicates( &mut last_values, "a".to_owned(), ParsedFlatValue::concrete(scalar("2")), &last_options, + Duplicates::Last, ) .unwrap(); let FlatValues::Concrete(last_entries) = &last_values else { @@ -139,28 +146,31 @@ fn default_insert_keeps_concrete_storage_until_parsing_is_required() { let mut combine_values = FlatValues::Concrete(Default::default()); let combine_options = DecodeOptions::new().with_duplicates(Duplicates::Combine); - insert_default_value( + insert_default_value_with_duplicates( &mut combine_values, "a".to_owned(), ParsedFlatValue::concrete(scalar("1")), &combine_options, + Duplicates::Combine, ) .unwrap(); - insert_default_value( + insert_default_value_with_duplicates( &mut combine_values, "a".to_owned(), ParsedFlatValue::concrete(scalar("2")), &combine_options, + Duplicates::Combine, ) .unwrap(); assert!(stores_parsed_value(&combine_values, "a")); let mut parsed_values = FlatValues::Concrete(Default::default()); - insert_default_value( + insert_default_value_with_duplicates( &mut parsed_values, "a".to_owned(), ParsedFlatValue::parsed(Node::Array(vec![Node::scalar(scalar("1"))]), true), &DecodeOptions::new(), + Duplicates::Combine, ) .unwrap(); assert!(stores_parsed_value_with_compaction(&parsed_values, "a")); @@ -169,11 +179,12 @@ fn default_insert_keeps_concrete_storage_until_parsing_is_required() { #[test] fn default_insert_first_ignores_late_parsed_values_for_existing_concrete_keys() { let mut values = FlatValues::Concrete([("a".to_owned(), scalar("1"))].into()); - insert_default_value( + insert_default_value_with_duplicates( &mut values, "a".to_owned(), ParsedFlatValue::parsed(Node::Array(vec![Node::scalar(scalar("2"))]), true), &DecodeOptions::new().with_duplicates(Duplicates::First), + Duplicates::First, ) .unwrap(); diff --git a/src/decode/accumulate/process.rs b/src/decode/accumulate/process.rs index 3cbcb77..8142a86 100644 --- a/src/decode/accumulate/process.rs +++ b/src/decode/accumulate/process.rs @@ -17,7 +17,10 @@ use super::build::{ build_plain_value, }; use super::combine::try_combine_direct_values; -use super::insert::{insert_default_value, insert_occupied_value, insert_value}; +use super::insert::{ + insert_default_value_with_duplicates, insert_occupied_value_with_duplicates, insert_value, + insert_value_with_duplicates, +}; pub(in crate::decode) fn process_query_part_default( part: &str, @@ -80,6 +83,7 @@ pub(in crate::decode) fn process_scanned_part_default_accumulator( return Ok(()); } update_structured_syntax_flag(part, &decoded_key, options, has_any_structured_syntax); + let duplicates = effective_duplicates_for_part(part, options); match values { DefaultAccumulator::Direct(_) => { @@ -104,7 +108,7 @@ pub(in crate::decode) fn process_scanned_part_default_accumulator( } } } - Entry::Occupied(mut entry) => match options.duplicates { + Entry::Occupied(mut entry) => match duplicates { Duplicates::First => DirectInsertOutcome::Done, Duplicates::Last => { match build_direct_value( @@ -171,7 +175,7 @@ pub(in crate::decode) fn process_scanned_part_default_accumulator( } => { let entries = values.ensure_parsed(); if via_duplicates { - insert_value(entries.entry(key), value, options) + insert_value_with_duplicates(entries.entry(key), value, options, duplicates) } else { entries.insert(key, value); Ok(()) @@ -180,7 +184,7 @@ pub(in crate::decode) fn process_scanned_part_default_accumulator( } } DefaultAccumulator::Parsed(entries) => { - let current_length = if matches!(options.duplicates, Duplicates::Combine) { + let current_length = if matches!(duplicates, Duplicates::Combine) { entries .get(&decoded_key) .map_or(0, ParsedFlatValue::list_length_for_combine) @@ -195,7 +199,7 @@ pub(in crate::decode) fn process_scanned_part_default_accumulator( current_length, DefaultStorageMode::PreferConcrete, )?; - insert_value(entries.entry(decoded_key), value, options) + insert_value_with_duplicates(entries.entry(decoded_key), value, options, duplicates) } } } @@ -312,6 +316,7 @@ fn process_scanned_part_default_with_mode( return Ok(()); } update_structured_syntax_flag(part, &decoded_key, options, has_any_structured_syntax); + let duplicates = effective_duplicates_for_part(part, options); if matches!(mode, DefaultStorageMode::PreferConcrete) && matches!(values, FlatValues::Concrete(_)) @@ -333,7 +338,7 @@ fn process_scanned_part_default_with_mode( parsed => (entry.key().clone(), parsed, false), } } - Entry::Occupied(mut entry) => match options.duplicates { + Entry::Occupied(mut entry) => match duplicates { Duplicates::First => return Ok(()), Duplicates::Last => { let value = build_default_value( @@ -370,14 +375,14 @@ fn process_scanned_part_default_with_mode( let entries = values.ensure_parsed(); if via_duplicates { - insert_value(entries.entry(key), value, options)?; + insert_value_with_duplicates(entries.entry(key), value, options, duplicates)?; } else { entries.insert(key, value); } return Ok(()); } - let current_length = if matches!(options.duplicates, Duplicates::Combine) { + let current_length = if matches!(duplicates, Duplicates::Combine) { values.get_list_length_for_combine(&decoded_key) } else { 0 @@ -390,7 +395,7 @@ fn process_scanned_part_default_with_mode( current_length, mode, )?; - insert_default_value(values, decoded_key, value, options)?; + insert_default_value_with_duplicates(values, decoded_key, value, options, duplicates)?; Ok(()) } @@ -415,21 +420,22 @@ pub(in crate::decode) fn process_scanned_part_custom( return Ok(()); } update_structured_syntax_flag(part, &decoded_key, options, has_any_structured_syntax); + let duplicates = effective_duplicates_for_part(part, options); match values.ensure_parsed().entry(decoded_key) { Entry::Occupied(mut entry) => { - if matches!(options.duplicates, Duplicates::First) { + if matches!(duplicates, Duplicates::First) { return Ok(()); } - let current_length = if matches!(options.duplicates, Duplicates::Combine) { + let current_length = if matches!(duplicates, Duplicates::Combine) { entry.get().list_length_for_combine() } else { 0 }; let value = build_custom_value(raw_value, part, effective_charset, options, current_length)?; - insert_occupied_value(&mut entry, value, options)?; + insert_occupied_value_with_duplicates(&mut entry, value, options, duplicates)?; } Entry::Vacant(entry) => { let value = build_custom_value(raw_value, part, effective_charset, options, 0)?; @@ -440,6 +446,14 @@ pub(in crate::decode) fn process_scanned_part_custom( Ok(()) } +fn effective_duplicates_for_part(part: ScannedPart<'_>, options: &DecodeOptions) -> Duplicates { + if part.has_bracket_suffix_assignment { + Duplicates::Combine + } else { + options.duplicates + } +} + fn advance_token_count( token_count: &mut usize, options: &DecodeOptions, diff --git a/src/decode/tests/duplicates.rs b/src/decode/tests/duplicates.rs index 48d583b..ec2c65c 100644 --- a/src/decode/tests/duplicates.rs +++ b/src/decode/tests/duplicates.rs @@ -1,6 +1,7 @@ use super::{ - DecodeOptions, Duplicates, Node, Value, combine_with_limit, decode, finalize_flat, - parse_query_string_values, stores_concrete_value, stores_parsed_value_with_compaction, + DecodeDecoder, DecodeOptions, Duplicates, Node, Value, combine_with_limit, decode, + finalize_flat, parse_query_string_values, stores_concrete_value, + stores_parsed_value_with_compaction, }; #[test] @@ -131,6 +132,48 @@ fn duplicate_first_and_last_keep_concrete_values_when_possible() { ); } +#[test] +fn bracket_notation_combines_regardless_of_duplicate_strategy() { + let first_options = DecodeOptions::new().with_duplicates(Duplicates::First); + let first = decode("a=1&a=2&b[]=1&b[]=2", &first_options).unwrap(); + assert_eq!(first.get("a"), Some(&Value::String("1".to_owned()))); + assert_eq!( + first.get("b"), + Some(&Value::Array(vec![ + Value::String("1".to_owned()), + Value::String("2".to_owned()), + ])) + ); + + let last_options = DecodeOptions::new().with_duplicates(Duplicates::Last); + let last = decode("a=1&a=2&b%5B%5D=1&b%5B%5D=2", &last_options).unwrap(); + assert_eq!(last.get("a"), Some(&Value::String("2".to_owned()))); + assert_eq!( + last.get("b"), + Some(&Value::Array(vec![ + Value::String("1".to_owned()), + Value::String("2".to_owned()), + ])) + ); +} + +#[test] +fn bracket_notation_combines_with_custom_decoders() { + let options = DecodeOptions::new() + .with_duplicates(Duplicates::Last) + .with_decoder(Some(DecodeDecoder::new(|input, _, _| input.to_owned()))); + + let decoded = decode("a=1&a=2&b[]=1&b[]=2", &options).unwrap(); + assert_eq!(decoded.get("a"), Some(&Value::String("2".to_owned()))); + assert_eq!( + decoded.get("b"), + Some(&Value::Array(vec![ + Value::String("1".to_owned()), + Value::String("2".to_owned()), + ])) + ); +} + #[test] fn duplicate_combine_keeps_flat_concrete_values_until_promotion_is_required() { let options = DecodeOptions::new().with_duplicates(Duplicates::Combine); diff --git a/src/merge.rs b/src/merge.rs index 9d970a7..5bd1760 100644 --- a/src/merge.rs +++ b/src/merge.rs @@ -132,6 +132,28 @@ pub(crate) fn merge( continue; } other => { + if !options.strict_merge { + if let Some(key) = legacy_scalar_merge_key(&other) { + entries.insert( + key, + Node::scalar(crate::value::Value::Bool(true)), + ); + } else if !is_empty_legacy_scalar(&other) { + finish_frame( + &mut stack, + &mut last_result, + Node::Array(vec![Node::Object(entries), other]), + ); + continue; + } + finish_frame( + &mut stack, + &mut last_result, + Node::Object(entries), + ); + continue; + } + finish_frame( &mut stack, &mut last_result, @@ -431,5 +453,16 @@ fn array_all_map_like_or_undefined(items: &[Node]) -> bool { .all(|item| item.is_undefined() || item.is_map_like()) } +fn legacy_scalar_merge_key(node: &Node) -> Option { + match node { + Node::Value(crate::value::Value::String(text)) if !text.is_empty() => Some(text.clone()), + _ => None, + } +} + +fn is_empty_legacy_scalar(node: &Node) -> bool { + matches!(node, Node::Value(crate::value::Value::String(text)) if text.is_empty()) +} + #[cfg(test)] mod tests; diff --git a/src/merge/tests.rs b/src/merge/tests.rs index 6da4a5e..32193a8 100644 --- a/src/merge/tests.rs +++ b/src/merge/tests.rs @@ -7,6 +7,10 @@ fn scalar(value: &str) -> Node { Node::scalar(Value::String(value.to_owned())) } +fn boolean(value: bool) -> Node { + Node::scalar(Value::Bool(value)) +} + #[test] fn merge_overflow_object_into_primitive_shifts_indices() { let overflow = Node::OverflowObject { @@ -254,6 +258,52 @@ fn merge_scalar_and_object_source_wraps_into_array() { assert_eq!(merged, Node::Array(vec![scalar("a"), source])); } +#[test] +fn merge_strict_merge_false_adds_string_scalar_as_object_key() { + let target = Node::Object([("b".to_owned(), scalar("c"))].into()); + + let merged = merge( + target, + scalar("d"), + &DecodeOptions::new().with_strict_merge(false), + ) + .unwrap(); + + assert_eq!( + merged, + Node::Object( + [ + ("b".to_owned(), scalar("c")), + ("d".to_owned(), boolean(true)) + ] + .into() + ) + ); +} + +#[test] +fn merge_strict_merge_false_ignores_empty_string_scalar() { + let target = Node::Object([("b".to_owned(), scalar("c"))].into()); + + let merged = merge( + target.clone(), + scalar(""), + &DecodeOptions::new().with_strict_merge(false), + ) + .unwrap(); + + assert_eq!(merged, target); +} + +#[test] +fn merge_strict_merge_true_wraps_object_scalar_conflicts() { + let target = Node::Object([("b".to_owned(), scalar("c"))].into()); + + let merged = merge(target.clone(), scalar("d"), &DecodeOptions::new()).unwrap(); + + assert_eq!(merged, Node::Array(vec![target, scalar("d")])); +} + #[test] fn merge_helpers_recognize_map_like_arrays_and_extract_entries() { assert!(array_all_map_like_or_undefined(&[ diff --git a/src/options/decode.rs b/src/options/decode.rs index e6ea753..116e1fe 100644 --- a/src/options/decode.rs +++ b/src/options/decode.rs @@ -28,6 +28,7 @@ pub struct DecodeOptions { pub(crate) interpret_numeric_entities: bool, pub(crate) parse_lists: bool, pub(crate) strict_depth: bool, + pub(crate) strict_merge: bool, pub(crate) strict_null_handling: bool, pub(crate) throw_on_limit_exceeded: bool, pub(crate) decoder: Option, @@ -52,6 +53,7 @@ impl Default for DecodeOptions { interpret_numeric_entities: false, parse_lists: true, strict_depth: false, + strict_merge: true, strict_null_handling: false, throw_on_limit_exceeded: false, decoder: None, @@ -255,6 +257,21 @@ impl DecodeOptions { self } + /// Returns whether object/scalar merge conflicts are preserved as arrays. + pub fn strict_merge(&self) -> bool { + self.strict_merge + } + + /// Enables or disables strict object/scalar merge handling. + /// + /// When enabled, object/scalar conflicts are wrapped in arrays to match + /// modern `qs`. Disabling this restores legacy `qs` behavior for scalar + /// string conflicts such as `a[b]=c&a=d`. + pub fn with_strict_merge(mut self, strict_merge: bool) -> Self { + self.strict_merge = strict_merge; + self + } + /// Returns whether missing values are preserved as [`crate::Value::Null`]. pub fn strict_null_handling(&self) -> bool { self.strict_null_handling diff --git a/tests/comparison/js/case.js b/tests/comparison/js/case.js index 80c2fcc..2d0aa1c 100644 --- a/tests/comparison/js/case.js +++ b/tests/comparison/js/case.js @@ -31,6 +31,7 @@ function normalizeDecodeOptions(options) { parameterLimit: options.parameterLimit, parseArrays: !!options.parseArrays, strictDepth: !!options.strictDepth, + strictMerge: options.strictMerge !== false, strictNullHandling: !!options.strictNullHandling, throwOnLimitExceeded: !!options.throwOnLimitExceeded, }; diff --git a/tests/porting_ledger.md b/tests/porting_ledger.md index 44c9505..b23029e 100644 --- a/tests/porting_ledger.md +++ b/tests/porting_ledger.md @@ -14,7 +14,7 @@ | Source file or family | Rust destination | Status | Node-backed | Reason | | --- | --- | --- | --- | --- | -| `node-qs/test/parse.js` basic, delimiter, dot, depth, array, charset, parameter-limit, comma cases | `tests/support/cases/node_parse.rs`, `tests/parity_decode.rs` | ported | yes | Core decode semantics map directly to the Rust surface. | +| `node-qs/test/parse.js` basic, delimiter, dot, depth, array, duplicates, `strictMerge`, charset, parameter-limit, comma cases | `tests/support/cases/node_parse.rs`, `tests/parity_decode.rs` | ported | yes | Core decode semantics map directly to the Rust surface. | | `node-qs/test/parse.js` prototype pollution, null-prototype, and circular host objects | not imported | skipped | no | JS runtime object-model behavior does not map to the Rust `Value` tree. The Rust custom decoder surface is covered locally instead of mirroring the Node callback tests verbatim. | | `node-qs/test/stringify.js` basic object/list/null/format/charset/filter-array/sort/dot cases | `tests/support/cases/node_stringify.rs`, `tests/parity_encode.rs` | ported | yes | Core encode semantics map directly to the Rust surface. | | `node-qs/test/stringify.js` JS date/buffer/cycle/prototype cases and callback-specific host-object assertions | not imported | skipped | no | Rust now exposes filter/sorter/value-encoder hooks, but the JS host-object and cycle cases still do not map to the Rust public contract. | diff --git a/tests/regressions.rs b/tests/regressions.rs index 7eb265c..a33067c 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -136,6 +136,13 @@ fn kotlin_model_defaults_match_the_public_baseline() { assert_eq!(decode_defaults.charset(), Charset::Utf8); assert_eq!(decode_defaults.parameter_limit(), 1000); assert_eq!(decode_defaults.duplicates(), Duplicates::Combine); + assert!(decode_defaults.strict_merge()); + assert!( + !decode_defaults + .clone() + .with_strict_merge(false) + .strict_merge() + ); let encode_defaults = EncodeOptions::new(); assert!(encode_defaults.encode()); diff --git a/tests/support/cases/node_parse.rs b/tests/support/cases/node_parse.rs index 3b718b4..e3e1424 100644 --- a/tests/support/cases/node_parse.rs +++ b/tests/support/cases/node_parse.rs @@ -352,6 +352,28 @@ pub(crate) fn cases() -> Vec { "foo=bar&foo=baz", DecodeOptions::new().with_duplicates(Duplicates::Last), ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "bracket notation combines with duplicates first", + "duplicates", + true, + ), + "a=1&a=2&b[]=1&b[]=2", + DecodeOptions::new().with_duplicates(Duplicates::First), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "bracket notation combines with duplicates last", + "duplicates", + true, + ), + "a=1&a=2&b[]=1&b[]=2", + DecodeOptions::new().with_duplicates(Duplicates::Last), + ), DecodeParityCase::new( CaseMeta::new( "node-qs", @@ -392,6 +414,50 @@ pub(crate) fn cases() -> Vec { "a=1&a[b]=2", DecodeOptions::new(), ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "strict merge wraps object then scalar conflicts by default", + "merging", + true, + ), + "a[b]=c&a=d", + DecodeOptions::new(), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "legacy merge adds scalar string keys when strict merge is disabled", + "merging", + true, + ), + "a[b]=c&a=d", + DecodeOptions::new().with_strict_merge(false), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "legacy merge ignores empty assigned scalars", + "merging", + true, + ), + "a[b]=c&a=", + DecodeOptions::new().with_strict_merge(false), + ), + DecodeParityCase::new( + CaseMeta::new( + "node-qs", + "parse.js", + "legacy merge ignores empty missing-value scalars", + "merging", + true, + ), + "a[b]=c&a", + DecodeOptions::new().with_strict_merge(false), + ), DecodeParityCase::new( CaseMeta::new( "node-qs", diff --git a/tests/support/mod.rs b/tests/support/mod.rs index dcdaeec..0320f43 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -313,6 +313,7 @@ fn decode_options_to_node_json(options: &DecodeOptions) -> JsonValue { "parameterLimit": options.parameter_limit(), "parseArrays": options.parse_lists(), "strictDepth": options.strict_depth(), + "strictMerge": options.strict_merge(), "strictNullHandling": options.strict_null_handling(), "throwOnLimitExceeded": options.throw_on_limit_exceeded(), }) From 94bf559c11eded2b02f931f7efd697197d911e1d Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 May 2026 10:40:55 +0100 Subject: [PATCH 2/2] fix(docs): clarify query-string decoding behavior and legacy merge mode --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b9089aa..8828e20 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,11 @@ let encoded = encode( assert_eq!(encoded, "user%5Bname%5D=alice&tags%5B%5D=x&tags%5B%5D=y"); ``` -Query-string decoding only produces `Null`, `String`, `Array`, and `Object`. Structured inputs passed to `encode` or `decode_pairs` may also contain `Bool`, numeric variants, and `Bytes`. +Query-string decoding normally produces `Null`, `String`, `Array`, and +`Object`. The legacy `with_strict_merge(false)` mode may also produce +`Bool(true)` marker values to match Node `qs` object/scalar merge behavior. +Structured inputs passed to `encode` or `decode_pairs` may also contain `Bool`, +numeric variants, and `Bytes`. ## Decoding