Skip to content

Commit

Permalink
Support serialization of Ruby objects via ERB
Browse files Browse the repository at this point in the history
You can send objects, that are __stringified by the `Object#to_s`__,
into fixture loaders (seeds, stubs etc.) via ERB bindings.

Those objects will be gracefully inserted into the resulting structure.

```yaml
---
foo: <%= user %>
```

```ruby
let(:user) { FactoryBot.create :user }

subject { load_fixture "#{__dir__}/data.yml", user: user }

it { is_expected.to eq foo: user }
```
  • Loading branch information
nepalez committed Jun 9, 2019
1 parent 56ec0e3 commit 58b398a
Show file tree
Hide file tree
Showing 7 changed files with 229 additions and 10 deletions.
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,47 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
The loader recognizes complex extensions like `data.yml.erb`
or `data.json.erb`, as well as `data.YAML` in upper register.

- Support for Ruby objects (including Activerecord models) serialization
in the parameters of fixtures (nepalez)

You can send objects, that are stringified in a default Ruby way,
into fixture loaders (seeds, stubs etc.) via ERB bindings.
Those objects will be gracefully inserted into the resulting structure:

```yaml
---
:account: <%= user %>
```
```ruby
let(:user) { FactoryBot.create :user }
subject { load_fixture "#{__dir__}/output.yml", user: user }

# The `user` object has been bound via ERB
it { is_expected.to eq account: user }
```
This feature can be used for adding RSpec [matching arguments](https://relishapp.com/rspec/rspec-mocks/v/3-8/docs/setting-constraints/matching-arguments):
```yaml
---
:foo: <%= foo %>
:bar: 3
```
```ruby
# Use the RSpec `anyting` matcher
subject { { foo: 4, bar: 3 } }

let(:template) { load_fixture "#{__dir__}/template.yml", foo: anyting }

# The `anyting` has been bound via ERB to the template
# Compare `{ foo: 4, bar: 3 }` to the template `{ foo: anything, bar: 3 }`
it { is_expected.to include(template) }
```

**Be careful though**: the trick won't work with objects whose default method `Object#to_s` has been overloaded.

## [0.0.5] - [2018-06-04]

### Added
Expand Down
12 changes: 2 additions & 10 deletions lib/fixturama.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
module Fixturama
require_relative "fixturama/config"
require_relative "fixturama/utils"
require_relative "fixturama/loader"
require_relative "fixturama/stubs"
require_relative "fixturama/seed"

Expand All @@ -28,12 +29,7 @@ def seed_fixture(path, **opts)
end

def load_fixture(path, **opts)
basename = Pathname.new(path).basename.to_s

read_fixture(path, **opts).tap do |content|
return YAML.load(content) if basename[YAML]
return JSON.parse(content) if basename[JSON]
end
Loader.new(path, opts).call
end

def read_fixture(path, **opts)
Expand All @@ -49,8 +45,4 @@ def read_fixture(path, **opts)
def fixturama_stubs
@fixturama_stubs ||= Stubs.new
end

# Matchers for YAML/YML/JSON in file extension like "data.yml.erb" etc.
YAML = /.+\.ya?ml(\.|\z)/i.freeze
JSON = /.+\.json(\.|\z)/i.freeze
end
80 changes: 80 additions & 0 deletions lib/fixturama/loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#
# Load fixture with some options
#
class Fixturama::Loader
require_relative "loader/value"
require_relative "loader/context"

def call
return load_yaml if yaml?
return load_json if json?

content
end

private

def initialize(path, opts = {})
@path = path
@opts = opts.to_h
end

def basename
@basename ||= Pathname.new(@path).basename.to_s
end

def yaml?
!basename[YAML_EXTENSION].nil?
end

def json?
!basename[JSON_EXTENSION].nil?
end

def context
@context ||= (yaml? || json?) ? Context.new(@opts) : Hashie::Mash.new(@opts)
end

def content
bindings = context.instance_eval { binding }
content = File.read(@path)

ERB.new(content).result(bindings)
end

def load_yaml
finalize YAML.load(content)
end

def load_json
finalize JSON.parse(content)
end

# Takes the nested data loaded from YAML or JSON-formatted fixture,
# and serializes its leafs to the corresponding values from a context
def finalize(data)
case data
when Array
data.map { |val| finalize(val) }
when Hash
data.each_with_object({}) { |(key, val), obj| obj[key] = finalize(val) }
when String
finalize_string(data)
else
data
end
end

# Converts strings of sort `#<Fixturama::Loader::Context[:key]>`
# to the corresponding value by the key
# @param [String] string
# @return [Object]
def finalize_string(string)
key = string.match(Value::MATCHER)&.captures&.first&.to_s
key ? context[key] : string
end

# Matchers for YAML/YML/JSON in file extension like "data.yml.erb" etc.
YAML_EXTENSION = /.+\.ya?ml(\.|\z)/i.freeze
JSON_EXTENSION = /.+\.json(\.|\z)/i.freeze
end
30 changes: 30 additions & 0 deletions lib/fixturama/loader/context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class Fixturama::Loader
#
# The context of some fixture
#
class Context
# Get value by key
# @param [#to_s] key
# @return [Object]
def [](key)
@values.send(key).instance_variable_get(:@value)
end

private

def initialize(values)
@values = \
Hash(values).each_with_object(Hashie::Mash.new) do |(key, val), obj|
obj[key] = Value.new(key, val)
end
end

def respond_to_missing?(name, *)
@values.respond_to?(name) || super
end

def method_missing(name, *args)
@values.respond_to?(name) ? @values.send(name, *args) : super
end
end
end
38 changes: 38 additions & 0 deletions lib/fixturama/loader/value.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
class Fixturama::Loader
#
# Wraps a value with a reference to its key
# in the [Fixturama::Loader::Context]
#
class Value
# Regex mather to extract value key from the stringified wrapper
MATCHER = /\A\#\<Fixturama::Loader::Context\[([^\]]+)\]\>\z/.freeze

def self.new(key, value)
case value
when String, Symbol, Numeric, TrueClass, FalseClass, NilClass then value
else super
end
end

# The sting representing the value with a reference to it in bindings
def to_s
"\"#<Fixturama::Loader::Context[#{@key}]>\""
end
alias to_str to_s

private

def initialize(key, value)
@key = key
@value = value
end

def method_missing(name, *args, &block)
@value.respond_to?(name) ? @value.send(name, *args, &block) : super
end

def respond_to_missing?(name, *)
@value.respond_to?(name) || super
end
end
end
32 changes: 32 additions & 0 deletions spec/fixturama/load_fixture/_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,41 @@
it { is_expected.to eq expected }
end

context "YAML with ruby object" do
subject { load_fixture("#{__dir__}/data.yaml", id: foobar) }

before { class Test::Foobar; end }

let(:foobar) { Test::Foobar.new }
let(:expected) { { "foo" => { "bar" => foobar } } }

it { is_expected.to eq expected }
end

context "JSON" do
subject { load_fixture("#{__dir__}/data.json", id: 42) }

it { is_expected.to eq expected }
end

context "JSON with ruby object" do
subject { load_fixture("#{__dir__}/data.json", id: foobar) }

before { class Test::Foobar; end }

let(:foobar) { Test::Foobar.new }
let(:expected) { { "foo" => { "bar" => foobar } } }

it { is_expected.to eq expected }
end

context "with RSpec argument matchers" do
subject { load_fixture("#{__dir__}/data.yaml", id: kind_of(Numeric)) }

it "loads the matcher", aggregate_failures: true do
expect("foo" => { "bar" => 42 }).to include subject
expect("foo" => { "bar" => 99 }).to include subject
expect("foo" => { "bar" => :a }).not_to include subject
end
end
end
6 changes: 6 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,10 @@
config.expect_with :rspec do |c|
c.syntax = :expect
end

config.around do |example|
module Test; end
example.run
Object.send(:remove_const, :Test)
end
end

0 comments on commit 58b398a

Please sign in to comment.