Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [Validations](#validations)
- [Working with objects](#working-with-objects)
- [Composition](#composition)
- [Error Handling](#error-handling)
- [DSL Reference](#dsl-reference)
- [Public Interface](#public-interface)

Expand Down Expand Up @@ -323,6 +324,77 @@ or `getter: -> { address&.installation&.street }`.
The flattening only works for reading: `site.city = "Hamburg"` is discarded, so assign to
`site.address.city` instead.

## Error Handling

Minitwin raises gem-specific exception classes so misuse of the DSL, of composition, of
dynamic aliases, of type coercion, and of the constructors can each be rescued on their own —
or all at once via the shared marker module:

```ruby
begin
UserTwin.from_object({ id: 42 })
rescue Minitwin::Error => e
# any Minitwin-raised error, regardless of which of the five classes below
end
```

| Class | Ruby base class | Raised when |
|---|---|---|
| `Minitwin::DefinitionError` | `ArgumentError` | The DSL itself is used incorrectly (e.g. `nested` without a block, `setter:` together with a block, `validates:` without ActiveModel). |
| `Minitwin::CompositionError` | `RuntimeError` | A composition source given via `on:` is missing, or the source object doesn't respond to the composed property. |
| `Minitwin::AliasError` | `ArgumentError` | A dynamic alias (`as: -> { ... }`) is invalid, forbidden, or collides with another alias or an existing method. |
| `Minitwin::CoercionError` | `TypeError` | A value assigned to a block/nested property cannot be converted into that nested twin (or a custom `type:` callable raises it explicitly). |
| `Minitwin::ParseError` | `ArgumentError` | Input data is structurally unusable — malformed JSON in `from_json`, or a Hash passed to `from_object` instead of `from_objects`. |

`AliasError` (raised from the dynamic-alias checks) and `CompositionError` (raised from the
`on:` composition checks) keep their original Ruby base class unchanged — `ArgumentError` and
`RuntimeError` respectively — so existing `rescue ArgumentError` / `rescue RuntimeError` call
sites around those two paths are unaffected by this release.

Four previously-`RuntimeError` paths, however, now raise an `ArgumentError`/`TypeError`
sibling instead:

- `property ... setter: ->(v) { ... } do ... end` (a `setter:` combined with a block) —
now `Minitwin::DefinitionError` (`ArgumentError`)
- Assigning an unconvertible value to a block/nested property — now `Minitwin::CoercionError`
(`TypeError`)
- `validates:` used without ActiveModel available — now `Minitwin::DefinitionError`
(`ArgumentError`)
- `from_object` called with a Hash instead of `from_objects` — now `Minitwin::ParseError`
(`ArgumentError`)

Code that specifically rescued `RuntimeError` around any of these four call sites must widen
the rescue to `StandardError` / `Minitwin::Error`, or catch the new specific class, to keep
catching them. `Minitwin::Error` is an additional, more specific way to catch any of the five
classes above, not a replacement hierarchy.

This release ships three behavior changes relative to earlier versions:

1. **`from_json` wraps malformed JSON.** A `JSON::ParserError` raised while parsing is now
wrapped into `Minitwin::ParseError`; the original error is still available via `#cause`:

```ruby
begin
UserTwin.from_json("{not valid json")
rescue Minitwin::ParseError => e
e.cause #=> #<JSON::ParserError: ...>
end
```

Code that rescued `JSON::ParserError` directly around `from_json` needs to rescue
`Minitwin::ParseError` (or `ArgumentError`) instead.
2. **Four `RuntimeError` sites were reclassified**, as listed above — `rescue RuntimeError`
around them must be widened.
3. **A custom `type:` callable that raises a `Minitwin::Error` subclass now propagates it**
instead of having it swallowed:

```ruby
property :x, type: ->(v) { raise Minitwin::ParseError, "bad input" if v.blank?; v }
```

Before this release: a `Minitwin::Error` raised inside a custom `type:` callable was
swallowed and the raw value returned. Now it propagates.

---

## DSL Reference
Expand Down
3 changes: 3 additions & 0 deletions lib/minitwin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@

# Setup Zeitwerk loader for the gem to support Rails autoloading
loader = Zeitwerk::Loader.for_gem
loader.ignore("#{__dir__}/minitwin/errors.rb")
loader.setup

require_relative "minitwin/errors"

class Minitwin
# Constants for internal variable naming
INTERNAL_MODEL_PREFIX = "@internal_model__"
Expand Down
3 changes: 3 additions & 0 deletions lib/minitwin/class_methods/constructors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def from_hash(args)
def from_json(body)
hash = JSON.parse(body, symbolize_names: true)
from_hash(hash)
rescue ::JSON::ParserError => exception
raise ParseError, "Invalid JSON passed to #{name}.from_json: #{exception.message}"
end

# Actually, this is expected to be an `ActionController::Parameters`
Expand All @@ -40,6 +42,7 @@ def from_params(params)
def from_object(model)
if model.is_a?(Hash)
raise(
ParseError,
"Input is not an object. If you want to instantiate a Minitwin with multiple " \
"objects, then use the pluralized 'from_objects'-method."
)
Expand Down
11 changes: 6 additions & 5 deletions lib/minitwin/class_methods/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,13 @@ def property(
ivar = Minitwin::Utils.ivar_name(name)

if block_given?
raise "setters are not possible in blocks" if setter
raise DefinitionError, "setters are not possible in blocks" if setter

nested_class = create_nested_class(name:, &block)

define_method("#{name}=") do |value|
coerced = self.class.send(:coerce_value_to_twin, value, nested_class)
raise "Unprocessable input for property '#{name}'." unless coerced.nil? || coerced.is_a?(nested_class)
raise CoercionError, "Unprocessable input for property '#{name}'." unless coerced.nil? || coerced.is_a?(nested_class)

instance_variable_set(ivar, coerced)
if !@__skip_alias_recompute__ && self.class.dynamic_aliases?
Expand Down Expand Up @@ -144,7 +144,7 @@ def property(
# @rbs as: Symbol | Proc
# @rbs return: void
def nested(name, as: nil, &block)
raise ArgumentError, "nested requires a block" unless block_given?
raise DefinitionError, "nested requires a block" unless block_given?

property(name, as: as, &block)

Expand Down Expand Up @@ -307,12 +307,13 @@ def build_composition_getter(name:, on:, default:, type:)
# Validate model
if model.nil?
raise(
CompositionError,
"Property '#{name}' refers to unknown composition source '#{on}' in #{self.class}. " \
"Ensure the model is provided via from_objects or a reader exists."
)
end
unless model.respond_to?(name)
raise "The instance of '#{model.class}' does not respond to '#{name}'."
raise CompositionError, "The instance of '#{model.class}' does not respond to '#{name}'."
end

raw = model.send(name)
Expand Down Expand Up @@ -373,7 +374,7 @@ def add_validation(name:, validates:)
return if validates.nil?
return if validates.respond_to?(:empty?) && validates.empty?

raise "Validation is not possible, because activemodel is not available" unless respond_to?(:validates)
raise DefinitionError, "Validation is not possible, because activemodel is not available" unless respond_to?(:validates)

if validates.is_a?(Proc)
validate do
Expand Down
6 changes: 6 additions & 0 deletions lib/minitwin/class_methods/types_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ def attempt_type_coercion(raw_value, type)
return raw_value unless type

type.call(raw_value)
rescue Minitwin::Error
# Re-raise: a Minitwin::Error (e.g. from a custom type: callable) must propagate
# instead of being swallowed by the broader rescue below, which is for ordinary
# coercion failures only. CoercionError/AliasError/ParseError/DefinitionError are
# all TypeError/ArgumentError subclasses, so this clause has to come first.
raise
Comment on lines +65 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Soll hier wirklich eine "leere" Exception geworfen werden?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nein, die ist nicht leer. Ein raise in einem rescue re-raised die Exception exakt so, wie sie ist. Das ist erforderlich, damit die zweite rescue Klausel keine von unseren neuen Typen schluckt. (die erben von den dort gefangenen) Aus diesem Grund spielt die Reihenfolge hier eine große Rolle. Ich werde einen Kommentar ergänzen, damit das klar wird.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Danke, mach das gern.

rescue *Minitwin.send(:coercion_error_classes)
raw_value
end
Expand Down
32 changes: 32 additions & 0 deletions lib/minitwin/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true
# rbs_inline: enabled

class Minitwin
# Marker: `rescue Minitwin::Error` catches every gem-specific error.
module Error; end

# DSL used incorrectly — fails at class-definition time.
class DefinitionError < ::ArgumentError
include Error
end

# A source object cannot supply a composed property.
class CompositionError < ::RuntimeError
include Error
end

# A dynamic alias is invalid, forbidden, or collides.
class AliasError < ::ArgumentError
include Error
end

# A value cannot be coerced into the expected twin type.
class CoercionError < ::TypeError
include Error
end

# Input data is structurally unusable (JSON, wrong object type).
class ParseError < ::ArgumentError
include Error
end
end
8 changes: 4 additions & 4 deletions lib/minitwin/initialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def __compute_nested_alias_name__(entry)

def __apply_dynamic_alias__(target_method, alias_name)
unless alias_name.is_a?(String) || alias_name.is_a?(Symbol)
raise ArgumentError, "Invalid alias name #{alias_name.inspect}: must be a String or Symbol"
raise AliasError, "Invalid alias name #{alias_name.inspect}: must be a String or Symbol"
end

alias_key = alias_name.to_sym
Expand All @@ -147,7 +147,7 @@ def __apply_dynamic_alias__(target_method, alias_name)

# Security check: prevent aliasing to forbidden method names
if FORBIDDEN_ALIAS_NAMES.include?(alias_key)
raise ArgumentError, "Cannot define dynamic alias '#{alias_key}': forbidden method name for security reasons"
raise AliasError, "Cannot define dynamic alias '#{alias_key}': forbidden method name for security reasons"
end

prev = aliases[target_method]
Expand All @@ -162,11 +162,11 @@ def __apply_dynamic_alias__(target_method, alias_name)

# Collision checks: alias already used by another target or an existing method
if aliases_rev.key?(alias_key) && aliases_rev[alias_key] != target_method
raise ArgumentError, "Dynamic alias '#{alias_key}' already defined for '#{aliases_rev[alias_key]}'"
raise AliasError, "Dynamic alias '#{alias_key}' already defined for '#{aliases_rev[alias_key]}'"
end

if respond_to?(alias_key, true) && aliases_rev[alias_key] != target_method
raise ArgumentError, "Cannot define dynamic alias '#{alias_key}': method already exists"
raise AliasError, "Cannot define dynamic alias '#{alias_key}': method already exists"
end

# Define forwarding method on the singleton class
Expand Down
32 changes: 32 additions & 0 deletions sig/generated/minitwin/errors.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated from lib/minitwin/errors.rb with RBS::Inline

class Minitwin
# Marker: `rescue Minitwin::Error` catches every gem-specific error.
module Error
end

# DSL used incorrectly — fails at class-definition time.
class DefinitionError < ::ArgumentError
include Error
end

# A source object cannot supply a composed property.
class CompositionError < ::RuntimeError
include Error
end

# A dynamic alias is invalid, forbidden, or collides.
class AliasError < ::ArgumentError
include Error
end

# A value cannot be coerced into the expected twin type.
class CoercionError < ::TypeError
include Error
end

# Input data is structurally unusable (JSON, wrong object type).
class ParseError < ::ArgumentError
include Error
end
end
2 changes: 1 addition & 1 deletion test/class_methods/constructors_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ class ActiveModelPerson
property :name
end

error = assert_raises(RuntimeError) do
error = assert_raises(Minitwin::ParseError) do
klass.from_object({ name: "test" })
end
assert_match(/use.*from_objects/, error.message)
Expand Down
8 changes: 4 additions & 4 deletions test/class_methods/dsl/composition_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def klass.collections(*)

# Should raise informative error
obj = klass.new
error = assert_raises(RuntimeError) { obj.name }
error = assert_raises(Minitwin::CompositionError) { obj.name }
assert_match(/unknown composition source/, error.message)
end

Expand All @@ -124,7 +124,7 @@ def klass.collections(*)
end

obj = klass.from_objects(model: model)
error = assert_raises(RuntimeError) { obj.name }
error = assert_raises(Minitwin::CompositionError) { obj.name }
assert_match(/does not respond to/, error.message)
end

Expand All @@ -148,7 +148,7 @@ def klass.collections(*)
end

obj = klass.from_objects(model: model)
error = assert_raises(RuntimeError) do
error = assert_raises(Minitwin::CompositionError) do
obj.name
end
assert_match(/does not respond to/, error.message)
Expand All @@ -160,7 +160,7 @@ def klass.collections(*)
end

obj = klass.new
error = assert_raises(RuntimeError) do
error = assert_raises(Minitwin::CompositionError) do
obj.name
end
assert_match(/unknown composition source/, error.message)
Expand Down
4 changes: 2 additions & 2 deletions test/class_methods/dsl/nested_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ class StringAliasNestedTwin < Minitwin
assert_equal "test", obj.my_nested_prop.value
end

test "nested without a block raises an ArgumentError" do
error = assert_raises(ArgumentError) do
test "nested without a block raises a Minitwin::DefinitionError" do
error = assert_raises(Minitwin::DefinitionError) do
Class.new(Minitwin) do
nested :invalid
end
Expand Down
11 changes: 11 additions & 0 deletions test/class_methods/dsl/properties_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ class << self
assert_match(/setters are not possible in blocks/, klass.caught_error.message)
end

test "raises Minitwin::CoercionError when a block property receives an unconvertible value" do
klass = Class.new(Minitwin) do
property :nested_thing do
property :value
end
end

error = assert_raises(Minitwin::CoercionError) { klass.new(nested_thing: 42) }
assert_match(/Unprocessable input for property/, error.message)
end

# --- defaults / types / boolean names (error handling) ---

test "property without type returns nil" do
Expand Down
2 changes: 1 addition & 1 deletion test/class_methods/dsl/validation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def self.name
end

# Should raise when trying to add validations without ActiveModel
error = assert_raises(RuntimeError) do
error = assert_raises(Minitwin::DefinitionError) do
base.class_eval do
property :name, validates: { presence: true }
end
Expand Down
Loading
Loading