Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial rules context interface #675

Merged
merged 2 commits into from
Nov 23, 2020
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ Gemfile.lock
doc
.yardoc/
.vscode
.ruby-version
.ruby-gemset
45 changes: 45 additions & 0 deletions docsite/source/rules.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,51 @@ PersonContract.new.call(email: 'bar', name: 'foo').errors.to_h
# {name: ['name rule error'], email: ['email rule error']}
```

### Rules context

Rules context is convenient for sharing data between rules or return data in the validation result. It can be used, for example, to return the data that was fetched from DB.

For example:

```ruby
class UpdateUserContract < Dry::Validation::Contract
option :user_repo

params do
required(:user_id).filled(:string)
end

rule(:user_id) do |context:|
context[:user] ||= user_repo.find(value)
key.failure(:not_found) unless context[:user]
end
end

contract = UserContract.new(address_repo: UserRepo.new)
contract.call(user_id: 42).context.each.to_h
# => {user: #<User id: 42>}
```

Initial context can be passed to the contract and in this case, the contract is not going to fetch user from the repo (we don't even need to pass the repo instance as a dependency because this code will not be executed here):

```ruby
user = UserRepo.new.find(42)
contract = UserContract.new
contract.call({user_id: 42}, context: {user: user}).context.each.to_h
# => {user: #<User id: 42>}
```

Also, defualt context can be provided on contract initialization:

```ruby
user = UserRepo.new.find(42)
contract = UserContract.new(default_context: {user: user})
contract.call(user_id: 42).context.each.to_h
# => {user: #<User id: 42>}
```

Context passed to the `call` method overrides keys from `default_context`.

### Defining a rule for each element of an array

To check each element of an array you can simply use `Rule#each` shortcut. It works just like a normal rule, which means it's only applied when a value passed schema checks and supports setting failure messages in the standard way.
Expand Down
15 changes: 13 additions & 2 deletions lib/dry/validation/contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ class Contract
# @api public
option :macros, default: -> { config.macros }

# @!attribute [r] default_context
# @return [Hash] Default context for rules
# @api public
option :default_context, default: -> { EMPTY_HASH }

# @!attribute [r] schema
# @return [Dry::Schema::Params, Dry::Schema::JSON, Dry::Schema::Processor]
# @api private
Expand All @@ -86,12 +91,18 @@ class Contract
# Apply the contract to an input
#
# @param [Hash] input The input to validate
# @param [Hash] context Initial context for rules
#
# @return [Result]
#
# @api public
def call(input)
Result.new(schema.(input), Concurrent::Map.new) do |result|
def call(input, context = EMPTY_HASH)
context_map = Concurrent::Map.new.tap do |map|
default_context.each { |key, value| map[key] = value }
context.each { |key, value| map[key] = value }
end

Result.new(schema.(input), context_map) do |result|
rules.each do |rule|
next if rule.keys.any? { |key| error?(result, key) }

Expand Down
25 changes: 22 additions & 3 deletions spec/integration/contract/evaluator/using_context_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
end

context "when key does not exist" do
subject(:contract) do
Dry::Validation.Contract do
subject(:contract) { contract_class.new }

let(:contract_class) do
Class.new(Dry::Validation::Contract) do
schema do
required(:email).filled(:string)
required(:user_id).filled(:integer)
Expand All @@ -33,7 +35,24 @@
end

it "exposes context in result" do
expect(contract.(user_id: 312, email: "jane@doe.org").context[:user]).to eql("jane")
expect(contract.(user_id: 312, email: "jane@doe.org").context.each.to_h).to eql(user: "jane")
end

it "uses the initial context" do
expect(contract.({user_id: 312}, {name: "John"}).context.each.to_h)
.to eql(user: "jane", name: "John")
end

context "when default context is defined" do
subject(:contract) do
contract_class.new(default_context: {user: "Redefined", name: "Redefined", details: "Present"})
end

it "initial context redefines it" do
expect(contract.({user_id: 312}, {name: "John"}).context.each.to_h)
.to eql(user: "jane", name: "John", details: "Present")
end
end

end
end