|
# 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 |