active-query 0.1.3 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 426f4c764e93fcdacda7b354b9f4597ced451648ac4a0e56052f9cfc239f6d3f
4
- data.tar.gz: b8e7db1ca8f99f4851a469feb412539c93a41e66f2d9d676ff619197dacf53a9
3
+ metadata.gz: 5636b341a804f0c3b2fb42b02b57ac502655c70c1cdc37c7e955004012375181
4
+ data.tar.gz: 4e48fbc0f6877a05d963aca4aa04bac4cf2691111cc9857381428b3ff9998f22
5
5
  SHA512:
6
- metadata.gz: 5437dc8c0c0e0ca61883a52799bc927c5bc710d708192be2fb1bd661c2f638e188d842be6098f3692d9fbd515a5659cdf881d684d49425ec9212e8d0b0d119b6
7
- data.tar.gz: 7352b91b01bfa4fe0e96a9d6ad026f6a8ad6e1182d372e879c4eb0fdf453f2f410cd3f0016426b4a22ff9e42dd1f5cd6a45d21269340d20d89c87173142889f7
6
+ metadata.gz: b8c12b1ebe7b7317af9e2201d3cc6a6edc8cda475a2967e175f2e14605f273d87e1fe514045c3597396c7b5291ef83d6470212d41048af0c3a7ed7179b48df39
7
+ data.tar.gz: ed6ba6312dcf12f8f83594ce2d0557466d495a7c901e351eff1490a641a8afcb9dda96f6885ae935e722ffca3738285b03cf2339380fc1a0c5edf451c8330b51
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-11
11
+
12
+ ### Added
13
+ - `ActiveQuery::TypeRegistry` for extensible type validation and coercion — register custom types via `TypeRegistry.register` with a `type_class:`, custom validator lambda, or coercer lambda
14
+ - Built-in type classes (`Types::String`, `Types::Integer`, `Types::Float`, `Types::Boolean`) with default coercion support
15
+ - Per-argument `coerce:` option in argument definitions, taking priority over registry-level coercion
16
+ - `TypeRegistry.unregister` to disable built-in coercion and fall back to strict `is_a?` validation
17
+ - Integer coercion restricted to base-10 strings to prevent unexpected hex/octal conversions
18
+
19
+ ### Fixed
20
+ - Replace `instance_of?` with `is_a?` in type validation so subclass instances (e.g. `ActiveSupport::SafeBuffer`) pass `String` type checks
21
+
10
22
  ## [0.1.3] - 2024-12-19
11
23
 
12
24
  ### Added
@@ -38,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
50
  - **Resolvers**: Support complex query logic with resolver classes
39
51
  - **Conditional Logic**: Apply scopes conditionally with `if`/`unless`
40
52
 
41
- [Unreleased]: https://github.com/matiasasis/active-query/compare/v0.1.3...HEAD
53
+ [Unreleased]: https://github.com/matiasasis/active-query/compare/v0.2.0...HEAD
54
+ [0.2.0]: https://github.com/matiasasis/active-query/compare/v0.1.3...v0.2.0
42
55
  [0.1.3]: https://github.com/matiasasis/active-query/compare/v0.0.1...v0.1.3
43
56
  [0.0.1]: https://github.com/matiasasis/active-query/releases/tag/v0.0.1
data/README.md CHANGED
@@ -97,6 +97,68 @@ ProductQuery.filter_products(
97
97
  ProductQuery.filter_products(name: 123, price: 'invalid', quantity: true, available: 'yes')
98
98
  ```
99
99
 
100
+ ### Custom Types & Coercion
101
+
102
+ ActiveQuery includes built-in type coercion for `String`, `Integer`, `Float`, and `Boolean`. When a value doesn't match the expected type, ActiveQuery will attempt to coerce it before validation:
103
+
104
+ ```ruby
105
+ # These all work — coercion converts the values automatically
106
+ ProductQuery.filter_products(name: :widget, price: '19.99', quantity: '10', available: 'true')
107
+ ```
108
+
109
+ **Built-in coercion behavior:**
110
+
111
+ | Type | Coerces from | Example |
112
+ |------|-------------|---------|
113
+ | `String` | Any (via `.to_s`) | `42` → `"42"` |
114
+ | `Integer` | Numeric strings | `"42"` → `42` |
115
+ | `Float` | Numeric strings, integers | `"1.5"` → `1.5`, `42` → `42.0` |
116
+ | `Boolean` | `"true"`, `"1"`, `1`, `"false"`, `"0"`, `0` | `"true"` → `true` |
117
+
118
+ **Registering a custom type class:**
119
+
120
+ Create a class that inherits from `ActiveQuery::Types::Base` and implements `.valid?` and `.coerce`:
121
+
122
+ ```ruby
123
+ # config/initializers/active_query.rb
124
+ class UuidType < ActiveQuery::Types::Base
125
+ UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
126
+
127
+ def self.valid?(value)
128
+ value.is_a?(String) && value.match?(UUID_REGEX)
129
+ end
130
+
131
+ def self.coerce(value)
132
+ value.to_s.downcase.strip
133
+ end
134
+ end
135
+
136
+ ActiveQuery::TypeRegistry.register(UuidType, type_class: UuidType)
137
+ ```
138
+
139
+ **Disabling built-in coercion:**
140
+
141
+ If you prefer strict type validation without coercion, unregister the built-in type:
142
+
143
+ ```ruby
144
+ # config/initializers/active_query.rb
145
+ ActiveQuery::TypeRegistry.unregister(Integer) # now "42" won't auto-convert to 42
146
+ ```
147
+
148
+ After unregistering, the type falls back to `is_a?` validation with no coercion.
149
+
150
+ **Per-argument coercion:**
151
+
152
+ You can also define coercion on individual arguments using the `coerce:` option:
153
+
154
+ ```ruby
155
+ query :by_number, 'Find by number',
156
+ { number: { type: Integer, coerce: ->(v) { v.to_i } } },
157
+ -> (number:) { scope.where(number: number) }
158
+ ```
159
+
160
+ Per-argument `coerce:` takes priority over the global TypeRegistry coercion.
161
+
100
162
  ### Optional Arguments and Defaults
101
163
 
102
164
  ```ruby
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types/base'
4
+ require_relative 'types/string'
5
+ require_relative 'types/integer'
6
+ require_relative 'types/float'
7
+ require_relative 'types/boolean'
8
+
9
+ module ActiveQuery
10
+ module TypeRegistry
11
+ @validators = {}
12
+ @coercers = {}
13
+ @type_classes = {}
14
+
15
+ def self.register(type, validator: nil, coerce: nil, type_class: nil)
16
+ @validators[type] = validator if validator
17
+ @coercers[type] = coerce if coerce
18
+ @type_classes[type] = type_class if type_class
19
+ end
20
+
21
+ def self.unregister(type)
22
+ @validators.delete(type)
23
+ @coercers.delete(type)
24
+ @type_classes.delete(type)
25
+ end
26
+
27
+ def self.valid?(type, value)
28
+ if @validators.key?(type)
29
+ @validators[type].call(value)
30
+ elsif @type_classes.key?(type)
31
+ @type_classes[type].valid?(value)
32
+ else
33
+ value.is_a?(type)
34
+ end
35
+ end
36
+
37
+ def self.coerce(type, value)
38
+ if @coercers.key?(type)
39
+ @coercers[type].call(value)
40
+ elsif @type_classes.key?(type)
41
+ @type_classes[type].coerce(value)
42
+ else
43
+ value
44
+ end
45
+ end
46
+
47
+ def self.coercer?(type)
48
+ @coercers.key?(type) || @type_classes.key?(type)
49
+ end
50
+
51
+ register(String, type_class: Types::String)
52
+ register(Integer, type_class: Types::Integer)
53
+ register(Float, type_class: Types::Float)
54
+ register(Types::Boolean, type_class: Types::Boolean)
55
+ end
56
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveQuery
4
+ module Types
5
+ class Base
6
+ def self.valid?(_value)
7
+ raise NotImplementedError, "#{name} must implement .valid?"
8
+ end
9
+
10
+ def self.coerce(value)
11
+ value
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveQuery
4
+ module Types
5
+ class Boolean < Base
6
+ def self.to_s = 'Boolean'
7
+ def self.inspect = 'Boolean'
8
+
9
+ TRUTHY = [true, 'true', '1', 1].freeze
10
+ FALSY = [false, 'false', '0', 0].freeze
11
+
12
+ def self.valid?(value)
13
+ value == true || value == false
14
+ end
15
+
16
+ def self.coerce(value)
17
+ return true if TRUTHY.include?(value)
18
+ return false if FALSY.include?(value)
19
+
20
+ value
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveQuery
4
+ module Types
5
+ class Float < Base
6
+ def self.valid?(value)
7
+ value.is_a?(::Float)
8
+ end
9
+
10
+ def self.coerce(value)
11
+ ::Kernel.Float(value)
12
+ rescue ArgumentError, TypeError
13
+ value
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveQuery
4
+ module Types
5
+ class Integer < Base
6
+ def self.valid?(value)
7
+ value.is_a?(::Integer)
8
+ end
9
+
10
+ def self.coerce(value)
11
+ ::Kernel.Integer(value, 10)
12
+ rescue ArgumentError, TypeError
13
+ value
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveQuery
4
+ module Types
5
+ class String < Base
6
+ def self.valid?(value)
7
+ value.is_a?(::String)
8
+ end
9
+
10
+ def self.coerce(value)
11
+ value.to_s
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveQuery
4
- VERSION = '0.1.3'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/active_query.rb CHANGED
@@ -5,13 +5,14 @@ require 'active_support'
5
5
  require 'active_support/concern'
6
6
  require_relative 'active_query/version'
7
7
  require_relative 'active_query/resolver'
8
+ require_relative 'active_query/type_registry'
8
9
  require_relative 'active_record_relation_extensions'
9
10
 
10
11
  module ActiveQuery
11
12
  module Base
12
13
  extend ::ActiveSupport::Concern
13
14
 
14
- class Boolean; end
15
+ Boolean = ActiveQuery::Types::Boolean
15
16
 
16
17
  included do
17
18
  infer_model
@@ -139,20 +140,20 @@ module ActiveQuery
139
140
  extra_params = given_args.keys - args_def.keys
140
141
  raise ArgumentError, "Unknown params: #{extra_params}" unless extra_params.empty?
141
142
 
142
- given_args.each do |given_arg|
143
- given_arg_config = args_def[given_arg.first]
143
+ given_args.each do |given_arg_name, given_arg_value|
144
+ given_arg_config = args_def[given_arg_name]
144
145
  given_arg_type = given_arg_config[:type]
145
- given_arg_name = given_arg.first
146
- given_arg_value = given_arg.second
147
146
 
148
- if given_arg_type == ActiveQuery::Base::Boolean
149
- unless given_arg_value == true || given_arg_value == false
150
- raise ArgumentError, ":#{given_arg_name} must be of type Boolean"
151
- end
152
- else
153
- unless given_arg_value.instance_of?(given_arg_type)
154
- raise ArgumentError, ":#{given_arg_name} must be of type #{given_arg_type}"
155
- end
147
+ if given_arg_config[:coerce]
148
+ given_args[given_arg_name] = given_arg_config[:coerce].call(given_arg_value)
149
+ given_arg_value = given_args[given_arg_name]
150
+ elsif ActiveQuery::TypeRegistry.coercer?(given_arg_type)
151
+ given_args[given_arg_name] = ActiveQuery::TypeRegistry.coerce(given_arg_type, given_arg_value)
152
+ given_arg_value = given_args[given_arg_name]
153
+ end
154
+
155
+ unless ActiveQuery::TypeRegistry.valid?(given_arg_type, given_arg_value)
156
+ raise ArgumentError, ":#{given_arg_name} must be of type #{given_arg_type}"
156
157
  end
157
158
  end
158
159
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active-query
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matias Asis
@@ -178,6 +178,12 @@ files:
178
178
  - lib/active-query.rb
179
179
  - lib/active_query.rb
180
180
  - lib/active_query/resolver.rb
181
+ - lib/active_query/type_registry.rb
182
+ - lib/active_query/types/base.rb
183
+ - lib/active_query/types/boolean.rb
184
+ - lib/active_query/types/float.rb
185
+ - lib/active_query/types/integer.rb
186
+ - lib/active_query/types/string.rb
181
187
  - lib/active_query/version.rb
182
188
  - lib/active_record_relation_extensions.rb
183
189
  homepage: https://github.com/matiasasis/active-query