Sinclair

Code Climate Test Coverage Issue Count Gem Version Codacy Badge Inline docs

sinclair

This gem helps the creation of complex gems/concerns that enables creation of methods on the fly through class methods

Current Release: 1.13.0

Next release

Yard Documentation

https://www.rubydoc.info/gems/sinclair/1.13.0

Installation

-  Install it
  gem install sinclair
-  Or add Sinclair to your `Gemfile` and `bundle install`:
  gem 'sinclair'
  bundle install sinclair

Usage

Sinclair builder

Sinclair can actually be used in several ways

  • as a stand alone object capable of adding methods to your class on the fly
  • as a builder inside a class method
  • extending the builder for more complex logics
Stand Alone usage creating methods on the fly
class Clazz
end

builder = Sinclair.new(Clazz)

builder.add_method(:twenty, '10 + 10')
builder.add_method(:eighty) { 4 * twenty }
builder.add_class_method(:one_hundred) { 100 }
builder.add_class_method(:one_hundred_twenty, 'one_hundred + 20')
builder.build

instance = Clazz.new

puts "Twenty => #{instance.twenty}" # Twenty => 20
puts "Eighty => #{instance.eighty}" # Eighty => 80

puts "One Hundred => #{Clazz.one_hundred}"        # One Hundred => 100
puts "One Hundred => #{Clazz.one_hundred_twenty}" # One Hundred Twenty => 120
Builder in class method
class HttpJsonModel
  attr_reader :json

  class << self
    def parse(attribute, path: [])
      builder = Sinclair.new(self)

      keys = (path + [attribute]).map(&:to_s)

      builder.add_method(attribute) do
        keys.inject(hash) { |h, key| h[key] }
      end

      builder.build
    end
  end

  def initialize(json)
    @json = json
  end

  def hash
    @hash ||= JSON.parse(json)
  end
end

class HttpPerson < HttpJsonModel
  parse :uid
  parse :name,     path: [:personal_information]
  parse :age,      path: [:personal_information]
  parse :username, path: [:digital_information]
  parse :email,    path: [:digital_information]
end

json = <<-JSON
  {
    "uid": "12sof511",
    "personal_information":{
      "name":"Bob",
      "age": 21
    },
    "digital_information":{
      "username":"lordbob",
      "email":"[email protected]"
    }
  }
JSON

person = HttpPerson.new(json)

person.uid      # returns '12sof511'
person.name     # returns 'Bob'
person.age      # returns 21
person.username # returns 'lordbob'
person.email    # returns '[email protected]'
Class method adding class methods
module EnvSettings
  def env_prefix(new_prefix=nil)
    @env_prefix = new_prefix if new_prefix
    @env_prefix
  end

  def from_env(*method_names)
    builder = Sinclair.new(self)

    method_names.each do |method_name|
      env_key = [env_prefix, method_name].compact.join('_').upcase

      builder.add_class_method(method_name, cached: true) do
        ENV[env_key]
      end

      builder.build
    end
  end
end

class MyServerConfig
  extend EnvSettings

  env_prefix :server

  from_env :host, :port
end

ENV['SERVER_HOST'] = 'myserver.com'
ENV['SERVER_PORT'] = '9090'

MyServerConfig.host # returns 'myserver.com'
MyServerConfig.port # returns '9090'
Extending the builder
class ValidationBuilder < Sinclair
  delegate :expected, to: :options_object

  def initialize(klass, options={})
    super
  end

  def add_validation(field)
    add_method("#{field}_valid?", "#{field}.is_a?#{expected}")
  end

  def add_accessors(fields)
    klass.send(:attr_accessor, *fields)
  end
end

module MyConcern
  extend ActiveSupport::Concern

  class_methods do
    def validate(*fields, expected_class)
      builder = ::ValidationBuilder.new(self, expected: expected_class)

      validatable_fields.concat(fields)
      builder.add_accessors(fields)

      fields.each do |field|
        builder.add_validation(field)
      end

      builder.build
    end

    def validatable_fields
      @validatable_fields ||= []
    end
  end

  def valid?
    self.class.validatable_fields.all? do |field|
      public_send("#{field}_valid?")
    end
  end
end

class MyClass
  include MyConcern
  validate :name, :surname, String
  validate :age, :legs, Integer

  def initialize(name: nil, surname: nil, age: nil, legs: nil)
    @name = name
    @surname = surname
    @age = age
    @legs = legs
  end
end

instance = MyClass.new
the instance will respond to the methods
```name``` ```name=``` ```name_valid?```
```surname``` ```surname=``` ```surname_valid?```
```age``` ```age=``` ```age_valid?```
```legs``` ```legs=``` ```legs_valid?```
```valid?```.
valid_object = MyClass.new(
  name: :name,
  surname: 'surname',
  age: 20,
  legs: 2
)
valid_object.valid? # returns true
invalid_object = MyClass.new(
  name: 'name',
  surname: 'surname',
  age: 20,
  legs: 2
)
invalid_object.valid? # returns false

Different ways of adding the methods

There are different ways to add a method, each accepting different options

Define method using block

Block methods accepts, as option

  • cache: defining the cashing of results
klass = Class.new
instance = klass.new

builder = Sinclair.new(klass)
builder.add_method(:random_number) { Random.rand(10..20) }
builder.build

instance.random_number # returns a number between 10 and 20
Define method using string

String methods accepts, as option

  • cache: defining the cashing of results
  • parameters: defining accepted parameters
  • named_parameters: defining accepted named parameters
# Example without parameters

class MyClass
end
instance = MyClass.new

builder = Sinclair.new(MyClass)
builder.add_method(:random_number, "Random.rand(10..20)")
builder.build

instance.random_number # returns a number between 10 and 20
# Example with parameters

class MyClass
end

builder = described_class.new(MyClass)
builder.add_class_method(
  :function, 'a ** b + c', parameters: [:a], named_parameters: [:b, { c: 15 }]
)
builder.build

MyClass.function(10, b: 2) # returns 115
Define method using a call to the class

Call method definitions right now have no options available

class MyClass
end

builder = Sinclair.new(MyClass)
builder.add_class_method(:attr_accessor, :number, type: :call)
builder.build

MyClass.number # returns nil
MyClass.number = 10
MyClass.number # returns 10

Caching the result

If wanted, the result of the method can be stored in an instance variable with the same name.

When caching, you can cache with type :full so that even nil values are cached

Example of simple cache usage
class MyModel
  attr_accessor :base, :expoent
end

builder = Sinclair.new(MyModel)

builder.add_method(:cached_power, cached: true) do
  base ** expoent
end

# equivalent of builder.add_method(:cached_power) do
#   @cached_power ||= base ** expoent
# end

builder.build

model.base    = 3
model.expoent = 2

model.cached_power # returns 9
model.expoent = 3
model.cached_power # returns 9 (from cache)
Usage of different cache types
module DefaultValueable
  def default_reader(*methods, value:, accept_nil: false)
    DefaultValueBuilder.new(
      self, value: value, accept_nil: accept_nil
    ).add_default_values(*methods)
  end
end

class DefaultValueBuilder < Sinclair
  def add_default_values(*methods)
    default_value = value

    methods.each do |method|
      add_method(method, cached: cache_type) { default_value }
    end

    build
  end

  private

  delegate :accept_nil, :value, to: :options_object

  def cache_type
    accept_nil ? :full : :simple
  end
end

class Server
  extend DefaultValueable

  attr_writer :host, :port

  default_reader :host, value: 'server.com', accept_nil: false
  default_reader :port, value: 80,           accept_nil: true

  def url
    return "http://#{host}" unless port

    "http://#{host}:#{port}"
  end
end

server = Server.new

server.url # returns 'http://server.com:80'

server.host = 'interstella.com'
server.port = 5555
server.url # returns 'http://interstella.com:5555'

server.host = nil
server.port = nil
server.url # return 'http://server.com'

Sinclair::Configurable

Configurable is a module that, when used, can add configurations to your classes/modules.

Configurations are read-only objects that can only be set using the configurable#configure method which accepts a block or hash

Using configurable
module MyConfigurable
  extend Sinclair::Configurable

  # port is defaulted to 80
  configurable_with :host, port: 80
end

MyConfigurable.configure(port: 5555) do |config|
  config.host 'interstella.art'
end

MyConfigurable.config.host # returns 'interstella.art'
MyConfigurable.config.port # returns 5555

# Configurable enables options that can be passed
MyConfigurable.as_options.host # returns 'interstella.art'

# Configurable enables options that can be passed with custom values
MyConfigurable.as_options(host: 'other').host # returns 'other'

MyConfigurable.reset_config

MyConfigurable.config.host # returns nil
MyConfigurable.config.port # returns 80

Configurations can also be done through custom classes

Using configration class
class MyServerConfig < Sinclair::Config
  config_attributes :host, :port

  def url
    if @port
      "http://#{@host}:#{@port}"
    else
      "http://#{@host}"
    end
  end
end

class Client
  extend Sinclair::Configurable

  configurable_by MyServerConfig
end

Client.configure do
  host 'interstella.com'
end

Client.config.url # returns 'http://interstella.com'

Client.configure do |config|
  config.port 8080
end

Client.config.url # returns 'http://interstella.com:8080'

Sinclair::EnvSettable

Settable allows classes to extract configuration from environments through a simple meta-programable way

Using env settable example
class ServiceClient
  extend Sinclair::EnvSettable
  attr_reader :username, :password, :host, :port

  settings_prefix 'SERVICE'

  with_settings :username, :password, port: 80, hostname: 'my-host.com'

  def self.default
    @default ||= new
  end

  def initialize(
    username: self.class.username,
    password: self.class.password,
    port: self.class.port,
    hostname: self.class.hostname
  )
    @username = username
    @password = password
    @port = port
    @hostname = hostname
  end
end

ENV['SERVICE_USERNAME'] = 'my-login'
ENV['SERVICE_HOSTNAME'] = 'host.com'

ServiceClient.default # returns #<ServiceClient:0x0000556fa1b366e8 @username="my-login", @password=nil, @port=80, @hostname="host.com">'

Sinclair::Options

Options allows projects to have an easy to configure option object

Example of using Options
class ConnectionOptions < Sinclair::Options
  with_options :timeout, :retries, port: 443, protocol: 'https'

  # skip_validation if you dont want to validate intialization arguments
end

options = ConnectionOptions.new(
  timeout: 10,
  protocol: 'http'
)

options.timeout  # returns 10
options.retries  # returns nil
options.protocol # returns 'http'
options.port     # returns 443

ConnectionOptions.new(invalid: 10) # raises Sinclair::Exception::InvalidOptions

Sinclair::Comparable

Comparable allows a class to implement quickly a == method comparing given attributes

Example of Comparable usage
class SampleModel
  include Sinclair::Comparable

  comparable_by :name
  attr_reader :name, :age

  def initialize(name: nil, age: nil)
    @name = name
    @age  = age
  end
end

model1 = model_class.new(name: 'jack', age: 21)
model2 = model_class.new(name: 'jack', age: 23)

model1 == model2 # returns true

Sinclair::Model

Model class for quickly creation of plain simple classes/models

When creating a model class, options can be passed

  • writter: Adds writter/setter methods (defaults to true)
  • comparable: Adds the fields when running a == method (defaults to true)
Example of simple usage
class Human < Sinclair::Model.for(:name, :age, { gender: :undefined }, **{})
end

human1 = Human.new(name: 'John Doe', age: 22)
human2 = Human.new(name: 'John Doe', age: 22)

human1.name      # returns 'John Doe'
human1.age       # returns 22
human1.gender    # returns :undefined
human1 == human2 # returns true
Example with options
class Tv < Sinclair::Model.for(:model, writter: false, comparable: false)
end

tv1 = Tv.new(model: 'Sans Sunga Xt')
tv2 = Tv.new(model: 'Sans Sunga Xt')

tv1 == tv2 # returns false

RSspec matcher

You can use the provided matcher to check that your builder is adding a method correctly

Sample of specs over adding methods
class DefaultValue
  delegate :build, to: :builder
  attr_reader :klass, :method, :value, :class_method

  def initialize(klass, method, value, class_method: false)
    @klass = klass
    @method = method
    @value = value
    @class_method = class_method
  end

  private

  def builder
    @builder ||= Sinclair.new(klass).tap do |b|
      if class_method
        b.add_class_method(method) { value }
      else
        b.add_method(method) { value }
      end
    end
  end
end

RSpec.describe Sinclair::Matchers do
  subject(:builder_class) { DefaultValue }

  let(:klass)         { Class.new }
  let(:method)        { :the_method }
  let(:value)         { Random.rand(100) }
  let(:builder)       { builder_class.new(klass, method, value) }
  let(:instance)      { klass.new }

  context 'when the builder runs' do
    it do
      expect { builder.build }.to add_method(method).to(instance)
    end
  end

  context 'when the builder runs' do
    it do
      expect { builder.build }.to add_method(method).to(klass)
    end
  end

  context 'when adding class methods' do
    subject(:builder) { builder_class.new(klass, method, value, class_method: true) }

    context 'when the builder runs' do
      it do
        expect { builder.build }.to add_class_method(method).to(klass)
      end
    end
  end
end
> bundle exec rspec
Sinclair::Matchers
  when the builder runs
    should add method 'the_method' to #<Class:0x000055e5d9b7f150> instances
      when the builder runs
        should add method 'the_method' to #<Class:0x000055e5d9b8c0a8> instances
      when adding class methods
        when the builder runs
          should add method class_method 'the_method' to #<Class:0x000055e5d9b95d88>

Projects Using

- [Arstotzka](https://github.com/darthjee/arstotzka)
- [Azeroth](https://github.com/darthjee/azeroth)
- [Magicka](https://github.com/darthjee/magicka)