README.md in dry-data-0.4.2 vs README.md in dry-data-0.5.0

- old
+ new

@@ -12,12 +12,55 @@ [![Dependency Status](https://gemnasium.com/dryrb/dry-data.svg)][gemnasium] [![Code Climate](https://codeclimate.com/github/dryrb/dry-data/badges/gpa.svg)][codeclimate] [![Test Coverage](https://codeclimate.com/github/dryrb/dry-data/badges/coverage.svg)][codeclimate] [![Inline docs](http://inch-ci.org/github/dryrb/dry-data.svg?branch=master)][inchpages] -A simple type system for Ruby with support for coercions. +A simple and extendible type system for Ruby with support for kernel coercions, +form coercions, sum types, constrained types and default-value types. +Used by: + +* [dry-validation](https://github.com/dryrb/dry-validation) for params coercions +* [rom-repository](https://github.com/rom-rb/rom-repository) for auto-mapped structs +* [rom](https://github.com/rom-rb/rom)'s adapters for relation schema definitions +* your project...? + +Articles: + +* ["Invalid Object Is An Anti-Pattern"](http://solnic.eu/2015/12/28/invalid-object-is-an-anti-pattern.html) + +## dry-data vs virtus + +[Virtus](https://github.com/solnic/virtus) has been a successful library, unfortunately +it is "only" a by-product of an ActiveRecord ORM which carries many issues typical +to ActiveRecord-like features that we all know from Rails, especially when it +comes to very complicated coercion logic, mixing unrelated concerns, polluting +application layer with concerns that should be handled at the bounderies etc. + +`dry-data` has been created to become a better tool that solves *similar* (but +not identical!) problems related to type-safety and coercions. It is a superior +solution because: + +* Types are [categorized](#built-in-type-categories), which is especially important for coercions +* Types are objects and they are easily reusable +* Has [structs](#structs) and [values](#values) with *a simple DSL* +* Has [constrained types](#constrained-types) +* Has [optional types](#optional-types) +* Has [defaults](#defaults) +* Has [sum-types](#sum-types) +* Has [enums](#enums) +* Has [hash type with type schemas](#hashes) +* Has [array type with member type](#arrays) +* Suitable for many use-cases while remaining simple, in example: + * Params coercions + * Domain "models" + * Defining various domain-specific, shared information using enums or values + * Annotating objects + * and more... +* There's no const-missing magic and complicated const lookups like in Virtus +* AND is roughly 10-12x faster than Virtus + ## Installation Add this line to your application's Gemfile: ```ruby @@ -44,111 +87,143 @@ - `strict` - doesn't coerce and checks the input type against the primitive class - `coercible` - tries to coerce and raises type-error if it failed - `form` - non-strict coercion types suitable for form params - `maybe` - accepts either a nil or something else +### Configuring Types Module + +In `dry-data` a type is an object with a constructor that knows how to handle +input. On top of that there are high-level types like a sum-type, constrained type, +optional type or default value type. + +To acccess all the built-in type objects you can configure `dry-data` with a +namespace module: + +``` ruby +module Types +end + +Dry::Data.configure do |config| + config.namespace = Types +end + +# after defining your custom types (if you've got any) you can finalize setup +Dry::Data.finalize + +# this defines all types under your namespace, in example: +Types::Coercible::String +# => #<Dry::Data::Type:0x007feffb104aa8 @constructor=#<Method: Kernel.String>, @primitive=String> +``` + +With types accessible as constants you can easily compose more complex types, +like sum-types or constrained types, in hash schemas or structs: + +``` ruby +Dry::Data.configure do |config| + config.namespace = Types +end + +Dry::Data.finalize + +module Types + Email = String.constrained(format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i) + Age = Int.constrained(gt: 18) +end + +class User < Dry::Data::Struct + attribute :name, Types::String + attribute :email, Types::Email + attribute :age, Types::Age +end +``` + ### Built-in Type Categories +Assuming you configured types under `Types` module namespace: + Non-coercible: -- `nil` -- `symbol` -- `class` -- `true` -- `false` -- `date` -- `date_time` -- `time` +- `Types::Nil` +- `Types::Symbol` +- `Types::Class` +- `Types::True` +- `Types::False` +- `Types::Date` +- `Types::DateTime` +- `Types::Time` Coercible types using kernel coercion methods: -- `coercible.string` -- `coercible.int` -- `coercible.float` -- `coercible.decimal` -- `coercible.array` -- `coercible.hash` +- `Types::Coercible::String` +- `Types::Coercible::Int` +- `Types::Coercible::Float` +- `Types::Coercible::Decimal` +- `Types::Coercible::Array` +- `Types::Coercible::Hash` Optional strict types: -- `maybe.strict.string` -- `maybe.strict.int` -- `maybe.strict.float` -- `maybe.strict.decimal` -- `maybe.strict.array` -- `maybe.strict.hash` +- `Types::Maybe::Strict::String` +- `Types::Maybe::Strict::Int` +- `Types::Maybe::Strict::Float` +- `Types::Maybe::Strict::Decimal` +- `Types::Maybe::Strict::Array` +- `Types::Maybe::Strict::Hash` Optional coercible types: -- `maybe.coercible.string` -- `maybe.coercible.int` -- `maybe.coercible.float` -- `maybe.coercible.decimal` -- `maybe.coercible.array` -- `maybe.coercible.hash` +- `Types::Maybe::Coercible::String` +- `Types::Maybe::Coercible::Int` +- `Types::Maybe::Coercible::Float` +- `Types::Maybe::Coercible::Decimal` +- `Types::Maybe::Coercible::Array` +- `Types::Maybe::Coercible::Hash` Coercible types suitable for form param processing: -- `form.nil` -- `form.date` -- `form.date_time` -- `form.time` -- `form.true` -- `form.false` -- `form.bool` -- `form.int` -- `form.float` -- `form.decimal` +- `Types::Form::Nil` +- `Types::Form::Date` +- `Types::Form::DateTime` +- `Types::Form::Time` +- `Types::Form::True` +- `Types::Form::False` +- `Types::Form::Bool` +- `Types::Form::Int` +- `Types::Form::Float` +- `Types::Form::Decimal` -### Accessing Built-in Types +### Strict vs Coercible Types ``` ruby -# default passthrough category -float = Dry::Data["float"] +Types::Strict::Int[1] # => 1 +Types::Strict::Int['1'] # => raises Dry::Data::ConstraintError -float[3.2] # => 3.2 -float["3.2"] # "3.2" - -# strict type-check category -int = Dry::Data["strict.int"] - -int[1] # => 1 -int['1'] # => raises TypeError - # coercible type-check group -string = Dry::Data["coercible.string"] -array = Dry::Data["coercible.array"] +Types::Coercible::String[:foo] # => 'foo' +Types::Coercible::Array[:foo] # => [:foo] -string[:foo] # => 'foo' -array[:foo] # => [:foo] - # form group -date = Dry::Data["form.date"] -date['2015-11-29'] # => #<Date: 2015-11-29 ((2457356j,0s,0n),+0s,2299161j)> +Types::Form::Date['2015-11-29'] # => #<Date: 2015-11-29 ((2457356j,0s,0n),+0s,2299161j)> ``` -### Optional types +### Optional Types All built-in types have their optional versions too, you can access them under -`"maybe.strict"` and `"maybe.coercible"` categories: +`"Types::Maybe::Strict"` and `"Maybe::Coercible"` categories: ``` ruby -maybe_int = Dry::Data["maybe.strict.int"] +Types::Maybe::Int[nil] # None +Types::Maybe::Int[123] # Some(123) -maybe_int[nil] # None -maybe_int[123] # Some(123) - -maybe_coercible_float = Dry::Data["maybe.coercible.float"] - -maybe_int[nil] # None -maybe_int['12.3'] # Some(12.3) +Types::Maybe::Coercible::Float[nil] # None +Types::Maybe::Coercible::Float['12.3'] # Some(12.3) ``` You can define your own optional types too: ``` ruby -maybe_string = Dry::Data["string"].optional +maybe_string = Types::Strict::String.optional maybe_string[nil] # => None maybe_string[nil].fmap(&:upcase) @@ -162,23 +237,35 @@ maybe_string['something'].fmap(&:upcase).value # => "SOMETHING" ``` +### Defaults + +A type with a default value will return the configured value when the input is `nil`: + +``` ruby +PostStatus = Types::Strict::String.default('draft') + +PostStatus[nil] # "draft" +PostStatus["published"] # "published" +PostStatus[true] # raises ConstraintError +``` + ### Sum-types You can specify sum types using `|` operator, it is an explicit way of defining what are the valid types of a value. In example `dry-data` defines `bool` type which is a sum-type consisting of `true` -and `false` types which is expressed as `Dry::Data['true'] | Dry::Data['false']` +and `false` types which is expressed as `Types::True | Types::False` (and it has its strict version, too). Another common case is defining that something can be either `nil` or something else: ``` ruby -nil_or_string = Dry::Data['strict.nil'] | Dry::Data['strict.string'] +nil_or_string = Types::Nil | Types::Strict::String nil_or_string[nil] # => nil nil_or_string["hello"] # => "hello" ``` @@ -189,93 +276,41 @@ a lower level guarantee that you're not instantiating objects that are broken. All types support constraints API, but not all constraints are suitable for a particular primitive, it's up to you to set up constraints that make sense. -Under the hood it uses [`dry-validation`](https://github.com/dryrb/dry-validation) +Under the hood it uses [`dry-logic`](https://github.com/dryrb/dry-logic) and all of its predicates are supported. -IMPORTANT: `dry-data` does not have a runtime dependency on `dry-validation` so -if you want to use contrained types you need to add it to your Gemfile - -If you want to use constrained type you need to require it explicitly: - ``` ruby -require "dry/data/type/constrained" -``` +string = Types::Strict::String.constrained(min_size: 3) -``` ruby -string = Dry::Data["strict.string"].constrained(min_size: 3) - string['foo'] # => "foo" string['fo'] # => Dry::Data::ConstraintError: "fo" violates constraints -email = Dry::Data['strict.string'].constrained( +email = Types::Strict::String.constrained( format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i ) email["jane@doe.org"] # => "jane@doe.org" email["jane"] # => Dry::Data::ConstraintError: "fo" violates constraints ``` -### Setting Type Constants +### Enums -Types can be stored as easily accessible constants in a configured namespace: - -``` ruby -module Types; end - -Dry::Data.configure do |config| - config.namespace = Types -end - -# after defining your custom types (if you've got any) you can finalize setup -Dry::Data.finalize - -# this defines all types under your namespace -Types::Coercible::String -# => #<Dry::Data::Type:0x007feffb104aa8 @constructor=#<Method: Kernel.String>, @primitive=String> -``` - -With types accessible as constants you can easily compose more complex types, -like sum-types or constrained types, in hash schemas or structs: - -``` ruby -Dry::Data.configure do |config| - config.namespace = Types -end - -Dry::Data.finalize - -module Types - Email = String.constrained(format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i) - Age = Int.constrained(gt: 18) -end - -class User < Dry::Data::Struct - attribute :name, Types::String - attribute :email, Types::Email - attribute :age, Types::Age -end -``` - -### Defining Enums - In many cases you may want to define an enum. For example in a blog application a post may have a finite list of statuses. Apart from accessing the current status value it is useful to have all possible values accessible too. Furthermore an enum is a `int => value` map, so you can store integers somewhere and have them mapped to enum values conveniently. -You can define enums for every type but it probably only makes sense for `string`: - ``` ruby # assuming we have types loaded into `Types` namespace # we can easily define an enum for our post struct class Post < Dry::Data::Struct Statuses = Types::Strict::String.enum('draft', 'published', 'archived') @@ -301,80 +336,105 @@ # nil is considered as something silly too Post::Statuses[nil] # => Dry::Data::ConstraintError: nil violates constraints ``` -### Defining a hash with explicit schema +### Hashes The built-in hash type has constructors that you can use to define hashes with explicit schemas and coercible values using the built-in types. -### Hash Schema +#### Hash Schema ``` ruby # using simple kernel coercions -hash = Dry::Data['hash'].schema(name: 'string', age: 'coercible.int') +hash = Types::Hash.schema(name: Types::String, age: Types::Coercible::Int) hash[name: 'Jane', age: '21'] # => { :name => "Jane", :age => 21 } # using form param coercions -hash = Dry::Data['hash'].schema(name: 'string', birthdate: 'form.date') +hash = Types::Hash.schema(name: Types::String, birthdate: Form::Date) hash[name: 'Jane', birthdate: '1994-11-11'] # => { :name => "Jane", :birthdate => #<Date: 1994-11-11 ((2449668j,0s,0n),+0s,2299161j)> } ``` -### Strict Hash +#### Strict Schema Strict hash will raise errors when keys are missing or value types are incorrect. ``` ruby -hash = Dry::Data['hash'].strict(name: 'string', age: 'coercible.int') +hash = Types::Hash.strict(name: 'string', age: 'coercible.int') hash[email: 'jane@doe.org', name: 'Jane', age: 21] # => Dry::Data::SchemaKeyError: :email is missing in Hash input ``` -### Symbolized Hash +#### Symbolized Schema Symbolized hash will turn string key names into symbols ``` ruby -hash = Dry::Data['hash'].symbolized(name: 'string', age: 'coercible.int') +hash = Types::Hash.symbolized(name: Types::String, age: Types::Coercible::Int) hash['name' => 'Jane', 'age' => '21'] # => { :name => "Jane", :age => 21 } ``` -### Defining a struct +### Arrays -You can define struct objects which will have attribute readers for specified -attributes using a simple dsl: +The built-in array type supports defining member type: ``` ruby +PostStatuses = Types::Strict::Array.member(Types::Coercible::String) + +PostStatuses[[:foo, :bar]] # ["foo", "bar"] +``` + +### Structs + +You can define struct objects which will have readers for specified attributes +using a simple dsl: + +``` ruby class User < Dry::Data::Struct - attribute :name, "maybe.coercible.string" - attribute :age, "coercible.int" + attribute :name, Types::Maybe::Coercible::String + attribute :age, Types::Coercible::Int end -# becomes available like any other type -user_type = Dry::Data["user"] +user = User.new(name: nil, age: '21') -user = user_type[name: nil, age: '21'] - user.name # None user.age # 21 -user = user_type[name: 'Jane', age: '21'] +user = User(name: 'Jane', age: '21') user.name # => Some("Jane") user.age # => 21 ``` +### Values + +You can define value objects which will behave like structs and have equality +methods too: + +``` ruby +class Location < Dry::Data::Value + attribute :lat, Types::Strict::Float + attribute :lat, Types::Strict::Float +end + +loc1 = Location.new(lat: 1.23, lng: 4.56) +loc2 = Location.new(lat: 1.23, lng: 4.56) + +loc1 == loc2 +# true +``` + ## Status and Roadmap -This library is in an early stage of development but you are encauraged to try it +This library is in an early stage of development but you are encouraged to try it out and provide feedback. For planned features check out [the issues](https://github.com/dryrb/dry-data/labels/feature). ## Development