RobertDober/lab42_data_class

View on GitHub
speculations/DATA_CLASSES.md

Summary

Maintainability
Test Coverage
# Data Classes

Given the following `DataClass`
```ruby
    let(:constraint_error) { Lab42::DataClass::ConstraintError }

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

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

### Context: Pattern Matching

Then we can pattern match on it:
```ruby
    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`
```ruby
    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
```ruby
    Dog.new(age: 18, name: "Vilma", breed: "Labrador", species: "dog")
```

But we will get `ConstraintError`s if we do not
```ruby
    expect do
      Dog.new(age: 18, name: "Vilma", breed: "Pug", species: "dog")
    end
      .to raise_error(constraint_error)
```

Or
```ruby
    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}`
- `Boolean` → `Set.new([false, true])`

Here is a simple example of their usage, detailed description can be found [here](speculations/BUILTIN_CONSTRAINTS.md)

Given a dataclass with a builtin constraint (needs an explicit require)
```ruby
    require "lab42/data_class/builtin_constraints"
    let(:entry) { DataClass(:value).with_constraint(value: PairOf(Symbol, Anything)) }
```

Then these constraints are well observed
```ruby
    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

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

or better

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

These special Constraints are described in detail [here](speculations/ATTRIBUTE_SETTING_CONSTRAINTS.md)

### Context: Defaults

Let us fix the code smell and introduce _default values for attributes_ at the same time

Given a better base
```ruby
    module WithAgeAndName
      extend Lab42::DataClass

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

And then a new Dog class
```ruby
    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
```ruby
    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
```ruby
    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
```ruby
    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
```ruby
    module Token
      extend Lab42::DataClass

      attributes :line, :content, lnb: 0
    end
```

And, say a `HeaderToken`  token
```ruby
    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
```ruby
    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
```ruby
    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
```ruby
    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
```ruby
    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
```ruby
    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`
```ruby
    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
```ruby
    let(:duplicate_definition_error) { Lab42::DataClass::DuplicateDefinitionError }
```


Then we must not define the same operation twice
```ruby
    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")
```

<!--SPDX-License-Identifier: Apache-2.0-->