zero_ruby 0.1.0.alpha1
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 +7 -0
- data/CHANGELOG.md +0 -0
- data/LICENSE.txt +21 -0
- data/README.md +245 -0
- data/lib/zero_ruby/argument.rb +75 -0
- data/lib/zero_ruby/configuration.rb +57 -0
- data/lib/zero_ruby/errors.rb +90 -0
- data/lib/zero_ruby/input_object.rb +121 -0
- data/lib/zero_ruby/lmid_store.rb +43 -0
- data/lib/zero_ruby/lmid_stores/active_record_store.rb +63 -0
- data/lib/zero_ruby/mutation.rb +141 -0
- data/lib/zero_ruby/push_processor.rb +126 -0
- data/lib/zero_ruby/schema.rb +124 -0
- data/lib/zero_ruby/type_names.rb +24 -0
- data/lib/zero_ruby/types/base_type.rb +54 -0
- data/lib/zero_ruby/types/big_int.rb +32 -0
- data/lib/zero_ruby/types/boolean.rb +30 -0
- data/lib/zero_ruby/types/float.rb +31 -0
- data/lib/zero_ruby/types/id.rb +33 -0
- data/lib/zero_ruby/types/integer.rb +31 -0
- data/lib/zero_ruby/types/iso8601_date.rb +43 -0
- data/lib/zero_ruby/types/iso8601_date_time.rb +43 -0
- data/lib/zero_ruby/types/string.rb +20 -0
- data/lib/zero_ruby/typescript_generator.rb +192 -0
- data/lib/zero_ruby/validator.rb +69 -0
- data/lib/zero_ruby/validators/allow_blank_validator.rb +31 -0
- data/lib/zero_ruby/validators/allow_null_validator.rb +26 -0
- data/lib/zero_ruby/validators/exclusion_validator.rb +29 -0
- data/lib/zero_ruby/validators/format_validator.rb +35 -0
- data/lib/zero_ruby/validators/inclusion_validator.rb +30 -0
- data/lib/zero_ruby/validators/length_validator.rb +42 -0
- data/lib/zero_ruby/validators/numericality_validator.rb +63 -0
- data/lib/zero_ruby/version.rb +5 -0
- data/lib/zero_ruby/zero_client.rb +25 -0
- data/lib/zero_ruby.rb +87 -0
- metadata +145 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../lmid_store"
|
|
4
|
+
|
|
5
|
+
module ZeroRuby
|
|
6
|
+
module LmidStores
|
|
7
|
+
# ActiveRecord-based LMID store using Zero's zero_0.clients table.
|
|
8
|
+
# This store provides proper transaction support with atomic LMID updates
|
|
9
|
+
# for concurrent access in production environments.
|
|
10
|
+
#
|
|
11
|
+
# Uses the same atomic increment pattern as Zero's TypeScript implementation.
|
|
12
|
+
# @see https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/zql-database.ts
|
|
13
|
+
#
|
|
14
|
+
# @example Usage
|
|
15
|
+
# ZeroRuby.configure do |config|
|
|
16
|
+
# config.lmid_store = :active_record
|
|
17
|
+
# end
|
|
18
|
+
class ActiveRecordStore < LmidStore
|
|
19
|
+
# The model class to use for client records.
|
|
20
|
+
# Defaults to ZeroRuby::ZeroClient.
|
|
21
|
+
attr_reader :model_class
|
|
22
|
+
|
|
23
|
+
def initialize(model_class: nil)
|
|
24
|
+
@model_class = model_class || default_model_class
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Atomically increment and return the last mutation ID for a client.
|
|
28
|
+
# Uses INSERT ... ON CONFLICT to handle both new and existing clients
|
|
29
|
+
# in a single atomic operation, minimizing lock duration.
|
|
30
|
+
#
|
|
31
|
+
# @param client_group_id [String] The client group ID
|
|
32
|
+
# @param client_id [String] The client ID
|
|
33
|
+
# @return [Integer] The new last mutation ID (post-increment)
|
|
34
|
+
def fetch_and_increment(client_group_id, client_id)
|
|
35
|
+
table = model_class.quoted_table_name
|
|
36
|
+
sql = model_class.sanitize_sql_array([<<~SQL.squish, {client_group_id:, client_id:}])
|
|
37
|
+
INSERT INTO #{table} ("clientGroupID", "clientID", "lastMutationID")
|
|
38
|
+
VALUES (:client_group_id, :client_id, 1)
|
|
39
|
+
ON CONFLICT ("clientGroupID", "clientID")
|
|
40
|
+
DO UPDATE SET "lastMutationID" = #{table}."lastMutationID" + 1
|
|
41
|
+
RETURNING "lastMutationID"
|
|
42
|
+
SQL
|
|
43
|
+
|
|
44
|
+
model_class.connection.select_value(sql)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Execute a block within an ActiveRecord transaction.
|
|
48
|
+
#
|
|
49
|
+
# @yield The block to execute within the transaction
|
|
50
|
+
# @return The result of the block
|
|
51
|
+
def transaction(&block)
|
|
52
|
+
model_class.transaction(&block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def default_model_class
|
|
58
|
+
require_relative "../zero_client"
|
|
59
|
+
ZeroRuby::ZeroClient
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "argument"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "validator"
|
|
6
|
+
|
|
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
|
|
14
|
+
|
|
15
|
+
module ClassMethods
|
|
16
|
+
# Declare an argument for this mutation
|
|
17
|
+
# @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,
|
|
26
|
+
type: type,
|
|
27
|
+
required: required,
|
|
28
|
+
validates: validates,
|
|
29
|
+
default: default,
|
|
30
|
+
description: description,
|
|
31
|
+
**options
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get all declared arguments for this mutation (including inherited)
|
|
36
|
+
def arguments
|
|
37
|
+
@arguments ||= if superclass.respond_to?(:arguments)
|
|
38
|
+
superclass.arguments.dup
|
|
39
|
+
else
|
|
40
|
+
{}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Coerce and validate raw arguments
|
|
45
|
+
# @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)
|
|
50
|
+
validated = {}
|
|
51
|
+
errors = []
|
|
52
|
+
|
|
53
|
+
arguments.each do |name, arg|
|
|
54
|
+
value = raw_args[name]
|
|
55
|
+
|
|
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
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Type coercion
|
|
69
|
+
begin
|
|
70
|
+
coerced = arg.coerce(value, ctx)
|
|
71
|
+
rescue CoercionError => e
|
|
72
|
+
errors << "#{name}: #{e.message}"
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
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}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
validated[name] = coerced
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
raise ValidationError.new(errors) if errors.any?
|
|
88
|
+
validated
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
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
|
|
110
|
+
|
|
111
|
+
# The context hash containing current_user, etc.
|
|
112
|
+
attr_reader :ctx
|
|
113
|
+
|
|
114
|
+
# Initialize a mutation with raw arguments and context
|
|
115
|
+
# @param raw_args [Hash] Raw input arguments (will be coerced and validated)
|
|
116
|
+
# @param ctx [Hash] The context hash
|
|
117
|
+
def initialize(raw_args, ctx)
|
|
118
|
+
@ctx = ctx
|
|
119
|
+
@args = self.class.coerce_and_validate!(raw_args, ctx)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Execute the mutation
|
|
123
|
+
# @return [Hash] Empty hash on success
|
|
124
|
+
# @raise [ZeroRuby::Error] On failure (formatted at boundary)
|
|
125
|
+
def call
|
|
126
|
+
execute(**@args)
|
|
127
|
+
{}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# 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(**)
|
|
138
|
+
raise NotImplementedError, "Subclasses must implement #execute"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRuby
|
|
4
|
+
# Processes Zero push requests with LMID tracking, version validation,
|
|
5
|
+
# and transaction support. This implements the same protocol as
|
|
6
|
+
# Zero's TypeScript implementation.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# processor = PushProcessor.new(
|
|
10
|
+
# schema: ZeroSchema,
|
|
11
|
+
# lmid_store: ZeroRuby.configuration.lmid_store_instance
|
|
12
|
+
# )
|
|
13
|
+
# result = processor.process(push_data, context)
|
|
14
|
+
#
|
|
15
|
+
# @see https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts
|
|
16
|
+
# @see https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/zql-database.ts
|
|
17
|
+
class PushProcessor
|
|
18
|
+
attr_reader :schema, :lmid_store, :max_retries
|
|
19
|
+
|
|
20
|
+
# @param schema [Class] The schema class for mutation processing
|
|
21
|
+
# @param lmid_store [LmidStore] The LMID store instance
|
|
22
|
+
# @param max_retries [Integer] Maximum retry attempts for retryable errors
|
|
23
|
+
def initialize(schema:, lmid_store:, max_retries: 3)
|
|
24
|
+
@schema = schema
|
|
25
|
+
@lmid_store = lmid_store
|
|
26
|
+
@max_retries = max_retries
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Process a Zero push request
|
|
30
|
+
#
|
|
31
|
+
# @param push_data [Hash] The parsed push request body
|
|
32
|
+
# @param context [Hash] Context to pass to mutations
|
|
33
|
+
# @return [Hash] The response hash
|
|
34
|
+
def process(push_data, context)
|
|
35
|
+
client_group_id = push_data["clientGroupID"]
|
|
36
|
+
mutations = push_data["mutations"] || []
|
|
37
|
+
results = []
|
|
38
|
+
|
|
39
|
+
mutations.each do |mutation_data|
|
|
40
|
+
result = process_mutation_with_lmid(mutation_data, client_group_id, context)
|
|
41
|
+
results << result
|
|
42
|
+
|
|
43
|
+
# If we hit an out-of-order error, stop processing the batch
|
|
44
|
+
break if result[:result][:error] == "ooo"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{mutations: results}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Process a single mutation with LMID validation and transaction support.
|
|
53
|
+
# Uses atomic increment-then-validate pattern matching Zero's TypeScript implementation.
|
|
54
|
+
def process_mutation_with_lmid(mutation_data, client_group_id, context)
|
|
55
|
+
mutation_id = mutation_data["id"]
|
|
56
|
+
client_id = mutation_data["clientID"]
|
|
57
|
+
|
|
58
|
+
mutation_id_obj = {
|
|
59
|
+
id: mutation_id,
|
|
60
|
+
clientID: client_id
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
lmid_store.transaction do
|
|
64
|
+
# Atomically increment LMID first, then validate.
|
|
65
|
+
# This matches the TypeScript implementation's approach for minimal lock duration.
|
|
66
|
+
last_mutation_id = lmid_store.fetch_and_increment(client_group_id, client_id)
|
|
67
|
+
check_lmid!(client_id, mutation_id, last_mutation_id)
|
|
68
|
+
|
|
69
|
+
result = execute_with_retry(mutation_data, context)
|
|
70
|
+
{id: mutation_id_obj, result: result}
|
|
71
|
+
end
|
|
72
|
+
rescue ZeroRuby::Error => e
|
|
73
|
+
{id: mutation_id_obj, result: format_error_response(e)}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Validate LMID against the post-increment value.
|
|
77
|
+
# The received mutation ID should equal the new last mutation ID.
|
|
78
|
+
#
|
|
79
|
+
# @raise [MutationAlreadyProcessedError] If mutation was already processed
|
|
80
|
+
# @raise [OutOfOrderMutationError] If mutation arrived out of order
|
|
81
|
+
def check_lmid!(client_id, received_id, last_mutation_id)
|
|
82
|
+
if received_id < last_mutation_id
|
|
83
|
+
raise MutationAlreadyProcessedError.new(
|
|
84
|
+
client_id: client_id,
|
|
85
|
+
received_id: received_id,
|
|
86
|
+
last_mutation_id: last_mutation_id - 1
|
|
87
|
+
)
|
|
88
|
+
elsif received_id > last_mutation_id
|
|
89
|
+
raise OutOfOrderMutationError.new(
|
|
90
|
+
client_id: client_id,
|
|
91
|
+
received_id: received_id,
|
|
92
|
+
expected_id: last_mutation_id
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Execute mutation with retry logic for app errors
|
|
98
|
+
def execute_with_retry(mutation_data, context)
|
|
99
|
+
attempts = 0
|
|
100
|
+
|
|
101
|
+
loop do
|
|
102
|
+
attempts += 1
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
return schema.execute_mutation(mutation_data, context)
|
|
106
|
+
rescue ZeroRuby::Error => e
|
|
107
|
+
raise e unless attempts < max_retries
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Format an error into Zero protocol response
|
|
113
|
+
def format_error_response(error)
|
|
114
|
+
result = {error: error.error_type, message: error.message}
|
|
115
|
+
|
|
116
|
+
case error
|
|
117
|
+
when ValidationError
|
|
118
|
+
result[:details] = {messages: error.errors}
|
|
119
|
+
else
|
|
120
|
+
result[:details] = error.details if error.details
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module ZeroRuby
|
|
6
|
+
# Schema class for registering and processing Zero mutations.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# class ZeroSchema < ZeroRuby::Schema
|
|
10
|
+
# mutation "works.create", handler: Mutations::WorkCreate
|
|
11
|
+
# mutation "works.update", handler: Mutations::WorkUpdate
|
|
12
|
+
# end
|
|
13
|
+
class Schema
|
|
14
|
+
class << self
|
|
15
|
+
# Register a mutation handler
|
|
16
|
+
# @param name [String] The mutation name (e.g., "works.create")
|
|
17
|
+
# @param handler [Class] The mutation class to handle this mutation
|
|
18
|
+
def mutation(name, handler:)
|
|
19
|
+
mutations[name.to_s] = handler
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get all registered mutations
|
|
23
|
+
def mutations
|
|
24
|
+
@mutations ||= if superclass.respond_to?(:mutations)
|
|
25
|
+
superclass.mutations.dup
|
|
26
|
+
else
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Generate TypeScript type definitions from registered mutations
|
|
32
|
+
# @return [String] TypeScript type definitions
|
|
33
|
+
def to_typescript
|
|
34
|
+
TypeScriptGenerator.new(self).generate
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Execute a Zero push request. This is the main entry point for processing mutations.
|
|
38
|
+
#
|
|
39
|
+
# @param push_data [Hash] The parsed push request body
|
|
40
|
+
# @param context [Hash] Context hash to pass to mutations (e.g., current_user:)
|
|
41
|
+
# @param lmid_store [LmidStore, nil] Optional LMID store override
|
|
42
|
+
# @return [Hash] Result hash: {mutations: [...]} on success, {error: {...}} on failure
|
|
43
|
+
#
|
|
44
|
+
# @example Basic usage
|
|
45
|
+
# body = JSON.parse(request.body.read)
|
|
46
|
+
# result = ZeroSchema.execute(body, context: {current_user: user})
|
|
47
|
+
# render json: result
|
|
48
|
+
def execute(push_data, context:, lmid_store: nil)
|
|
49
|
+
push_version = push_data["pushVersion"]
|
|
50
|
+
supported_version = ZeroRuby.configuration.supported_push_version
|
|
51
|
+
|
|
52
|
+
unless push_version == supported_version
|
|
53
|
+
return {
|
|
54
|
+
error: {
|
|
55
|
+
kind: "PushFailed",
|
|
56
|
+
reason: "UnsupportedPushVersion",
|
|
57
|
+
message: "Unsupported push version: #{push_version}. Expected: #{supported_version}"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
store = lmid_store || ZeroRuby.configuration.lmid_store_instance
|
|
63
|
+
processor = PushProcessor.new(
|
|
64
|
+
schema: self,
|
|
65
|
+
lmid_store: store,
|
|
66
|
+
max_retries: ZeroRuby.configuration.max_retry_attempts
|
|
67
|
+
)
|
|
68
|
+
processor.process(push_data, context)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Execute a single mutation.
|
|
72
|
+
# Used by PushProcessor for LMID-tracked mutations.
|
|
73
|
+
# @param mutation_data [Hash] The mutation data from Zero
|
|
74
|
+
# @param context [Hash] Context hash to pass to mutations
|
|
75
|
+
# @return [Hash] Empty hash on success
|
|
76
|
+
# @raise [MutationNotFoundError] If the mutation is not registered
|
|
77
|
+
# @raise [ZeroRuby::Error] If the mutation fails
|
|
78
|
+
def execute_mutation(mutation_data, context)
|
|
79
|
+
name = normalize_mutation_name(mutation_data["name"])
|
|
80
|
+
raw_args = extract_args(mutation_data)
|
|
81
|
+
params = transform_keys(raw_args)
|
|
82
|
+
|
|
83
|
+
ctx = context.freeze
|
|
84
|
+
handler = mutations[name]
|
|
85
|
+
|
|
86
|
+
raise MutationNotFoundError.new(name) unless handler
|
|
87
|
+
|
|
88
|
+
handler.new(params, ctx).call
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Normalize mutation name (convert | to . for Zero's format)
|
|
94
|
+
def normalize_mutation_name(name)
|
|
95
|
+
return "" if name.nil?
|
|
96
|
+
name.tr("|", ".")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Extract args from mutation data
|
|
100
|
+
def extract_args(mutation_data)
|
|
101
|
+
args = mutation_data["args"]
|
|
102
|
+
return {} if args.nil?
|
|
103
|
+
|
|
104
|
+
# Zero sends args as an array with a single object
|
|
105
|
+
args.is_a?(Array) ? (args.first || {}) : args
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Transform camelCase string keys to snake_case symbols (deep)
|
|
109
|
+
def transform_keys(object)
|
|
110
|
+
case object
|
|
111
|
+
when Hash
|
|
112
|
+
object.each_with_object({}) do |(key, value), result|
|
|
113
|
+
new_key = key.to_s.gsub(/([A-Z])/, '_\1').downcase.delete_prefix("_").to_sym
|
|
114
|
+
result[new_key] = transform_keys(value)
|
|
115
|
+
end
|
|
116
|
+
when Array
|
|
117
|
+
object.map { |e| transform_keys(e) }
|
|
118
|
+
else
|
|
119
|
+
object
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRuby
|
|
4
|
+
# Provides shorthand constants for ZeroRuby types.
|
|
5
|
+
# Include this module to use ID, Boolean, etc. without the ZeroRuby::Types:: prefix.
|
|
6
|
+
#
|
|
7
|
+
# This is automatically included in Mutation and InputObject, so you can write:
|
|
8
|
+
#
|
|
9
|
+
# class PostCreate < ZeroRuby::Mutation
|
|
10
|
+
# argument :id, ID, required: true
|
|
11
|
+
# argument :title, String, required: true
|
|
12
|
+
# argument :active, Boolean, required: true
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Note: String, Integer, and Float work automatically because Ruby's built-in
|
|
16
|
+
# classes are resolved to ZeroRuby types by the argument system.
|
|
17
|
+
module TypeNames
|
|
18
|
+
ID = ZeroRuby::Types::ID
|
|
19
|
+
Boolean = ZeroRuby::Types::Boolean
|
|
20
|
+
BigInt = ZeroRuby::Types::BigInt
|
|
21
|
+
ISO8601Date = ZeroRuby::Types::ISO8601Date
|
|
22
|
+
ISO8601DateTime = ZeroRuby::Types::ISO8601DateTime
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRuby
|
|
4
|
+
module Types
|
|
5
|
+
# Base class for all types. Provides the interface for type coercion.
|
|
6
|
+
class BaseType
|
|
7
|
+
class << self
|
|
8
|
+
def name
|
|
9
|
+
raise NotImplementedError, "Subclasses must implement .name"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def coerce_input(value, _ctx = nil)
|
|
13
|
+
raise NotImplementedError, "Subclasses must implement .coerce_input"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def valid?(value)
|
|
17
|
+
coerce_input(value)
|
|
18
|
+
true
|
|
19
|
+
rescue CoercionError
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
# Helper to raise CoercionError with consistent formatting
|
|
26
|
+
# @param value [Object] The invalid value
|
|
27
|
+
# @param message [String, nil] Custom message (optional)
|
|
28
|
+
def coercion_error!(value, message = nil)
|
|
29
|
+
displayed_value = format_value_for_error(value)
|
|
30
|
+
msg = message || "#{displayed_value} is not a valid #{name}"
|
|
31
|
+
raise CoercionError.new(msg, value: value, expected_type: name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Format a value for display in error messages
|
|
37
|
+
# Truncates long strings and handles various types
|
|
38
|
+
def format_value_for_error(value)
|
|
39
|
+
case value
|
|
40
|
+
when ::String
|
|
41
|
+
truncated = (value.length > 50) ? "#{value[0, 50]}..." : value
|
|
42
|
+
"'#{truncated}'"
|
|
43
|
+
when ::Symbol
|
|
44
|
+
":#{value}"
|
|
45
|
+
when ::NilClass
|
|
46
|
+
"nil"
|
|
47
|
+
else
|
|
48
|
+
value.inspect.then { |s| (s.length > 50) ? "#{s[0, 50]}..." : s }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_type"
|
|
4
|
+
|
|
5
|
+
module ZeroRuby
|
|
6
|
+
module Types
|
|
7
|
+
# BigInt type for large integers beyond 32-bit range.
|
|
8
|
+
# Ruby's Integer handles arbitrary precision natively.
|
|
9
|
+
# Accepts integers and numeric strings, coerces to Integer.
|
|
10
|
+
class BigInt < BaseType
|
|
11
|
+
class << self
|
|
12
|
+
def name
|
|
13
|
+
"BigInt"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def coerce_input(value, _ctx = nil)
|
|
17
|
+
return nil if value.nil?
|
|
18
|
+
return value if value.is_a?(::Integer)
|
|
19
|
+
|
|
20
|
+
if value.is_a?(::String)
|
|
21
|
+
coercion_error!(value, "empty string is not a valid #{name}") if value.empty?
|
|
22
|
+
result = Kernel.Integer(value, exception: false)
|
|
23
|
+
coercion_error!(value) if result.nil?
|
|
24
|
+
result
|
|
25
|
+
else
|
|
26
|
+
coercion_error!(value, "#{format_value_for_error(value)} (#{value.class}) cannot be coerced to #{name}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_type"
|
|
4
|
+
|
|
5
|
+
module ZeroRuby
|
|
6
|
+
module Types
|
|
7
|
+
class Boolean < BaseType
|
|
8
|
+
TRUTHY_VALUES = [true, "true", "1", 1].freeze
|
|
9
|
+
FALSY_VALUES = [false, "false", "0", 0].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def name
|
|
13
|
+
"Boolean"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def coerce_input(value, _ctx = nil)
|
|
17
|
+
return nil if value.nil?
|
|
18
|
+
|
|
19
|
+
if TRUTHY_VALUES.include?(value)
|
|
20
|
+
true
|
|
21
|
+
elsif FALSY_VALUES.include?(value)
|
|
22
|
+
false
|
|
23
|
+
else
|
|
24
|
+
coercion_error!(value, "#{format_value_for_error(value)} is not a valid #{name}; expected true, false, \"true\", \"false\", 0, or 1")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_type"
|
|
4
|
+
|
|
5
|
+
module ZeroRuby
|
|
6
|
+
module Types
|
|
7
|
+
class Float < BaseType
|
|
8
|
+
class << self
|
|
9
|
+
def name
|
|
10
|
+
"Float"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def coerce_input(value, _ctx = nil)
|
|
14
|
+
return nil if value.nil?
|
|
15
|
+
return value if value.is_a?(::Float)
|
|
16
|
+
|
|
17
|
+
if value.is_a?(::Integer)
|
|
18
|
+
value.to_f
|
|
19
|
+
elsif value.is_a?(::String)
|
|
20
|
+
coercion_error!(value, "empty string is not a valid #{name}") if value.empty?
|
|
21
|
+
result = Kernel.Float(value, exception: false)
|
|
22
|
+
coercion_error!(value) if result.nil?
|
|
23
|
+
result
|
|
24
|
+
else
|
|
25
|
+
coercion_error!(value, "#{format_value_for_error(value)} (#{value.class}) cannot be coerced to #{name}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_type"
|
|
4
|
+
|
|
5
|
+
module ZeroRuby
|
|
6
|
+
module Types
|
|
7
|
+
# ID type for unique identifiers (database PKs, FKs, etc.)
|
|
8
|
+
# Accepts strings and integers, always coerces to String.
|
|
9
|
+
class ID < BaseType
|
|
10
|
+
class << self
|
|
11
|
+
def name
|
|
12
|
+
"ID"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def coerce_input(value, _ctx = nil)
|
|
16
|
+
return nil if value.nil?
|
|
17
|
+
|
|
18
|
+
case value
|
|
19
|
+
when ::String
|
|
20
|
+
coercion_error!(value, "empty string is not a valid #{name}") if value.empty?
|
|
21
|
+
value
|
|
22
|
+
when ::Integer
|
|
23
|
+
value.to_s
|
|
24
|
+
when ::Symbol
|
|
25
|
+
value.to_s
|
|
26
|
+
else
|
|
27
|
+
coercion_error!(value, "#{format_value_for_error(value)} (#{value.class}) cannot be coerced to #{name}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_type"
|
|
4
|
+
|
|
5
|
+
module ZeroRuby
|
|
6
|
+
module Types
|
|
7
|
+
class Integer < BaseType
|
|
8
|
+
class << self
|
|
9
|
+
def name
|
|
10
|
+
"Integer"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def coerce_input(value, _ctx = nil)
|
|
14
|
+
return nil if value.nil?
|
|
15
|
+
return value if value.is_a?(::Integer)
|
|
16
|
+
|
|
17
|
+
if value.is_a?(::String)
|
|
18
|
+
coercion_error!(value, "empty string is not a valid #{name}") if value.empty?
|
|
19
|
+
result = Kernel.Integer(value, exception: false)
|
|
20
|
+
coercion_error!(value) if result.nil?
|
|
21
|
+
result
|
|
22
|
+
elsif value.is_a?(::Float)
|
|
23
|
+
value.to_i
|
|
24
|
+
else
|
|
25
|
+
coercion_error!(value, "#{format_value_for_error(value)} (#{value.class}) cannot be coerced to #{name}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|