zero_ruby 0.1.0.alpha2 → 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 +124 -70
  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 +10 -1
  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 +201 -77
  9. data/lib/zero_ruby/push_processor.rb +108 -34
  10. data/lib/zero_ruby/schema.rb +38 -14
  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 +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: 8d78f7995f5a44093ed0202e53dd345587f73a2d2a675e31c158fe01ed392e6e
4
+ data.tar.gz: 9fbbb4cdce370fca8c035e0fad0cd0dbbeaa9ace5cf92061cdab6274223987ae
5
5
  SHA512:
6
- metadata.gz: 83b6256cf6db475ad7f6c5de6d958f6ed72627dd2d2ae5211183d2ed2fe2c346338fbda6cb52ca79aa7aaf7c343e342e017b4015004cba3c97d351da80b679de
7
- data.tar.gz: 694ac953e5df97e3a747054846612923928d24eb3a184d5f53c999895419f2ba3885fb128034c8bd54b934c976e832d8cf58fb5a7469c8ace7e75f91185266c4
6
+ metadata.gz: 6b4e963190e7911e4b9b137933165bd20eebabf2e9a183f9ae7404e0f3ab6b00c2f321098fdf58aaf2ab84fbca7490e1e82b47bb5ed2e3616aa10151173ba2ba
7
+ data.tar.gz: 35b53c46c18655067e7e393893539fa451baf956a3f3a827cd9edfd340e33b29eb55ef9327297ae279a3604717244f3d04cf5ddb2bba5f28308b200c6f724b19
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, 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,36 +93,15 @@ 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, Boolean.default(false)
127
105
  end
128
106
  end
129
107
  ```
@@ -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,131 @@ 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`, the following are available:
172
+
173
+ - **Direct constants**: `ID`, `Boolean`, `ISO8601Date`, `ISO8601DateTime`
174
+ - **Via Types module**: `Types::String`, `Types::Integer`, `Types::Float`
175
+
176
+ (Note: `String`, `Integer`, `Float` can't be direct constants because they conflict with Ruby's built-in classes)
192
177
 
193
178
  ```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
- }
179
+ # Basic types
180
+ argument :name, Types::String
181
+ argument :count, Types::Integer
182
+ argument :price, Types::Float
183
+ argument :active, Boolean
184
+ argument :id, ID # Non-empty string
185
+ argument :date, ISO8601Date
186
+ argument :timestamp, ISO8601DateTime
187
+
188
+ # Optional types (accepts nil)
189
+ argument :nickname, Types::String.optional
190
+
191
+ # Default values
192
+ argument :status, Types::String.default("draft")
193
+ argument :enabled, Boolean.default(false)
194
+ ```
200
195
 
201
- argument :age, Integer, required: true,
202
- validates: {
203
- numericality: { greater_than: 0, less_than: 150 }
204
- }
196
+ ## Validation with Constraints
205
197
 
206
- argument :status, String, required: true,
207
- validates: {
208
- inclusion: { in: %w[draft published archived] }
209
- }
198
+ Use dry-types constraints for validation:
210
199
 
211
- argument :username, String, required: true,
212
- validates: {
213
- exclusion: { in: %w[admin root system], message: "is reserved" }
214
- }
200
+ ```ruby
201
+ # Length constraints
202
+ argument :title, Types::String.constrained(min_size: 1, max_size: 200)
203
+ argument :code, Types::String.constrained(size: 6) # Exact size
215
204
 
216
- argument :email, String, required: true,
217
- validates: {
218
- allow_null: false,
219
- allow_blank: false
220
- }
205
+ # Numeric constraints
206
+ argument :age, Types::Integer.constrained(gt: 0, lt: 150)
207
+ argument :quantity, Types::Integer.constrained(gteq: 1, lteq: 100)
208
+
209
+ # Format (regex)
210
+ argument :slug, Types::String.constrained(format: /\A[a-z0-9-]+\z/)
211
+
212
+ # Inclusion
213
+ argument :status, Types::String.constrained(included_in: %w[draft published archived])
214
+
215
+ # Exclusion
216
+ argument :username, Types::String.constrained(excluded_from: %w[admin root system])
217
+
218
+ # Non-empty (filled)
219
+ argument :email, Types::String.constrained(filled: true)
220
+
221
+ # Combine constraints
222
+ argument :name, Types::String.constrained(min_size: 1, max_size: 100, format: /\A[a-zA-Z ]+\z/)
221
223
  ```
222
224
 
223
- ## Type coercion & checking
225
+ ### Available Constraints
226
+
227
+ | Constraint | Description | Example |
228
+ |------------|-------------|---------|
229
+ | `min_size` | Minimum length | `min_size: 1` |
230
+ | `max_size` | Maximum length | `max_size: 200` |
231
+ | `size` | Exact length | `size: 6` |
232
+ | `gt` | Greater than | `gt: 0` |
233
+ | `gteq` | Greater than or equal | `gteq: 1` |
234
+ | `lt` | Less than | `lt: 100` |
235
+ | `lteq` | Less than or equal | `lteq: 99` |
236
+ | `format` | Regex pattern | `format: /\A\d+\z/` |
237
+ | `included_in` | Value must be in list | `included_in: %w[a b c]` |
238
+ | `excluded_from` | Value must not be in list | `excluded_from: %w[x y]` |
239
+ | `filled` | Non-empty string | `filled: true` |
224
240
 
225
- Types automatically coerce compatible values and raise `CoercionError` for invalid input:
241
+ ## Type coercion
242
+
243
+ Types automatically coerce compatible values:
226
244
 
227
245
  | Type | Accepts | Rejects |
228
246
  |------|---------|---------|
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 |
247
+ | `String` | `"hello"` | `nil` |
248
+ | `Integer` | `42`, `"42"`, `3.7` → `3` | `"abc"`, `""` |
249
+ | `Float` | `3.14`, `"3.14"`, `42` → `42.0` | `"abc"`, `""` |
250
+ | `Boolean` | `true`, `false`, `"true"`, `"false"` | `"yes"`, `1`, `0` |
251
+ | `ID` | `"abc"` | `""` (empty string) |
252
+ | `ISO8601Date` | `"2025-01-15"` → `Date` | `"invalid"`, `""` |
253
+ | `ISO8601DateTime` | `"2025-01-15T10:30:00Z"` → `DateTime` | `"invalid"`, `""` |
254
+
255
+ ## Manual transaction control
256
+
257
+ 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:
258
+
259
+ ```ruby
260
+ class PostUpdate < ApplicationMutation
261
+ skip_auto_transaction
262
+
263
+ argument :id, ID
264
+ argument :post_input, Types::PostInput
265
+
266
+ def execute(id:, post_input:)
267
+ # 1. Pre-transaction (LMID incremented on error)
268
+ post = current_user.posts.find(id)
269
+ authorize! post, to: :update?
270
+
271
+ # 2. Transaction (Transaction rolled back, LMID incremented on error)
272
+ transact do
273
+ post.update!(**post_input)
274
+ end
275
+
276
+ # 3. Post-commit - only runs if transact succeeded
277
+ NotificationService.notify_update(id)
278
+ end
279
+ end
280
+ ```
281
+
282
+ With `skip_auto_transaction`, you **must** call `transact { }` or `TransactNotCalledError` is raised.
283
+
284
+ ### LMID behavior by phase
285
+
286
+ | Phase | On Error |
287
+ |-------|----------|
288
+ | Pre-transaction | LMID advanced in separate transaction |
289
+ | Transaction | LMID advanced in separate transaction (original tx rolled back) |
290
+ | Post-commit | LMID already committed with transaction |
237
291
 
238
292
  ## References
239
293
 
240
294
  - [Zero Documentation](https://zero.rocicorp.dev/docs/mutators)
241
295
  - [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)
296
+ - [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,171 @@
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
+ match = message.match(/:(\w+) has invalid type/)
25
+ field = match ? match[1] : "field"
26
+ ["#{field}: invalid type"]
27
+ elsif message.include?("violates constraints")
28
+ # Extract constraint info and format it
29
+ [format_constraint_message(message)]
30
+ else
31
+ [message]
32
+ end
33
+ end
34
+
35
+ # Format a Dry::Types::CoercionError into user-friendly message
36
+ # @param error [Dry::Types::CoercionError] The coercion error
37
+ # @return [String] Formatted error message
38
+ def format_coercion_error(error)
39
+ message = error.message
40
+ input = error.respond_to?(:input) ? error.input : nil
41
+
42
+ type = extract_type_name(message)
43
+ if input
44
+ value_str = format_value(input)
45
+ "#{value_str} is not a valid #{type}"
46
+ else
47
+ lookup_message(:type?, type) || "must be a #{type}"
48
+ end
49
+ end
50
+
51
+ # Format a Dry::Types::ConstraintError into user-friendly message
52
+ # @param error [Dry::Types::ConstraintError] The constraint error
53
+ # @return [String] Formatted error message
54
+ def format_constraint_error(error)
55
+ format_constraint_message(error.message)
56
+ end
57
+
58
+ private
59
+
60
+ # Format a constraint violation message
61
+ def format_constraint_message(message)
62
+ # Parse predicate and args from messages like:
63
+ # "max_size?(50, \"value\") failed" or "gt?(0, 0) failed"
64
+ if (match = message.match(/(\w+\?)\(([^)]*)\)/))
65
+ predicate = match[1]
66
+ args_str = match[2]
67
+ args = parse_args(args_str)
68
+
69
+ lookup_message(predicate, *args) || fallback_message(predicate, args)
70
+ else
71
+ message
72
+ end
73
+ end
74
+
75
+ # Look up a message from dry-schema's message templates
76
+ def lookup_message(predicate, *args)
77
+ # Convert predicate to symbol without ?
78
+ key = predicate.to_s.chomp("?").to_sym
79
+
80
+ # Try to get message from dry-schema
81
+ begin
82
+ result = MESSAGES.call(key, path: [:base], tokens: build_tokens(key, args))
83
+ result&.text
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
88
+
89
+ # Build tokens hash for message interpolation
90
+ def build_tokens(key, args)
91
+ case key
92
+ when :max_size, :min_size, :size
93
+ {num: args[0]}
94
+ when :gt, :gteq, :lt, :lteq
95
+ {num: args[0]}
96
+ when :included_in, :excluded_from
97
+ {list: args[0]}
98
+ when :format
99
+ {format: args[0]}
100
+ else
101
+ {num: args[0]}
102
+ end
103
+ end
104
+
105
+ # Fallback messages when dry-schema lookup fails
106
+ def fallback_message(predicate, args)
107
+ case predicate
108
+ when "max_size?"
109
+ "size cannot be greater than #{args[0]}"
110
+ when "min_size?"
111
+ "size cannot be less than #{args[0]}"
112
+ when "gt?"
113
+ "must be greater than #{args[0]}"
114
+ when "gteq?"
115
+ "must be greater than or equal to #{args[0]}"
116
+ when "lt?"
117
+ "must be less than #{args[0]}"
118
+ when "lteq?"
119
+ "must be less than or equal to #{args[0]}"
120
+ when "included_in?"
121
+ "must be one of: #{args[0]}"
122
+ when "format?"
123
+ "is in invalid format"
124
+ when "filled?"
125
+ "must be filled"
126
+ else
127
+ "is invalid"
128
+ end
129
+ end
130
+
131
+ # Parse args from constraint error message
132
+ def parse_args(args_str)
133
+ # Simple parsing - split by comma and clean up
134
+ args_str.split(",").map do |arg|
135
+ arg = arg.strip
136
+ if arg.start_with?('"') && arg.end_with?('"')
137
+ arg[1..-2] # Remove quotes
138
+ elsif arg.match?(/^-?\d+$/)
139
+ arg.to_i
140
+ elsif arg.match?(/^-?\d+\.\d+$/)
141
+ arg.to_f
142
+ else
143
+ arg
144
+ end
145
+ end
146
+ end
147
+
148
+ # Extract type name from error message
149
+ def extract_type_name(message)
150
+ case message
151
+ when /Integer/ then "integer"
152
+ when /Float/ then "number"
153
+ when /String/ then "string"
154
+ when /Bool|TrueClass|FalseClass/ then "boolean"
155
+ when /Date/ then "date"
156
+ when /Array/ then "array"
157
+ when /Hash/ then "object"
158
+ else "value"
159
+ end
160
+ end
161
+
162
+ # Format a value for display in error messages
163
+ def format_value(value)
164
+ return "nil" if value.nil?
165
+
166
+ str = value.to_s
167
+ (str.length > 50) ? "#{str[0, 50]}..." : str
168
+ end
169
+ end
170
+ end
171
+ 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 DatabaseTransactionError < 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,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