Skip to content

RobertDober/lab42_data_class

Repository files navigation

Issue Count CI Coverage Status Gem Version Gem Downloads

Lab42::DataClass

An Immutable DataClass for Ruby

Exposes a class factory function Kernel::DataClass and a module Lab42::DataClass which can extend classes to become Data Classes.

Also exposes two tuple classes, Pair and Triple

Synopsis

Having immutable Objects has many well known advantages that I will not ponder upon in detail here.

One advantage which is of particular interest though is that, as every, modification is in fact the creation of a new object strong contraints on the data can easily be maintained, and this library makes that available to the user.

Therefore we can summarise the features (or not so features, that is for you to decide and you to chose to use or not):

  • Immutable with an Interface à la OpenStruct
  • Attributes are predefined and can have default values
  • Construction with keyword arguments, exclusively
  • Conversion to Hash instances (if you must)
  • Pattern matching exactly like Hash instances
  • Possibility to impose strong constraints on attributes
  • Predefined constraints and concise syntax for constraints
  • Possibility to impose arbitrary validation (constraints on the whole object)
  • Declaration of dependent attributes which are memoized (thank you Immutability)
  • Inheritance with mixin of other dataclasses (multiple if you must)

Usage

  gem install lab42_data_class

With bundler

  gem 'lab42_data_class'

In your code

require 'lab42/data_class'

Speculations (literate specs)

The following specs are executed with the speculate about gem.

Given that we have imported the Lab42 namespace

    DataClass = Lab42::DataClass

Context: Data Classes

Basic Use Case

Given a simple Data Class

    class SimpleDataClass
      extend DataClass
      attributes :a, :b
    end

And an instance of it

    let(:simple_instance) { SimpleDataClass.new(a: 1, b: 2) }

Then we access the fields

    expect(simple_instance.a).to eq(1)
    expect(simple_instance.b).to eq(2)

And we convert to a hash

    expect(simple_instance.to_h).to eq(a: 1, b: 2)

And we can derive new instances

    new_instance = simple_instance.merge(b: 3)
    expect(new_instance.to_h).to eq(a: 1, b: 3)
    expect(simple_instance.to_h).to eq(a: 1, b: 2)

For detailed speculations please see here

Context: DataClass function

As seen in the speculations above it seems appropriate to declare a Class and extend it as we will add quite some code for constraints, derived attributes and validations.

However a more concise Factory Function might still be very useful in some use cases...

Enter Kernel::DataClass The Function

Context: Just Attributes

If there are no Constraints, Derived Attributes, Validation or Inheritance this concise syntax might easily be preferred by many:

Given some example instances like these

    let(:my_data_class) { DataClass(:name, email: nil) }
    let(:my_instance) { my_data_class.new(name: "robert") }

Then we can access its fields

    expect(my_instance.name).to eq("robert")
    expect(my_instance[:email]).to be_nil

But we cannot access undefined fields

    expect{ my_instance.undefined }.to raise_error(NoMethodError)

And this is even true for the [] syntax

    expect{ my_instance[:undefined] }.to raise_error(KeyError)

And we need to provide values to fields without defaults

    expect{ my_data_class.new(email: "[email protected]") }
      .to raise_error(ArgumentError, "missing initializers for [:name]")

And we can extract the values

    expect(my_instance.to_h).to eq(name: "robert", email: nil)

Context: Immutable → self

Then my_instance is frozen:

    expect(my_instance).to be_frozen

And we cannot even mute my_instance by means of metaprogramming

    expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)

Context: Immutable → Cloning

Given

    let(:other_instance) { my_instance.merge(email: "[email protected]") }

Then we have a new instance with the old instance unchanged

    expect(other_instance.to_h).to eq(name: "robert", email: "[email protected]")
    expect(my_instance.to_h).to eq(name: "robert", email: nil)

And the new instance is frozen again

    expect(other_instance).to be_frozen

For speculations how to add all the other features to the Factory Function syntax please look here

Context: Pair and Triple

Two special cases of a DataClass which behave like Tuple of size 2 and 3 in Elixir

They distinguish themselves from DataClass classes by accepting only positional arguments, and cannot be converted to hashes.

These are actually two classes and not class factories as they have a fixed interface , but let us speculate about them to learn what they can do for us.

Context: Constructor functions

Given a pair

    let(:token) { Pair("12", 12) }
    let(:node)  { Triple("42", 4, 2) }

Then we can access their elements

    expect(token.first).to eq("12")
    expect(token.second).to eq(12)
    expect(node.first).to eq("42")
    expect(node.second).to eq(4)
    expect(node.third).to eq(2)

And we can treat them like Indexable

    expect(token[1]).to eq(12)
    expect(token[-2]).to eq("12")
    expect(node[2]).to eq(2)

And convert them to arrays of course

    expect(token.to_a).to eq(["12", 12])
    expect(node.to_a).to eq(["42", 4, 2])

And they behave like arrays in pattern matching too

    token => [str, int]
    node  => [root, lft, rgt]
    expect(str).to eq("12")
    expect(int).to eq(12)
    expect(root).to eq("42")
    expect(lft).to eq(4)
    expect(rgt).to eq(2)

And of course the factory functions are equivalent to the constructors

    expect(token).to eq(Lab42::Pair.new("12", 12))
    expect(node).to eq(Lab42::Triple.new("42", 4, 2))

Context: Pseudo Assignments

... in reality return a new object

Given an instance of Pair

    let(:original) { Pair(1, 1) }

And one of Triple

    let(:xyz) { Triple(1, 1, 1) }

Then

    second = original.set_first(2)
    third  = second.set_second(2)
    expect(original).to eq( Pair(1, 1) )
    expect(second).to eq(Pair(2, 1))
    expect(third).to eq(Pair(2, 2))

And also

    second = xyz.set_first(2)
    third  = second.set_second(2)
    fourth = third.set_third(2)
    expect(xyz).to eq(Triple(1, 1, 1))
    expect(second).to eq(Triple(2, 1, 1))
    expect(third).to eq(Triple(2, 2, 1))
    expect(fourth).to eq(Triple(2, 2, 2))

Context: List

A List is what a list is in Lisp or Elixir it exposes the following API

Given such a list

    let(:three) { List(*%w[a b c]) }

Then this becomes really a linked_list

    expect(three.car).to eq("a")
    expect(three.cdr).to eq(List(*%w[b c]))

For all details please consult the List speculations

LICENSE

Copyright 2022 Robert Dober [email protected]

Apache-2.0 c.f LICENSE

About

A Data Class for Ruby, finally!

Resources

License

Stars

Watchers

Forks

Packages

No packages published