data_model 0.0.1 → 0.2.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 (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 +88 -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 +296 -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 +110 -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,110 @@
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 email
23
+ Example.new(
24
+ [:string, { format: "@" }],
25
+ variants: {
26
+ valid: "foo@bar.com",
27
+ invalid: "invalid"
28
+ },
29
+ )
30
+ end
31
+
32
+ sig { returns(Example) }
33
+ def email_regexp
34
+ Example.new(
35
+ [:string, { format: /@/ }],
36
+ variants: {
37
+ valid: "foo@bar.com",
38
+ invalid: "invalid"
39
+ },
40
+ )
41
+ end
42
+
43
+ sig { returns(Example) }
44
+ def email_proc
45
+ Example.new(
46
+ [:string, { format: ->(val) { val.match?(/@/) } }],
47
+ variants: {
48
+ valid: "foo@bar.com",
49
+ invalid: "invalid"
50
+ },
51
+ )
52
+ end
53
+
54
+ sig { returns(Example) }
55
+ def optional
56
+ Example.new(
57
+ [:string, { optional: true }],
58
+ variants: {
59
+ valid: "valid",
60
+ blank: "",
61
+ missing: nil
62
+ },
63
+ )
64
+ end
65
+
66
+ sig { returns(Example) }
67
+ def inclusion
68
+ Example.new(
69
+ [:string, { included: ["valid"] }],
70
+ variants: {
71
+ valid: "valid",
72
+ outside: "invalid"
73
+ },
74
+ )
75
+ end
76
+
77
+ sig { returns(Example) }
78
+ def exclusion
79
+ Example.new(
80
+ [:string, { excluded: ["invalid"] }],
81
+ variants: {
82
+ valid: "valid",
83
+ inside: "invalid"
84
+ },
85
+ )
86
+ end
87
+
88
+ sig { returns(Example) }
89
+ def allow_blank
90
+ Example.new(
91
+ [:string, { allow_blank: true }],
92
+ variants: {
93
+ blank: "",
94
+ not_blank: "content",
95
+ missing: nil
96
+ },
97
+ )
98
+ end
99
+
100
+ sig { returns(Example) }
101
+ def dont_allow_blank
102
+ Example.new(
103
+ [:string, { allow_blank: false }],
104
+ variants: {
105
+ blank: ""
106
+ },
107
+ )
108
+ end
109
+ end
110
+ 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