Repository navigation
feat!: a clearing request now clears, and an empty list is one - #7
AlexandreBernard wants to merge 3 commits into
Conversation
6085ea1 to
47f9870
Compare
Ezveus
left a comment
There was a problem hiding this comment.
Review of 47f9870. All 105 tests pass. Findings 1–5 below are bugs reproduced with a throwaway script; the rest are cleanup/docs/test.
Blocking: an explicit null on a reference association writes to the DB during map (inline comments on L289).
Bug — [] is silently ignored (lib/model_mapper.rb:572, outside the diff so posted here): smart_presence turns an empty array into nil, and since the raw value is not nil, explicit_null? returns false and the param is skipped. Repro: a Widget with one Part, then { parts: [] } → w.reload.parts.count is still 1, while { parts: nil } empties it. The README says a multi-valued param empties to [] on null; a client sending [] — the natural JSON for "none left" — gets no error and no change.
| if value.nil? | ||
| # Cleared by an explicit null. A param holding several values empties to `[]` — assigning | ||
| # nil to a collection association raises, and an empty list is what "none left" means. | ||
| (param_config.multiple? || param_config.array?) ? [] : nil |
There was a problem hiding this comment.
Bug (reproduced) — has_one reference: explicit null writes to the DB during map. Assigning manual = nil on a persisted parent makes ActiveRecord nullify the current child immediately, which breaks the "mapping is write-free" rule the README and b1bea5f/87769ef rely on.
Repro: persisted Widget with a Manual, then map_to_model! with { manual: nil, price: 'abc' } → raises ValidationError, yet Manual.find(m.id).widget_id is already nil. Before this PR the null was skipped.
There was a problem hiding this comment.
Reframed after #14 / #13. The root cause predates this PR: assigning any value to a has_one/has_many reference on a persisted record writes to the database during map (measured on master a759e6d with a non-null replacement), tracked in #13. On master, a null only reaches it through an explicit nullable true, and the README warns about it. What this PR changes is that it would happen by default, for every undeclared reference receiving a null. So it is still blocking here: either #13 is fixed first, or has_one/has_many references are left out of the default flip.
| if value.nil? | ||
| # Cleared by an explicit null. A param holding several values empties to `[]` — assigning | ||
| # nil to a collection association raises, and an empty list is what "none left" means. | ||
| (param_config.multiple? || param_config.array?) ? [] : nil |
There was a problem hiding this comment.
Bug (reproduced) — many: true reference: null assigns parts = [] during map. For a persisted has_many, ActiveRecord nullifies (or with dependent: :destroy, deletes) the rows immediately, before validation completes and without any save.
Repro: { parts: nil, price: 'abc' } through map_to_model! → raises ValidationError, but Part.find(pt.id).widget_id is already nil. The caller has no way to undo it.
There was a problem hiding this comment.
Reframed after #14 / #13. The root cause predates this PR: assigning any value to a has_one/has_many reference on a persisted record writes to the database during map (measured on master a759e6d with a non-null replacement), tracked in #13. On master, a null only reaches it through an explicit nullable true, and the README warns about it. What this PR changes is that it would happen by default, for every undeclared reference receiving a null. So it is still blocking here: either #13 is fixed first, or has_one/has_many references are left out of the default flip.
| # null, which CLEARS the value (RFC 7386 merge-patch semantics: absent means "leave alone", | ||
| # null means "erase"). `nullable false` refuses that null rather than writing it. | ||
| if value.nil? && allow_nil | ||
| next unless explicit_null?(source_params, param_config) |
There was a problem hiding this comment.
Bug (reproduced) — an attribute with a default can never be cleared. An explicit null is replaced by the default. README/CHANGELOG say "a key sent with an explicit null clears the value".
Repro: attribute :status do default 'dflt' end, then { status: nil } → status is 'dflt', not nil.
Also (altitude): key presence is checked again by a second dig in explicit_null?, separate from validate_param's extraction, and the two lookups disagree (string/symbol, smart_presence on []/{}/"") — that's the root of the string-key and [] bugs. Having validate_param return a single read (e.g. [value, present]) and branching on it would remove both.
There was a problem hiding this comment.
Still relevant — and now decided on master. #14 (merged as 857a9c5) settled the target semantics: an explicit null clears a param that has a default; default only answers an absent key (issue #6, RFC 7396). On master, nullable true on such a param clears it (test_nullable_true_does_not_apply_the_default). If this PR keeps applying the default to a null, an app that declared nullable true today changes behaviour when this merges — please align.
The altitude point in this comment (two divergent reads of the key) is resolved by #14: there is a single read_path now.
| def test_explicit_null_empties_an_array_of_references | ||
| category = Category.create!(name: 'Tools') | ||
| widget = Widget.create! | ||
| widget.categories = [category] if widget.respond_to?(:categories=) |
There was a problem hiding this comment.
Test — dead setup. Widget has no categories= (only attr_accessor :category_ids), so widget.categories = [category] does nothing; category_ids starts out nil and the test only proves nil → []. It would not catch a regression where a populated list is not emptied. Suggest widget.category_ids = [category.id].
|
|
||
| **Upgrading:** an attribute that relied on "a null means leave it alone" needs `nullable false`. | ||
|
|
||
| ## 0.5.0 |
There was a problem hiding this comment.
Changelog — phantom 0.5.0. The former "Unreleased" section is renamed 0.5.0, but the gemspec goes from 0.4.1 straight to 0.6.0 and there is no 0.5.0 tag, so the changelog lists a release that never existed and the upgrade notes point at the wrong version.
A key sent with an explicit null was skipped exactly like an absent key, so a payload could never clear a value (issue #6). PR #7 makes a null clear by default, which is breaking for clients that send null to mean "leave it alone"; this change prepares it without changing any default behaviour. - An explicit null (key present in its parent, raw value nil) on a param that declares no `nullable` is still skipped, but warns once per mapper class and param per process. - `nullable true` clears the value now (nil, or [] for a collection) and skips `default`; `nullable false` refuses the null with InvalidNilValueError. Both silence the warning. - `required` still wins; "", [], {} and a nil/empty parent section are not nulls; the key is matched with the same keys `validate_param` digs with, so a string key cannot clear what it cannot set. - `nullable` on a param mapped by a sub-mapper raises ConfigurationError. 226 runs, 0 failures (190 before). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A key sent with an explicit null was skipped exactly like an absent key, so a payload could never clear a value (issue #6). PR #7 makes a null clear by default, which is breaking for clients that send null to mean "leave it alone"; this change prepares it without changing any default behaviour. - An explicit null (key present in its parent, raw value nil) on a param that declares no `nullable` is still skipped, but warns once per mapper class and param per process. - `nullable true` clears the value now (nil, or [] for a collection) and skips `default`; `nullable false` refuses the null with InvalidNilValueError. Both silence the warning. - `required` still wins; "", [], {} and a nil/empty parent section are not nulls; the key is matched with the same keys `validate_param` digs with, so a string key cannot clear what it cannot set. - `nullable` on a param mapped by a sub-mapper raises ConfigurationError. 226 runs, 0 failures (190 before). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Update after #14 (merged into #14 ships the deprecation step for this change without touching the default:
This PR now conflicts with
Where the earlier review stands:
|
Second step of moduloTech#13, after the deprecation warning of the previous PR (the model is moduloTech#14 then moduloTech#7). A reference `association` whose assignment ActiveRecord would write to the database at once (a has_one, has_many or HABTM of a persisted record, given a value that differs from the current one) is now refused with an InvalidValueError on the param's path unless it declares `immediate_write true`. Nothing is assigned or written: the map is write-free again, including when it then fails validation. `immediate_write false` now says the same as leaving it undeclared; it stays a distinct state so a child mapper can override its parent either way. The deprecation warning is removed. Related to moduloTech#13. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… test The upgrade note pointed at the step-1 warning, which no release has shipped yet (both steps sit under Unreleased), so it is dropped. The breaking entry now says the deprecated `type :array, of: :referential` onto a `*_ids` writer is not covered and still writes during the map. A test pins the undeclared refusal of a `nullable true` clear on a has_one, the case where the cleared value is nil. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An explicit null on a param declaring no `nullable` was treated like an
absent key — skipped, or replaced by its `default` — while warning that a
future release would clear the value. This is that release: `nullable`
defaults to true, the warning is gone, and `{ name: null }` erases the
value where a payload without `name` still leaves it alone (RFC 7396).
`nullable false` is unchanged and refuses the null. An app that already
declared `nullable` sees no change, which is what the warning asked for.
An empty list on a multi-valued param is a clearing request too. It is
what a client sends once it has removed every element, and it used to do
nothing at all while a null emptied the param — two spellings of one
intention with opposite outcomes, and the silent one is the one a JSON
client produces naturally. On a scalar param `[]` still says nothing.
A `default` now answers an absent key only: erasing a value and falling
back to a default are opposite intentions, so the null wins.
This sits on top of the reference-write refusal because the flip is what
makes that refusal load-bearing. Clearing a has_one/has_many reference
goes through the ActiveRecord writer, which applies it to the database at
once; until now a null only reached it through an explicit `nullable
true`, so a payload like `{ manual: null, price: 'abc' }` would detach the
child and then answer 422 for the price, with nothing to roll it back.
The clear is now refused unless the reference declares `immediate_write
true`, so the default flip cannot cost anyone their data.
47f9870 to
900e11c
Compare
PR #7 makes `[]` on a multi-valued param (`many: true`, `type :array`) clear the value, like a null: it is what a client sends once it has removed every element. Master releases before #7, and until now it left `[]` outside `nullable` entirely, so #7's promise that "an app that already declared `nullable` sees no change" did not hold. Measured on master 857a9c5, `type :array` holding [1, 2], payload { numbers: [] }: nullable true -> [1, 2] (#7: []) nullable false -> [1, 2] (#7: ValidationError on numbers) and the undeclared case emitted no warning, so `[]` would have gone straight to breaking with no deprecation step. `[]` on a multi-valued param is now governed exactly like a null: undeclared keeps skipping it (or applying the `default`) and warns once per mapper and param, whichever spelling arrives first; `nullable true` clears it, weighed by `authorize` like a null clear; `nullable false` refuses it with InvalidNilValueError. `required` still wins. `""`, `{}` and `[]` on a scalar param are not clearing requests, and a sub-mapper association keeps skipping both. Tests: 322 -> 334 runs, 0 failures. Six mutations of the new branch (collection check, emptiness check, no `[]` clearing, warning wording, assign gate, required gate) each turn at least one test red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
45951d2 to
bc06fbc
Compare
Second step of #13, after the deprecation warning of the previous PR (the model is #14 then #7). A reference `association` whose assignment ActiveRecord would write to the database at once (a has_one, has_many or HABTM of a persisted record, given a value that differs from the current one) is now refused with an InvalidValueError on the param's path unless it declares `immediate_write true`. Nothing is assigned or written: the map is write-free again, including when it then fails validation. `immediate_write false` now says the same as leaving it undeclared; it stays a distinct state so a child mapper can override its parent either way. The deprecation warning is removed. Related to #13. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bc06fbc to
8a6396a
Compare
What changes
#14 shipped the deprecation step: an explicit
nullon a param declaring nonullableis stilltreated like an absent key — skipped, or replaced by its
default— and warns that a future releasewill clear the value. This is that release.
nullablenow defaults totrueand the warning is gone:nullable falseis unchanged and refuses the null. An app that already declarednullableon aparam sees no change at all — that is exactly what the previous release's warning asked for.
An empty list is a clearing request too
On a multi-valued param (
many: true,type :array),[]now clears, likenull. It is what aclient sends once it has removed every element (
JSON.stringify({ parts: [] })), and until now itdid nothing while
nullemptied the param — two spellings of one intention with opposite outcomes,and the silent one is the one a JSON client produces naturally. Measured before this PR:
On a scalar param
[]still says nothing and changes nothing.A
defaultanswers an absent key onlyErasing a value and falling back to a default are opposite intentions, so a null wins over a
default. This aligns with whatmasteralready does fornullable true.Why it is stacked on #16
The flip is what makes the reference-write refusal load-bearing, so it must not land before it.
Clearing a
has_one/has_manyreference goes through the ActiveRecord association writer, whichapplies it to the database at once (#13). Until now a null only reached it through an explicit
nullable true. After the flip it would happen by default, for every undeclared reference receivinga null — so
{ "manual": null, "price": "abc" }would detach the child, then answer 422 for theprice, with nothing to roll it back. Measured on
master:Stacked on #16, the same payload:
#16's guard already covered the clearing path, so the stacking is all it took. Two
TestImmediateWritecases that asserted "a null is ignored, so nothing is written" now assert "anull is refused, and nothing is written".
Base:
refuse-reference-write(#16), itself onfix/reference-write-during-map(#15). Merge thosefirst, or retarget once they land.
Upgrading
A param that relied on "a null means leave it alone" needs
nullable false. Anything that alreadydeclares
nullableis unaffected. Clearing ahas_one/has_manyreference needsimmediate_write true, which is #16's contract, not a new one here.Tests
308 runs, 0 failures. The warning-machinery tests are gone with the warning; the undeclared-null
cases now assert the clear (including over a
default, and under a lambdarequiredansweringfalse), and new cases cover
[]clearing a multi-valued param,[]saying nothing on a scalar, andnullable falserefusing an empty list.🤖 Generated with Claude Code