zero_ruby 0.1.0.alpha1 → 0.1.0.alpha4

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +141 -90
  3. data/lib/zero_ruby/configuration.rb +0 -5
  4. data/lib/zero_ruby/error_formatter.rb +171 -0
  5. data/lib/zero_ruby/errors.rb +24 -0
  6. data/lib/zero_ruby/input_object.rb +56 -93
  7. data/lib/zero_ruby/lmid_stores/active_record_store.rb +0 -1
  8. data/lib/zero_ruby/mutation.rb +206 -80
  9. data/lib/zero_ruby/push_processor.rb +134 -37
  10. data/lib/zero_ruby/schema.rb +64 -16
  11. data/lib/zero_ruby/type_names.rb +13 -8
  12. data/lib/zero_ruby/types.rb +54 -0
  13. data/lib/zero_ruby/typescript_generator.rb +126 -58
  14. data/lib/zero_ruby/version.rb +1 -1
  15. data/lib/zero_ruby.rb +11 -34
  16. metadata +48 -22
  17. data/lib/zero_ruby/argument.rb +0 -75
  18. data/lib/zero_ruby/types/base_type.rb +0 -54
  19. data/lib/zero_ruby/types/big_int.rb +0 -32
  20. data/lib/zero_ruby/types/boolean.rb +0 -30
  21. data/lib/zero_ruby/types/float.rb +0 -31
  22. data/lib/zero_ruby/types/id.rb +0 -33
  23. data/lib/zero_ruby/types/integer.rb +0 -31
  24. data/lib/zero_ruby/types/iso8601_date.rb +0 -43
  25. data/lib/zero_ruby/types/iso8601_date_time.rb +0 -43
  26. data/lib/zero_ruby/types/string.rb +0 -20
  27. data/lib/zero_ruby/validator.rb +0 -69
  28. data/lib/zero_ruby/validators/allow_blank_validator.rb +0 -31
  29. data/lib/zero_ruby/validators/allow_null_validator.rb +0 -26
  30. data/lib/zero_ruby/validators/exclusion_validator.rb +0 -29
  31. data/lib/zero_ruby/validators/format_validator.rb +0 -35
  32. data/lib/zero_ruby/validators/inclusion_validator.rb +0 -30
  33. data/lib/zero_ruby/validators/length_validator.rb +0 -42
  34. data/lib/zero_ruby/validators/numericality_validator.rb +0 -63
@@ -1,120 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "argument"
4
- require_relative "errors"
5
- require_relative "validator"
3
+ require "dry-struct"
4
+ require_relative "types"
5
+ require_relative "type_names"
6
6
 
7
7
  module ZeroRuby
8
8
  # Base class for input objects (nested argument types).
9
- # Similar to GraphQL-Ruby's InputObject pattern.
9
+ # Uses Dry::Struct for type coercion and validation.
10
+ #
11
+ # Includes ZeroRuby::TypeNames for convenient type access:
12
+ # - ID, Boolean, ISO8601Date, ISO8601DateTime (direct constants)
13
+ # - Types::String, Types::Integer, Types::Float (via Types module)
10
14
  #
11
15
  # @example
12
16
  # class Types::PostInput < ZeroRuby::InputObject
13
- # argument :id, ID, required: true
14
- # argument :title, String, required: true
15
- # argument :body, String, required: false
17
+ # argument :id, ID
18
+ # argument :title, Types::String.constrained(min_size: 1, max_size: 200)
19
+ # argument :body, Types::String.optional
20
+ # argument :published, Boolean.default(false)
16
21
  # end
17
22
  #
18
23
  # class PostCreate < ZeroRuby::Mutation
19
- # argument :post_input, Types::PostInput, required: true
20
- # argument :notify, Boolean, required: false
24
+ # argument :post_input, Types::PostInput
25
+ # argument :notify, Boolean.default(false)
21
26
  #
22
- # def execute(post_input:, notify: nil)
23
- # Post.create!(**post_input)
27
+ # def execute
28
+ # # args[:post_input].title, args[:post_input].body, etc.
29
+ # # Or use **args[:post_input] to splat into method calls
24
30
  # end
25
31
  # end
26
- class InputObject
27
- include TypeNames
32
+ class InputObject < Dry::Struct
33
+ include ZeroRuby::TypeNames
34
+
35
+ # Transform string keys to symbols (for JSON input)
36
+ transform_keys(&:to_sym)
37
+
38
+ # Use permissive schema that allows omitting optional attributes
39
+ # This matches the behavior where missing optional keys are omitted from result
40
+ schema schema.strict(false)
28
41
 
29
42
  class << self
30
- # Declare an argument for this input object
31
- def argument(name, type, required: true, validates: nil, default: Argument::NOT_PROVIDED, description: nil, **options)
32
- arguments[name.to_sym] = Argument.new(
33
- name: name,
34
- type: type,
35
- required: required,
36
- validates: validates,
37
- default: default,
38
- description: description,
39
- **options
40
- )
41
- end
43
+ # Alias attribute to argument for DSL compatibility
44
+ # @param name [Symbol] The argument name
45
+ # @param type [Dry::Types::Type] The type (from ZeroRuby::Types)
46
+ # @param description [String, nil] Optional description (stored for documentation, not passed to Dry::Struct)
47
+ def argument(name, type, description: nil, **_options)
48
+ # Store description for documentation/TypeScript generation
49
+ argument_descriptions[name.to_sym] = description if description
42
50
 
43
- # Get all declared arguments (including inherited)
44
- def arguments
45
- @arguments ||= if superclass.respond_to?(:arguments)
46
- superclass.arguments.dup
51
+ # Use attribute? for optional types (allows key to be omitted)
52
+ # Use attribute for required types (key must be present)
53
+ if optional_type?(type)
54
+ attribute?(name, type)
47
55
  else
48
- {}
56
+ attribute(name, type)
49
57
  end
50
58
  end
51
59
 
52
- # Coerce and validate raw input
53
- # @param raw_args [Hash] Raw input
54
- # @param ctx [Hash] The context hash
55
- # @return [Hash] Validated and coerced hash (only includes keys present in input or with defaults)
56
- # @raise [ZeroRuby::ValidationError] If validation fails
57
- def coerce(value, ctx)
58
- return nil if value.nil?
59
- return nil unless value.is_a?(Hash)
60
-
61
- validated = {}
62
- errors = []
63
-
64
- arguments.each do |name, arg|
65
- key_present = value.key?(name) || value.key?(name.to_s)
66
- val = if key_present
67
- value[name].nil? ? value[name.to_s] : value[name]
68
- end
69
-
70
- # Check required
71
- if arg.required? && !key_present && !arg.has_default?
72
- errors << "#{name} is required"
73
- next
74
- end
75
-
76
- # Apply default if key not present
77
- if !key_present && arg.has_default?
78
- validated[name] = arg.default
79
- next
80
- end
81
-
82
- # Skip if key not present (don't add to validated hash)
83
- next unless key_present
84
-
85
- # Handle nil values - include in hash but skip coercion
86
- if val.nil?
87
- validated[name] = nil
88
- next
89
- end
90
-
91
- # Type coercion (handles nested InputObjects)
92
- begin
93
- coerced = arg.coerce(val, ctx)
94
- rescue CoercionError => e
95
- errors << "#{name}: #{e.message}"
96
- next
97
- rescue ValidationError => e
98
- # Nested InputObject validation errors - prefix with field name
99
- e.errors.each do |err|
100
- errors << "#{name}.#{err}"
101
- end
102
- next
103
- end
60
+ # Get stored argument descriptions
61
+ def argument_descriptions
62
+ @argument_descriptions ||= {}
63
+ end
104
64
 
105
- # Run validators
106
- if arg.validators.any?
107
- validation_errors = Validator.validate!(arg.validators, nil, ctx, coerced)
108
- validation_errors.each do |err|
109
- errors << "#{name} #{err}"
110
- end
111
- end
65
+ # Check if a type is optional (can accept nil or has a default)
66
+ def optional_type?(type)
67
+ return false unless type.respond_to?(:optional?)
68
+ type.optional? || (type.respond_to?(:default?) && type.default?)
69
+ end
112
70
 
113
- validated[name] = coerced
71
+ # Returns argument metadata for TypeScript generation
72
+ # @return [Hash<Symbol, Hash>] Map of argument name to metadata
73
+ def arguments_metadata
74
+ schema.keys.each_with_object({}) do |key, hash|
75
+ hash[key.name] = {
76
+ type: key.type,
77
+ required: key.required?,
78
+ name: key.name
79
+ }
114
80
  end
115
-
116
- raise ValidationError.new(errors) if errors.any?
117
- validated
118
81
  end
119
82
  end
120
83
  end
@@ -55,7 +55,6 @@ module ZeroRuby
55
55
  private
56
56
 
57
57
  def default_model_class
58
- require_relative "../zero_client"
59
58
  ZeroRuby::ZeroClient
60
59
  end
61
60
  end
@@ -1,38 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "argument"
3
+ require_relative "types"
4
+ require_relative "type_names"
4
5
  require_relative "errors"
5
- require_relative "validator"
6
6
 
7
7
  module ZeroRuby
8
- # Mixin that provides the argument DSL for mutations.
9
- # Inspired by graphql-ruby's HasArguments pattern.
10
- module HasArguments
11
- def self.included(base)
12
- base.extend(ClassMethods)
13
- end
8
+ # Base class for Zero mutations.
9
+ # Provides argument DSL with dry-types validation.
10
+ #
11
+ # Includes ZeroRuby::TypeNames for convenient type access:
12
+ # - ID, Boolean, ISO8601Date, ISO8601DateTime (direct constants)
13
+ # - Types::String, Types::Integer, Types::Float (via Types module)
14
+ #
15
+ # By default (auto_transact: true), the entire execute method runs inside
16
+ # a transaction with LMID tracking. For 3-phase control, set auto_transact false.
17
+ #
18
+ # @example Simple mutation (auto_transact: true, default)
19
+ # class WorkCreate < ZeroRuby::Mutation
20
+ # argument :id, ID
21
+ # argument :title, Types::String.constrained(max_size: 200)
22
+ #
23
+ # def execute(id:, title:)
24
+ # authorize! Work, to: :create?
25
+ # Work.create!(id: id, title: title) # Runs inside auto-wrapped transaction
26
+ # end
27
+ # end
28
+ #
29
+ # @example 3-phase mutation (skip_auto_transaction)
30
+ # class WorkUpdate < ZeroRuby::Mutation
31
+ # skip_auto_transaction
32
+ #
33
+ # argument :id, ID
34
+ # argument :title, Types::String
35
+ #
36
+ # def execute(id:, title:)
37
+ # work = Work.find(id)
38
+ # authorize! work, to: :update? # Pre-transaction
39
+ #
40
+ # transact do
41
+ # work.update!(title: title) # Transaction
42
+ # end
43
+ #
44
+ # notify_update(work) # Post-commit
45
+ # end
46
+ # end
47
+ class Mutation
48
+ include ZeroRuby::TypeNames
49
+
50
+ # The context hash containing current_user, etc.
51
+ attr_reader :ctx
52
+
53
+ # The validated arguments hash
54
+ attr_reader :args
55
+
56
+ class << self
57
+ # Opt-out of auto-transaction wrapping.
58
+ # By default, execute is wrapped in a transaction with LMID tracking.
59
+ # Call this to use explicit 3-phase model where you must call transact { }.
60
+ #
61
+ # @return [void]
62
+ def skip_auto_transaction
63
+ @skip_auto_transaction = true
64
+ end
65
+
66
+ # Check if auto-transaction is skipped for this mutation
67
+ # @return [Boolean] true if skip_auto_transaction was called
68
+ def skip_auto_transaction?
69
+ @skip_auto_transaction == true
70
+ end
14
71
 
15
- module ClassMethods
16
72
  # Declare an argument for this mutation
17
73
  # @param name [Symbol] The argument name
18
- # @param type [Class] The type class (e.g., ZeroRuby::Types::String)
19
- # @param required [Boolean] Whether the argument is required
20
- # @param validates [Hash] Validation configuration
21
- # @param default [Object] Default value if not provided
22
- # @param description [String] Description of the argument
23
- def argument(name, type, required: true, validates: nil, default: Argument::NOT_PROVIDED, description: nil, **options)
24
- arguments[name.to_sym] = Argument.new(
25
- name: name,
74
+ # @param type [Dry::Types::Type] The type (from ZeroRuby::Types or dry-types)
75
+ # @param description [String, nil] Optional description for documentation
76
+ def argument(name, type, description: nil)
77
+ arguments[name.to_sym] = {
26
78
  type: type,
27
- required: required,
28
- validates: validates,
29
- default: default,
30
79
  description: description,
31
- **options
32
- )
80
+ name: name.to_sym
81
+ }
33
82
  end
34
83
 
35
84
  # Get all declared arguments for this mutation (including inherited)
85
+ # @return [Hash<Symbol, Hash>] Map of argument name to config
36
86
  def arguments
37
87
  @arguments ||= if superclass.respond_to?(:arguments)
38
88
  superclass.arguments.dup
@@ -41,100 +91,176 @@ module ZeroRuby
41
91
  end
42
92
  end
43
93
 
44
- # Coerce and validate raw arguments
94
+ # Coerce and validate raw arguments.
95
+ # Collects "field is required" errors for all missing required fields,
96
+ # but lets Dry exceptions bubble up for type/constraint errors.
97
+ #
45
98
  # @param raw_args [Hash] Raw input arguments
46
- # @param ctx [Hash] The context hash
47
- # @return [Hash] Validated and coerced arguments
48
- # @raise [ZeroRuby::ValidationError] If validation fails
49
- def coerce_and_validate!(raw_args, ctx)
99
+ # @return [Hash] Validated and coerced arguments (may contain InputObject instances)
100
+ # @raise [ZeroRuby::ValidationError] If required fields are missing
101
+ # @raise [Dry::Types::CoercionError] If type coercion fails
102
+ # @raise [Dry::Types::ConstraintError] If constraint validation fails
103
+ # @raise [Dry::Struct::Error] If InputObject validation fails
104
+ def coerce_and_validate!(raw_args)
50
105
  validated = {}
51
106
  errors = []
52
107
 
53
- arguments.each do |name, arg|
54
- value = raw_args[name]
108
+ arguments.each do |name, config|
109
+ type = config[:type]
110
+ str_key = name.to_s
111
+ key_present = raw_args.key?(str_key)
112
+ value = raw_args[str_key]
55
113
 
56
- # Check required
57
- if arg.required? && value.nil? && !arg.has_default?
58
- errors << "#{name} is required"
59
- next
60
- end
61
-
62
- # Apply default if needed, or nil for optional args without defaults
63
- if value.nil?
64
- validated[name] = arg.has_default? ? arg.default : nil
114
+ # Handle InputObject types (subclasses of InputObject)
115
+ if input_object_type?(type)
116
+ if value.nil? && key_present
117
+ # Explicit nil
118
+ validated[name] = nil
119
+ elsif value.nil? && !key_present
120
+ # Missing key - check if required
121
+ if required_type?(type)
122
+ errors << "#{name} is required"
123
+ end
124
+ # Skip if optional and not provided
125
+ else
126
+ # Let Dry::Struct errors bubble up
127
+ validated[name] = type.new(value)
128
+ end
65
129
  next
66
130
  end
67
131
 
68
- # Type coercion
69
- begin
70
- coerced = arg.coerce(value, ctx)
71
- rescue CoercionError => e
72
- errors << "#{name}: #{e.message}"
132
+ # Handle missing key
133
+ if !key_present
134
+ if has_default?(type)
135
+ validated[name] = get_default(type)
136
+ elsif required_type?(type)
137
+ errors << "#{name} is required"
138
+ end
73
139
  next
74
140
  end
75
141
 
76
- # Run validators
77
- if arg.validators.any?
78
- validation_errors = Validator.validate!(arg.validators, nil, ctx, coerced)
79
- validation_errors.each do |err|
80
- errors << "#{name} #{err}"
142
+ # Handle explicit null
143
+ if value.nil?
144
+ if required_type?(type)
145
+ errors << "#{name} is required"
146
+ else
147
+ validated[name] = nil
81
148
  end
149
+ next
82
150
  end
83
151
 
84
- validated[name] = coerced
152
+ # Coerce and validate value - let Dry exceptions bubble up
153
+ validated[name] = type[value]
85
154
  end
86
155
 
87
156
  raise ValidationError.new(errors) if errors.any?
88
157
  validated
89
158
  end
90
- end
91
- end
92
159
 
93
- # Base class for Zero mutations.
94
- # Provides argument DSL, validation, and error handling.
95
- #
96
- # @example
97
- # class WorkCreate < ZeroRuby::Mutation
98
- # argument :id, ID, required: true
99
- # argument :title, String, required: true,
100
- # validates: { length: { maximum: 200 } }
101
- #
102
- # def execute(id:, title:)
103
- # authorize! Work, to: :create?
104
- # Work.create!(id: id, title: title)
105
- # end
106
- # end
107
- class Mutation
108
- include HasArguments
109
- include TypeNames
160
+ private
110
161
 
111
- # The context hash containing current_user, etc.
112
- attr_reader :ctx
162
+ # Check if a type is an InputObject class
163
+ def input_object_type?(type)
164
+ type.is_a?(Class) && type < InputObject
165
+ end
166
+
167
+ # Check if a type has a default value
168
+ def has_default?(type)
169
+ type.respond_to?(:default?) && type.default?
170
+ end
171
+
172
+ # Get the default value for a type
173
+ def get_default(type)
174
+ return nil unless has_default?(type)
175
+ type[]
176
+ end
177
+
178
+ # Check if a type is required (not optional and not with default)
179
+ def required_type?(type)
180
+ return true unless type.respond_to?(:optional?)
181
+ !type.optional? && !has_default?(type)
182
+ end
183
+ end
113
184
 
114
185
  # Initialize a mutation with raw arguments and context
115
186
  # @param raw_args [Hash] Raw input arguments (will be coerced and validated)
116
187
  # @param ctx [Hash] The context hash
117
188
  def initialize(raw_args, ctx)
118
189
  @ctx = ctx
119
- @args = self.class.coerce_and_validate!(raw_args, ctx)
190
+ @args = self.class.coerce_and_validate!(raw_args)
120
191
  end
121
192
 
122
193
  # Execute the mutation
123
- # @return [Hash] Empty hash on success
194
+ # @param transact_proc [Proc] Block that wraps transactional work (internal use)
195
+ # @return [Hash] Empty hash on success, or {data: ...} if execute returns a Hash
124
196
  # @raise [ZeroRuby::Error] On failure (formatted at boundary)
125
- def call
126
- execute(**@args)
127
- {}
197
+ def call(&transact_proc)
198
+ @transact_proc = transact_proc
199
+ data = execute(**@args)
200
+ result = {}
201
+ result[:data] = data if data.is_a?(Hash) && !data.empty?
202
+ result
128
203
  end
129
204
 
130
205
  private
131
206
 
207
+ # Wrap database operations in a transaction with LMID tracking.
208
+ #
209
+ # Behavior depends on skip_auto_transaction:
210
+ # - Default (no skip) - Just executes the block (already in transaction)
211
+ # - skip_auto_transaction - Wraps block in transaction via transact_proc
212
+ #
213
+ # For skip_auto_transaction mutations, you MUST call this method.
214
+ # For default mutations, calling this is optional (no-op, just runs block).
215
+ #
216
+ # @yield Block containing database operations
217
+ # @return [Object] Result of the block
218
+ def transact(&block)
219
+ raise "transact requires a block" unless block_given?
220
+
221
+ if @transact_proc
222
+ # Manual transact mode (auto_transact: false) - use the provided proc
223
+ @transact_proc.call(&block)
224
+ else
225
+ # Auto transact mode - already in transaction, just execute the block
226
+ block.call
227
+ end
228
+ end
229
+
230
+ # Convenience method to access current_user from context.
231
+ # Override in your ApplicationMutation if you need different behavior.
232
+ def current_user
233
+ ctx[:current_user]
234
+ end
235
+
132
236
  # Implement this method in subclasses to define mutation logic.
133
- # Arguments declared with `argument` are passed as keyword arguments.
134
- # Access context via ctx[:key] (e.g., ctx[:current_user]).
135
- # No return value needed - just perform the mutation.
136
- # Raise an exception to signal failure.
137
- def execute(**)
237
+ # Arguments are passed as keyword arguments matching your declared arguments.
238
+ # Access context via ctx[:key] or use the current_user helper.
239
+ #
240
+ # By default (auto_transact: true), the entire execute method runs inside
241
+ # a transaction with LMID tracking. Just write your database operations directly.
242
+ #
243
+ # For 3-phase control, use `skip_auto_transaction` and call transact { ... }:
244
+ # - Pre-transaction: code before transact (auth, validation)
245
+ # - Transaction: code inside transact { } (database operations)
246
+ # - Post-commit: code after transact returns (side effects)
247
+ #
248
+ # @example Simple mutation (default auto_transact: true)
249
+ # def execute(id:, title:)
250
+ # authorize! Post, to: :create?
251
+ # Post.create!(id: id, title: title)
252
+ # end
253
+ #
254
+ # @example 3-phase mutation (skip_auto_transaction)
255
+ # def execute(id:, title:)
256
+ # authorize! Post, to: :create? # Pre-transaction
257
+ # result = transact do
258
+ # Post.create!(id: id, title: title) # Transaction
259
+ # end
260
+ # NotificationService.notify(result.id) # Post-commit
261
+ # {id: result.id}
262
+ # end
263
+ def execute(**args)
138
264
  raise NotImplementedError, "Subclasses must implement #execute"
139
265
  end
140
266
  end