data_model 0.3.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +11 -2
  3. data/.shadowenv.d/.gitignore +2 -0
  4. data/.shadowenv.d/550-ruby.lisp +37 -0
  5. data/.solargraph.yml +22 -0
  6. data/Gemfile.lock +38 -3
  7. data/Rakefile +0 -6
  8. data/Steepfile +27 -0
  9. data/data_model.gemspec +2 -2
  10. data/lib/data_model/boolean.rb +0 -2
  11. data/lib/data_model/builtin/array.rb +32 -25
  12. data/lib/data_model/builtin/big_decimal.rb +15 -14
  13. data/lib/data_model/builtin/boolean.rb +10 -7
  14. data/lib/data_model/builtin/date.rb +15 -12
  15. data/lib/data_model/builtin/float.rb +14 -13
  16. data/lib/data_model/builtin/hash.rb +100 -35
  17. data/lib/data_model/builtin/integer.rb +14 -13
  18. data/lib/data_model/builtin/numeric.rb +35 -0
  19. data/lib/data_model/builtin/object.rb +28 -0
  20. data/lib/data_model/builtin/or.rb +73 -0
  21. data/lib/data_model/builtin/string.rb +15 -16
  22. data/lib/data_model/builtin/symbol.rb +14 -13
  23. data/lib/data_model/builtin/time.rb +17 -14
  24. data/lib/data_model/builtin.rb +9 -9
  25. data/lib/data_model/error.rb +33 -33
  26. data/lib/data_model/errors.rb +107 -143
  27. data/lib/data_model/fixtures/array.rb +22 -9
  28. data/lib/data_model/fixtures/big_decimal.rb +9 -7
  29. data/lib/data_model/fixtures/boolean.rb +5 -5
  30. data/lib/data_model/fixtures/date.rb +13 -11
  31. data/lib/data_model/fixtures/example.rb +7 -7
  32. data/lib/data_model/fixtures/float.rb +9 -7
  33. data/lib/data_model/fixtures/hash.rb +22 -10
  34. data/lib/data_model/fixtures/integer.rb +9 -7
  35. data/lib/data_model/fixtures/numeric.rb +31 -0
  36. data/lib/data_model/fixtures/object.rb +27 -0
  37. data/lib/data_model/fixtures/or.rb +29 -0
  38. data/lib/data_model/fixtures/string.rb +15 -32
  39. data/lib/data_model/fixtures/symbol.rb +9 -7
  40. data/lib/data_model/fixtures/time.rb +13 -11
  41. data/lib/data_model/logging.rb +5 -8
  42. data/lib/data_model/model.rb +11 -8
  43. data/lib/data_model/registry.rb +129 -0
  44. data/lib/data_model/scanner.rb +24 -29
  45. data/lib/data_model/struct.rb +112 -0
  46. data/lib/data_model/testing/minitest.rb +33 -9
  47. data/lib/data_model/testing.rb +0 -2
  48. data/lib/data_model/type.rb +39 -23
  49. data/lib/data_model/version.rb +1 -3
  50. data/lib/data_model.rb +10 -19
  51. metadata +24 -21
  52. data/lib/data_model/type_registry.rb +0 -68
  53. data/sorbet/config +0 -4
  54. data/sorbet/rbi/annotations/rainbow.rbi +0 -269
  55. data/sorbet/rbi/gems/minitest@5.18.0.rbi +0 -1491
  56. data/sorbet/rbi/gems/zeitwerk.rbi +0 -196
  57. data/sorbet/rbi/gems/zeitwerk@2.6.7.rbi +0 -966
  58. data/sorbet/rbi/todo.rbi +0 -5
  59. data/sorbet/tapioca/config.yml +0 -13
  60. data/sorbet/tapioca/require.rb +0 -4
@@ -0,0 +1,129 @@
1
+ module DataModel
2
+ # Registry allows for different type implementations to be used by the scanner.
3
+ # It also acts as an error message registry, mostly for pragmatic reasons.
4
+ class Registry
5
+ # Default types that will be used if alternative type map is not given
6
+ # @return [Hash] the default type map
7
+ def self.default_types
8
+ Builtin.types
9
+ end
10
+
11
+ # Default error messages that will be used if alternative error messages are not given
12
+ # @return [Hash] the default error messages
13
+ def self.default_error_messages
14
+ Errors.error_messages
15
+ end
16
+
17
+ # Singleton instance that will be used globally unless instances given
18
+ # @param types [Hash] the type map to use
19
+ # @param errors [Hash] the error message map to use
20
+ # @return [Registry] the singleton instance
21
+ def self.instance(types: default_types, errors: default_error_messages)
22
+ @instance ||= new(types:, errors:)
23
+ end
24
+
25
+ # Register a type on the global instance
26
+ # @param name [Symbol] the name of the type
27
+ # @param type [Type] the type to register
28
+ # @return [void]
29
+ def self.register(name, type)
30
+ instance.register(name, type)
31
+ end
32
+
33
+ # Instanciate a new type registry. Default errors will always be used, but additional
34
+ # errors can be registered.
35
+ # @param types [Hash] the type map to use
36
+ # @param errors [Hash] the error message map to use
37
+ # @return [Registry] the new instance
38
+ def initialize(types: self.class.default_types, errors: self.class.default_error_messages)
39
+ @error_messages = nil
40
+
41
+ if errors
42
+ errors.each { |type, builder| register_error_message(type, &builder) }
43
+ end
44
+
45
+ @types = {}
46
+ types.each { |(name, type)| register(name, type) }
47
+ end
48
+
49
+ # Register a type on this instance
50
+ # @param name [Symbol] the name of the Type
51
+ # @param type [Type] the type to register
52
+ # @return [void]
53
+ def register(name, type)
54
+ @types[name] = type
55
+ end
56
+
57
+ # Check if a type is registered
58
+ # @param name [Symbol] the name of the type
59
+ # @return [Boolean] whether the type is registered
60
+ def type?(name)
61
+ @types.key?(name)
62
+ end
63
+
64
+ # Access and configure registered type
65
+ # @param name [Symbol] the name of the Type
66
+ # @param args [Hash] the arguments to pass to the Type
67
+ # @param params [Array] the parameters to configure the Type with
68
+ # @return [Type] the configured type
69
+ def type(name, args: {}, params: nil)
70
+ if !type?(name)
71
+ raise "#{name} is not registered as a type"
72
+ end
73
+
74
+ t = @types.fetch(name).new(args, registry: self)
75
+
76
+ if params
77
+ t.configure(params)
78
+ end
79
+
80
+ return t
81
+ end
82
+
83
+ ## API
84
+
85
+ # Register a custom error message for use with custom errors
86
+ # @param type [Symbol] the type of error to register
87
+ # @param block [Proc] the block to use to build the error message, shoudl take the error context and return a string
88
+ # @return [void]
89
+ def register_error_message(type, &block)
90
+ error_message_builders[type] = block
91
+ end
92
+
93
+ # Get the error message builders
94
+ # @return [Hash] the error message builders
95
+ def error_message_builders
96
+ if @error_messages.nil?
97
+ @error_messages ||= {}
98
+ end
99
+
100
+ @error_messages
101
+ end
102
+
103
+ # Build the error message for a given error
104
+ # @param error [Error] the error to build the message for
105
+ # @return [String] the error message
106
+ # @raise [RuntimeError] if no error message builder is registered for the error type
107
+ def error_message(error)
108
+ type = error[0]
109
+ ctx = error[1]
110
+
111
+ builder = error_message_builders[type]
112
+
113
+ if builder.nil?
114
+ raise "no error message builder for #{type}"
115
+ end
116
+
117
+ builder.call(ctx)
118
+ end
119
+
120
+ # Build error messages from error object
121
+ # @param error [Error] the error to build the messages for
122
+ # @return [Hash] the error messages
123
+ def error_messages(error)
124
+ error.to_h.transform_values do |error_list|
125
+ error_list.map { |e| error_message(e) }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -1,43 +1,37 @@
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.
19
1
  module DataModel
2
+ # The scanner is responsible for scanning a schema into a data structure that is easier to work with.
3
+ #
4
+ # schema eg:
5
+ # [:string, { min: 1, max: 10}]
6
+ # [:tuple, { title: "coordinates" }, :double, :double]
7
+ # [:hash, { open: false },
8
+ # [:first_name, :string]
9
+ # [:last_name, :string]]
10
+ #
11
+ # first param is type, which is a key lookup in the registry
12
+ # second param is args, this is optional, but is a way to configure a type
13
+ # rest are type params. these are used to configure a type at the point of instantiation. Think of them as generics.
14
+ #
15
+ # params are either
16
+ # symbol, for example tuple types
17
+ # array, for object types to configure child properties.
20
18
  module Scanner
21
- include Kernel
22
19
  include Logging
23
-
24
- extend T::Sig
25
20
  extend self
26
21
 
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
22
+ # cant use DataModel::Struct because it would be a circular dependency
23
+ Node = ::Struct.new(:type, :args, :params)
32
24
 
33
25
  # Scan a schema, which is defined as a data structure, into a struct that is easier to work with.
34
26
  # "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)
27
+ # @param schema [Array] the schema to scan
28
+ # @param registry [Registry] the registry to use
29
+ # @return [Node] the scanned node
30
+ def scan(schema, registry = Registry.instance)
37
31
  # state:
38
32
  # nil (start) -> :type (we have a type) -> :args (we have arguments)
39
33
  scanned = Node.new
40
- state = T.let(nil, T.nilable(Symbol))
34
+ state = nil
41
35
 
42
36
  log.debug("scanning schema: #{schema.inspect}")
43
37
 
@@ -61,6 +55,7 @@ module DataModel
61
55
  raise "expected type params at (#{dbg}), which should be either a symbol or an array"
62
56
  end
63
57
 
58
+ scanned.params ||= []
64
59
  scanned.params << token
65
60
  log.debug("collecting params at (#{dbg})")
66
61
 
@@ -0,0 +1,112 @@
1
+ module DataModel
2
+ # A struct that has typed properties, which will raise when the wrong type is assigned
3
+ class Struct
4
+ class << self
5
+ # types configured by the prop macro
6
+ # @return [Hash] the types configured
7
+ attr_reader :types
8
+
9
+ # required types configured by the prop macro
10
+ # @return [Array<Symbol>] the required types configured
11
+ attr_reader :required_types
12
+ end
13
+
14
+ # Define a property on the struct.
15
+ # @param name [Symbol] the name of the property
16
+ # @param schema [Array] the schema to define
17
+ # @option opts [Boolean] :default the default value for the property
18
+ # @return [void]
19
+ def self.prop(name, schema, opts = {})
20
+ has_default = opts.key?(:default)
21
+ default = opts[:default]
22
+
23
+ # ensure values are initialized
24
+ @types ||= {}
25
+ @required_types ||= []
26
+
27
+ # if a prop does not have a default, it is required
28
+ if !has_default
29
+ @required_types << name
30
+ end
31
+
32
+ # store the data model for the prop
33
+ schema = Array(schema)
34
+ @types[name] = DataModel.define(schema)
35
+
36
+ # field getter
37
+ define_method(name) do
38
+ @values ||= {}
39
+ types = self.class.types
40
+
41
+ if !types.key?(name)
42
+ raise "No prop configured for #{name}"
43
+ end
44
+
45
+ if !@values.key?(name) && has_default
46
+ return default
47
+ end
48
+
49
+ return @values[name]
50
+ end
51
+
52
+ # field setter
53
+ define_method("#{name}=") do |val|
54
+ @values ||= {}
55
+ types = self.class.types
56
+
57
+ if !types.key?(name)
58
+ raise "No prop configured for #{name}"
59
+ end
60
+
61
+ model = types.fetch(name)
62
+
63
+ if @coerce
64
+ val, err = model.coerce(val)
65
+ else
66
+ err = model.validate(val)
67
+ end
68
+
69
+ if @strict && err.any?
70
+ errors.merge_child(prop, err)
71
+ raise "Invalid value for #{name}: #{err.to_messages.inspect}"
72
+ end
73
+
74
+ @values[name] = val
75
+ end
76
+ end
77
+
78
+ # @return [Error] the errors for this struct
79
+ attr_reader :errors
80
+
81
+ # @param data [Hash] the data to initialize the struct with
82
+ # @param coerce [Boolean] whether to coerce the data if it is not correctly typed
83
+ # @param strict [Boolean] whether to raise if a required field is missing
84
+ # @return [void]
85
+ def initialize(data = {}, coerce: false, strict: true)
86
+ @coerce = coerce
87
+ @strict = strict
88
+ @values = {}
89
+ @errors = Error.new
90
+
91
+ if data.nil?
92
+ return
93
+ end
94
+
95
+ for k, v in data
96
+ send("#{k}=", v)
97
+ end
98
+
99
+ for req in self.class.required_types
100
+ if !data.key?(req)
101
+ raise "Missing required field #{req}"
102
+ end
103
+ end
104
+ end
105
+
106
+ # convert struct to a hash
107
+ # @return [Hash] the struct as a hash
108
+ def to_hash
109
+ @values
110
+ end
111
+ end
112
+ end
@@ -1,15 +1,18 @@
1
- # typed: strict
2
-
3
1
  require "minitest/assertions"
4
2
 
5
3
  module DataModel
4
+ # Provides assertions for minitest
6
5
  module Testing::Minitest
7
- extend T::Sig
8
6
  include Minitest::Assertions
9
- include Kernel
10
7
 
11
- sig { params(err: Error, type: Symbol, key: T.nilable(Symbol)).void }
8
+ # Assert that a child model error was found.
9
+ # @param err [Error] the error to check
10
+ # @param type [Symbol] the type of error to check for
11
+ # @param key [Array(Symbol)] limit checking to a specific child key
12
+ # @return [void]
12
13
  def assert_child_model_error(err, type, key = nil)
14
+ refute_nil(err)
15
+
13
16
  assert(err.children.any?, "validate was successful, but should not have been")
14
17
 
15
18
  for k in key ? [key] : err.children.keys
@@ -18,8 +21,13 @@ module DataModel
18
21
  end
19
22
  end
20
23
 
21
- sig { params(err: Error, type: Symbol).void }
24
+ # Assert that a model error was found.
25
+ # @param err [Error] the error to check
26
+ # @param type [Symbol] the type of error to check for
27
+ # @return [void]
22
28
  def assert_model_error(err, type)
29
+ refute_nil err
30
+
23
31
  assert(err.base.any?, "validate was successful, but should not have been")
24
32
 
25
33
  found = err.base.any? { |(t, _ctx)| t == type }
@@ -27,8 +35,14 @@ module DataModel
27
35
  assert(found, "validation was not successful, but #{type} error was not found #{err.inspect}")
28
36
  end
29
37
 
30
- sig { params(err: Error, type: T.nilable(Symbol), key: T.nilable(Symbol)).void }
38
+ # Assert that no child error is found
39
+ # @param err [Error] the error to check
40
+ # @param type [Symbol] the type of error to check for
41
+ # @param key [Symbol, Array<Symbol>] limit checking to a specific child key
42
+ # @return [void]
31
43
  def refute_child_model_error(err, type = nil, key = nil)
44
+ refute_nil(err)
45
+
32
46
  if !err.any?
33
47
  return
34
48
  end
@@ -44,8 +58,13 @@ module DataModel
44
58
  end
45
59
  end
46
60
 
47
- sig { params(err: Error, type: T.nilable(Symbol)).void }
61
+ # Assert that no base error is present
62
+ # @param err [Error] the error to check
63
+ # @param type [Symbol] the type of error to check for
64
+ # @return [void]
48
65
  def refute_model_error(err, type = nil)
66
+ refute_nil(err)
67
+
49
68
  if !err.any?
50
69
  return
51
70
  end
@@ -60,8 +79,13 @@ module DataModel
60
79
  refute(found, "#{type} error was found #{err.inspect}")
61
80
  end
62
81
 
63
- sig { params(err: Error, type: T.nilable(Symbol)).void }
82
+ # Assert that no errors are present
83
+ # @param err [Error] the error to check
84
+ # @param type [Symbol] the type of error to check for
85
+ # @return [void]
64
86
  def refute_all_errors(err, type = nil)
87
+ refute_nil(err)
88
+
65
89
  if !err.any?
66
90
  return
67
91
  end
@@ -1,5 +1,3 @@
1
- # typed: strict
2
-
3
1
  module DataModel
4
2
  module Testing
5
3
  end
@@ -1,32 +1,36 @@
1
- # typed: strict
2
-
3
- # Mixin included on every type. Type::Generic and Type::Parent are higher level specializations.
4
1
  module DataModel
2
+ # @abstract Base class for all types.
3
+ # Types have arguments, which configure the act of reading / validating / coercing data.
4
+ # They also have parameters, which configure the type itself, such as generic or child specification
5
+ # Arguments are passed to the type when it is invoked, and parameters are passed to the type when it is configured.
6
+ # Parameters are really only used in complex types, such as Array or Hash. If you don't need them, you can ignore them.
5
7
  class Type
6
- extend T::Sig
7
- extend T::Helpers
8
-
9
- abstract!
10
-
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] }
14
-
15
- sig { params(args: TArguments, registry: TypeRegistry).void }
16
- def initialize(args, registry: TypeRegistry.instance)
8
+ # @param args [Hash] type arguments, configures the reading process
9
+ # @param registry [Registry] the registry to use
10
+ # @return [void]
11
+ def initialize(args, registry: Registry.instance)
17
12
  @type_args = args
18
13
  @type_registry = registry
19
14
  end
20
15
 
21
- sig { returns(TArguments) }
16
+ # @return [Hash] the type arguments
22
17
  attr_reader :type_args
23
18
 
24
- # configure must be overridden to use params
25
- sig { overridable.params(params: TTypeParams).void }
19
+ # @return [Registry] the type Registry
20
+ attr_reader :type_registry
21
+
22
+ # configure must be overridden to use params. If you don't need params, you can ignore this.
23
+ # @param params [Array] type parameters, configures the type itself
24
+ # @return [void]
26
25
  def configure(params); end
27
26
 
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) }
27
+ # invoke another type by name. This is useful for specifying types like UUIDs which are specialized strings
28
+ # @param name [Symbol] the name of the type to invoke
29
+ # @param val [Object] the value to read
30
+ # @param coerce [Boolean] whether to coerce the value
31
+ # @param args [Hash] type arguments, configures the reading process
32
+ # @param params [Array] type parameters, configures the type itself
33
+ # @return [Array(Object, Error)] the result of reading the value
30
34
  def invoke(name, val, coerce: false, args: {}, params: nil)
31
35
  t = instantiate(name, args:, params:)
32
36
 
@@ -35,16 +39,28 @@ module DataModel
35
39
  return result
36
40
  end
37
41
 
38
- # instanciate another type
39
- sig { params(name: Symbol, args: Type::TArguments, params: T.nilable(TTypeParams)).returns(Type) }
42
+ # instanciate another type by name
43
+ # @param name [Symbol] the name of the type to instantiate
44
+ # @param args [Hash] type arguments, configures the reading Process
45
+ # @param params [Array] type parameters, configures the type itself
46
+ # @return [Type] the instantiated type
40
47
  def instantiate(name, args: {}, params: nil)
41
48
  t = @type_registry.type(name, args:, params:)
42
49
 
43
50
  return t
44
51
  end
45
52
 
46
- # default reader
47
- sig { abstract.params(data: Object, coerce: T::Boolean).returns(TTypeResult) }
53
+ # @abstract default reader, must be overridden for a type to be useful
54
+ # @param data [untyped] the data to read
55
+ # @param coerce [Boolean] whether to coerce the value
56
+ # @return [Array(Object, Error)] the result of reading the value
48
57
  def read(data, coerce: false); end
58
+
59
+ # name of the type without module prefix as a string
60
+ # useful for generating error messages
61
+ # @return [String] the type name
62
+ def type_name
63
+ @type_name ||= self.class.name.split("::").last
64
+ end
49
65
  end
50
66
  end
@@ -1,5 +1,3 @@
1
- # typed: strict
2
-
3
1
  module DataModel
4
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
5
3
  end
data/lib/data_model.rb CHANGED
@@ -1,5 +1,3 @@
1
- # typed: strict
2
-
3
1
  require "logger"
4
2
  require "bigdecimal"
5
3
  require "date"
@@ -7,28 +5,18 @@ require "time"
7
5
 
8
6
  require "bundler/setup"
9
7
  require "zeitwerk"
10
- require "sorbet-runtime"
11
8
 
12
- loader = T.let(Zeitwerk::Loader.for_gem, Zeitwerk::Loader)
9
+ loader = Zeitwerk::Loader.for_gem
13
10
  loader.setup
14
11
 
15
12
  module DataModel
16
- extend T::Sig
17
13
  extend self
18
14
 
19
- TSchema = T.type_alias { T::Array[Object] }
20
- TData = T.type_alias { Object }
21
-
22
- # an error is a tuple of [error_type, error_context], where context
23
- # provides additional information about the error
24
- TError = T.type_alias { [Symbol, Object] }
25
-
26
- # a map of symbol => type, suitable for sending to a TypeRegistry
27
- TTypeMap = T.type_alias { T::Hash[Symbol, T.class_of(Type)] }
28
-
29
15
  # Scan a schema and create a data model, which is a configured type.
30
- sig { params(schema: TSchema, registry: TypeRegistry).returns(Model) }
31
- def define(schema, registry: TypeRegistry.instance)
16
+ # @param schema [Array] the schema to define
17
+ # @param registry [Registry] the registry to use
18
+ # @return [Model] the model built from the schema
19
+ def define(schema, registry: Registry.instance)
32
20
  scanned = Scanner.scan(schema, registry)
33
21
 
34
22
  type = registry.type(
@@ -42,8 +30,11 @@ module DataModel
42
30
  return model
43
31
  end
44
32
 
45
- sig { params(name: Symbol, type: T.class_of(Type)).void }
33
+ # Register a global type, which is available to all models.
34
+ # @param name [Symbol] the name of the Type
35
+ # @param type [Type] the type to register
36
+ # @return [void]
46
37
  def register_global_type(name, type)
47
- TypeRegistry.register(name, type)
38
+ Registry.register(name, type)
48
39
  end
49
40
  end