Skip to content

Latest commit

 

History

History
332 lines (251 loc) · 8.94 KB

DATA_CLASSES.md

File metadata and controls

332 lines (251 loc) · 8.94 KB

Data Classes

Given the following DataClass

    let(:constraint_error) { Lab42::DataClass::ConstraintError }

    class Animal
      extend Lab42::DataClass
      attributes :name, :age, :species
    end

And some specimen

    let(:vilma) { Animal.new(name: "Vilma", species: "dog", age: 18) } # RIP my dear lab42

Context: Pattern Matching

Then we can pattern match on it:

    vilma => {name:, species:}
    expect(name).to eq("Vilma")
    expect(species).to eq("dog")

Context: Constraints

Data Classes can have very specific constraints on their attributes, we shall speculate about this by using Inheritance on the fly

Given a specialised form of Animal

    class Dog < Animal
      extend Lab42::DataClass
      attributes :breed

      constraint :breed, Set.new(["Labrador", "Australian Shepherd"])
      constraint :species, [:==, "dog"] # This of course is a code smell, the base class needing to be constrained
                                        # but for the sake of the demonstration please bear with me (just do not do
                                        # this at home)
    end

Then we can instantiate an object as long as we obey the constraints

    Dog.new(age: 18, name: "Vilma", breed: "Labrador", species: "dog")

But we will get ConstraintErrors if we do not

    expect do
      Dog.new(age: 18, name: "Vilma", breed: "Pug", species: "dog")
    end
      .to raise_error(constraint_error)

Or

    expect do
      Dog.new(age: 18, name: "Vilma", breed: "Labrador", species: "human")
    end
      .to raise_error(constraint_error)

Context: Builtin Constraints

There are the following Builtin Constraints

Enumerable Constraints
  • All?(constraint) a constraint that holds for all elements → -> { _1.all?(&constraint) }
  • Any? a constraint that holds for any element → -> { _1.any?(&constraint) }
  • PairOf(fst, snd)-> { Pair === _1 && fst.(_1.first) && snd.(_1.second) }
  • TripleOf(fst, snd, trd)-> { Triple === _1 && fst.(_1.first) && snd.(_1.second) && trd.(_1.third) }
High Order Constraints
  • Option(constraint) either nil or satisfies the constraint → -> { _1.nil? || constraint.(_1) }
  • Not(constraint) negation of a constraint → -> { !constraint.(_1) }
  • Choice(*constraints) satisfies one of the constraints, again useful in v0.8 with ListOf, e.g. ListOf(Choice(Symbol, String))-> { |v| constraints.any?{ |c| c.(v) } }
  • Lambda(arity=-1) a callable with the given arity → -> { _1.respond_to?(:arity) && _1.arity == arity }
String Constraints
  • StartsWith(string)-> { _1.start_with?(string) }
  • EndsWith(string)-> { _1.end_with?(string) }
  • Contains(string)-> { _1.contains?(string) }
Miscellaneous
  • Anything useful with PairOf or TripleOf e.g. PairOf(Symbol, Anything)-> {true}
  • BooleanSet.new([false, true])

Here is a simple example of their usage, detailed description can be found here

Given a dataclass with a builtin constraint (needs an explicit require)

    require "lab42/data_class/builtin_constraints"
    let(:entry) { DataClass(:value).with_constraint(value: PairOf(Symbol, Anything)) }

Then these constraints are well observed

    expect(entry.new(value: Pair(:world, 42)).value).to eq(Pair(:world, 42))
    expect{ entry.new(value: Pair("world", 43)) }
      .to raise_error(Lab42::DataClass::ConstraintError)
    expect{ entry.new(value: Triple(:world, 43, nil)) }
      .to raise_error(Lab42::DataClass::ConstraintError)

Attribute Setting Constraints

These are special builtin constraints that allow to set attributes in a very specific, controlled way such as that the constraint on the attribute needs only be partially checked.

A good example is the ListOf constraint.

If an attribute has the ListOf constraint then its dataclass instance gets a special set method that allows to create a new dataclass instance in which only the change in the attribute and not the whole attribute needs to be constraint checked.

Therefore we can still

      some_instance.merge(list: some_instance.list.cons(1)) # Bad O(n)

or better

      some_instance.set(:list).cons(1) # Goof O(1)

These special Constraints are described in detail here

Context: Defaults

Let us fix the code smell and introduce default values for attributes at the same time

Given a better base

    module WithAgeAndName
      extend Lab42::DataClass

      attributes :name, :age
      constraint :name, String
      constraint :age, [:>=, 0]
    end

And then a new Dog class

    class BetterDog
      include WithAgeAndName
      extend Lab42::DataClass

      AllowedBreeds = [
        "Labrador", "Australian Shepherd"
      ]

      attributes breed: "Labrador"

      constraint :breed, Set.new(AllowedBreeds)
    end

Then construction can use the defaults now

    expect(BetterDog.new(age: 18, name: "Vilma").to_h)
      .to eq(name: "Vilma", age: 18, breed: "Labrador")

And is object to the constraints of the included module

    expect do
      BetterDog.new(age: 18, name: :Vilma)
    end
      .to raise_error(constraint_error, "value :Vilma is not allowed for attribute :name")

And of the constraints of the base class too

    expect do
      BetterDog.new(age: 18, name: "Vilma", breed: "Pug")
    end
      .to raise_error(constraint_error, %{value "Pug" is not allowed for attribute :breed})

Context: Derived Attributes

Let us change the domain for Derived Attributes now and assume that we are parsing Markdown and use Data Classes for our tokens produced by the Scanner, a Real World Use Case™ for once:

Given a base token

    module Token
      extend Lab42::DataClass

      attributes :line, :content, lnb: 0
    end

And, say a HeaderToken token

    class HeaderToken
      include Token
      extend Lab42::DataClass

      derive :content do
        _1.line.gsub(/^\s*\#+\s*/, "")
      end

      derive :level do
        _1.line[/^\s*\#+\s*/].strip.length
      end
    end

Then we can observe how defaults and derivations provide us with the final object

    expect(HeaderToken.new(line: "# Hello").to_h)
      .to eq(line: "# Hello", content: "Hello", lnb: 0, level: 1)

Context: Validation

With Derived Attributes we could assure that dependant data was correct, but sometimes dependency is more lose and can be expressed with Validations

The difference between Constraints and Validations is simply that a Validation is a block that will validate the whole instance of a Data Class.

Given a DataClass

    let(:validation_error) { Lab42::DataClass::ValidationError }
    class Person
      extend Lab42::DataClass

      attributes :name, :age, member: false

      validate :members_are_18 do
        _1.age >= 18 || !_1.member
      end
    end

Then we can assure that all members are at least 18 years old

    expect do
      Person.new(name: "junior", age: 17, member: true)
    end
      .to raise_error(validation_error)

And of course validation is also carried out when new instances are derived

    senior = Person.new(name: "senior", age: 42, member: true)
    expect do
      senior.merge(name: "offspring", age: 10)
    end
      .to raise_error(validation_error)

Context: Validation, a code smell?

I guess to many validations might in fact be a code smell, and even the simple example above might be better modelled with Constraints in mind

Given a Person module

    module Person1
      extend Lab42::DataClass

      attributes :name, :age, :member
      constraint :member, Set.new([false, true])
    end

    class Adult
      include Person1
      extend Lab42::DataClass

      constraint :age, [:>=, 18]
    end

    class Child
      include Person1
      extend Lab42::DataClass

      constraint :age, [:<, 18]
      derive(:member){ false }
    end

Seems to be a much cleaner approach

Then it also works better in the way that we cannot merge an Adult into a Child

    expect{ Adult.new(name: "senior", age: 18, member: true) }
      .not_to raise_error

    expect(Child.new(name: "junior", age: 17).to_h).to eq(name: "junior", age: 17, member: false)

Context: Error Handling

Duplicate Deriveds

Given an Operation DataClass

    let(:duplicate_definition_error) { Lab42::DataClass::DuplicateDefinitionError }

Then we must not define the same operation twice

    expect do
      Class.new do
        extend Lab42::DataClass
        attributes(lhs: 0, rhs: 0)

        derive(:result) {_1.lhs + _1.rhs}
        derive(:result) {_1.lhs + _1.rhs}
      end
    end
      .to raise_error(duplicate_definition_error, "Redefinition of derived attribute :result")