Skip to content

Instantly share code, notes, and snippets.

@jamessom
Forked from serradura/README.md
Created July 23, 2020 02:23
Show Gist options
  • Save jamessom/5f8f9da0f1cfb29b5fdb1b8be5ab4c25 to your computer and use it in GitHub Desktop.
Save jamessom/5f8f9da0f1cfb29b5fdb1b8be5ab4c25 to your computer and use it in GitHub Desktop.
ORM (225 LOC and ~510 LOC of tests) - An Object-relational mapping implementation using only the Ruby standard library.

ORM

Development dependencies:

Features:

  1. ORM::Attributes
  2. ORM::Model
  3. ORM::Record

ORM::Attributes

class User
  include ::ORM::Attributes

  attribute :name
  attribute :age
end

user = User.new name: '@serradura'

user.attributes #=> {:name=>"@serradura", :age=>nil}

# getters
user.name       #=> "@serradura"
user.age        #=> nil

# predicates
user.age?       #=> false

# setters
user.age = 31   #=> 31
user.age        #=> 31
user.age?       #=> true

######################
# Readonly attribute #
######################

class Person
  include ::ORM::Attributes

  attribute :name #, readonly: false # (default)
  attribute :age   , readonly: true
end

person = Person.new name: 'Rodrigo', age: 31

person.attributes         #=> {:name=>"Rodrigo", :age=>31}

person.name = 'Serradura' #=> "Serradura"

person.attributes         #=> {:name=>"Serradura", :age=>31}
person.name               #=> "Serradura"
person.name?              #=> true
person.age                #=> 31
person.age?               #=> true

person.age = 32           #=> NoMethodError (undefined method `age=' for #<Person:0x00007fe2dd29be28 @name="Serradura", @age=31>)

#################
# Readonly mode #
#################

class UserReadOnly
  include ORM::Attributes
  include ORM::ReadOnlyMode # <<<<<<<<<<

  attribute :name
  attribute :age
end

user = UserReadOnly.new name: '@serradura'

user.attributes #=> {:name=>"@serradura", :age=>nil}

user.name       #=> "@serradura"
user.name?      #=> true
user.age        #=> nil
user.age?       #=> false

user.name = 'r' #=> NoMethodError (undefined method `name=' for #<UserReadOnly:0x00007fb2f6376008 @name="@serradura">)
user.age  = 1   #=> NoMethodError (undefined method `age=' for #<UserReadOnly:0x00007fb2f6376008 @name="@serradura">)

ORM::Model

Same features of ORM::Attributes with validation and id (readonly) attribute.

class UserModel < ORM::Model
  attribute :name #, readonly: true
  attribute :age  #, readonly: false

  def valid? # true by default
    attributes.slice(:name, :age)
              .values
              .none?(&:nil?)
  end
end

user = UserModel.new name: '@serradura'

##############
# Validation #
##############

user.valid?     #=> false
user.age = 31   #=> 31
user.valid?     #=> true

######################################
# id attribute (readonly by default) #
######################################

user.id     #=> nil
user.id?    #=> false
user.id = 1 #=> NoMethodError (undefined method `id=' for #<UserModel:0x00007fb2f6411648>)

user = UserModel.new id: 1, name: '@serradura', age: 31

user.id         #=> 1
user.id?        #=> true
user.attributes #=> {:name=>"@serradura", :age=>31, :id=>1}

#########################
# after_initialize hook #
#########################

class Calc < ORM::Model
  attr_reader :b

  attribute :a

  def valid?
    a > 0 && b > 0
  end

  def sum
    a + b
  end

  private # after_initializer will be invoked independent of its visibility

  def after_initialize(attrs) # receives the initializer data
    @b = attrs[:a] + 1
  end
end

calc = Calc.new a: 1

calc.valid? #=> true
calc.a      #=> 1
calc.b      #=> 2
calc.sum    #=> 3

calc.a = 2  #=> 2
calc.sum    #=> 4

#################
# Readonly mode #
#################

class CalcReadOnly < ORM::Model::ReadOnly
  attr_reader :b

  attribute :a

  def valid?
    a > 0 && b > 0
  end

  def sum
    a + b
  end

  private # after_initializer will be invoked independent of its visibility

  def after_initialize(attrs) # receives the initializer data
    @b = attrs[:a] + 1
  end
end

calc = CalcReadOnly.new a: 1

calc.valid? #=> true
calc.a      #=> 1
calc.b      #=> 2
calc.sum    #=> 3

calc.a = 2  #=> NoMethodError (undefined method `a=' for #<CalcReadOnly:0x00007f8a1082b260 @a=1, @b=2>)
calc.sum    #=> 3

ORM::Record

Same features of ORM::Model and more (e.g: Repository commands, store data in a yaml file).

class UserRecord < ORM::Record
  attribute :name #, readonly: true
  attribute :age  #, readonly: false

  def valid? # true by default
    attributes.slice(:name, :age)
              .values
              .none?(&:nil?)
  end
end

#########################################################
# Repository methods (similar with ActiveRecord methods)#
#########################################################

user = UserRecord.create name: 'R'
#=> #<UserRecord:0x00007f8a10902be8 @destroyed=false, @name="R">

user.valid?     #=> false
user.persisted? #=> false

user = UserRecord.create name: 'R', age: 1
#=> #<UserRecord:0x00007f8a0fa348a0 @destroyed=false, @name="R", @age=1, @id="e1b0f84a-67d6-4aac-adc9-df6bc0b2d069">

user.valid?      #=> true
user.persisted?  #=> true
user.id          #=> "e1b0f84a-67d6-4aac-adc9-df6bc0b2d069"
user.attributes  #=> {:name=>"R", :age=>1, :id=>"e1b0f84a-67d6-4aac-adc9-df6bc0b2d069"}
UserRecord.count #=> 1

user.age = nil   #=> nil
user.valid?      #=> false
user.save        #=> false

user.age = 2     #=> 2
user.save        #=> true
UserRecord.count #=> 1

UserRecord.find(user.id)
#=> #<UserRecord:0x00007f8a0fa348a0 @destroyed=false, @name="R", @age=2, @id="e1b0f84a-67d6-4aac-adc9-df6bc0b2d069">

# Query methods:
# --------------
UserRecord.relation
UserRecord.select
UserRecord.reject
UserRecord.find
UserRecord.count

# CRUD - Class methods:
# ---------------------
UserRecord.create
UserRecord.update
UserRecord.destroy

# CRUD - Instance methods:
# ------------------------
user.save
user.destroy

# Other instance methods:
# -----------------------
user.id         # ORM::Model
user.valid?     # ORM::Model
user.persisted? # ORM::Record

#################
# Readonly mode #
#################

class UserRecordReadOnly < ORM::Record::ReadOnly
  attribute :name
  attribute :age

  def valid? # true by default
    attributes.slice(:name, :age)
              .values
              .none?(&:nil?)
  end
end

user = UserRecordReadOnly.create name: 'S', age: 2
#=> #<UserRecordReadOnly:0x00007f8a108e2640 @destroyed=false, @name="S", @age=2, @id="0705e773-8294-45fb-8278-66c4d938faec">

user.name = 'R' #=> NoMethodError (undefined method `name=' for #<UserRecordReadOnly:0x00007f8a108e2640>)
user.age  = 3   #=> NoMethodError (undefined method `age=' for #<UserRecordReadOnly:0x00007f8a108e2640>)

user.save       #=> SecurityError (SecurityError)
user.destroy    #=> SecurityError (SecurityError)
# frozen_string_literal: true
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'u-test', '0.7.0'
end
require 'set'
require 'yaml/store'
require 'securerandom'
module ORM
module ReadOnlyMode
def self.included(base)
base.class_eval do
def self.readonly_attributes?
true
end
end
end
end
module Attributes
module ClassMethods
def __attributes__
@attributes ||= Set.new
end
def attributes
__attributes__.to_a
end
def readonly_attributes?
false
end
def attribute(attr_name, readonly: false)
return unless [String, Symbol].include?(attr_name.class)
name = attr_name.to_sym.tap &__attributes__.method(:add)
unless readonly_attributes? || readonly
define_method("#{name}=") do |val|
instance_variable_set("@#{name}", val)
end
end
define_method(name) { instance_variable_get("@#{name}") }
define_method("#{name}?") { !!public_send(name) }
end
end
def self.included(base)
base.extend(ClassMethods)
base.send(:private_class_method, :__attributes__)
end
def initialize(attrs = {})
attributes = attrs.is_a?(Hash) ? attrs : {}
attributes.each {|name, val| instance_variable_set("@#{name}", val) }
end
def attributes
self.class.attributes.each_with_object({}) do |name, memo|
memo[name] = public_send(name)
end
end
end
class Model
include Attributes
attribute :id, readonly: true
def initialize(attrs = nil)
super(attrs)
send(:after_initialize, attrs) if respond_to?(:after_initialize, true)
end
def attributes
data = super
id? ? data.merge(id: id) : data
end
class ReadOnly < Model
include ORM::ReadOnlyMode
end
end
class Store
include Enumerable
attr_reader :file
def initialize(model, _file_)
folder = File.expand_path(File.dirname(_file_))
filename = "#{model.name.downcase}.store"
@file = File.join(folder, filename).freeze
@store = YAML::Store.new(@file)
end
def file?
File.exists?(file)
end
def transaction
@store.transaction { yield(@store) }
end
def each
transaction do |store|
store.roots.each { |root| yield(root, store.fetch(root)) }
end
end
end
module RepositoryMethods
def store
@store ||= Store.new(self, __FILE__)
block_given? ? @store.transaction { |store| yield(store) } : @store
end
def relation
store.map { |_id, val| new(val) }
end
def select
relation.select { |record| yield(record) }
end
def reject
relation.reject { |record| yield(record) }
end
def find(uuid = nil)
return relation.find { |record| yield(record) } if block_given?
relation.find { |record| record.id == uuid }
end
def count
return relation.count { |record| yield(record) } if block_given?
return store { |store| store.roots.size }
end
def create(attributes = {})
new(attributes).tap do |record|
if record.valid?
record_id = SecureRandom.uuid
record.instance_variable_set(:@id, record_id)
store { |store| store[record_id] = record.attributes }
end
end
end
def update(id, attributes = {})
record = find(id)
return unless record
new_record = new(record.attributes.merge(attributes))
return new_record unless new_record.valid?
new_record.instance_variable_set(:@id, id)
store { |store| store[id] = new_record.attributes }
new_record
end
def destroy(uuid = nil)
data = store { |store| store.delete(uuid) }.tap{ |rec| rec.delete(:id) }
new(data)
end
end
class Record < Model
extend RepositoryMethods
def initialize(attrs = nil)
@destroyed = false
super(attrs)
end
def valid?
true
end
def persisted?
id? && !@destroyed
end
def save
forbid_if_readonly do
return false unless valid?
if persisted?
self.class.update(id, attributes)
else
@id = self.class.create(attributes).id
end
@destroyed = false if @destroyed
return true
end
end
def destroy
forbid_if_readonly do
@destroyed = true
self.class.destroy(id)
end
end
private
def forbid_if_readonly
raise SecurityError if self.class.readonly_attributes?
yield
end
class ReadOnly < Record
include ORM::ReadOnlyMode
end
end
end
# --- Test suite
module TestUtils
module ORM
module AttributeClasses
class User
include ::ORM::Attributes
attribute :name
attribute :age
end
class WithAReadonlyAttribute
include ::ORM::Attributes
attribute :foo
attribute :bar, readonly: true
end
end
module ModelClasses
class User < ::ORM::Model
attribute :name
attribute :age
def valid?
attributes.values.none?(&:nil?)
end
end
class WithAReadonlyAttribute < ::ORM::Model
attribute :foo
attribute :bar, readonly: true
end
end
module RecordClasses
class User < ::ORM::Record
attribute :name
attribute :age
def valid?
attributes.values.none?(&:nil?)
end
end
class WithAReadonlyAttribute < ::ORM::Record
attribute :foo
attribute :bar, readonly: true
end
end
end
module RecordHelpers
require 'fileutils'
def drop_store_of(model)
store = model.store
FileUtils.rm(store.file) if store.file?
yield(store.file)
FileUtils.rm(store.file)
refute store.file?
end
def uuid?(val)
pattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
!(String(val) =~ pattern).nil?
end
end
end
# -- ORM::Attributes, ORM::Model and ORM::Record attributes
class TestORMAttributes < Microtest::Test
User = TestUtils::ORM::AttributeClasses::User
WithAReadonlyAttribute =
TestUtils::ORM::AttributeClasses::WithAReadonlyAttribute
def setup_all
@user_class = self.class.const_get(:User)
@class_with_a_readonly_attribute =
self.class.const_get(:WithAReadonlyAttribute)
end
test 'constructor' do
user = @user_class.new name: 'Rodrigo', age: 32
assert user.name == 'Rodrigo' && user.age == 32
end
test 'attribute setters and getters' do
user = @user_class.new
user.name = 'Rodrigo'
user.age = 31
assert user.name == 'Rodrigo'
assert user.age == 31
end
test 'attribute predicates' do
user = @user_class.new
refute user.name?
user.name = false
refute user.name?
user.name = ''
assert user.name?
end
test 'read only attributes' do
obj = @class_with_a_readonly_attribute.new foo: 'foo', bar: 'bar'
assert obj.foo == 'foo'
assert obj.bar == 'bar'
assert obj.foo?
assert obj.bar?
obj.foo = 1
assert obj.foo == 1
begin
obj.bar = 1
rescue NoMethodError => exception
assert true
end
end
test 'attributes' do
assert @user_class.new.attributes == {name: nil, age: nil}
assert @user_class.new(name: 'R').attributes == {name: 'R', age: nil}
assert @user_class.new(name: 'R', age: 1).attributes == {name: 'R', age: 1}
end
end
class TestORMModelAttributes < TestORMAttributes
User = TestUtils::ORM::ModelClasses::User
WithAReadonlyAttribute = TestUtils::ORM::ModelClasses::WithAReadonlyAttribute
end
class TestORMRecordAttributes < TestORMAttributes
User = TestUtils::ORM::RecordClasses::User
WithAReadonlyAttribute = TestUtils::ORM::RecordClasses::WithAReadonlyAttribute
end
# -- ORM::Model and ORM::Record after_initialize hook
class TestORMModelAfterInitializeHook < Microtest::Test
User = TestUtils::ORM::ModelClasses::User
def setup_all
@user_class = self.class.const_get(:User)
end
test '#after_initialize hook as a public method' do
@user_class.class_eval do
def after_initialize(attributes)
return unless attributes == {name: 'Serradura'}
@age = 31
throw :public_method
end
end
catch :public_method do
user = @user_class.new name: 'Serradura'
assert user.age == 31
end
@user_class.remove_method :after_initialize
end
test '#after_initialize hook as a private method' do
@user_class.class_eval do
private def after_initialize(attributes)
return unless attributes == {age: 31}
@name = 'Rodrigo'
throw :private_method
end
end
catch :private_method do
user = @user_class.new age: 31
assert user.name == 'Serradura'
end
@user_class.remove_method :after_initialize
end
end
class TestORMRecordAfterInitializeHook < TestORMModelAfterInitializeHook
User = TestUtils::ORM::RecordClasses::User
end
# -- ORM::Model and ORM::Record validations
class TestORMModelValidation < Microtest::Test
User = TestUtils::ORM::ModelClasses::User
def setup_all
@user_class = self.class.const_get(:User)
end
test '#valid?' do
user = @user_class.new(name: 'Rodrigo', age: 31)
assert user.valid?
invalid_users = [
nil,
{},
{ name: nil, age: 31 },
{ name: nil, age: nil },
{ name: 'Rodrigo', age: nil }
].map &User.method(:new)
refute invalid_users.all?(&:valid?)
@user_class.new(name: nil, age: 31).tap do |user2|
refute user2.valid?
user2.name = 'R'
assert user2.valid?
end
end
end
class TestORMRecordValidation < TestORMModelValidation
User = TestUtils::ORM::RecordClasses::User
end
# -- ORM::Record
class TestORMRecords < Microtest::Test
include TestUtils::RecordHelpers
User = TestUtils::ORM::RecordClasses::User
test '.store' do
drop_store_of(User) do |store_file|
User.store { |store| store['test'] = 1 }
assert File.read(store_file) == "---\ntest: 1\n"
end
end
test '.create' do
drop_store_of(User) do
user = User.create(name: 'Rodrigo', age: 31)
assert uuid?(user.id)
assert user.is_a?(User)
assert user.attributes == {id: user.id, name: 'Rodrigo', age: 31}
end
end
test '.relation' do
drop_store_of(User) do
User.create(name: 'Rodrigo', age: 31)
User.create(name: 'Serradura', age: 31)
assert User.relation.all? { |user| user.is_a?(User) }
assert User.relation.map(&:id).all?(&method(:uuid?))
assert User.relation.map(&:age).all?{ |age| age == 31 }
assert User.relation.map(&:name).all?{ |name| %w[Rodrigo Serradura].include?(name) }
end
end
test '.count' do
drop_store_of(User) do
User.create(name: 'Rodrigo', age: 31)
User.create(name: 'Serradura', age: 31)
assert User.count == 2
assert User.count { |user| user.name == 'Serradura' } == 1
end
end
test '.find' do
drop_store_of(User) do
user1 = User.create(name: 'Rodrigo', age: 31)
user2 = User.create(name: 'Serradura', age: 31)
assert User.find(user1.id).attributes == user1.attributes
assert User.find{ |user| user.name == 'Serradura' }.attributes == user2.attributes
end
end
test '.select' do
drop_store_of(User) do
user1 = User.create(name: 'A', age: 1)
user2 = User.create(name: 'B', age: 2)
user2 = User.create(name: 'C', age: 1)
relation = User.select { |user| user.age == 1 }
assert relation.size == 2
assert relation.all? { |user| user.is_a?(User) }
assert relation.map(&:id).all?(&method(:uuid?))
end
end
test '.reject' do
drop_store_of(User) do
user1 = User.create(name: 'A', age: 1)
user2 = User.create(name: 'B', age: 2)
user2 = User.create(name: 'C', age: 1)
relation = User.reject { |user| user.age == 1 }
assert relation.size == 1
assert relation.all? { |user| user.is_a?(User) }
assert relation.map(&:id).all?(&method(:uuid?))
end
end
test '.update' do
drop_store_of(User) do
user = User.create(name: 'Rodrigo', age: 31)
updated_user = User.update(user.id, age: 32)
refute updated_user.attributes == user.attributes
assert uuid?(updated_user.id)
assert updated_user.is_a?(User)
assert updated_user.attributes == {id: user.id, name: 'Rodrigo', age: 32}
assert User.count == 1
assert User.find(updated_user.id).attributes == updated_user.attributes
end
end
test '.destroy' do
drop_store_of(User) do
user = User.create(name: 'Rodrigo', age: 31)
user = User.create(name: 'Serradura', age: 31)
User.destroy(user.id)
assert User.count == 1
end
end
test '#save' do
drop_store_of(User) do
user = User.new(name: 'Rodrigo', age: 31)
assert user.save
assert user.persisted?
assert User.count == 1
user.name = 'serradura'
assert user.save
assert user.persisted?
assert User.count == 1
assert User.count { |user| user.name == 'serradura' }
end
end
test '#persisted?' do
drop_store_of(User) do
user = User.create(name: 'Rodrigo', age: 31)
assert user.persisted?
invalid_users = [
{ name: nil, age: 31 },
{ name: nil, age: nil },
{ name: 'Rodrigo', age: nil }
].map &User.method(:create)
refute invalid_users.all?(&:persisted?)
end
end
test '#destroy' do
drop_store_of(User) do
User.create(name: 'Rodrigo', age: 31)
user = User.create(name: 'Serradura', age: 31)
destroyed_user = user.destroy
assert User.count == 1
assert user.id?
refute user.persisted?
refute destroyed_user.id?
refute destroyed_user.persisted?
user.save
assert user.persisted?
assert User.count == 2
end
end
end
# -- ORM::Attributes, ORM::Model and ORM::Record as readonly attributes
class TestORMAttributesInReadOnlyMode < Microtest::Test
class User
include ::ORM::Attributes
include ::ORM::ReadOnlyMode
attribute :name
attribute :age
end
def setup_all
@user_class = self.class.const_get(:User)
end
test 'constructor' do
user = @user_class.new name: 'Rodrigo', age: 31
assert user.attributes == { name: 'Rodrigo', age: 31 }
end
test 'forbid setters' do
user = @user_class.new name: 'Rodrigo', age: 31
begin
user.name = 'Rodrigo'
rescue NoMethodError
assert true
end
begin
user.age = 31
rescue NoMethodError
assert true
end
end
test 'attribute predicates' do
user = @user_class.new name: 'Rodrigo', age: 31
assert user.name?
assert user.age?
end
end
class TestORMModelInReadOnlyMode < TestORMAttributesInReadOnlyMode
class User < ::ORM::Model::ReadOnly
attribute :name
attribute :age
end
end
class TestORMRecordInReadOnlyMode < TestORMAttributesInReadOnlyMode
include TestUtils::RecordHelpers
class User < ::ORM::Record::ReadOnly
attribute :name
attribute :age
end
test 'forbid to save' do
drop_store_of(User) do
user = User.create(name: 'Rodrigo', age: 31)
assert User.count == 1
begin
user.save
rescue SecurityError
assert true
end
assert User.count == 1
end
end
test 'forbid to destroy' do
drop_store_of(User) do
user = User.create(name: 'Rodrigo', age: 31)
assert User.count == 1
begin
user.destroy
rescue SecurityError
assert true
end
User.destroy(user.id)
assert User.count == 0
end
end
end
Microtest.call
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment