Giter VIP home page Giter VIP logo

value_semantics's Introduction

Gem Version Build Status Mutation Coverage

ValueSemantics

A gem for making value classes.

Generates modules that provide conventional value semantics for a given set of attributes. The behaviour is similar to an immutable Struct class, plus extensible, lightweight validation and coercion.

These are intended for internal use, as opposed to validating user input like ActiveRecord. Invalid or missing attributes cause an exception for developers, not an error message intended for application users.

See:

Defining and Creating Value Objects

class Person
  include ValueSemantics.for_attributes {
    name String, default: "Anon Emous"
    birthday Either(Date, nil), coerce: true
  }

  def self.coerce_birthday(value)
    if value.is_a?(String)
      Date.parse(value)
    else
      value
    end
  end
end

Person.new(name: "Tom", birthday: "2020-12-25")
#=> #<Person name="Tom" birthday=#<Date: 2020-12-25 ((2459209j,0s,0n),+0s,2299161j)>>

Person.new(birthday: Date.today)
#=> #<Person name="Anon Emous" birthday=#<Date: 2020-08-30 ((2459092j,0s,0n),+0s,2299161j)>>

Person.new(birthday: nil)
#=> #<Person name="Anon Emous" birthday=nil>

Value objects are typically initialized with keyword arguments or a Hash, but will accept any object that responds to #to_h.

The curly bracket syntax used with ValueSemantics.for_attributes is, unfortunately, mandatory due to Ruby's precedence rules. For a shorter alternative method that works better with do/end, see Convenience (Monkey Patch) below.

Using Value Objects

require 'value_semantics'

class Person
  include ValueSemantics.for_attributes {
    name
    age default: 31
  }
end

tom = Person.new(name: 'Tom')


# Read-only attributes
tom.name    #=> "Tom"
tom[:name]  #=> "Tom"

# Convert to Hash
tom.to_h  #=> {:name=>"Tom", :age=>31}

# Non-destructive updates
tom.with(age: 99) #=> #<Person name="Tom" age=99>
tom # (unchanged) #=> #<Person name="Tom" age=31>

# Equality
other_tom = Person.new(name: 'Tom', age: 31)
tom == other_tom  #=> true
tom.eql?(other_tom)  #=> true
tom.hash == other_tom.hash  #=> true

# Ruby 2.7+ pattern matching
case tom
in name: "Tom", age:
  puts age
end
# outputs: 31

Convenience (Monkey Patch)

There is a shorter way to define value attributes:

  require 'value_semantics/monkey_patched'

  class Monkey
    value_semantics do
      name String
      age Integer
    end
  end

This is disabled by default, to avoid polluting every class with an extra class method.

This convenience method can be enabled in two ways:

  1. Add a require: option to your Gemfile like this:

    gem 'value_semantics', '~> 3.3', require: 'value_semantics/monkey_patched'
  2. Alternatively, you can call ValueSemantics.monkey_patch! somewhere early in the boot sequence of your code -- at the top of your script, for example, or config/boot.rb if it's a Rails project.

    require 'value_semantics'
    ValueSemantics.monkey_patch!

Defaults

Defaults can be specified in one of two ways: the :default option, or the :default_generator option.

class Cat
  include ValueSemantics.for_attributes {
    paws Integer, default: 4
    born_at Time, default_generator: ->{ Time.now }
  }
end

Cat.new
#=> #<Cat paws=4 born_at=2020-08-30 22:27:12.237812 +1000>

The default option is a single value.

The default_generator option is a callable object, which returns a default value. In the example above, default_generator is a lambda that returns the current time.

Only one of these options can be used per attribute.

Validation (Types)

Each attribute may optionally have a validator, to check that values are correct.

Validators are objects that implement the === method, which means you can use Class objects (like String), and also things like regular expressions. Anything that you can use in a case/when expression will work.

class Person
  include ValueSemantics.for_attributes {
    name String
    birthday /\d{4}-\d{2}-\d{2}/
  }
end

Person.new(name: 'Tom', birthday: '2000-01-01')  # works
Person.new(name: 5,     birthday: '2000-01-01')
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Person` are invalid:
#=*       - name: 5

Person.new(name: 'Tom', birthday: "1970-01-01")  # works
Person.new(name: 'Tom', birthday: "hello")
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Person` are invalid:
#=*       - birthday: "hello"

Built-in Validators

The ValueSemantics DSL comes with a small number of built-in validators, for common situations:

class LightSwitch
  include ValueSemantics.for_attributes {
    # Bool: only allows `true` or `false`
    on? Bool()

    # ArrayOf: validates elements in an array
    light_ids ArrayOf(Integer)

    # HashOf: validates keys/values of a homogeneous hash
    toggle_stats HashOf(Symbol => Integer)

    # RangeOf: validates ranges
    levels RangeOf(Integer)

    # Either: value must match at least one of a list of validators
    color Either(Integer, String, nil)

    # these validators are composable
    wierd_attr Either(Bool(), ArrayOf(Bool()))
  }
end

LightSwitch.new(
  on?: true,
  light_ids: [11, 12, 13],
  toggle_stats: { day: 42, night: 69 },
  levels: (0..10),
  color: "#FFAABB",
  wierd_attr: [true, false, true, true],
)
#=> #<LightSwitch on?=true light_ids=[11, 12, 13] toggle_stats={:day=>42, :night=>69} levels=0..10 color="#FFAABB" wierd_attr=[true, false, true, true]>

Custom Validators

A custom validator might look something like this:

module DottedQuad
  def self.===(value)
    value.split('.').all? do |part|
      ('0'..'255').cover?(part)
    end
  end
end

class Server
  include ValueSemantics.for_attributes {
    address DottedQuad
  }
end

Server.new(address: '127.0.0.1')
#=> #<Server address="127.0.0.1">

Server.new(address: '127.0.0.999')
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Server` are invalid:
#=*       - address: "127.0.0.999"

Default attribute values also pass through validation.

Coercion

Coercion allows non-standard or "convenience" values to be converted into proper, valid values, where possible.

For example, an object with an Pathname attribute may allow string values, which are then coerced into Pathname objects.

Using the option coerce: true, coercion happens through a custom class method called coerce_#{attr}, which takes the raw value as an argument, and returns the coerced value.

require 'pathname'

class Document
  include ValueSemantics.for_attributes {
    path Pathname, coerce: true
  }

  def self.coerce_path(value)
    if value.is_a?(String)
      Pathname.new(value)
    else
      value
    end
  end
end

Document.new(path: '~/Documents/whatever.doc')
#=> #<Document path=#<Pathname:~/Documents/whatever.doc>>

Document.new(path: Pathname.new('~/Documents/whatever.doc'))
#=> #<Document path=#<Pathname:~/Documents/whatever.doc>>

Document.new(path: 42)
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Document` are invalid:
#=*       - path: 42

You can also use any callable object as a coercer. That means, you could use a lambda:

class Document
  include ValueSemantics.for_attributes {
    path Pathname, coerce: ->(value) { Pathname.new(value) }
  }
end

Or a custom class:

class MyPathCoercer
  def call(value)
    Pathname.new(value)
  end
end

class Document
  include ValueSemantics.for_attributes {
    path Pathname, coerce: MyPathCoercer.new
  }
end

Or reuse an existing method:

class Document
  include ValueSemantics.for_attributes {
    path Pathname, coerce: Pathname.method(:new)
  }
end

Coercion happens before validation. If coercion is not possible, coercers can return the raw value unchanged, allowing the validator to fail with a nice, descriptive exception. Another option is to raise an error within the coercion method.

Default attribute values also pass through coercion. For example, the default value could be a string, which would then be coerced into an Pathname object.

Built-in Coercers

ValueSemantics provides a few built-in coercer objects via the DSL.

class Config
  include ValueSemantics.for_attributes {
    # ArrayCoercer: takes an element coercer
    paths coerce: ArrayCoercer(Pathname.method(:new))

    # HashCoercer: takes a key and value coercer
    env coerce: HashCoercer(
      keys: :to_sym.to_proc,
      values: :to_i.to_proc,
    )
  }
end

config = Config.new(
  paths: ['/a', '/b'],
  env: { 'AAAA' => '1', 'BBBB' => '2' },
)

config.paths #=> [#<Pathname:/a>, #<Pathname:/b>]
config.env #=> {:AAAA=>1, :BBBB=>2}

Nesting

It is fairly common to nest value objects inside each other. This works as expected, but coercion is not automatic.

For nested coercion, use the .coercer class method that ValueSemantics provides. It returns a coercer object that accepts strings for attribute names, and will ignore attributes that the value class does not define, instead of raising an error.

This works well in combination with ArrayCoercer.

class CrabClaw
  include ValueSemantics.for_attributes {
    size Either(:big, :small)
  }
end

class Crab
  include ValueSemantics.for_attributes {
    left_claw CrabClaw, coerce: CrabClaw.coercer
    right_claw CrabClaw, coerce: CrabClaw.coercer
  }
end

class Ocean
  include ValueSemantics.for_attributes {
    crabs ArrayOf(Crab), coerce: ArrayCoercer(Crab.coercer)
  }
end

ocean = Ocean.new(
  crabs: [
    {
      'left_claw' => { 'size' => :small },
      'right_claw' => { 'size' => :small },
      voiced_by: 'Samuel E. Wright',  # this attr will be ignored
    }, {
      'left_claw' => { 'size' => :big },
      'right_claw' => { 'size' => :big },
    }
  ]
)

ocean.crabs.first #=> #<Crab left_claw=#<CrabClaw size=:small> right_claw=#<CrabClaw size=:small>>
ocean.crabs.first.right_claw.size #=> :small

ValueSemantics::Struct

This is a convenience for making a new class and including ValueSemantics in one step, similar to how Struct works from the Ruby standard library. For example:

Pigeon = ValueSemantics::Struct.new do
  name String, default: "Jannie"
end

Pigeon.new.name #=> "Jannie"

Known Issues

Some valid attribute names result in invalid Ruby syntax when using the DSL. In these situations, you can use the DSL method def_attr instead.

For example, if you want an attribute named then:

# Can't do this:
class Conditional
  include ValueSemantics.for_attributes {
    then String
    else String
  }
end
#=> !!! SyntaxError: README.md:461: syntax error, unexpected `then'
#=*         then String
#=*         ^~~~


# This will work
class Conditional
  include ValueSemantics.for_attributes {
    def_attr :then, String
    def_attr :else, String
  }
end

Installation

Add this line to your application's Gemfile:

gem 'value_semantics'

And then execute:

$ bundle

Or install it yourself as:

$ gem install value_semantics

Contributing

Bug reports and pull requests are welcome on GitHub at: https://github.com/tomdalling/value_semantics

Keep in mind that this gem aims to be as close to 100% backwards compatible as possible.

I'm happy to accept PRs that:

  • Improve error messages for a better developer experience, especially those that support a TDD workflow.
  • Add new and helpful built-in validators and coercers
  • Implement automatic freezing of value objects (must be opt-in)

License

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

value_semantics's People

Contributors

andyw8 avatar tomdalling avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

value_semantics's Issues

Allow `with` to work on protected attributes

I would like to have a ValueSemantics object where some/all of the attributes are protected. This is easy:

class X
  value_semantics do
    something String
  end

  protected :something
end

However, if I do this then X.new(something: "hai").with(something: "wut") does not work. This is because with relies on to_h and to_h uses public_send. Now, to_h probably should use public_send since otherwise it would allow getting a hash with values that were otherwise protected, probably not the intent. However, there is not reason for with to be banned here because the constructor is not banned so creating a value with this attributes is fine.

How do I build a base class and extend or overwrite attributes?

Hi there,

Thanks for this gem, it makes such a difference to my codebases :)

I have a problem I'm not sure how to solve. I have two very similar Contact classes... one is a sender, and the other is a receiver.

About 80% of their attributes are identical, except for the following differences:

  1. Some attrs are required on the sender, and not on the receiver, and vice-versa
  2. The receiver has 1 extra attr the sender doesn't have
  3. The sender has 2 extra attrs the receiver doesn't have

I attempted to make a BaseContact class that defines the common attrs and add fairly lax validators to it, I then attempted to inherit from the BaseContact, and then redefine the changed attrs using a new for_attributes block.

Unfortunately, it looks like doing that completely blows away the previous definition in the base class.

I also tried making calls to ValueSemantics::Attribute.define but that didn't work either...

I really don't mind repeating myself here, but this was a headscratcher for me, and I was wondering if you had a solution or some guidance?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.