Skip to content

Instantly share code, notes, and snippets.

@kml
Last active August 5, 2016 08:56
Show Gist options
  • Save kml/35e02b2bc0b6c75f9f9f to your computer and use it in GitHub Desktop.
Save kml/35e02b2bc0b6c75f9f9f to your computer and use it in GitHub Desktop.
NestedAttributes

NestedAttributes

Installation

Add this line to your application's Gemfile:

gem "nested-attributes", git: "https://gist.github.com/35e02b2bc0b6c75f9f9f.git"

And then execute:

$ bundle install

Example usage

Suppose you have an Rails application called MyApp:

module MyApp
  class Application < Rails::Application
  end
end

Inside config/secrets.yml is stored nested confguration for S3 bucket name:

development:
  aws:
    s3:
      bucket_name: mybucket

Inside application (for example some initializer) you read this by really defensive method:

Rails.application.secrets.try(:[], "aws").try(:[], "s3").try(:[], "bucket_name")

Please stop! Define Secrets model with attributes definition and validations:

class Secrets < NestedAttributes::Base
  attribute :aws do
    attribute :s3 do
      attribute :bucket_name, String
      validates :bucket_name, presence: true
    end
  end
end

Then add MyApp.secrets method:

module MyApp
  def self.secrets
    @secrets ||= Secrets.new(Rails.application.secrets).validate!
  end
end

Inside application read secrets with dot notation. Leaf attribute will always exist.

MyApp.aws.s3.bucket_name

If validation requires value to be present (like in this sample) application will raise NestedAttributes::InvalidMessageError exception while starting:

[Secrets] Errors: {:aws=>{:s3=>{:bucket_name=>["can't be blank"]}}}

License

The gem is available as open source under the terms of the MIT License.

source "https://rubygems.org"
gemspec
PATH
remote: .
specs:
nested-attributes (0.0.1.pre)
activemodel
virtus
GEM
remote: https://rubygems.org/
specs:
activemodel (4.2.6)
activesupport (= 4.2.6)
builder (~> 3.1)
activesupport (4.2.6)
i18n (~> 0.7)
json (~> 1.7, >= 1.7.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
awesome_print (1.6.1)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
builder (3.2.2)
coderay (1.1.1)
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
equalizer (0.0.11)
i18n (0.7.0)
ice_nine (0.11.2)
json (1.8.3)
method_source (0.8.2)
minitest (5.8.4)
pry (0.10.3)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
slop (~> 3.4)
slop (3.6.0)
thread_safe (0.3.5)
tzinfo (1.2.2)
thread_safe (~> 0.1)
virtus (1.0.5)
axiom-types (~> 0.1)
coercible (~> 1.0)
descendants_tracker (~> 0.0, >= 0.0.3)
equalizer (~> 0.0, >= 0.0.9)
PLATFORMS
ruby
DEPENDENCIES
awesome_print
bundler (~> 1.11)
minitest (~> 5.1)
nested-attributes!
pry
BUNDLED WITH
1.11.2
module NestedAttributes
VERSION = "0.0.1.pre"
end
# encoding: utf-8
require_relative "nested-attributes-version"
Gem::Specification.new do |spec|
spec.name = "nested-attributes"
spec.version = NestedAttributes::VERSION
spec.authors = ["kml"]
spec.email = ["[email protected]"]
spec.summary = "NestedAttributes"
spec.description = "NestedAttributes"
spec.homepage = "https://gist.github.com/kml/35e02b2bc0b6c75f9f9f"
spec.license = "MIT"
spec.files = ["nested-attributes.rb", "nested-attributes-version.rb"]
spec.require_paths = ["."]
spec.add_dependency "virtus"
spec.add_dependency "activemodel"
spec.add_development_dependency "bundler", "~> 1.11"
spec.add_development_dependency "minitest", "~> 5.1"
spec.add_development_dependency "pry"
spec.add_development_dependency "awesome_print"
end
require "nested-attributes-version"
require "virtus"
require "active_model"
module NestedAttributes
class InvalidMessageError < ArgumentError
def initialize(object)
@object = object
super(build_message)
end
def errors
@object.errors
end
private
def build_message
"[#{@object.class}] Errors: #{@object.nested_errors_hash}"
end
end
AttributeDefinitionError = Class.new(StandardError)
class Base
include ActiveModel::Model
include Virtus.model
def self.attribute(name, type = nil, options = {}, &block)
if block_given?
if type && options.any?
raise AttributeDefinitionError, "Unable to specify type and options"
end
if type && !type.is_a?(Hash)
raise AttributeDefinitionError, "Pass options hash as second argument"
end
klass = Class.new(NestedAttributes::Base) do
class << self
attr_accessor :name
end
end
klass.name = name.to_s.camelize
klass.class_eval(&block)
options = (type || {})
if options.key?(:default) && !options[:default].is_a?(Hash)
raise AttributeDefinitionError, "Default must be a Hash"
end
options = options.merge(default: {})
type = klass
end
if type.is_a?(Class) && type < NestedAttributes::Base
options[:default] ||= {}
unless options[:default].is_a?(Hash)
raise AttributeDefinitionError, "Default must be a Hash"
end
end
assert_valid_name(name)
finalize = options.fetch(:finalize, true)
attr = Virtus::Attribute.build(type, options.merge(name: name, finalize: false))
if attr.options[:coercer].is_a?(Proc)
coercer = attr.options[:coercer].call(attr.type)
attr.options[:coercer] = coercer
attr.instance_variable_set(:@coercer, coercer)
end
attr.finalize if finalize
attribute_set << attr
self
end
validate do
attribute_set.each do |attribute|
next unless attribute.primitive < NestedAttributes::Base
errors.add(attribute.name, :invalid) if public_send(attribute.name).invalid?
end
end
def validate!
raise InvalidMessageError.new(self) if invalid?
self
end
def nested_errors_hash
errors.each_with_object({}) do |(attribute, message), hash|
hash[attribute] = if public_send(attribute).respond_to?(:nested_errors_hash)
public_send(attribute).nested_errors_hash
else
errors[attribute]
end
end
end
end
end
require 'bundler/setup'
Bundler.require
require 'nested-attributes'
require 'pry'
require 'minitest/autorun'
describe "attributes" do
it "simple attribute" do
example = Class.new(NestedAttributes::Base) do
attribute :key1, String
end
example.new.key1.must_be_nil
example.new(key1: "value1").key1.must_equal "value1"
lambda { example.new.unknown }.must_raise NoMethodError
end
it "invalid definition" do
exception = lambda do
Class.new(NestedAttributes::Base) do
attribute :key1, String do
attribute :key10, String
end
end
end.must_raise NestedAttributes::AttributeDefinitionError
exception.message.must_equal "Pass options hash as second argument"
exception = lambda do
Class.new(NestedAttributes::Base) do
attribute :key1, String, {some: :option} do
attribute :key10, String
end
end
end.must_raise NestedAttributes::AttributeDefinitionError
exception.message.must_equal "Unable to specify type and options"
exception = lambda do
Class.new(NestedAttributes::Base) do
attribute :key1, {default: "1"} do
attribute :key10, String
end
end
end.must_raise NestedAttributes::AttributeDefinitionError
exception.message.must_equal "Default must be a Hash"
end
it "simple nested attribute" do
example = Class.new(NestedAttributes::Base) do
attribute :key1 do
attribute :key10, String
attribute :key11, String, default: "value11"
attribute :key12
attribute :key13, Array[Symbol]
end
end
example.new(key1: {key10: "value10"}).key1.key10.must_equal "value10"
example.new.key1.key10.must_be_nil
example.new.key1.key11.must_equal "value11"
example.new.key1.key12.must_be_nil
example.new(key1: {key13: ["value13"]}).key1.key13.must_equal [:value13]
end
it "nested attribute from subclass" do
key1 = Class.new(NestedAttributes::Base) do
attribute :key10, String
attribute :key11, String, default: "value11"
end
example = Class.new(NestedAttributes::Base) do
attribute :key1, key1
end
example.new(key1: {key10: "value10"}).key1.key10.must_equal "value10"
example.new.key1.key10.must_be_nil
example.new.key1.key11.must_equal "value11"
exception = lambda do
Class.new(NestedAttributes::Base) do
attribute :key1, key1, default: "1"
end
end.must_raise NestedAttributes::AttributeDefinitionError
exception.message.must_equal "Default must be a Hash"
end
end
describe "validations" do
it "validates nested attributes" do
Partner = Class.new(NestedAttributes::Base) do
attribute :person_data do
attribute :pesel, String
attribute :id_type, String
validates :pesel, presence: true
validates :id_type, presence: true
end
attribute :company_data do
attribute :name, String
attribute :nip, String
validates :name, presence: true
validates :nip, presence: true
end
end
partner = Partner.new
partner.valid?.must_equal false
partner.errors.to_hash.must_equal({
person_data: ["is invalid"],
company_data: ["is invalid"]
})
partner.person_data.valid?.must_equal false
partner.person_data.errors.to_hash.must_equal({
pesel: ["can't be blank"],
id_type: ["can't be blank"]
})
partner.company_data.valid?.must_equal false
partner.company_data.errors.to_hash.must_equal({
name: ["can't be blank"],
nip: ["can't be blank"]
})
partner.nested_errors_hash.must_equal({
person_data: {
pesel: ["can't be blank"],
id_type: ["can't be blank"]
},
company_data: {
name: ["can't be blank"],
nip: ["can't be blank"]
}
})
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment