mixture 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +9 -0
  5. data/.travis.yml +6 -0
  6. data/CODE_OF_CONDUCT.md +13 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +74 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +43 -0
  11. data/Rakefile +8 -0
  12. data/lib/mixture/attribute.rb +31 -0
  13. data/lib/mixture/attribute_list.rb +29 -0
  14. data/lib/mixture/coerce/array.rb +15 -0
  15. data/lib/mixture/coerce/base.rb +64 -0
  16. data/lib/mixture/coerce/date.rb +20 -0
  17. data/lib/mixture/coerce/datetime.rb +20 -0
  18. data/lib/mixture/coerce/float.rb +19 -0
  19. data/lib/mixture/coerce/hash.rb +14 -0
  20. data/lib/mixture/coerce/integer.rb +19 -0
  21. data/lib/mixture/coerce/nil.rb +20 -0
  22. data/lib/mixture/coerce/object.rb +34 -0
  23. data/lib/mixture/coerce/rational.rb +19 -0
  24. data/lib/mixture/coerce/set.rb +14 -0
  25. data/lib/mixture/coerce/string.rb +19 -0
  26. data/lib/mixture/coerce/symbol.rb +14 -0
  27. data/lib/mixture/coerce/time.rb +19 -0
  28. data/lib/mixture/coerce.rb +70 -0
  29. data/lib/mixture/errors.rb +14 -0
  30. data/lib/mixture/extensions/attributable.rb +64 -0
  31. data/lib/mixture/extensions/coercable.rb +31 -0
  32. data/lib/mixture/extensions/hashable.rb +37 -0
  33. data/lib/mixture/extensions/validatable.rb +52 -0
  34. data/lib/mixture/extensions.rb +30 -0
  35. data/lib/mixture/model.rb +13 -0
  36. data/lib/mixture/type.rb +142 -0
  37. data/lib/mixture/validate/base.rb +21 -0
  38. data/lib/mixture/validate/match.rb +17 -0
  39. data/lib/mixture/validate/presence.rb +17 -0
  40. data/lib/mixture/validate.rb +35 -0
  41. data/lib/mixture/version.rb +9 -0
  42. data/lib/mixture.rb +29 -0
  43. data/mixture.gemspec +30 -0
  44. 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
@@ -0,0 +1,13 @@
1
+ # encoding: utf-8
2
+
3
+ module Mixture
4
+ # A model.
5
+ #
6
+ # @example
7
+ # class MyClass
8
+ # include Mixture::Model
9
+ # mixture_modules :attributable, :hashable
10
+ # end
11
+ module Model
12
+ end
13
+ end
@@ -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
@@ -0,0 +1,9 @@
1
+ # encoding: utf-8
2
+
3
+ #
4
+ module Mixture
5
+ # The current version of Mixture.
6
+ #
7
+ # @return [String]
8
+ VERSION = "0.1.0"
9
+ 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