Skip to content

Commit

Permalink
'relate' caster
Browse files Browse the repository at this point in the history
  • Loading branch information
EugZol committed Oct 5, 2023
1 parent 90066ba commit 2b7d652
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 7 deletions.
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ It is currently used in production in several projects (mainly as request parame
- [`validate(active_model_validations, name = 'Anonymous')`](#validateactive_model_validations-name--anonymous)
- [`compare(reference_value, error_key = nil)`](#comparereference_value-error_key--nil)
- [`included_in(*reference_values, error_key: nil)`](#included_inreference_values-error_key-nil)
- [`relate(left, op, right, error_key: nil)`](#relateleft-op-right-error_key-nil)
- [`transform { |value| ... }`](#transform--value--)
- [`transform_if_present { |value| ... }`](#transform_if_present--value--)
- [Array schemas](#array-schemas)
Expand Down Expand Up @@ -176,10 +177,10 @@ array = Datacaster.schema { array }
array.(nil)

# In this README
#=> Datacaster::ErrorResult(['should be an array'])
# => Datacaster::ErrorResult(['should be an array'])

# In reality
#=> <Datacaster::ErrorResult([#<Datacaster::I18nValues::Key(.array, datacaster.errors.array) {:value=>nil}>])>
# => <Datacaster::ErrorResult([#<Datacaster::I18nValues::Key(.array, datacaster.errors.array) {:value=>nil}>])>
```

See [section on i18n](#internationalization-i18n) for details.
Expand Down Expand Up @@ -902,6 +903,44 @@ Returns ValidResult if and only if `reference_values.include?` the value.

I18n keys: `error_key`, `'.included_in'`, `'datacaster.errors.included_in'`. Adds `reference` i18n variable, setting it to `reference_values.map(&:to_s).join(', ')`.

#### `relate(left, op, right, error_key: nil)`

Returns ValidResult if and only if `left`, `right` and `op` returns valid result. Doesn't transform the value.

Use `relate` to check relations between object keys:

```ruby
ordered =
# Check that hash[:a] < hash[:b]
Datacaster.schema do
transform_to_hash(
a: relate(:a, :<, :b) & pick(:a),
b: pick(:b)
)
end

ordered.(a: 1, b: 2)
# => Datacaster::ValidResult({:a=>1, :b=>2})

ordered.(a: 2, b: 1)
# => Datacaster::ErrorResult({:a=>["a should be < b"]})

ordered.({})
# => Datacaster::ErrorResult({:a=>["a should be < b"]})
```

Notice that shortcut definitions are available (illustrated in the example above) for the `relate` caster:

* `:key` provided as 'left' or 'right' argument is exactly the same as `pick(:key)` (works for a string, a symbol or an integer)
* `:method` provided as 'op' argument is exactly the same as `check { |(l, r)| l.respond_to?(method) && l.public_send(method, r) }` (works for a string or a symbol)

Formally, `relate(left, op, right, error_key: error_key)` will:

* call the `left` caster with the original value, return the result unless it's valid
* call the `right` caster with the original value, return the result unless it's valid
* call the `op` caster with the `[left_result, right_result]`, return the result unless it's valid
* return the original value as valid result

#### `transform { |value| ... }`

Always returns ValidResult. Transforms the value: returns whatever the block has returned.
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ en:
integer32: is not a 32-bit integer
iso8601: is not a string with ISO-8601 date and time
must_be: "is not %{reference}"
relate: "%{left} should be %{op} %{right}"
responds_to: "does not respond to %{reference}"
string: is not a string
to_boolean: does not look like a boolean
Expand Down
6 changes: 5 additions & 1 deletion lib/datacaster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ def absent
Datacaster::Absent.instance
end

def instance?(object)
object.is_a?(Mixin)
end

private

def build_schema(i18n_scope: nil, &block)
raise "Expected block" unless block

datacaster = DefinitionDSL.eval(&block)

unless datacaster.is_a?(Base)
unless Datacaster.instance?(datacaster)
raise "Datacaster instance should be returned from a block (e.g. result of 'hash_schema(...)' call)"
end

Expand Down
8 changes: 4 additions & 4 deletions lib/datacaster/caster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ def initialize(&block)
def cast(object, runtime:)
result = Runtimes::Base.(runtime, @cast, object)

raise TypeError.new("Either Datacaster::Result or Dry::Monads::Result " \
"should be returned from cast block") unless [Datacaster::Result, Dry::Monads::Result].any? { |k| result.is_a?(k) }

if result.is_a?(Dry::Monads::Result)
if defined?(Dry::Monads::Result) && result.is_a?(Dry::Monads::Result)
result = result.success? ? Datacaster.ValidResult(result.value!) : Datacaster.ErrorResult(result.failure)
end

raise TypeError.new("Either Datacaster::Result or Dry::Monads::Result " \
"should be returned from cast block") unless result.is_a?(Datacaster::Result)

result
end

Expand Down
46 changes: 46 additions & 0 deletions lib/datacaster/predefined.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,52 @@ def transform_to_value(value)
transform { value }
end

# min_amount: has_relation(:min_amount, :>, :max_amount)

def relate(left, op, right, error_key: nil)
error_keys = ['.relate', 'datacaster.errors.relate']
additional_vars = {}

{left: left, op: op, right: right}.each do |k, v|
if [String, Symbol, Integer].any? { |c| v.is_a?(c) }
additional_vars[k] = v
elsif !Datacaster.instance?(v)
raise RuntimeError, "expected #{k} to be String, Symbol, Integer or Datacaster::Base, but got #{v.inspect}", caller
end
end

if op.is_a?(Integer)
raise RuntimeError, "expected op to be String, Symbol or Datacaster::Base, but got #{op.inspect}", caller
end

if [left, op, right].none? { |x| Datacaster.instance?(x) }
error_keys.unshift(".#{left}.#{op}.#{right}")
end
error_keys.unshift(error_key) if error_key

left = pick(left) unless Datacaster.instance?(left)
right = pick(right) unless Datacaster.instance?(right)
op_caster = op
unless Datacaster.instance?(op_caster)
op_caster = check { |(l, r)| l.respond_to?(op) && l.public_send(op, r) }
end

cast do |value|
left_result = left.(value)
next left_result unless left_result.valid?
i18n_var!(:left, left_result.value) unless additional_vars.key?(:left)

right_result = right.(value)
next right_result unless right_result.valid?
i18n_var!(:right, right_result.value) unless additional_vars.key?(:right)

result = op_caster.([left_result.value, right_result.value])
next Datacaster.ErrorResult([I18nValues::Key.new(error_keys)]) unless result.valid?

Datacaster.ValidResult(value)
end.i18n_vars(additional_vars)
end

def remove
transform { Datacaster.absent }
end
Expand Down
28 changes: 28 additions & 0 deletions spec/datacaster_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,34 @@
end
end

describe "relate typecasting" do
it "performs picks and transforms with shotrcut definition" do
schema = Datacaster.schema { relate(:a, :<, :b) }

expect(schema.(a: 1, b: 2).to_dry_result).to eq Success(a: 1, b: 2)
expect(schema.(a: 2, b: 1).to_dry_result).to eq Failure(["a should be < b"])
end

it "performs picks and transforms with full definition" do
schema = Datacaster.schema { relate(transform(&:length), :==, transform_to_value(5)) }

expect(schema.([1, 2, 3, 4, 5]).to_dry_result).to eq Success([1, 2, 3, 4, 5])
expect(schema.([1, 2, 3, 4]).to_dry_result).to eq Failure(["4 should be == 5"])
end

it "passes first pick result on error" do
schema = Datacaster.schema { relate(check('datacaster.errors.integer') { false }, :<, check { false }) }

expect(schema.(b: 1).to_dry_result).to eq Failure(["is not an integer"])
end

it "passes second pick result on error if first is valid" do
schema = Datacaster.schema { relate(pass, :<, check('datacaster.errors.integer') { false }) }

expect(schema.(b: 1).to_dry_result).to eq Failure(["is not an integer"])
end
end

describe "string optional param typecasting" do
subject { described_class.schema { optional_param(string) } }

Expand Down

0 comments on commit 2b7d652

Please sign in to comment.