mixture 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +3 -0
- data/.rubocop.yml +9 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +8 -0
- data/lib/mixture/attribute.rb +31 -0
- data/lib/mixture/attribute_list.rb +29 -0
- data/lib/mixture/coerce/array.rb +15 -0
- data/lib/mixture/coerce/base.rb +64 -0
- data/lib/mixture/coerce/date.rb +20 -0
- data/lib/mixture/coerce/datetime.rb +20 -0
- data/lib/mixture/coerce/float.rb +19 -0
- data/lib/mixture/coerce/hash.rb +14 -0
- data/lib/mixture/coerce/integer.rb +19 -0
- data/lib/mixture/coerce/nil.rb +20 -0
- data/lib/mixture/coerce/object.rb +34 -0
- data/lib/mixture/coerce/rational.rb +19 -0
- data/lib/mixture/coerce/set.rb +14 -0
- data/lib/mixture/coerce/string.rb +19 -0
- data/lib/mixture/coerce/symbol.rb +14 -0
- data/lib/mixture/coerce/time.rb +19 -0
- data/lib/mixture/coerce.rb +70 -0
- data/lib/mixture/errors.rb +14 -0
- data/lib/mixture/extensions/attributable.rb +64 -0
- data/lib/mixture/extensions/coercable.rb +31 -0
- data/lib/mixture/extensions/hashable.rb +37 -0
- data/lib/mixture/extensions/validatable.rb +52 -0
- data/lib/mixture/extensions.rb +30 -0
- data/lib/mixture/model.rb +13 -0
- data/lib/mixture/type.rb +142 -0
- data/lib/mixture/validate/base.rb +21 -0
- data/lib/mixture/validate/match.rb +17 -0
- data/lib/mixture/validate/presence.rb +17 -0
- data/lib/mixture/validate.rb +35 -0
- data/lib/mixture/version.rb +9 -0
- data/lib/mixture.rb +29 -0
- data/mixture.gemspec +30 -0
- metadata +156 -0
@@ -0,0 +1,14 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
# All mixture errors inherit this.
|
5
|
+
class BasicError < StandardError
|
6
|
+
end
|
7
|
+
|
8
|
+
# Occurs when a value can't be coerced into another value.
|
9
|
+
class CoercionError < BasicError
|
10
|
+
end
|
11
|
+
|
12
|
+
class ValidationError < BasicError
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
module Extensions
|
5
|
+
# Has the mixture have attributes.
|
6
|
+
module Attributable
|
7
|
+
# The class methods for attribution.
|
8
|
+
module ClassMethods
|
9
|
+
def attribute(name, options = {})
|
10
|
+
name = name.to_s.intern
|
11
|
+
attr = attributes.create(name, options)
|
12
|
+
define_method(attr.getter) { attribute(name) }
|
13
|
+
define_method(attr.setter) { |v| attribute(name, v) }
|
14
|
+
attr
|
15
|
+
end
|
16
|
+
|
17
|
+
def attributes
|
18
|
+
@_attributes ||= AttributeList.new
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# The instance methods for attribution.
|
23
|
+
module InstanceMethods
|
24
|
+
# Sets the attributes on the instance.
|
25
|
+
def attributes=(attrs)
|
26
|
+
attrs.each { |key, value| attribute(key, value) }
|
27
|
+
end
|
28
|
+
|
29
|
+
def attributes
|
30
|
+
Hash[self.class.attributes.map do |name, attr|
|
31
|
+
[name, instance_variable_get(attr.ivar)]
|
32
|
+
end]
|
33
|
+
end
|
34
|
+
|
35
|
+
def unknown_attribute(attr)
|
36
|
+
fail ArgumentError, "Unknown attribute #{attr} passed"
|
37
|
+
end
|
38
|
+
|
39
|
+
def attribute(key, value = Undefined)
|
40
|
+
attr = self.class.attributes.fetch(key) do
|
41
|
+
return unknown_attribute(key)
|
42
|
+
end
|
43
|
+
|
44
|
+
return instance_variable_get(attr.ivar) if value == Undefined
|
45
|
+
|
46
|
+
value = attr.update(value)
|
47
|
+
instance_variable_set(attr.ivar, value)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Called by Ruby when the module is included. This just
|
52
|
+
# extends the base by the {ClassMethods} module and includes
|
53
|
+
# into the base the {InstanceMethods} module.
|
54
|
+
#
|
55
|
+
# @param base [Object]
|
56
|
+
# @return [void]
|
57
|
+
# @api private
|
58
|
+
def self.included(base)
|
59
|
+
base.extend ClassMethods
|
60
|
+
base.send :include, InstanceMethods
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
module Extensions
|
5
|
+
# Extends the attribute definition to allow coercion.
|
6
|
+
module Coercable
|
7
|
+
def self.coerce_attribute(attribute, value)
|
8
|
+
return value unless attribute.options[:type]
|
9
|
+
attr_type = Type.infer(attribute.options[:type])
|
10
|
+
value_type = Type.infer(value)
|
11
|
+
|
12
|
+
block = Coerce.coerce(value_type, attr_type)
|
13
|
+
|
14
|
+
begin
|
15
|
+
block.call(value)
|
16
|
+
rescue StandardError => e
|
17
|
+
raise CoercionError, "#{e.class}: #{e.message}", e.backtrace
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Called by Ruby when the module is included.
|
22
|
+
#
|
23
|
+
# @param base [Object]
|
24
|
+
# @return [void]
|
25
|
+
# @api private
|
26
|
+
def self.included(base)
|
27
|
+
base.attributes.callbacks[:update] << method(:coerce_attribute)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Mixture
|
6
|
+
module Extensions
|
7
|
+
# Has the mixture respond to `#[]` and `#[]=`.
|
8
|
+
module Hashable
|
9
|
+
extend Forwardable
|
10
|
+
include Enumerable
|
11
|
+
include Comparable
|
12
|
+
|
13
|
+
delegate [:each, :<=>] => :attributes
|
14
|
+
|
15
|
+
def [](key)
|
16
|
+
attribute(key.to_s.intern)
|
17
|
+
end
|
18
|
+
|
19
|
+
def []=(key, value)
|
20
|
+
attribute(key.to_s.intern, value)
|
21
|
+
end
|
22
|
+
|
23
|
+
def key?(key)
|
24
|
+
self.class.attributes.key?(key.to_s.intern)
|
25
|
+
end
|
26
|
+
|
27
|
+
def fetch(key, default = Unknown)
|
28
|
+
case
|
29
|
+
when key?(key.to_s.intern) then attribute(key.to_s.intern)
|
30
|
+
when block_given? then yield(key.to_s.intern)
|
31
|
+
when default != Unknown then default
|
32
|
+
else fail KeyError, "Undefined attribute #{key.to_s.intern}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
module Extensions
|
5
|
+
# Allows attributes to be validated based on a predefined set of
|
6
|
+
# principles.
|
7
|
+
module Validatable
|
8
|
+
# The class methods.
|
9
|
+
module ClassMethods
|
10
|
+
# Creates a new validation for the given attribute. The
|
11
|
+
# attribute _must_ be defined before this call, otherwise it
|
12
|
+
# _will_ error.
|
13
|
+
def validate(name, options = {})
|
14
|
+
attributes.fetch(name).options[:validate] = options
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# The instance methods.
|
19
|
+
module InstanceMethods
|
20
|
+
# Validates the attributes on the record.
|
21
|
+
def valid?
|
22
|
+
@errors = Hash.new { |h, k| h[k] = [] }
|
23
|
+
self.class.attributes.each do |name, attribute|
|
24
|
+
next unless attribute.options[:validate]
|
25
|
+
Validate.validate(self, attribute, attribute(name))
|
26
|
+
end
|
27
|
+
!@errors.values.any?(&:any?)
|
28
|
+
end
|
29
|
+
|
30
|
+
def invalid?
|
31
|
+
!valid?
|
32
|
+
end
|
33
|
+
|
34
|
+
def errors
|
35
|
+
@errors ||= Hash.new { |h, k| h[k] = [] }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Called by Ruby when the module is included. This just
|
40
|
+
# extends the base by the {ClassMethods} module and includes
|
41
|
+
# into the base the {InstanceMethods} module.
|
42
|
+
#
|
43
|
+
# @param base [Object]
|
44
|
+
# @return [void]
|
45
|
+
# @api private
|
46
|
+
def self.included(base)
|
47
|
+
base.extend ClassMethods
|
48
|
+
base.send :include, InstanceMethods
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
# All of the extensions of mixture. Handles registration of
|
5
|
+
# extensions, so that extensions can be referend by a name instead
|
6
|
+
# of the constant.
|
7
|
+
module Extensions
|
8
|
+
def self.register(name, extension)
|
9
|
+
extensions[name.to_s.downcase.intern] = extension
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.[](name)
|
13
|
+
extensions.fetch(name)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.extensions
|
17
|
+
@_extensions ||= {}
|
18
|
+
end
|
19
|
+
|
20
|
+
require "mixture/extensions/attributable"
|
21
|
+
require "mixture/extensions/coercable"
|
22
|
+
require "mixture/extensions/hashable"
|
23
|
+
require "mixture/extensions/validatable"
|
24
|
+
|
25
|
+
register :attribute, Attributable
|
26
|
+
register :coerce, Coercable
|
27
|
+
register :hash, Hashable
|
28
|
+
register :validate, Validatable
|
29
|
+
end
|
30
|
+
end
|
data/lib/mixture/type.rb
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Mixture
|
6
|
+
# A type. This can be represented as a constant. This is normally
|
7
|
+
# anything that inherits `Class`. One instance per type.
|
8
|
+
class Type
|
9
|
+
extend Forwardable
|
10
|
+
@instances = {}
|
11
|
+
BooleanClass = Class.new
|
12
|
+
InstanceClass = Class.new
|
13
|
+
|
14
|
+
BUILTIN_TYPES = %w(
|
15
|
+
Object Array Hash Integer Rational Float Set String Symbol
|
16
|
+
Time Date DateTime
|
17
|
+
).map(&:intern).freeze
|
18
|
+
|
19
|
+
TYPE_ALIASES = {
|
20
|
+
true => BooleanClass,
|
21
|
+
false => BooleanClass,
|
22
|
+
nil => NilClass,
|
23
|
+
nil: NilClass,
|
24
|
+
bool: BooleanClass,
|
25
|
+
boolean: BooleanClass,
|
26
|
+
str: ::String,
|
27
|
+
string: ::String,
|
28
|
+
int: ::Integer,
|
29
|
+
integer: ::Integer,
|
30
|
+
rational: ::Rational,
|
31
|
+
float: ::Float,
|
32
|
+
array: ::Array,
|
33
|
+
set: ::Set,
|
34
|
+
symbol: ::Symbol,
|
35
|
+
time: ::Time,
|
36
|
+
date_time: ::DateTime,
|
37
|
+
date: ::Date
|
38
|
+
}.freeze
|
39
|
+
|
40
|
+
# Protect our class from being initialized by accident by anyone
|
41
|
+
# who doesn't know what they're doing. They would have to try
|
42
|
+
# really hard to initialize this class, and in which case, that
|
43
|
+
# is acceptable.
|
44
|
+
private_class_method :new
|
45
|
+
|
46
|
+
# Returns a {Type} from a given class. It assumes that the type
|
47
|
+
# given is a class, and passes it to {#new} - which will error if
|
48
|
+
# it isn't.
|
49
|
+
#
|
50
|
+
# @param type [Class] A type.
|
51
|
+
# @return [Mixture::Type]
|
52
|
+
def self.from(type)
|
53
|
+
@instances.fetch(type) do
|
54
|
+
@instances[type] = new(type)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# (see .from)
|
59
|
+
def self.[](type)
|
60
|
+
from(type)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Determines the best type that represents the given value. If
|
64
|
+
# the given value is a {Type}, it returns the value. If the
|
65
|
+
# given value is already defined as a {Type}, it returns the
|
66
|
+
# {Type}. If the value's class is already defined as a {Type},
|
67
|
+
# it returns the class's {Type}; otherwise, it returns the types
|
68
|
+
# for Class and Object, depending on if the value is a Class or
|
69
|
+
# not, respectively.
|
70
|
+
#
|
71
|
+
# @example
|
72
|
+
# Mixture::Type.infer(Integer) # => Mixture::Type::Integer
|
73
|
+
#
|
74
|
+
# @example
|
75
|
+
# Mixture::Type.infer(1) # => Mixture::Type::Integer
|
76
|
+
#
|
77
|
+
# @example
|
78
|
+
# Mixture::Type.infer(MyClass) # => Mixture::Type::Instance
|
79
|
+
#
|
80
|
+
# @example
|
81
|
+
# Mixture::Type.infer(Object.new) # => Mixture::Type::Object
|
82
|
+
#
|
83
|
+
# @param value [Object] The value to infer.
|
84
|
+
# @return [Mixture::Type]
|
85
|
+
def self.infer(value)
|
86
|
+
case
|
87
|
+
when TYPE_ALIASES.key?(value) then from(TYPE_ALIASES[value])
|
88
|
+
when value.is_a?(Type) then value
|
89
|
+
when value.is_a?(Class) then infer_class(value)
|
90
|
+
else infer_class(value.class)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.infer_class(klass)
|
95
|
+
if klass.is_a?(Type)
|
96
|
+
klass
|
97
|
+
else
|
98
|
+
basic_ancestors = klass.ancestors - ancestors
|
99
|
+
from(basic_ancestors.find { |a| @instances.key?(a) } || klass)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.load
|
104
|
+
BUILTIN_TYPES.each do |sym|
|
105
|
+
const_set(sym, from(::Object.const_get(sym)))
|
106
|
+
end
|
107
|
+
|
108
|
+
@instances[BooleanClass] = new(BooleanClass, name: "Boolean")
|
109
|
+
const_set("Boolean", @instances[BooleanClass])
|
110
|
+
@instances[InstanceClass] = new(InstanceClass, name: "Instance")
|
111
|
+
const_set("Instance", @instances[InstanceClass])
|
112
|
+
@instances[NilClass] = new(NilClass, name: "Nil")
|
113
|
+
const_set("Nil", @instances[NilClass])
|
114
|
+
end
|
115
|
+
|
116
|
+
attr_reader :name
|
117
|
+
|
118
|
+
def initialize(type, options = {})
|
119
|
+
fail ArgumentError, "Expected a Class, got #{type.class}" unless
|
120
|
+
type.is_a?(Class)
|
121
|
+
@type = type
|
122
|
+
@name = options.fetch(:name, @type.name)
|
123
|
+
end
|
124
|
+
|
125
|
+
def to_s
|
126
|
+
"#{self.class.name}(#{@name})"
|
127
|
+
end
|
128
|
+
alias_method :inspect, :to_s
|
129
|
+
|
130
|
+
def method_name
|
131
|
+
@_method_name ||= begin
|
132
|
+
body = name
|
133
|
+
.gsub(/^([A-Z])/) { |m| m.downcase }
|
134
|
+
.gsub(/::([A-Z])/) { |_, m| "_#{m.downcase}" }
|
135
|
+
.gsub(/([A-Z])/) { |m| "_#{m.downcase}" }
|
136
|
+
:"to_#{body}"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
Mixture::Type.load
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
module Validate
|
5
|
+
class Base
|
6
|
+
def initialize(options)
|
7
|
+
@options = options
|
8
|
+
end
|
9
|
+
|
10
|
+
def validate(record, attribute, value)
|
11
|
+
@record = record
|
12
|
+
@attribute = attribute
|
13
|
+
@value = value
|
14
|
+
end
|
15
|
+
|
16
|
+
def error(message)
|
17
|
+
fail ValidationError, message
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
module Validate
|
5
|
+
# Checks that a value matches.
|
6
|
+
class Match < Base
|
7
|
+
def validate(record, attribute, value)
|
8
|
+
super
|
9
|
+
error("Value does not match") unless match?
|
10
|
+
end
|
11
|
+
|
12
|
+
def match?
|
13
|
+
@value =~ @options
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
module Validate
|
5
|
+
# Checks that a value is present.
|
6
|
+
class Presence < Base
|
7
|
+
def validate(record, attribute, value)
|
8
|
+
super
|
9
|
+
error("Value is empty") if empty?
|
10
|
+
end
|
11
|
+
|
12
|
+
def empty?
|
13
|
+
@value.nil? || (@value.respond_to?(:empty?) && @value.empty?)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module Mixture
|
4
|
+
module Validate
|
5
|
+
def self.register(name, validator)
|
6
|
+
validations[name] = validator
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.validations
|
10
|
+
@_validations ||= {}
|
11
|
+
end
|
12
|
+
|
13
|
+
require "mixture/validate/base"
|
14
|
+
require "mixture/validate/match"
|
15
|
+
require "mixture/validate/presence"
|
16
|
+
|
17
|
+
register :match, Match
|
18
|
+
register :format, Match
|
19
|
+
register :presence, Presence
|
20
|
+
|
21
|
+
def self.validate(record, attribute, value)
|
22
|
+
attribute.options[:validate].each do |k, v|
|
23
|
+
validator = validations.fetch(k).new(v)
|
24
|
+
validate_with(validator, record, attribute, value)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.validate_with(validator, record, attribute, value)
|
29
|
+
validator.validate(record, attribute, value)
|
30
|
+
|
31
|
+
rescue ValidationError => e
|
32
|
+
record.errors[attribute] << e
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/mixture.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require "set"
|
4
|
+
require "time"
|
5
|
+
require "date"
|
6
|
+
|
7
|
+
# The mixture module.
|
8
|
+
module Mixture
|
9
|
+
# An undefined value. This is used in place so that we can be sure
|
10
|
+
# that an argument wasn't passed.
|
11
|
+
#
|
12
|
+
# @return [Object]
|
13
|
+
Undefined = Object.new.freeze
|
14
|
+
|
15
|
+
# A proc that returns its first argument.
|
16
|
+
#
|
17
|
+
# @return [Proc{(Object) => Object}]
|
18
|
+
Itself = proc { |value| value }
|
19
|
+
|
20
|
+
require "mixture/version"
|
21
|
+
require "mixture/errors"
|
22
|
+
require "mixture/type"
|
23
|
+
require "mixture/attribute"
|
24
|
+
require "mixture/attribute_list"
|
25
|
+
require "mixture/coerce"
|
26
|
+
require "mixture/validate"
|
27
|
+
require "mixture/extensions"
|
28
|
+
require "mixture/type"
|
29
|
+
end
|
data/mixture.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
lib = File.expand_path("../lib", __FILE__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require "mixture/version"
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = "mixture"
|
9
|
+
spec.version = Mixture::VERSION
|
10
|
+
spec.authors = ["Jeremy Rodi"]
|
11
|
+
spec.email = ["redjazz96@gmail.com"]
|
12
|
+
|
13
|
+
spec.summary = "Handle validation, coercion, and attributes."
|
14
|
+
spec.description = "Handle validation, coercion, and attributes."
|
15
|
+
spec.homepage = "https://github.com/medcat/mixture"
|
16
|
+
spec.license = "MIT"
|
17
|
+
|
18
|
+
spec.files = `git ls-files -z`.split("\x0")
|
19
|
+
.reject { |f| f.match(%r{^(test|spec|features)/}) }
|
20
|
+
spec.bindir = "bin"
|
21
|
+
spec.executables = spec.files
|
22
|
+
.grep(%r{^bin/}) { |f| File.basename(f) }
|
23
|
+
spec.require_paths = ["lib"]
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler"
|
26
|
+
spec.add_development_dependency "rake"
|
27
|
+
spec.add_development_dependency "rspec"
|
28
|
+
spec.add_development_dependency "pry"
|
29
|
+
spec.add_development_dependency "coveralls"
|
30
|
+
end
|