data_model 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.editorconfig +6 -2
  3. data/.rubocop.yml +11 -2
  4. data/.ruby-version +2 -0
  5. data/Gemfile.lock +91 -54
  6. data/Guardfile +20 -0
  7. data/Rakefile +32 -0
  8. data/data_model.gemspec +52 -0
  9. data/lib/data_model/boolean.rb +7 -0
  10. data/lib/data_model/builtin/array.rb +73 -0
  11. data/lib/data_model/builtin/big_decimal.rb +64 -0
  12. data/lib/data_model/builtin/boolean.rb +37 -0
  13. data/lib/data_model/builtin/date.rb +60 -0
  14. data/lib/data_model/builtin/float.rb +64 -0
  15. data/lib/data_model/builtin/hash.rb +119 -0
  16. data/lib/data_model/builtin/integer.rb +64 -0
  17. data/lib/data_model/builtin/string.rb +67 -0
  18. data/lib/data_model/builtin/symbol.rb +64 -0
  19. data/lib/data_model/builtin/time.rb +60 -0
  20. data/lib/data_model/builtin.rb +23 -0
  21. data/lib/data_model/error.rb +107 -0
  22. data/lib/data_model/errors.rb +278 -0
  23. data/lib/data_model/fixtures/array.rb +61 -0
  24. data/lib/data_model/fixtures/big_decimal.rb +55 -0
  25. data/lib/data_model/fixtures/boolean.rb +35 -0
  26. data/lib/data_model/fixtures/date.rb +53 -0
  27. data/lib/data_model/fixtures/example.rb +29 -0
  28. data/lib/data_model/fixtures/float.rb +53 -0
  29. data/lib/data_model/fixtures/hash.rb +66 -0
  30. data/lib/data_model/fixtures/integer.rb +53 -0
  31. data/lib/data_model/fixtures/string.rb +77 -0
  32. data/lib/data_model/fixtures/symbol.rb +56 -0
  33. data/lib/data_model/fixtures/time.rb +53 -0
  34. data/lib/data_model/logging.rb +23 -0
  35. data/lib/data_model/model.rb +21 -44
  36. data/lib/data_model/scanner.rb +92 -56
  37. data/lib/data_model/testing/minitest.rb +79 -0
  38. data/lib/data_model/testing.rb +6 -0
  39. data/lib/data_model/type.rb +41 -39
  40. data/lib/data_model/type_registry.rb +68 -0
  41. data/lib/data_model/version.rb +3 -1
  42. data/lib/data_model.rb +32 -16
  43. data/sorbet/config +4 -0
  44. data/sorbet/rbi/annotations/rainbow.rbi +269 -0
  45. data/sorbet/rbi/gems/minitest@5.18.0.rbi +1491 -0
  46. data/sorbet/rbi/gems/zeitwerk.rbi +196 -0
  47. data/sorbet/rbi/gems/zeitwerk@2.6.7.rbi +966 -0
  48. data/sorbet/rbi/todo.rbi +5 -0
  49. data/sorbet/tapioca/config.yml +13 -0
  50. data/sorbet/tapioca/require.rb +4 -0
  51. metadata +139 -17
  52. data/config/sus.rb +0 -2
  53. data/fixtures/schema.rb +0 -14
  54. data/lib/data_model/registry.rb +0 -44
@@ -0,0 +1,77 @@
1
+ # typed: strict
2
+
3
+ module DataModel
4
+ module Fixtures::String
5
+ extend self
6
+ extend T::Sig
7
+ include Fixtures
8
+
9
+ sig { returns(Example) }
10
+ def simple
11
+ Example.new(
12
+ [:string],
13
+ variants: {
14
+ valid: "valid",
15
+ other_type: 22,
16
+ missing: nil
17
+ },
18
+ )
19
+ end
20
+
21
+ sig { returns(Example) }
22
+ def optional
23
+ Example.new(
24
+ [:string, { optional: true }],
25
+ variants: {
26
+ valid: "valid",
27
+ blank: "",
28
+ missing: nil
29
+ },
30
+ )
31
+ end
32
+
33
+ sig { returns(Example) }
34
+ def inclusion
35
+ Example.new(
36
+ [:string, { included: ["valid"] }],
37
+ variants: {
38
+ valid: "valid",
39
+ outside: "invalid"
40
+ },
41
+ )
42
+ end
43
+
44
+ sig { returns(Example) }
45
+ def exclusion
46
+ Example.new(
47
+ [:string, { excluded: ["invalid"] }],
48
+ variants: {
49
+ valid: "valid",
50
+ inside: "invalid"
51
+ },
52
+ )
53
+ end
54
+
55
+ sig { returns(Example) }
56
+ def allow_blank
57
+ Example.new(
58
+ [:string, { allow_blank: true }],
59
+ variants: {
60
+ blank: "",
61
+ not_blank: "content",
62
+ missing: nil
63
+ },
64
+ )
65
+ end
66
+
67
+ sig { returns(Example) }
68
+ def dont_allow_blank
69
+ Example.new(
70
+ [:string, { allow_blank: false }],
71
+ variants: {
72
+ blank: ""
73
+ },
74
+ )
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,56 @@
1
+ # typed: strict
2
+
3
+ module DataModel
4
+ module Fixtures::Symbol
5
+ extend self
6
+ include Fixtures
7
+ extend T::Sig
8
+
9
+ sig { returns(Example) }
10
+ def simple
11
+ Example.new(
12
+ [:symbol],
13
+ variants: {
14
+ valid: :valid,
15
+ coerce: "valid",
16
+ missing: nil,
17
+ other_type: 22
18
+ },
19
+ )
20
+ end
21
+
22
+ sig { returns(Example) }
23
+ def optional
24
+ Example.new(
25
+ [:symbol, { optional: true }],
26
+ variants: {
27
+ missing: nil,
28
+ present: :valid,
29
+ number: 22
30
+ },
31
+ )
32
+ end
33
+
34
+ sig { returns(Example) }
35
+ def inclusion
36
+ Example.new(
37
+ [:symbol, { included: [:valid] }],
38
+ variants: {
39
+ valid: :valid,
40
+ outside: :outside
41
+ },
42
+ )
43
+ end
44
+
45
+ sig { returns(Example) }
46
+ def exclusion
47
+ Example.new(
48
+ [:symbol, { excluded: [:invalid] }],
49
+ variants: {
50
+ valid: :valid,
51
+ inside: :invalid
52
+ },
53
+ )
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,53 @@
1
+ # typed: strict
2
+
3
+ module DataModel
4
+ module Fixtures::Time
5
+ extend T::Sig
6
+ extend self
7
+ include Fixtures
8
+
9
+ sig { returns(::Time) }
10
+ def earliest_time
11
+ return ::Time.now - 1
12
+ end
13
+
14
+ sig { returns(::Time) }
15
+ def latest_time
16
+ return ::Time.now + 1
17
+ end
18
+
19
+ sig { returns(T::Hash[Symbol, Object]) }
20
+ def variants
21
+ now = ::Time.now
22
+
23
+ {
24
+ time: now,
25
+ string: [now.strftime("%H:%M:%S.%6N"), now],
26
+ invalid: "invalid",
27
+ early: earliest_time - 1,
28
+ late: latest_time + 1,
29
+ missing: nil
30
+ }
31
+ end
32
+
33
+ sig { returns(Example) }
34
+ def simple
35
+ Example.new([:time], variants:)
36
+ end
37
+
38
+ sig { returns(Example) }
39
+ def optional
40
+ Example.new([:time, { optional: true }], variants:)
41
+ end
42
+
43
+ sig { returns(Example) }
44
+ def earliest
45
+ Example.new([:time, { earliest: earliest_time }], variants:)
46
+ end
47
+
48
+ sig { returns(Example) }
49
+ def latest
50
+ Example.new([:time, { latest: latest_time }], variants:)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+
3
+ require "logger"
4
+
5
+ module DataModel
6
+ module Logging
7
+ extend T::Sig
8
+ include Kernel
9
+
10
+ sig { returns(Logger) }
11
+ def log
12
+ target = T.let(respond_to?(:name) ? self : self.class, T.any(Class, Module))
13
+
14
+ logger = Logger.new(
15
+ STDERR,
16
+ level: Logger::FATAL,
17
+ progname: target.name,
18
+ )
19
+
20
+ return @log ||= T.let(logger, T.nilable(Logger))
21
+ end
22
+ end
23
+ end
@@ -1,54 +1,31 @@
1
- module DataModel
2
- module Model
3
- extend self
4
-
5
- def defaults
6
- {
7
- # name of validator if a child, and validating a named property
8
- property: nil,
1
+ # typed: strict
9
2
 
10
- # context passed to read and write
11
- config: {},
12
- types: nil,
13
- children: [],
14
-
15
- # default writer
16
- write: ->(val) { val }
17
- }
18
- end
3
+ module DataModel
4
+ class Model
5
+ extend T::Sig
19
6
 
20
- def validate!(model)
21
- model => {
22
- property:, config:, types:,
23
- read: Proc, write: Proc,
24
- children: Array,
25
- }
7
+ sig { params(schema: TSchema, type: Type).void }
8
+ def initialize(schema, type)
9
+ @schema = schema
10
+ @type = type
26
11
  end
27
12
 
28
- def read(model, data)
29
- validate! model
30
- invoke(model, :read, data)
31
- end
13
+ sig { returns(TSchema) }
14
+ attr_reader :schema
32
15
 
33
- def write(model, data)
34
- validate! model
35
- invoke(model, :write, data)
16
+ # Validate data against the model. This will return true if the data is valid,
17
+ # or false if it is not. If it is not valid, it will raise an exception.
18
+ sig { params(data: TData).returns(Error) }
19
+ def validate(data)
20
+ _, err = @type.read(data)
21
+ return err
36
22
  end
37
23
 
38
- private
39
-
40
- def invoke(model, action, data)
41
- fn = model.fetch(action)
42
-
43
- case fn.arity
44
- when 1
45
- fn.call(data)
46
- when 2
47
- ctx = model.slice(:config, :types, :children)
48
- fn.call(data, ctx)
49
- else
50
- raise "expected an arity of 1 or 2, got: #{fn.arity}"
51
- end
24
+ # Read data with the model. This will return a tuple of [data, error]
25
+ sig { params(data: TData).returns([TData, Error]) }
26
+ def coerce(data)
27
+ result = @type.read(data, coerce: true)
28
+ return result
52
29
  end
53
30
  end
54
31
  end
@@ -1,67 +1,103 @@
1
+ # typed: strict
2
+
3
+ # Scan a schema into a struct that can be inspected to construct a model validator
4
+ #
5
+ # schema eg:
6
+ # [:string, { min: 1, max: 10}]
7
+ # [:tuple, { title: "coordinates" }, :double, :double]
8
+ # [:hash, { open: false },
9
+ # [:first_name, :string]
10
+ # [:last_name, :string]]
11
+ #
12
+ # first param is type, which is a key lookup in the registry
13
+ # second param is args, this is optional, but is a way to configure a type
14
+ # rest are type params. these are used to configure a type at the point of instantiation. Think of them as generics.
15
+ #
16
+ # params are either
17
+ # symbol, for example tuple types
18
+ # array, for object types to configure child properties.
1
19
  module DataModel
2
20
  module Scanner
21
+ include Kernel
22
+ include Logging
23
+
24
+ extend T::Sig
3
25
  extend self
4
26
 
5
- def scan(schema, registry = Registry.instance)
6
- start = { validator: nil, property: nil, state: :start }
7
- result = schema.each_with_index.reduce(start) do |context, (token, pos)|
8
- context => {state:, validator:, property:}
9
- peek = schema[pos + 1]
10
-
11
- # detect named validators, a name could be any type so cannot be detected
12
- # by ruby type and state. detect name by ensuring we are at the start
13
- # of scanning, and the next element is a valid type.
14
- if state == :start && registry.type?(peek)
15
- context.merge(
16
- property: token,
17
- state: :named,
18
- )
19
- else
20
- # with the special case of a name aside, we can detect the meaning
21
- # of further tokens by their type and scanner state
22
- case token
23
- when Symbol
24
- unless [:start, :named].include?(state)
25
- raise "got a symbol at pos #{pos}, but validator already defined"
26
- end
27
-
28
- unless registry.type?(token)
29
- raise "expected a type in pos #{pos}, but found #{token.inspect} which is not a registered type"
30
- end
31
-
32
- context.merge(
33
- validator: registry.type(token, property),
34
- state: :defined,
35
- )
36
-
37
- when Hash
38
- unless state == :defined
39
- raise "got a hash at pos #{pos}, but state is not :defined (#{state.inspect})"
40
- end
41
-
42
- context.merge(
43
- validator: validator.merge(config: token),
44
- state: :configured,
45
- )
46
-
47
- when Array
48
- unless [:defined, :configured].include?(state)
49
- raise "#{schema.inspect} at pos #{pos}: expected (String | Hash | Symbol | Array), got #{token.class.name}"
50
- end
51
-
52
- children = token.map { |s| scan(s, registry) }
53
-
54
- context.merge(
55
- validator: validator.merge(children:),
56
- state: :complete,
57
- )
58
- else
59
- raise "got token #{token.inspect} at position #{pos} which was unexpected given the scanner was in a state of #{state}"
27
+ class Node < T::Struct
28
+ prop :type, Symbol, default: :nothing
29
+ prop :args, T::Hash[Symbol, Object], default: {}
30
+ prop :params, T::Array[Object], default: []
31
+ end
32
+
33
+ # Scan a schema, which is defined as a data structure, into a struct that is easier to work with.
34
+ # "Syntax" validations will be enforced at this level.
35
+ sig { params(schema: TSchema, registry: DataModel::TypeRegistry).returns(Node) }
36
+ def scan(schema, registry = TypeRegistry.instance)
37
+ # state:
38
+ # nil (start) -> :type (we have a type) -> :args (we have arguments)
39
+ scanned = Node.new
40
+ state = T.let(nil, T.nilable(Symbol))
41
+
42
+ log.debug("scanning schema: #{schema.inspect}")
43
+
44
+ for pos in (0...schema.length)
45
+ token = schema[pos]
46
+ dbg = "pos: #{pos}, token: #{token.inspect}, state: #{state.inspect}"
47
+ log.debug(dbg)
48
+
49
+ # detect optional args missing
50
+ if !token.is_a?(Hash) && state == :type
51
+ log.debug("detected optional args missing at (#{dbg}), moving state to :args")
52
+
53
+ # move state forward
54
+ state = :args
55
+ end
56
+
57
+ # we are just collecting params at this point
58
+ if state == :args
59
+
60
+ if !token.is_a?(Array) && !token.is_a?(Symbol)
61
+ raise "expected type params at (#{dbg}), which should be either a symbol or an array"
60
62
  end
63
+
64
+ scanned.params << token
65
+ log.debug("collecting params at (#{dbg})")
66
+
67
+ next
68
+ end
69
+
70
+ # we can determine meaning based on type and state
71
+ case token
72
+ when Symbol
73
+ if !state.nil?
74
+ raise "got a symbol at(#{dbg}), but validator already defined"
75
+ end
76
+
77
+ if !registry.type?(token)
78
+ # TODO: need a much better error here, this is what people see when registration is not there
79
+ raise "expected a type in (#{dbg}), but found #{token.inspect} which is not a registered type"
80
+ end
81
+
82
+ scanned.type = token
83
+ state = :type
84
+ log.debug("got a symbol, determined token is a type at (#{dbg}), moving state to :type")
85
+
86
+ when Hash
87
+ if state != :type
88
+ raise "got a hash at (#{dbg}), but state is not :type (#{state.inspect})"
89
+ end
90
+
91
+ scanned.args = token
92
+ state = :args
93
+ log.debug("got a hash, determined token is args at (#{dbg}), moving state to :args")
94
+
95
+ else
96
+ raise "got token #{token.inspect} at (#{dbg}) which was unexpected given the scanner was in a state of #{state}"
61
97
  end
62
98
  end
63
99
 
64
- result.fetch(:validator)
100
+ return scanned
65
101
  end
66
102
  end
67
103
  end
@@ -0,0 +1,79 @@
1
+ # typed: strict
2
+
3
+ require "minitest/assertions"
4
+
5
+ module DataModel
6
+ module Testing::Minitest
7
+ extend T::Sig
8
+ include Minitest::Assertions
9
+ include Kernel
10
+
11
+ sig { params(err: Error, type: Symbol, key: T.nilable(Symbol)).void }
12
+ def assert_child_model_error(err, type, key = nil)
13
+ assert(err.children.any?, "validate was successful, but should not have been")
14
+
15
+ for k in key ? [key] : err.children.keys
16
+ found = err.children[k]&.any? { |(t, _ctx)| t == type }
17
+ assert(found, "validation was not successful, but #{type} error was not found #{err.inspect}")
18
+ end
19
+ end
20
+
21
+ sig { params(err: Error, type: Symbol).void }
22
+ def assert_model_error(err, type)
23
+ assert(err.base.any?, "validate was successful, but should not have been")
24
+
25
+ found = err.base.any? { |(t, _ctx)| t == type }
26
+
27
+ assert(found, "validation was not successful, but #{type} error was not found #{err.inspect}")
28
+ end
29
+
30
+ sig { params(err: Error, type: T.nilable(Symbol), key: T.nilable(Symbol)).void }
31
+ def refute_child_model_error(err, type = nil, key = nil)
32
+ if !err.any?
33
+ return
34
+ end
35
+
36
+ if type.nil?
37
+ refute(err.base.any?, "validation was not successful #{err.inspect}")
38
+ return
39
+ end
40
+
41
+ for k in key ? [key] : err.children.keys
42
+ found = err.children[k]&.any? { |(t, _ctx)| t == type }
43
+ refute(found, "validation was not successful, but #{type} error was not found #{err.inspect}")
44
+ end
45
+ end
46
+
47
+ sig { params(err: Error, type: T.nilable(Symbol)).void }
48
+ def refute_model_error(err, type = nil)
49
+ if !err.any?
50
+ return
51
+ end
52
+
53
+ if type.nil?
54
+ refute(err.base.any?, "validation was not successful #{err.inspect}")
55
+ return
56
+ end
57
+
58
+ found = err.base.any? { |(t, _ctx)| t == type }
59
+
60
+ refute(found, "#{type} error was found #{err.inspect}")
61
+ end
62
+
63
+ sig { params(err: Error, type: T.nilable(Symbol)).void }
64
+ def refute_all_errors(err, type = nil)
65
+ if !err.any?
66
+ return
67
+ end
68
+
69
+ if type.nil?
70
+ refute(err.all.any?, "validation was not successful #{err.inspect}")
71
+ return
72
+ end
73
+
74
+ found = err.all.any? { |(t, _ctx)| t == type }
75
+
76
+ refute(found, "#{type} error was found #{err.inspect}")
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+
3
+ module DataModel
4
+ module Testing
5
+ end
6
+ end
@@ -1,48 +1,50 @@
1
+ # typed: strict
2
+
3
+ # Mixin included on every type. Type::Generic and Type::Parent are higher level specializations.
1
4
  module DataModel
2
- module Type
3
- extend self
5
+ class Type
6
+ extend T::Sig
7
+ extend T::Helpers
4
8
 
5
- def string
6
- {
7
- read: lambda do |val|
8
- err = {}
9
+ abstract!
9
10
 
10
- unless val.is_a? String
11
- err[:root] = "#{val} is not a string, it is a #{val.class.name}"
12
- end
11
+ TArguments = T.type_alias { T::Hash[Symbol, T.untyped] }
12
+ TTypeParams = T.type_alias { T::Array[Object] }
13
+ TTypeResult = T.type_alias { [Object, Error] }
13
14
 
14
- [val, err]
15
- end
16
- }
15
+ sig { params(args: TArguments, registry: TypeRegistry).void }
16
+ def initialize(args, registry: TypeRegistry.instance)
17
+ @type_args = args
18
+ @type_registry = registry
17
19
  end
18
20
 
19
- def hash
20
- {
21
- read: lambda do |val, ctx|
22
- err = {}
23
- ctx => {config:, children:}
24
-
25
- unless val.is_a? Hash
26
- err[:root] = "#{val} is not a hash, it is a #{val.class.name}"
27
- return [val, err]
28
- end
29
-
30
- # TODO: this needs to be wayyyy better
31
- if !config[:open?] && (val.length > children.length)
32
- err[:root] = "more elements found in closed hash then specified children"
33
- end
34
-
35
- children.each do |child|
36
- child => {property:}
37
- v, e = Model.read(child, val.fetch(property))
38
-
39
- val = val.merge({ property => v })
40
- err[property] = e
41
- end
42
-
43
- [val, err]
44
- end
45
- }
21
+ sig { returns(TArguments) }
22
+ attr_reader :type_args
23
+
24
+ # configure must be overridden to use params
25
+ sig { overridable.params(params: TTypeParams).void }
26
+ def configure(params); end
27
+
28
+ # invoke another type by name
29
+ sig { params(name: Symbol, val: Object, coerce: T::Boolean, args: Type::TArguments, params: T.nilable(TTypeParams)).returns(TTypeResult) }
30
+ def invoke(name, val, coerce: false, args: {}, params: nil)
31
+ t = instantiate(name, args:, params:)
32
+
33
+ result = t.read(val, coerce:)
34
+
35
+ return result
46
36
  end
37
+
38
+ # instanciate another type
39
+ sig { params(name: Symbol, args: Type::TArguments, params: T.nilable(TTypeParams)).returns(Type) }
40
+ def instantiate(name, args: {}, params: nil)
41
+ t = @type_registry.type(name, args:, params:)
42
+
43
+ return t
44
+ end
45
+
46
+ # default reader
47
+ sig { abstract.params(data: Object, coerce: T::Boolean).returns(TTypeResult) }
48
+ def read(data, coerce: false); end
47
49
  end
48
50
  end
@@ -0,0 +1,68 @@
1
+ # typed: strict
2
+
3
+ module DataModel
4
+ # TypeRegistry allows for different type implementations to be used by the scanner.
5
+ # It also acts as an error message registry, mostly for pragmatic reasons.
6
+ class TypeRegistry
7
+ include Errors
8
+ extend T::Sig
9
+
10
+ # Default types that will be used if alternative type map is not given
11
+ sig { returns(TTypeMap) }
12
+ def self.default_types
13
+ Builtin.types
14
+ end
15
+
16
+ # Singleton instance that will be used globally unless instances given
17
+ sig { params(types: TTypeMap, errors: T.nilable(TErrorMessages)).returns(TypeRegistry) }
18
+ def self.instance(types: default_types, errors: nil)
19
+ @instance ||= T.let(new(types:, errors:), T.nilable(TypeRegistry))
20
+ end
21
+
22
+ # Register a type on the global instance
23
+ sig { params(name: Symbol, type: T.class_of(Type)).void }
24
+ def self.register(name, type)
25
+ instance.register(name, type)
26
+ end
27
+
28
+ # Instanciate a new type registry. Default errors will always be used, but additional
29
+ # errors can be registered.
30
+ sig { params(types: TTypeMap, errors: T.nilable(TErrorMessages)).void }
31
+ def initialize(types: self.class.default_types, errors: nil)
32
+ if errors
33
+ errors.each { |type, builder| register_error_message(type, &builder) }
34
+ end
35
+
36
+ @types = T.let({}, TTypeMap)
37
+ types.each { |(name, type)| register(name, type) }
38
+ end
39
+
40
+ # Register a type on this instance
41
+ sig { params(name: Symbol, type: T.class_of(Type)).void }
42
+ def register(name, type)
43
+ @types[name] = type
44
+ end
45
+
46
+ # Check if a type is registered
47
+ sig { params(name: Symbol).returns(T::Boolean) }
48
+ def type?(name)
49
+ @types.key?(name)
50
+ end
51
+
52
+ # Access and configure registered type
53
+ sig { params(name: Symbol, args: Type::TArguments, params: T.nilable(T::Array[Object])).returns(Type) }
54
+ def type(name, args: {}, params: nil)
55
+ if !type?(name)
56
+ raise "#{name} is not registered as a type"
57
+ end
58
+
59
+ t = @types.fetch(name).new(args, registry: self)
60
+
61
+ if params
62
+ t.configure(params)
63
+ end
64
+
65
+ return t
66
+ end
67
+ end
68
+ end