zero_ruby 0.1.0.alpha2 → 0.1.0.alpha5

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 +120 -71
  3. data/lib/zero_ruby/configuration.rb +0 -5
  4. data/lib/zero_ruby/error_formatter.rb +173 -0
  5. data/lib/zero_ruby/errors.rb +10 -1
  6. data/lib/zero_ruby/input_object.rb +55 -93
  7. data/lib/zero_ruby/lmid_stores/active_record_store.rb +0 -1
  8. data/lib/zero_ruby/mutation.rb +235 -80
  9. data/lib/zero_ruby/push_processor.rb +54 -35
  10. data/lib/zero_ruby/schema.rb +38 -14
  11. data/lib/zero_ruby/type_names.rb +7 -15
  12. data/lib/zero_ruby/types.rb +52 -0
  13. data/lib/zero_ruby/typescript_generator.rb +167 -57
  14. data/lib/zero_ruby/version.rb +1 -1
  15. data/lib/zero_ruby.rb +12 -35
  16. metadata +46 -20
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d8baa0973deb010422a2e5d6679af4580b9c37666b2649f383c0bf6525a77c9
4
- data.tar.gz: 27e0a5593d3344c6260d96838e94ce94a8af36225b861318091ed57532a92dda
3
+ metadata.gz: 68451ec0c319aab33578ee4ac8aa28f61426ade64f4185047528387c5c95f3e5
4
+ data.tar.gz: fc5b90718c5c0aa5ee11020b02e01d422cee0108bb1095c9aac8eb184dd6c050
5
5
  SHA512:
6
- metadata.gz: 83b6256cf6db475ad7f6c5de6d958f6ed72627dd2d2ae5211183d2ed2fe2c346338fbda6cb52ca79aa7aaf7c343e342e017b4015004cba3c97d351da80b679de
7
- data.tar.gz: 694ac953e5df97e3a747054846612923928d24eb3a184d5f53c999895419f2ba3885fb128034c8bd54b934c976e832d8cf58fb5a7469c8ace7e75f91185266c4
6
+ metadata.gz: b60c2a285735db948bf33ebd52f78fc09329ed3c0a012cc000dab2eda668bf37920058dd4444317ff8bde80fcf70713014ed24186037fdf6b9acf35f32f3dc6b
7
+ data.tar.gz: bb7c53048a48d3c65c0303be1d67bf59368e3a2eec7ae8ce482c50e94304a8295be05ffb062e4591162a5d2cfb53f66d322b807e9c9c26c0039ec5ac3967e5d9
data/README.md CHANGED
@@ -2,13 +2,10 @@
2
2
 
3
3
  A Ruby gem for handling [Zero](https://zero.rocicorp.dev/) mutations with type safety, validation, and full protocol support.
4
4
 
5
- 0.1.0.alpha1
6
-
7
5
  ## Features
8
6
 
9
- - **Type coercion & checking** - String, Integer, Float, Boolean, ID, BigInt, ISO8601Date, ISO8601DateTime with automatic conversion and runtime type validation
10
- - **Type generation** - Generates typescript types you can use for your frontend mutators
11
- - **Argument validation** - length, numericality, format, inclusion, exclusion, etc.
7
+ - **Type coercion & validation** - Built on [dry-types](https://dry-rb.org/gems/dry-types/) with String, Integer, Float, Boolean, ID, ISO8601Date, ISO8601DateTime
8
+ - **Type generation** - Generates TypeScript types for your frontend mutators
12
9
  - **LMID tracking** - Duplicate and out-of-order mutation detection using Zero's `zero_0.clients` table
13
10
  - **Push protocol** - Version validation, transaction wrapping, retry logic
14
11
 
@@ -22,18 +19,20 @@ gem 'zero_ruby'
22
19
 
23
20
  ## Usage
24
21
 
25
-
26
22
  ### 1. Define mutations
27
23
 
24
+ By default, the entire `execute` method runs inside a transaction with LMID (Last Mutation ID) tracking. Use [`skip_auto_transaction`](#manual-transaction-control) to manually control what runs inside the LMID transaction.
25
+
28
26
  ```ruby
29
27
  # app/zero/mutations/post_update.rb
30
28
  module Mutations
31
29
  class PostUpdate < ApplicationMutation
32
- argument :id, ID, required: true
33
- argument :post_input, Types::PostInput, required: true
30
+ argument :id, Types::ID
31
+ argument :post_input, Types::PostInput
34
32
 
35
33
  def execute(id:, post_input:)
36
34
  post = current_user.posts.find(id)
35
+ authorize! post, to: :update?
37
36
  post.update!(**post_input)
38
37
  end
39
38
  end
@@ -94,43 +93,22 @@ end
94
93
  match '/zero/push', to: 'zero#push', via: [:get, :post]
95
94
  ```
96
95
 
97
- ## Base classes (optional)
98
-
99
- Create base classes to share behavior across mutations and input types:
100
-
101
- ```ruby
102
- # app/zero/types/base_input_object.rb
103
- module Types
104
- class BaseInputObject < ZeroRuby::InputObject
105
- # Add shared behavior across all input objects here
106
- end
107
- end
108
-
109
- # app/zero/mutations/application_mutation.rb
110
- class ApplicationMutation < ZeroRuby::Mutation
111
- def current_user
112
- ctx[:current_user]
113
- end
114
- end
115
- ```
116
-
117
96
  ## Define custom input types (optional)
118
97
 
119
98
  ```ruby
120
99
  # app/zero/types/post_input.rb
121
100
  module Types
122
101
  class PostInput < Types::BaseInputObject
123
- argument :title, String, required: true,
124
- validates: { length: { minimum: 1, maximum: 200 } }
125
- argument :body, String, required: false
126
- argument :published, Boolean, required: false, default: false
102
+ argument :title, Types::String.constrained(min_size: 1, max_size: 200)
103
+ argument :body, Types::String.optional
104
+ argument :published, Types::Boolean.default(false)
127
105
  end
128
106
  end
129
107
  ```
130
108
 
131
109
  ## Configuration
132
110
 
133
- Create an initializer to customize settings (all options have sensible defaults):
111
+ Create an initializer to customize settings:
134
112
 
135
113
  ```ruby
136
114
  # config/initializers/zero_ruby.rb
@@ -138,8 +116,8 @@ ZeroRuby.configure do |config|
138
116
  # Storage backend (:active_record is the only built-in option)
139
117
  config.lmid_store = :active_record
140
118
 
141
- # Retry attempts for transient errors
142
- config.max_retry_attempts = 3
119
+ # Retry attempts for transient errors (default: 1)
120
+ config.max_retry_attempts = 1
143
121
 
144
122
  # Push protocol version (reject requests with different version)
145
123
  config.supported_push_version = 1
@@ -164,7 +142,7 @@ ZeroRuby generates TypeScript type definitions from your Ruby mutations. GET req
164
142
  }
165
143
  ```
166
144
 
167
- ### Using with Zero Mutators
145
+ ### Use with Zero Mutators
168
146
 
169
147
  ```typescript
170
148
  import { defineMutator, defineMutators } from '@rocicorp/zero'
@@ -188,55 +166,126 @@ export const mutators = defineMutators({
188
166
  export type Mutators = typeof mutators
189
167
  ```
190
168
 
191
- ## Validation
169
+ ## Types
170
+
171
+ ZeroRuby provides types built on [dry-types](https://dry-rb.org/gems/dry-types/). When you inherit from `ZeroRuby::Mutation` or `ZeroRuby::InputObject`, types are available via the `Types` module:
192
172
 
193
173
  ```ruby
194
- argument :name, String, required: true,
195
- validates: {
196
- length: { minimum: 1, maximum: 100 },
197
- format: { with: /\A[a-z]+\z/i, message: "only letters allowed" },
198
- allow_blank: false
199
- }
174
+ # Basic types
175
+ argument :name, Types::String
176
+ argument :count, Types::Integer
177
+ argument :price, Types::Float
178
+ argument :active, Types::Boolean
179
+ argument :id, Types::ID # Non-empty string
180
+ argument :date, Types::ISO8601Date
181
+ argument :timestamp, Types::ISO8601DateTime
182
+
183
+ # Optional types (accepts nil)
184
+ argument :nickname, Types::String.optional
185
+
186
+ # Default values
187
+ argument :status, Types::String.default("draft")
188
+ argument :enabled, Types::Boolean.default(false)
189
+ ```
200
190
 
201
- argument :age, Integer, required: true,
202
- validates: {
203
- numericality: { greater_than: 0, less_than: 150 }
204
- }
191
+ ## Validation with Constraints
205
192
 
206
- argument :status, String, required: true,
207
- validates: {
208
- inclusion: { in: %w[draft published archived] }
209
- }
193
+ Use dry-types constraints for validation:
210
194
 
211
- argument :username, String, required: true,
212
- validates: {
213
- exclusion: { in: %w[admin root system], message: "is reserved" }
214
- }
195
+ ```ruby
196
+ # Length constraints
197
+ argument :title, Types::String.constrained(min_size: 1, max_size: 200)
198
+ argument :code, Types::String.constrained(size: 6) # Exact size
215
199
 
216
- argument :email, String, required: true,
217
- validates: {
218
- allow_null: false,
219
- allow_blank: false
220
- }
200
+ # Numeric constraints
201
+ argument :age, Types::Integer.constrained(gt: 0, lt: 150)
202
+ argument :quantity, Types::Integer.constrained(gteq: 1, lteq: 100)
203
+
204
+ # Format (regex)
205
+ argument :slug, Types::String.constrained(format: /\A[a-z0-9-]+\z/)
206
+
207
+ # Inclusion
208
+ argument :status, Types::String.constrained(included_in: %w[draft published archived])
209
+
210
+ # Exclusion
211
+ argument :username, Types::String.constrained(excluded_from: %w[admin root system])
212
+
213
+ # Non-empty (filled)
214
+ argument :email, Types::String.constrained(filled: true)
215
+
216
+ # Combine constraints
217
+ argument :name, Types::String.constrained(min_size: 1, max_size: 100, format: /\A[a-zA-Z ]+\z/)
221
218
  ```
222
219
 
223
- ## Type coercion & checking
220
+ ### Available Constraints
221
+
222
+ | Constraint | Description | Example |
223
+ |------------|-------------|---------|
224
+ | `min_size` | Minimum length | `min_size: 1` |
225
+ | `max_size` | Maximum length | `max_size: 200` |
226
+ | `size` | Exact length | `size: 6` |
227
+ | `gt` | Greater than | `gt: 0` |
228
+ | `gteq` | Greater than or equal | `gteq: 1` |
229
+ | `lt` | Less than | `lt: 100` |
230
+ | `lteq` | Less than or equal | `lteq: 99` |
231
+ | `format` | Regex pattern | `format: /\A\d+\z/` |
232
+ | `included_in` | Value must be in list | `included_in: %w[a b c]` |
233
+ | `excluded_from` | Value must not be in list | `excluded_from: %w[x y]` |
234
+ | `filled` | Non-empty string | `filled: true` |
235
+
236
+ ## Type coercion
224
237
 
225
- Types automatically coerce compatible values and raise `CoercionError` for invalid input:
238
+ Types automatically coerce compatible values:
226
239
 
227
240
  | Type | Accepts | Rejects |
228
241
  |------|---------|---------|
229
- | `String` | Any value (via `.to_s`) | - |
230
- | `Integer` | `42`, `"42"`, `3.7` → `3` | `"abc"`, `""`, arrays, hashes |
231
- | `Float` | `3.14`, `"3.14"`, `42` → `42.0` | `"abc"`, `""`, arrays, hashes |
232
- | `Boolean` | `true`, `false`, `"true"`, `"false"`, `0`, `1` | `"yes"`, `"maybe"`, other values |
233
- | `ID` | `"abc"`, `123` `"123"`, `:sym` `"sym"` | `""`, arrays, hashes |
234
- | `BigInt` | `123`, `"9007199254740993"` | `"abc"`, `""`, floats |
235
- | `ISO8601Date` | `"2025-01-15"`, `Date`, `Time` → `Date` | `"invalid"`, `""`, integers |
236
- | `ISO8601DateTime` | `"2025-01-15T10:30:00Z"`, `Time`, `DateTime` | `"invalid"`, `""`, integers |
242
+ | `String` | `"hello"` | `nil` |
243
+ | `Integer` | `42`, `"42"`, `3.7` → `3` | `"abc"`, `""` |
244
+ | `Float` | `3.14`, `"3.14"`, `42` → `42.0` | `"abc"`, `""` |
245
+ | `Boolean` | `true`, `false`, `"true"`, `"false"` | `"yes"`, `1`, `0` |
246
+ | `ID` | `"abc"` | `""` (empty string) |
247
+ | `ISO8601Date` | `"2025-01-15"` → `Date` | `"invalid"`, `""` |
248
+ | `ISO8601DateTime` | `"2025-01-15T10:30:00Z"` → `DateTime` | `"invalid"`, `""` |
249
+
250
+ ## Manual transaction control
251
+
252
+ By default, the entire `execute` method runs inside a transaction in order to atomically commit database changes with the LMID update. Use `skip_auto_transaction` when you need to run code before or after the transaction:
253
+
254
+ ```ruby
255
+ class PostUpdate < ApplicationMutation
256
+ skip_auto_transaction
257
+
258
+ argument :id, Types::ID
259
+ argument :post_input, Types::PostInput
260
+
261
+ def execute(id:, post_input:)
262
+ # 1. Pre-transaction (LMID incremented on error)
263
+ post = current_user.posts.find(id)
264
+ authorize! post, to: :update?
265
+
266
+ # 2. Transaction (Transaction rolled back, LMID incremented on error)
267
+ transact do
268
+ post.update!(**post_input)
269
+ end
270
+
271
+ # 3. Post-commit - only runs if transact succeeded
272
+ NotificationService.notify_update(id)
273
+ end
274
+ end
275
+ ```
276
+
277
+ With `skip_auto_transaction`, you **must** call `transact { }` or `TransactNotCalledError` is raised.
278
+
279
+ ### LMID behavior by phase
280
+
281
+ | Phase | On Error |
282
+ |-------|----------|
283
+ | Pre-transaction | LMID advanced in separate transaction |
284
+ | Transaction | LMID advanced in separate transaction (original tx rolled back) |
285
+ | Post-commit | LMID already committed with transaction |
237
286
 
238
287
  ## References
239
288
 
240
289
  - [Zero Documentation](https://zero.rocicorp.dev/docs/mutators)
241
290
  - [Zero Server Implementation](https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts)
242
- - Inspired by [graphql-ruby](https://github.com/rmosolgo/graphql-ruby)
291
+ - [dry-types Documentation](https://dry-rb.org/gems/dry-types/)
@@ -6,22 +6,17 @@ module ZeroRuby
6
6
  # @example
7
7
  # ZeroRuby.configure do |config|
8
8
  # config.lmid_store = :active_record
9
- # config.max_retry_attempts = 3
10
9
  # end
11
10
  class Configuration
12
11
  # LMID (Last Mutation ID) tracking settings
13
12
  # The LMID store backend: :active_record or a custom LmidStore instance
14
13
  attr_accessor :lmid_store
15
14
 
16
- # Maximum retry attempts for ApplicationError during mutation processing
17
- attr_accessor :max_retry_attempts
18
-
19
15
  # The push version supported by this configuration
20
16
  attr_accessor :supported_push_version
21
17
 
22
18
  def initialize
23
19
  @lmid_store = :active_record
24
- @max_retry_attempts = 3
25
20
  @supported_push_version = 1
26
21
  end
27
22
 
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema"
4
+
5
+ module ZeroRuby
6
+ # Formats Dry::Types and Dry::Struct errors into user-friendly messages
7
+ # using dry-schema's built-in message templates.
8
+ module ErrorFormatter
9
+ # Get the dry-schema messages backend for English
10
+ MESSAGES = Dry::Schema::Messages::YAML.build
11
+
12
+ class << self
13
+ # Format a Dry::Struct::Error into user-friendly messages
14
+ # @param error [Dry::Struct::Error] The struct error
15
+ # @return [Array<String>] Formatted error messages
16
+ def format_struct_error(error)
17
+ message = error.message
18
+
19
+ if message.include?("is missing")
20
+ match = message.match(/:(\w+) is missing/)
21
+ field = match ? match[1] : "field"
22
+ ["#{field} is required"]
23
+ elsif message.include?("has invalid type")
24
+ # Matches both ":title has invalid type" and "has invalid type for :title"
25
+ match = message.match(/:(\w+) has invalid type/) ||
26
+ message.match(/has invalid type for :(\w+)/)
27
+ field = match ? match[1] : "field"
28
+ ["#{field}: invalid type"]
29
+ elsif message.include?("violates constraints")
30
+ # Extract constraint info and format it
31
+ [format_constraint_message(message)]
32
+ else
33
+ [message]
34
+ end
35
+ end
36
+
37
+ # Format a Dry::Types::CoercionError into user-friendly message
38
+ # @param error [Dry::Types::CoercionError] The coercion error
39
+ # @return [String] Formatted error message
40
+ def format_coercion_error(error)
41
+ message = error.message
42
+ input = error.respond_to?(:input) ? error.input : nil
43
+
44
+ type = extract_type_name(message)
45
+ if input
46
+ value_str = format_value(input)
47
+ "#{value_str} is not a valid #{type}"
48
+ else
49
+ lookup_message(:type?, type) || "must be a #{type}"
50
+ end
51
+ end
52
+
53
+ # Format a Dry::Types::ConstraintError into user-friendly message
54
+ # @param error [Dry::Types::ConstraintError] The constraint error
55
+ # @return [String] Formatted error message
56
+ def format_constraint_error(error)
57
+ format_constraint_message(error.message)
58
+ end
59
+
60
+ private
61
+
62
+ # Format a constraint violation message
63
+ def format_constraint_message(message)
64
+ # Parse predicate and args from messages like:
65
+ # "max_size?(50, \"value\") failed" or "gt?(0, 0) failed"
66
+ if (match = message.match(/(\w+\?)\(([^)]*)\)/))
67
+ predicate = match[1]
68
+ args_str = match[2]
69
+ args = parse_args(args_str)
70
+
71
+ lookup_message(predicate, *args) || fallback_message(predicate, args)
72
+ else
73
+ message
74
+ end
75
+ end
76
+
77
+ # Look up a message from dry-schema's message templates
78
+ def lookup_message(predicate, *args)
79
+ # Convert predicate to symbol without ?
80
+ key = predicate.to_s.chomp("?").to_sym
81
+
82
+ # Try to get message from dry-schema
83
+ begin
84
+ result = MESSAGES.call(key, path: [:base], tokens: build_tokens(key, args))
85
+ result&.text
86
+ rescue
87
+ nil
88
+ end
89
+ end
90
+
91
+ # Build tokens hash for message interpolation
92
+ def build_tokens(key, args)
93
+ case key
94
+ when :max_size, :min_size, :size
95
+ {num: args[0]}
96
+ when :gt, :gteq, :lt, :lteq
97
+ {num: args[0]}
98
+ when :included_in, :excluded_from
99
+ {list: args[0]}
100
+ when :format
101
+ {format: args[0]}
102
+ else
103
+ {num: args[0]}
104
+ end
105
+ end
106
+
107
+ # Fallback messages when dry-schema lookup fails
108
+ def fallback_message(predicate, args)
109
+ case predicate
110
+ when "max_size?"
111
+ "size cannot be greater than #{args[0]}"
112
+ when "min_size?"
113
+ "size cannot be less than #{args[0]}"
114
+ when "gt?"
115
+ "must be greater than #{args[0]}"
116
+ when "gteq?"
117
+ "must be greater than or equal to #{args[0]}"
118
+ when "lt?"
119
+ "must be less than #{args[0]}"
120
+ when "lteq?"
121
+ "must be less than or equal to #{args[0]}"
122
+ when "included_in?"
123
+ "must be one of: #{args[0]}"
124
+ when "format?"
125
+ "is in invalid format"
126
+ when "filled?"
127
+ "must be filled"
128
+ else
129
+ "is invalid"
130
+ end
131
+ end
132
+
133
+ # Parse args from constraint error message
134
+ def parse_args(args_str)
135
+ # Simple parsing - split by comma and clean up
136
+ args_str.split(",").map do |arg|
137
+ arg = arg.strip
138
+ if arg.start_with?('"') && arg.end_with?('"')
139
+ arg[1..-2] # Remove quotes
140
+ elsif arg.match?(/^-?\d+$/)
141
+ arg.to_i
142
+ elsif arg.match?(/^-?\d+\.\d+$/)
143
+ arg.to_f
144
+ else
145
+ arg
146
+ end
147
+ end
148
+ end
149
+
150
+ # Extract type name from error message
151
+ def extract_type_name(message)
152
+ case message
153
+ when /Integer/ then "integer"
154
+ when /Float/ then "number"
155
+ when /String/ then "string"
156
+ when /Bool|TrueClass|FalseClass/ then "boolean"
157
+ when /Date/ then "date"
158
+ when /Array/ then "array"
159
+ when /Hash/ then "object"
160
+ else "value"
161
+ end
162
+ end
163
+
164
+ # Format a value for display in error messages
165
+ def format_value(value)
166
+ return "nil" if value.nil?
167
+
168
+ str = value.to_s
169
+ (str.length > 50) ? "#{str[0, 50]}..." : str
170
+ end
171
+ end
172
+ end
173
+ end
@@ -90,7 +90,8 @@ module ZeroRuby
90
90
 
91
91
  # Raised for database transaction errors.
92
92
  # Triggers top-level PushFailed with reason: "database"
93
- class DatabaseError < Error
93
+ # Wraps unexpected errors during transaction execution (matching TS behavior).
94
+ class TransactionError < Error
94
95
  end
95
96
 
96
97
  # Raised when push data is malformed or missing required fields.
@@ -102,4 +103,12 @@ module ZeroRuby
102
103
  # Wrapped as app error per Zero protocol (no "internal" error type at mutation level)
103
104
  class InternalError < Error
104
105
  end
106
+
107
+ # Raised when a mutation doesn't call the transact block.
108
+ # All mutations MUST call transact.call { ... } to wrap their database operations.
109
+ class TransactNotCalledError < Error
110
+ def initialize
111
+ super("Mutation must call transact block")
112
+ end
113
+ end
105
114
  end
@@ -1,120 +1,82 @@
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 via the Types module
12
+ # (e.g., Types::String, Types::ID, Types::Boolean).
10
13
  #
11
14
  # @example
12
15
  # class Types::PostInput < ZeroRuby::InputObject
13
- # argument :id, ID, required: true
14
- # argument :title, String, required: true
15
- # argument :body, String, required: false
16
+ # argument :id, Types::ID
17
+ # argument :title, Types::String.constrained(min_size: 1, max_size: 200)
18
+ # argument :body, Types::String.optional
19
+ # argument :published, Types::Boolean.default(false)
16
20
  # end
17
21
  #
18
22
  # class PostCreate < ZeroRuby::Mutation
19
- # argument :post_input, Types::PostInput, required: true
20
- # argument :notify, Boolean, required: false
23
+ # argument :post_input, Types::PostInput
24
+ # argument :notify, Types::Boolean.default(false)
21
25
  #
22
- # def execute(post_input:, notify: nil)
23
- # Post.create!(**post_input)
26
+ # def execute
27
+ # # args[:post_input].title, args[:post_input].body, etc.
28
+ # # Or use **args[:post_input] to splat into method calls
24
29
  # end
25
30
  # end
26
- class InputObject
27
- include TypeNames
31
+ class InputObject < Dry::Struct
32
+ include ZeroRuby::TypeNames
33
+
34
+ # Transform string keys to symbols (for JSON input)
35
+ transform_keys(&:to_sym)
36
+
37
+ # Use permissive schema that allows omitting optional attributes
38
+ # This matches the behavior where missing optional keys are omitted from result
39
+ schema schema.strict(false)
28
40
 
29
41
  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
42
+ # Alias attribute to argument for DSL compatibility
43
+ # @param name [Symbol] The argument name
44
+ # @param type [Dry::Types::Type] The type (from ZeroRuby::Types)
45
+ # @param description [String, nil] Optional description (stored for documentation, not passed to Dry::Struct)
46
+ def argument(name, type, description: nil, **_options)
47
+ # Store description for documentation/TypeScript generation
48
+ argument_descriptions[name.to_sym] = description if description
42
49
 
43
- # Get all declared arguments (including inherited)
44
- def arguments
45
- @arguments ||= if superclass.respond_to?(:arguments)
46
- superclass.arguments.dup
50
+ # Use attribute? for optional types (allows key to be omitted)
51
+ # Use attribute for required types (key must be present)
52
+ if optional_type?(type)
53
+ attribute?(name, type)
47
54
  else
48
- {}
55
+ attribute(name, type)
49
56
  end
50
57
  end
51
58
 
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
59
+ # Get stored argument descriptions
60
+ def argument_descriptions
61
+ @argument_descriptions ||= {}
62
+ end
104
63
 
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
64
+ # Check if a type is optional (can accept nil or has a default)
65
+ def optional_type?(type)
66
+ return false unless type.respond_to?(:optional?)
67
+ type.optional? || (type.respond_to?(:default?) && type.default?)
68
+ end
112
69
 
113
- validated[name] = coerced
70
+ # Returns argument metadata for TypeScript generation
71
+ # @return [Hash<Symbol, Hash>] Map of argument name to metadata
72
+ def arguments_metadata
73
+ schema.keys.each_with_object({}) do |key, hash|
74
+ hash[key.name] = {
75
+ type: key.type,
76
+ required: key.required?,
77
+ name: key.name
78
+ }
114
79
  end
115
-
116
- raise ValidationError.new(errors) if errors.any?
117
- validated
118
80
  end
119
81
  end
120
82
  end