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