Skip to content

Commit 37cd0ee

Browse files
authored
Merge pull request #48 from serradura/feature/micro-entity-issue-9
3.1.0: composition in Micro::Attributes + Micro::Attributes.new + hash API (closes #9)
2 parents 9afc237 + 08979d1 commit 37cd0ee

20 files changed

Lines changed: 4146 additions & 649 deletions

CHANGELOG.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
> **Note:** This gem was originally published as `micro-attributes` (`0.1.0`) and renamed to `u-attributes` starting with `0.2.0` on 2019-07-02.
99
10+
## [3.1.0] - 2026-05-25
11+
### Added
12+
- **Composition baked into `Micro::Attributes`** (closes [#9](https://github.com/serradura/u-attributes/issues/9)) — every class that includes `Micro::Attributes` (directly or via `Micro::Attributes.with(...)`) now supports:
13+
- **Block-form `attribute :foo do ... end`** that defines an anonymous nested class inline. The inline child inherits the host's full feature mix (strict, symbol keys, ActiveModel, etc.).
14+
- **Hash → child-instance coercion** when `accept:` is another `Micro::Attributes` class. Already-built instances pass through unchanged. Nested coercion composes recursively to any depth.
15+
- **Deep validation bubbling.** Any descendant's `attributes_errors?` (or AM `valid?`) is mirrored up the chain as a `'is invalid'` marker; the leaf retains the original message. For classes with `:activemodel_validations`, a `__validate_nested_entities__` validator is auto-registered so `parent.valid?` reflects deep descendant invalidity. Mixed trees (AM root + accept-only leaf) work via an `attributes_errors?` fallback.
16+
- **`Micro::Attributes.new(options = {}, &block)`**`Struct.new`-style class factory. Returns a fresh class that includes `Micro::Attributes.with(...)` with the requested features merged on top of the preset `{ initialize: true, accept: true }`. The block is `class_eval`d so attributes can be declared inline.
17+
- **Hash-style configuration for `Micro::Attributes.with`** — alongside the positional symbol API, `with` now accepts a self-documenting hash:
18+
```ruby
19+
Micro::Attributes.with(
20+
initialize: true | :strict,
21+
accept: true | :strict,
22+
diff: true,
23+
keys_as: :symbol | :string | :indifferent,
24+
active_model: :validations
25+
)
26+
```
27+
Omit a key (or pass `false` / `nil`) to disable the feature. Both APIs can be mixed; the existing positional form (`with(:initialize, :accept)`, `with(initialize: :strict)`) is fully preserved.
28+
- **`with(...)` class macro** added to every `Micro::Attributes` includer. Sugar for `include ::Micro::Attributes.with(...)`; layer extra features inline (`with :keys_as_symbol`, `with active_model: :validations`, etc.).
29+
- **Multi-key hash to `Micro::Attributes.with` / `.without`**`with(initialize: :strict, accept: :strict)` now honors every key. Previously `fetch_key` returned the first matching strict variant and silently dropped the others.
30+
31+
### Changed
32+
- **Heads up — silent behavior shift for downstream consumers:** `Micro::Attributes.with(initialize: :strict, accept: :strict)` and `Micro::Attributes.without(initialize: :strict, accept: :strict)` now resolve to a different feature module than 3.0.x because the multi-key strict hash bug was fixed. Pre-3.1 `with(initialize: :strict, accept: :strict)` returned only `AcceptStrict`; post-3.1 it returns `AcceptStrict_InitializeStrict`. Pre-3.1 `without(initialize: :strict, accept: :strict)` only excluded `AcceptStrict`; post-3.1 it excludes both strict variants. Any code that relied on the silent drop will get a different feature mix on upgrade.
33+
- **Heads up — `__validate_nested_entities__` is now auto-registered on every `Micro::Attributes` includer that mixes in `:activemodel_validations`** (pre-3.1 the registration existed only inside `Micro::Entity`, which is gone). Any class on `with(:accept, :activemodel_validations)` whose nested attribute targets are also AM-enabled will now have `valid?` recurse into descendants for the first time — descendant invalidity will surface in the parent's `errors` as `'is invalid'`. If your previous "siloed validity" was load-bearing, register your own validator that doesn't recurse, or use `accept:` without AM on the descendant types. **This affects `u-case` downstream**: any `Micro::Case` subclass with `with_activemodel_validation` and a nested `accept: SomeAMValidatedClass` now propagates descendant invalidity into `Failure(:invalid_attributes)` — previously the Case succeeded if `accept:` itself didn't reject.
34+
- **Heads up — block-form inline children now always include `:initialize` and `:accept`,** even if the host class explicitly chose a minimal feature mix (e.g. `include Micro::Attributes.with(:diff)` only). Pre-3.1 the inline child mirrored the host's feature mix; post-3.1 init+accept are added on top of whatever the host has. This makes `attribute :foo do ... end` uniformly hash-constructible and accept-checking (the only behaviors that make block-form sensible), but it does add an Accept-validation surface that didn't exist before for hosts that opted out of Accept. If a parent class without Accept has a block-form child with `accept: SomeType` declarations, those declarations are now honored — `obj.child.attributes_errors?` will be true on invalid input.
35+
- **Heads up — `attribute :foo, accept: X do ... end` now raises `ArgumentError`.** Pre-3.1 a block on `attribute` / `attribute!` was silently ignored; the first cut of this PR consumed the block to build an inline class and silently overwrote any explicit `options[:accept]`. The final 3.1.0 behavior raises loudly when both are passed, since "which wins?" is genuinely ambiguous. Pre-existing call sites that passed a block on accident (and saw it ignored) will surface as `ArgumentError: attribute :name: cannot pass both \`accept:\` and a block`. Drop one of the two.
36+
- **Heads up — Coercion is prepended on every `Micro::Attributes` includer.** Any user who overrode `__attribute_assign` (the instance method, private internal API) on their host class would have been silently intercepted by Coercion's prepended override. To make the override less inviting and avoid even a theoretical collision, the instance-level per-attribute hook has been renamed from `__attribute_assign` (double underscore) to `___attribute_assign` (triple underscore). The class-method macro of the prior name in `Macros` keeps its name and signature (`__attribute_assign(key, can_overwrite, opt)` — different concern, no MRO collision; preserved for `u-case` v4 introspection). If you reached into the instance-level method, rename to the triple-underscore form.
37+
- **Heads up — `Coercion` rescues `ArgumentError` from `klass.new(value)`.** When the hash → child coercion blows up (typically missing required keys on a strict child), the raw hash is left in place so Accept's KindOf check (`expected to be a kind of <Klass>`) rejects it into `attributes_errors` instead of letting the inner raise escape. This preserves u-case's `Failure(:invalid_attributes)` envelope for non-strict outer Cases that hold `accept:` nested entities. For strict outer Cases the rejection still raises — only the message changes (controlled "kind of" wording instead of the bare "missing keyword" from inside the child).
38+
39+
### Fixed
40+
- `attribute!` (subclass overwrite) with a `default:` did not clear the inherited `__attributes_required__` entry when the parent had `Initialize::Strict`. `Child.new({})` raised `ArgumentError: missing keyword: ...` even though the child gave the attribute a default. `__attributes_required_add` is now an add-or-remove sync (called from `__attributes_data_to_assign`) so the required set reflects the current options. **`default:` always wins**when an attribute is declared with a default, `required: true` is treated as a docs hint (matches 3.0.x behavior) and the attribute is not added to the required set, regardless of strict-mode or explicit `required: true`.
41+
- `attribute!` (subclass overwrite) couldn't change an attribute's Ruby visibility back from `private`/`protected` to `public` — it updated `__attributes_data__` (and so the `#attributes` hash reflected the new visibility), but the inherited reader method retained its original Ruby visibility. The class-method `__attribute_assign` macro now re-applies visibility for already-defined attributes when overwriting (via the new private class method `__attribute_reapply_visibility`).
42+
- Block-form `attribute :name do ... end` and `attribute! :name do ... end` no longer silently ignore the block. The block is captured to build an anonymous inline class; passing both `accept:` and a block raises `ArgumentError` (see Changed §).
43+
- Block-form nested attributes (`attribute :foo do ... end`) no longer leak the host class's user-defined attributes — or any sibling attributes added to the same class body after the block runs — into the inline nested class. The inline child is now built by replaying every `Micro::Attributes::With::*` module found on the host's ancestors, so the feature mix is reconstructed independently of `self`'s declared attributes.
44+
- Block-form inline classes used in an `:activemodel_validations` host no longer raise `"Class name cannot be blank"` when ActiveModel renders error messages. The inline class now exposes a `model_name` (and lazily-resolved `to_s` / `inspect`) with an explicit name like `"Order(customer)"`, so `errors.full_messages` works and the parent's heap address never leaks into validation output — even when the host is itself an anonymous class created via `Micro::Attributes.new { ... }`.
45+
- `Composition::Coercion` now gates on a precise arity check (`arity == 1 || arity == -1 || arity == -2`) instead of `klass.include?(Features::Initialize)`. The check covers `Features::Initialize` includers AND user-defined hash constructors (`def initialize(arg); self.attributes = arg; end` — the long-standing `u-case` v4 idiom). Multi-required-arg constructors (`def initialize(a, b)`, arity 2+) are correctly SKIPPED so they don't crash on `klass.new(hash)` — the value passes through to the standard accept check instead.
46+
- Inline-class `inspect` now filters by `self.class.attributes_by_visibility[:public]` instead of the `@__*` prefix. This hides BOTH (a) ActiveModel internals (`@errors`, `@validation_context`, `@context_for_validation`) and (b) private/protected attribute VALUES, which the previous ivar-prefix filter let leak when AM was in the mix or when the host had private attrs.
47+
- The `model_name` singleton on inline classes is now defined ONLY when the inline class includes `ActiveModel::Validations`. The previous always-define approach flipped `respond_to?(:model_name)` from false → true on AM-less hosts, breaking duck-typing feature-detection in third-party libraries.
48+
- `Micro::Attributes.new` now rejects any `:initialize` value that isn't `true` or `:strict` — covers `false`, `nil`, and garbage values like `'on'`. Pre-fix only `== false` was caught, so `Micro::Attributes.new(initialize: nil)` silently built a class with no hash constructor.
49+
- Layered `Micro::Attributes.with(...)` calls — two `include`s or `include` + `with` class macro — now reach block-form inline children with the **full** combined feature mix. The previous "first-include-wins" cache silently dropped features for inline children; ancestors are now scanned at build time so every layered feature is replayed.
50+
- Block-form `attribute :foo do ... end` works when the host class includes `Micro::Attributes` (or `Features::*`) DIRECTLY without going through `Micro::Attributes.with(...)` — the `u-case` usage pattern. Pre-fix the inline child fell back to bare `Micro::Attributes` (no `:initialize`) and hashes weren't coerced. The build path now detects every `Features::*` module already in the host's ancestors and rebuilds an equivalent `with(...)` mix for the inline child, always including `:initialize` and `:accept` defaults so block-form has a hash constructor and accept-validation.
51+
- Inline-class `model_name` singleton is now defined unconditionally with an at-call-time `defined?(::ActiveModel::Name)` check. Previously the singleton was only defined when AM was loaded at inline-class build time — gem authors who define classes eagerly and let Rails autoload AM later (a real load-order pattern) would otherwise hit the original `"Class name cannot be blank"` error.
52+
- Instance-level `inspect` on a block-form inline instance no longer leaks the anonymous class's heap address. Ruby's default `Object#inspect` reads `Module#name` (still `nil` on anonymous inline classes) rather than `to_s`, so the previous fix at the class level didn't help instances. Inline classes now define `inspect` to use the stable class label.
53+
- The Coercion bubble (writes `'is invalid'` to the parent's `attributes_errors`) is gated on `attribute_data[3] == :public`. Private/protected nested-entity attribute names don't surface through the new bubble path — pre-existing direct Accept reject errors are unchanged (still surface for all visibilities, matching 3.0.x).
54+
- `__validate_nested_entities__` iterates `attributes_by_visibility[:public]` instead of all attributes, so private/protected nested-attribute names don't surface through the new auto-registered AM validator (the new cascade respects visibility; pre-existing direct AM validators are unchanged).
55+
- `Micro::Attributes.new(active_model: :validations) { ... }` no longer raises `ActiveModel::Name#initialize: Class name cannot be blank` the first time `errors.full_messages` runs. The factory class now installs a `model_name` singleton (mirroring the inline-child fix at `Macros#__micro_attributes_build_inline_class__`) that resolves the label lazily via `self.name || self.inspect`, so AM error rendering works whether the result is assigned to a constant or kept anonymous.
56+
- `__validate_nested_entities__` no longer wipes a shared child's pre-existing errors. AM's `valid?` calls `errors.clear` before re-running validators, so a child instance whose caller had added errors externally (or that was already-validated and shared across parents) would silently lose those errors as soon as one parent ran `parent.valid?`. The validator now short-circuits to "invalid" when `child.errors.any?` is already true and skips the re-validation in that case.
57+
- Block-form `attribute :foo do ... end` no longer overwrites a user-defined `def inspect` placed inside the block. The macro's default `inspect` is now only installed when the inline class doesn't already define one directly (`instance_methods(false)`), so customizations declared in the block take precedence.
58+
1059
## [3.0.2] - 2026-05-24
1160
### Added
1261
- This `CHANGELOG.md`, covering the full history of the gem (from `micro-attributes 0.1.0` through `u-attributes 3.0.2`) following the [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) spec.
@@ -260,6 +309,7 @@ First stable release.
260309
- `Micro::Attributes` mixin with the `.attribute` / `.attributes` macros for declaring attributes on a plain Ruby object.
261310
- Generated reader methods plus the `with_attribute` / `with_attributes` constructors that return a new instance with the updated values (no setters).
262311

312+
[3.1.0]: https://github.com/serradura/u-attributes/compare/v3.0.2...v3.1.0
263313
[3.0.2]: https://github.com/serradura/u-attributes/compare/v3.0.1...v3.0.2
264314
[3.0.1]: https://github.com/serradura/u-attributes/compare/v3.0.0...v3.0.1
265315
[3.0.0]: https://github.com/serradura/u-attributes/compare/v2.8.0...v3.0.0

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ gemspec
77

88
gem "rake", "~> 13.0"
99

10-
gem "u-case", "~> 4.5", ">= 4.5.1"
10+
gem "u-case", "~> 5.7"
1111

1212
group :test do
1313
gem "logger"

0 commit comments

Comments
 (0)