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.
- checksums.yaml +4 -4
- data/README.md +124 -70
- data/lib/zero_ruby/configuration.rb +0 -5
- data/lib/zero_ruby/error_formatter.rb +171 -0
- data/lib/zero_ruby/errors.rb +10 -1
- data/lib/zero_ruby/input_object.rb +56 -93
- data/lib/zero_ruby/lmid_stores/active_record_store.rb +0 -1
- data/lib/zero_ruby/mutation.rb +201 -77
- data/lib/zero_ruby/push_processor.rb +108 -34
- data/lib/zero_ruby/schema.rb +38 -14
- data/lib/zero_ruby/type_names.rb +13 -8
- data/lib/zero_ruby/types.rb +54 -0
- data/lib/zero_ruby/typescript_generator.rb +126 -58
- data/lib/zero_ruby/version.rb +1 -1
- data/lib/zero_ruby.rb +11 -34
- metadata +46 -20
- data/lib/zero_ruby/argument.rb +0 -75
- data/lib/zero_ruby/types/base_type.rb +0 -54
- data/lib/zero_ruby/types/big_int.rb +0 -32
- data/lib/zero_ruby/types/boolean.rb +0 -30
- data/lib/zero_ruby/types/float.rb +0 -31
- data/lib/zero_ruby/types/id.rb +0 -33
- data/lib/zero_ruby/types/integer.rb +0 -31
- data/lib/zero_ruby/types/iso8601_date.rb +0 -43
- data/lib/zero_ruby/types/iso8601_date_time.rb +0 -43
- data/lib/zero_ruby/types/string.rb +0 -20
- data/lib/zero_ruby/validator.rb +0 -69
- data/lib/zero_ruby/validators/allow_blank_validator.rb +0 -31
- data/lib/zero_ruby/validators/allow_null_validator.rb +0 -26
- data/lib/zero_ruby/validators/exclusion_validator.rb +0 -29
- data/lib/zero_ruby/validators/format_validator.rb +0 -35
- data/lib/zero_ruby/validators/inclusion_validator.rb +0 -30
- data/lib/zero_ruby/validators/length_validator.rb +0 -42
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d78f7995f5a44093ed0202e53dd345587f73a2d2a675e31c158fe01ed392e6e
|
|
4
|
+
data.tar.gz: 9fbbb4cdce370fca8c035e0fad0cd0dbbeaa9ace5cf92061cdab6274223987ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 &
|
|
10
|
-
- **Type generation** - Generates
|
|
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
|
|
33
|
-
argument :post_input, Types::PostInput
|
|
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,
|
|
124
|
-
|
|
125
|
-
argument :
|
|
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 =
|
|
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
|
-
###
|
|
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
|
-
##
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
validates: {
|
|
203
|
-
numericality: { greater_than: 0, less_than: 150 }
|
|
204
|
-
}
|
|
196
|
+
## Validation with Constraints
|
|
205
197
|
|
|
206
|
-
|
|
207
|
-
validates: {
|
|
208
|
-
inclusion: { in: %w[draft published archived] }
|
|
209
|
-
}
|
|
198
|
+
Use dry-types constraints for validation:
|
|
210
199
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
+
## Type coercion
|
|
242
|
+
|
|
243
|
+
Types automatically coerce compatible values:
|
|
226
244
|
|
|
227
245
|
| Type | Accepts | Rejects |
|
|
228
246
|
|------|---------|---------|
|
|
229
|
-
| `String` |
|
|
230
|
-
| `Integer` | `42`, `"42"`, `3.7` → `3` | `"abc"`, `""
|
|
231
|
-
| `Float` | `3.14`, `"3.14"`, `42` → `42.0` | `"abc"`, `""
|
|
232
|
-
| `Boolean` | `true`, `false`, `"true"`, `"false"
|
|
233
|
-
| `ID` | `"abc"
|
|
234
|
-
| `
|
|
235
|
-
| `
|
|
236
|
-
|
|
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
|
-
-
|
|
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
|
data/lib/zero_ruby/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "
|
|
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
|
-
#
|
|
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
|
|
14
|
-
# argument :title, String,
|
|
15
|
-
# argument :body, String
|
|
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
|
|
20
|
-
# argument :notify, Boolean
|
|
24
|
+
# argument :post_input, Types::PostInput
|
|
25
|
+
# argument :notify, Boolean.default(false)
|
|
21
26
|
#
|
|
22
|
-
# def execute
|
|
23
|
-
#
|
|
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
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|