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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +245 -0
  5. data/lib/zero_ruby/argument.rb +75 -0
  6. data/lib/zero_ruby/configuration.rb +57 -0
  7. data/lib/zero_ruby/errors.rb +90 -0
  8. data/lib/zero_ruby/input_object.rb +121 -0
  9. data/lib/zero_ruby/lmid_store.rb +43 -0
  10. data/lib/zero_ruby/lmid_stores/active_record_store.rb +63 -0
  11. data/lib/zero_ruby/mutation.rb +141 -0
  12. data/lib/zero_ruby/push_processor.rb +126 -0
  13. data/lib/zero_ruby/schema.rb +124 -0
  14. data/lib/zero_ruby/type_names.rb +24 -0
  15. data/lib/zero_ruby/types/base_type.rb +54 -0
  16. data/lib/zero_ruby/types/big_int.rb +32 -0
  17. data/lib/zero_ruby/types/boolean.rb +30 -0
  18. data/lib/zero_ruby/types/float.rb +31 -0
  19. data/lib/zero_ruby/types/id.rb +33 -0
  20. data/lib/zero_ruby/types/integer.rb +31 -0
  21. data/lib/zero_ruby/types/iso8601_date.rb +43 -0
  22. data/lib/zero_ruby/types/iso8601_date_time.rb +43 -0
  23. data/lib/zero_ruby/types/string.rb +20 -0
  24. data/lib/zero_ruby/typescript_generator.rb +192 -0
  25. data/lib/zero_ruby/validator.rb +69 -0
  26. data/lib/zero_ruby/validators/allow_blank_validator.rb +31 -0
  27. data/lib/zero_ruby/validators/allow_null_validator.rb +26 -0
  28. data/lib/zero_ruby/validators/exclusion_validator.rb +29 -0
  29. data/lib/zero_ruby/validators/format_validator.rb +35 -0
  30. data/lib/zero_ruby/validators/inclusion_validator.rb +30 -0
  31. data/lib/zero_ruby/validators/length_validator.rb +42 -0
  32. data/lib/zero_ruby/validators/numericality_validator.rb +63 -0
  33. data/lib/zero_ruby/version.rb +5 -0
  34. data/lib/zero_ruby/zero_client.rb +25 -0
  35. data/lib/zero_ruby.rb +87 -0
  36. 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