dry-validation-rust 0.1.0.pre5-aarch64-linux
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 +74 -0
- data/LICENSE +21 -0
- data/NOTICE.md +28 -0
- data/README.md +459 -0
- data/docs/ARCHITECTURE.md +256 -0
- data/docs/COMPATIBILITY.md +198 -0
- data/docs/FEASIBILITY.md +207 -0
- data/docs/SUPPORT_MATRIX.md +66 -0
- data/docs/VERIFICATION.md +128 -0
- data/dry-validation-rust.gemspec +58 -0
- data/lib/dry/schema.rb +6 -0
- data/lib/dry/validation/rust/block_keyword_parameters.rb +20 -0
- data/lib/dry/validation/rust/config.rb +74 -0
- data/lib/dry/validation/rust/contract/result.rb +180 -0
- data/lib/dry/validation/rust/contract/values.rb +73 -0
- data/lib/dry/validation/rust/contract.rb +400 -0
- data/lib/dry/validation/rust/errors.rb +14 -0
- data/lib/dry/validation/rust/evaluator.rb +295 -0
- data/lib/dry/validation/rust/failures.rb +57 -0
- data/lib/dry/validation/rust/generated_predicates.rb +14 -0
- data/lib/dry/validation/rust/macros.rb +45 -0
- data/lib/dry/validation/rust/message.rb +41 -0
- data/lib/dry/validation/rust/message_backend.rb +115 -0
- data/lib/dry/validation/rust/message_set.rb +159 -0
- data/lib/dry/validation/rust/native.rb +25 -0
- data/lib/dry/validation/rust/native.so +0 -0
- data/lib/dry/validation/rust/path.rb +65 -0
- data/lib/dry/validation/rust/path_trie.rb +57 -0
- data/lib/dry/validation/rust/result.rb +3 -0
- data/lib/dry/validation/rust/rule.rb +62 -0
- data/lib/dry/validation/rust/schema/dsl.rb +76 -0
- data/lib/dry/validation/rust/schema/field_builder.rb +156 -0
- data/lib/dry/validation/rust/schema/field_definition.rb +99 -0
- data/lib/dry/validation/rust/schema/predicate_block.rb +56 -0
- data/lib/dry/validation/rust/schema/processor_hooks.rb +46 -0
- data/lib/dry/validation/rust/schema/result.rb +67 -0
- data/lib/dry/validation/rust/schema/ruby_type_processor.rb +44 -0
- data/lib/dry/validation/rust/schema.rb +323 -0
- data/lib/dry/validation/rust/values.rb +3 -0
- data/lib/dry/validation/rust/version.rb +10 -0
- data/lib/dry/validation/rust.rb +55 -0
- data/lib/dry/validation.rb +66 -0
- data/lib/dry-schema.rb +3 -0
- data/lib/dry-validation.rb +3 -0
- data/lib/dry_validation_rust.rb +3 -0
- data/predicates.yml +67 -0
- data/rust-toolchain.toml +9 -0
- metadata +233 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Runtime shape
|
|
4
|
+
|
|
5
|
+
The implementation separates definition-time Ruby flexibility from
|
|
6
|
+
call-time native processing.
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
flowchart TD
|
|
10
|
+
A["Ruby contract DSL"] --> B["Serializable schema description"]
|
|
11
|
+
B --> C["Rust immutable Engine plan"]
|
|
12
|
+
D["Input Hash"] --> C
|
|
13
|
+
C --> E["Coerced output + schema errors"]
|
|
14
|
+
E --> F["Ordered Ruby rules/macros"]
|
|
15
|
+
F --> G["Compatible Result"]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The plan is compiled once per contract schema. Contract instances reuse the
|
|
19
|
+
same typed native object.
|
|
20
|
+
|
|
21
|
+
## Definition phase
|
|
22
|
+
|
|
23
|
+
### Ruby DSL
|
|
24
|
+
|
|
25
|
+
`Schema::DSL` and `Schema::FieldBuilder` capture:
|
|
26
|
+
|
|
27
|
+
- schema mode;
|
|
28
|
+
- key name and requiredness;
|
|
29
|
+
- type, nullable, and filled flags;
|
|
30
|
+
- array member plan;
|
|
31
|
+
- nested children;
|
|
32
|
+
- predicate name and arguments.
|
|
33
|
+
|
|
34
|
+
Supported native predicates are serialized into the plan. Predicates whose
|
|
35
|
+
semantics are specifically Ruby-owned (Regexp, inclusion, `eql?`) remain on
|
|
36
|
+
the Ruby field definitions and run after native structural processing.
|
|
37
|
+
|
|
38
|
+
### Plan boundary
|
|
39
|
+
|
|
40
|
+
Ruby serializes the schema description to JSON. This happens only while the
|
|
41
|
+
schema is defined. The native constructor:
|
|
42
|
+
|
|
43
|
+
1. parses JSON using serde;
|
|
44
|
+
2. checks the engine-plan version;
|
|
45
|
+
3. builds Rust structs and enums;
|
|
46
|
+
4. stores only Rust-owned values.
|
|
47
|
+
|
|
48
|
+
No unmarked Ruby object is stored in the Rust heap. This avoids a common and
|
|
49
|
+
serious Magnus GC error: a raw Ruby `VALUE` hidden in a Rust `Vec` or
|
|
50
|
+
`HashMap` could otherwise be collected.
|
|
51
|
+
|
|
52
|
+
The native object exposes `field_count` and `plan_bytes` for diagnostics.
|
|
53
|
+
|
|
54
|
+
### Rust extension layout
|
|
55
|
+
|
|
56
|
+
The native extension keeps the Magnus binding in `lib.rs` and separates plan parsing, traversal, coercion, predicate evaluation, errors, and GVL-bound Ruby calls into focused modules.
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
ext/dry_validation_rust/src/
|
|
60
|
+
├── lib.rs # Magnus binding, module declarations, and re-exports
|
|
61
|
+
├── plan.rs # Plan deserialization, PredicateArg, and version check
|
|
62
|
+
├── engine.rs # Input traversal, output construction, errors, depth guard
|
|
63
|
+
├── coercion.rs # Mode-specific scalar coercion
|
|
64
|
+
├── predicates.rs # Native predicate evaluation
|
|
65
|
+
├── error.rs # Native errors, path parts, and error helpers
|
|
66
|
+
└── ruby_bridge.rs # GVL-bound calls into CRuby
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Call phase
|
|
70
|
+
|
|
71
|
+
### Native schema processor
|
|
72
|
+
|
|
73
|
+
`Engine#call` receives a Ruby Hash and processes each declared field:
|
|
74
|
+
|
|
75
|
+
1. look up a symbol key and, in Params/JSON mode, its string form;
|
|
76
|
+
2. report required-key failures;
|
|
77
|
+
3. handle nullable/filled state;
|
|
78
|
+
4. apply mode-specific coercion;
|
|
79
|
+
5. validate the expected type;
|
|
80
|
+
6. recurse into nested hashes or array members;
|
|
81
|
+
7. apply supported native predicates;
|
|
82
|
+
8. write a symbol-keyed filtered output Hash;
|
|
83
|
+
9. return error tuples of path, code, and text.
|
|
84
|
+
|
|
85
|
+
Unknown input keys are omitted, matching the default schema behavior.
|
|
86
|
+
|
|
87
|
+
The processor preserves an invalid raw value in output while reporting its
|
|
88
|
+
type error. That is important for result inspection and follows the common
|
|
89
|
+
dry-schema behavior.
|
|
90
|
+
|
|
91
|
+
### Ruby predicate completion
|
|
92
|
+
|
|
93
|
+
The Ruby `Schema` walks the already processed output for predicates that
|
|
94
|
+
should preserve Ruby semantics:
|
|
95
|
+
|
|
96
|
+
- Regexp `format?`;
|
|
97
|
+
- `included_in?` and `excluded_from?`;
|
|
98
|
+
- `eql?` and `not_eql?`.
|
|
99
|
+
|
|
100
|
+
They are skipped when the same path already has a structural/type error.
|
|
101
|
+
|
|
102
|
+
### Contract rules
|
|
103
|
+
|
|
104
|
+
`Contract#call` creates a `Result` and visits class rules in declaration order.
|
|
105
|
+
A rule is skipped when a declared dependency path intersects a schema error.
|
|
106
|
+
|
|
107
|
+
`Evaluator` uses `instance_exec`, so a rule retains the familiar environment:
|
|
108
|
+
|
|
109
|
+
- `value` and `values`;
|
|
110
|
+
- key and base failure accumulators;
|
|
111
|
+
- context and index keyword arguments;
|
|
112
|
+
- schema/rule error queries;
|
|
113
|
+
- delegation to private or public contract methods;
|
|
114
|
+
- injected option readers.
|
|
115
|
+
|
|
116
|
+
`rule.each` expands its root array to indexed evaluator paths after member
|
|
117
|
+
schema processing.
|
|
118
|
+
|
|
119
|
+
### Results and messages
|
|
120
|
+
|
|
121
|
+
`Result` combines immutable schema messages with rule messages. It provides:
|
|
122
|
+
|
|
123
|
+
- success/failure predicates;
|
|
124
|
+
- processed values and Hash conversion;
|
|
125
|
+
- path queries;
|
|
126
|
+
- context;
|
|
127
|
+
- message-set conversion/filtering;
|
|
128
|
+
- hash and tuple pattern matching.
|
|
129
|
+
|
|
130
|
+
`MessageSet#to_h` builds nested hashes for paths, including integer array
|
|
131
|
+
indexes. Explicit rule metadata is preserved as a Hash payload.
|
|
132
|
+
|
|
133
|
+
## Coercion modes
|
|
134
|
+
|
|
135
|
+
| Mode | Key behavior | Value behavior |
|
|
136
|
+
| -------- | --------------------------------------- | ----------------------------------- |
|
|
137
|
+
| `schema` | Symbol keys only | No coercion |
|
|
138
|
+
| `json` | String or symbol input to symbol output | No scalar coercion |
|
|
139
|
+
| `params` | String or symbol input to symbol output | Supported HTTP-like scalar coercion |
|
|
140
|
+
|
|
141
|
+
Params coercions currently include integer, finite float, boolean, symbol,
|
|
142
|
+
Date, DateTime, Time, and BigDecimal. Empty strings become nil for `maybe`
|
|
143
|
+
fields.
|
|
144
|
+
|
|
145
|
+
## GVL and concurrency
|
|
146
|
+
|
|
147
|
+
The plan contains only immutable Rust data and can be safely reused by Ruby
|
|
148
|
+
threads. Each call allocates its own output and error state.
|
|
149
|
+
|
|
150
|
+
The processor still calls CRuby APIs to read Hashes, create output objects,
|
|
151
|
+
create Date/Time/BigDecimal values, and invoke a small set of Ruby operators.
|
|
152
|
+
Therefore it must hold the GVL.
|
|
153
|
+
|
|
154
|
+
The design must never call CRuby APIs inside a `without_gvl` region.
|
|
155
|
+
|
|
156
|
+
### Possible future batch engine
|
|
157
|
+
|
|
158
|
+
A GVL-releasing batch path would require:
|
|
159
|
+
|
|
160
|
+
1. under GVL, convert supported Ruby input into Rust-owned enums;
|
|
161
|
+
2. release GVL;
|
|
162
|
+
3. validate/coerce the Rust values in parallel or serial native code;
|
|
163
|
+
4. reacquire GVL;
|
|
164
|
+
5. materialize Ruby results.
|
|
165
|
+
|
|
166
|
+
This is attractive for large arrays or validation jobs, but the copy cost can
|
|
167
|
+
outweigh the gain for web-sized Hashes. It should be a distinct API with
|
|
168
|
+
separate benchmarks.
|
|
169
|
+
|
|
170
|
+
## Error safety
|
|
171
|
+
|
|
172
|
+
Native conversion returns `Result` values through Magnus. Ruby exceptions
|
|
173
|
+
raised by CRuby calls become `magnus::Error` and unwind safely into Ruby.
|
|
174
|
+
Schema-plan parse/version errors become `ArgumentError` and are wrapped by the
|
|
175
|
+
Ruby layer as `NativeExtensionError` with context.
|
|
176
|
+
|
|
177
|
+
Rust panics must not be used for user input. The current plan parser and
|
|
178
|
+
processor return errors for malformed definitions and conversion failures.
|
|
179
|
+
|
|
180
|
+
## Packaging
|
|
181
|
+
|
|
182
|
+
The gem uses the standard native extension contract:
|
|
183
|
+
|
|
184
|
+
- `ext/dry_validation_rust/extconf.rb`;
|
|
185
|
+
- `rb_sys/mkmf` to generate a Makefile;
|
|
186
|
+
- Cargo `cdylib` output;
|
|
187
|
+
- an `Init_native` entry point;
|
|
188
|
+
- installation under `dry_validation_rust/native`.
|
|
189
|
+
|
|
190
|
+
`Cargo.lock` is included for reproducible source-gem builds. Release builds
|
|
191
|
+
enable thin LTO, one codegen unit, and debug-info stripping.
|
|
192
|
+
|
|
193
|
+
The source checkout loader also looks for
|
|
194
|
+
`ext/dry_validation_rust/native.so`, allowing tests without installing the gem.
|
|
195
|
+
|
|
196
|
+
## Portability
|
|
197
|
+
|
|
198
|
+
Current target:
|
|
199
|
+
|
|
200
|
+
- CRuby 3.3+;
|
|
201
|
+
- Linux and macOS source builds expected;
|
|
202
|
+
- x86-64 Linux verified in this work;
|
|
203
|
+
- Windows unverified;
|
|
204
|
+
- JRuby and TruffleRuby unsupported by this native backend.
|
|
205
|
+
|
|
206
|
+
Precompiled platform gems should be considered only after CI covers Ruby
|
|
207
|
+
3.3/3.4/current, glibc and musl Linux, macOS arm64/x86-64, and Windows if it is
|
|
208
|
+
in scope.
|
|
209
|
+
|
|
210
|
+
## Production hardening roadmap
|
|
211
|
+
|
|
212
|
+
### Phase 1 — parity harness
|
|
213
|
+
|
|
214
|
+
- Derive behavior cases from public documentation with independent test code.
|
|
215
|
+
- Run the same fixture corpus in separate processes against upstream and Rust.
|
|
216
|
+
- Compare processed values, ordered errors, paths, metadata, and exceptions.
|
|
217
|
+
- Add property tests for nested structures and coercion edge cases.
|
|
218
|
+
|
|
219
|
+
### Phase 2 — schema surface
|
|
220
|
+
|
|
221
|
+
- Implement strict unexpected-key validation.
|
|
222
|
+
- Expand type/coercion parity, including constructors and array/hash edge cases.
|
|
223
|
+
- Support predicate composition ASTs and custom predicate callbacks.
|
|
224
|
+
- Add standalone schema composition and processor hooks.
|
|
225
|
+
|
|
226
|
+
### Phase 3 — messages/configuration
|
|
227
|
+
|
|
228
|
+
- Implement YAML namespaces, locales, tokens, full-message key translation,
|
|
229
|
+
and configurable load paths.
|
|
230
|
+
- Add an I18n adapter.
|
|
231
|
+
- Implement hints and info message extensions.
|
|
232
|
+
|
|
233
|
+
### Phase 4 — contract ecosystem
|
|
234
|
+
|
|
235
|
+
- Complete macro parity and predicate-as-macro messages.
|
|
236
|
+
- Add monad extension compatibility.
|
|
237
|
+
- Test dry-auto_inject and Rails autoload/reload behavior.
|
|
238
|
+
- Formalize Ractor behavior or explicitly reject it.
|
|
239
|
+
|
|
240
|
+
### Phase 5 — safety and performance
|
|
241
|
+
|
|
242
|
+
- Fuzz plan deserialization and recursive input processing.
|
|
243
|
+
- Add recursion/depth and allocation guards for hostile payloads.
|
|
244
|
+
- Use sanitizers and `cargo audit` in CI.
|
|
245
|
+
- Benchmark upstream and native across representative payload matrices.
|
|
246
|
+
- Track throughput, latency percentiles, Ruby allocations, native allocations,
|
|
247
|
+
and peak RSS.
|
|
248
|
+
|
|
249
|
+
### Phase 6 — release engineering
|
|
250
|
+
|
|
251
|
+
- CI platform/Ruby matrix.
|
|
252
|
+
- Reproducible source gem and optional platform gems.
|
|
253
|
+
- SemVer compatibility policy and upstream-version target.
|
|
254
|
+
- Security policy, changelog, and maintainer contact.
|
|
255
|
+
- Public naming/relationship discussion with Hanakai maintainers before
|
|
256
|
+
publication.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Compatibility target and matrix
|
|
2
|
+
|
|
3
|
+
## Target
|
|
4
|
+
|
|
5
|
+
The public target for the `0.1.x` line is the common `dry-validation` contract
|
|
6
|
+
surface. The authoritative machine-readable pin is `dry-validation` 1.11.1 in
|
|
7
|
+
the root `Gemfile`; its locked `dry-schema` dependency is 1.16.0. This is not a
|
|
8
|
+
claim of full behavioral compatibility. `bundle exec rake compatibility:differential`
|
|
9
|
+
executes the initial corpus against both engines in separate Ruby processes.
|
|
10
|
+
|
|
11
|
+
Version, platform, and release-line support are documented in
|
|
12
|
+
[SUPPORT_MATRIX.md](SUPPORT_MATRIX.md).
|
|
13
|
+
|
|
14
|
+
For `0.1.x`, the side-by-side API rooted at
|
|
15
|
+
`Dry::Validation::Rust::Contract` is stable: a breaking change to its documented
|
|
16
|
+
public surface, including its nested `Result` and `Values` types and the `Schema`,
|
|
17
|
+
`MessageSet`, and `Evaluator` types, requires a minor release. Exact-compatibility
|
|
18
|
+
entrypoints remain experimental and are excluded from this compatibility promise.
|
|
19
|
+
|
|
20
|
+
Legend:
|
|
21
|
+
|
|
22
|
+
- ✅ implemented and covered by this prototype's tests;
|
|
23
|
+
- 🟡 partial or intentionally narrower;
|
|
24
|
+
- ❌ not implemented;
|
|
25
|
+
- N/A intentionally left to Ruby rather than translated.
|
|
26
|
+
|
|
27
|
+
## Loading and factories
|
|
28
|
+
|
|
29
|
+
| Surface | Status | Notes |
|
|
30
|
+
| ---------------------------------------- | ------ | ----------------------------------- |
|
|
31
|
+
| `require "dry/validation/rust"` | ✅ | Side-by-side namespace |
|
|
32
|
+
| `Dry::Validation::Rust::Contract` | ✅ | Safe migration superclass |
|
|
33
|
+
| `require "dry/validation"` | ✅ | Exact replacement entrypoint |
|
|
34
|
+
| `Dry::Validation::Contract` | ✅ | Alias in exact mode |
|
|
35
|
+
| `Dry::Validation.Contract { ... }` | ✅ | Exact factory |
|
|
36
|
+
| `Dry::Validation::Rust.Contract { ... }` | ✅ | Safe factory |
|
|
37
|
+
| `Dry::Schema.Params` / `JSON` / `define` | 🟡 | Minimal exact-mode factories |
|
|
38
|
+
| Co-install exact mode with upstream gems | ❌ | Require path and constant collision |
|
|
39
|
+
|
|
40
|
+
## Schema definition
|
|
41
|
+
|
|
42
|
+
| Surface | Status | Notes |
|
|
43
|
+
| ----------------------------------------- | ------ | ----------------------------------------------------------------- |
|
|
44
|
+
| `params do ... end` | ✅ | HTTP-like key/scalar coercion |
|
|
45
|
+
| `json do ... end` | ✅ | Key normalization, no scalar coercion |
|
|
46
|
+
| `schema do ... end` | ✅ | Symbol keys, no coercion |
|
|
47
|
+
| `required(:key)` | ✅ | |
|
|
48
|
+
| `optional(:key)` | ✅ | |
|
|
49
|
+
| `value(:type)` | ✅ | |
|
|
50
|
+
| `filled(:type)` / `filled` | ✅ | Nil and empty String/Array/Hash |
|
|
51
|
+
| `maybe(:type)` | ✅ | Params empty string becomes nil |
|
|
52
|
+
| `hash do ... end` | ✅ | Recursive |
|
|
53
|
+
| `array(:type)` | ✅ | Coerced primitive members |
|
|
54
|
+
| `array(:hash) { ... }` | ✅ | Nested member schema |
|
|
55
|
+
| External schema arguments | ✅ | Rust schemas only |
|
|
56
|
+
| Contract schema inheritance | ✅ | Child schema extends parent |
|
|
57
|
+
| Multiple schema declaration guard | ✅ | Raises `DuplicateSchemaError` |
|
|
58
|
+
| Key validation when declaring rules | ✅ | Common nested paths |
|
|
59
|
+
| Predicate-composition blocks | ✅ | Supported predicates only; boolean AST composition is unsupported |
|
|
60
|
+
| Schema `before` / `after` processor hooks | ✅ | `:value_coercer` only; callbacks run outside the native engine. Before hooks receive an isolated deep copy of the input. |
|
|
61
|
+
| Schema merge operators / AST access | ❌ | |
|
|
62
|
+
| `config.validate_keys = true` | ✅ | Rejects unknown keys in `params` and `json` schemas |
|
|
63
|
+
| Filtering DSL | ❌ | |
|
|
64
|
+
|
|
65
|
+
## Types and coercions
|
|
66
|
+
|
|
67
|
+
| Type | Params | JSON/schema checks | Notes |
|
|
68
|
+
| ------------------------------ | ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| `:any` | ✅ | ✅ | |
|
|
70
|
+
| `:nil` | N/A | ✅ | |
|
|
71
|
+
| `:bool` / `:true` / `:false` | ✅ | ✅ | `true/false`, `t/f`, `1/0`, `on/off`, `yes/no`, and `y/n` strings |
|
|
72
|
+
| `:integer` | ✅ | ✅ | Native signed-64-bit decimal, underscore, and `0x`/`0b`/`0o` paths; Ruby fallback preserves Bignums and unusual syntax |
|
|
73
|
+
| `:float` | ✅ | ✅ | Native finite decimal/scientific literals; Ruby fallback preserves overflow and other syntax; literal `Infinity` and `NaN` rejected |
|
|
74
|
+
| `:decimal` | ✅ | ✅ | Native finite `bigdecimal` parse followed by BigDecimal construction; Ruby fallback preserves arbitrary precision; infinities and NaN rejected |
|
|
75
|
+
| `:string` | ✅ | ✅ | No non-string-to-string coercion |
|
|
76
|
+
| `:symbol` | ✅ | ✅ | |
|
|
77
|
+
| `:array` / `:hash` | ✅ | ✅ | |
|
|
78
|
+
| `:date` | ✅ | ✅ | Native `YYYY-MM-DD`; Ruby ISO 8601 fallback for broader date syntax |
|
|
79
|
+
| `:date_time` / `:datetime` | ✅ | ✅ | Native whole-second RFC 3339 and timezone-free `YYYY-MM-DDTHH:MM:SS`; Ruby fallback otherwise |
|
|
80
|
+
| `:time` | ✅ | ✅ | Native RFC 3339; Ruby `Time.parse` fallback for time-only and broader syntax |
|
|
81
|
+
| dry-types objects/constructors | ✅ | ✅ | Ruby-owned fields call `#try`; conversion failures use the generic `is invalid` message |
|
|
82
|
+
| Sum types | ✅ | ✅ | Ruby-owned direct `value(type)` fields only |
|
|
83
|
+
| Enums/maps/intersections | ❌ | ❌ | |
|
|
84
|
+
| Params hash-to-array coercion | ❌ | N/A | |
|
|
85
|
+
|
|
86
|
+
The supported scalar corpus is differentially checked against the pinned
|
|
87
|
+
upstream version for numeric boundaries, boolean spellings, temporal parsing,
|
|
88
|
+
and symbols. This is a focused compatibility slice, not a claim of complete
|
|
89
|
+
`dry-types` coercion parity. Custom type objects are intentionally processed
|
|
90
|
+
by Ruby after native traversal; their conversion output is preserved, but
|
|
91
|
+
dry-schema's type-specific failure-message compilation and custom array-member
|
|
92
|
+
types remain unsupported.
|
|
93
|
+
|
|
94
|
+
## Schema predicates
|
|
95
|
+
|
|
96
|
+
| Predicate | Status | Owner |
|
|
97
|
+
| ----------------------------------- | ------ | ---------------------------------------------- |
|
|
98
|
+
| `gt?`, `gteq?`, `lt?`, `lteq?` | ✅ | Rust |
|
|
99
|
+
| `size?`, `min_size?`, `max_size?` | ✅ | Rust |
|
|
100
|
+
| `odd?`, `even?` | ✅ | Rust |
|
|
101
|
+
| `format?` | ✅ | Ruby Regexp |
|
|
102
|
+
| `included_in?`, `excluded_from?` | ✅ | Ruby |
|
|
103
|
+
| `eql?`, `not_eql?` | ✅ | Ruby |
|
|
104
|
+
| Arbitrary/custom predicate name | ❌ | Explicit error at execution |
|
|
105
|
+
| Boolean predicate AST composition | ❌ | Predicate blocks support sequential calls only |
|
|
106
|
+
| UUID and other dry-logic predicates | ❌ | |
|
|
107
|
+
|
|
108
|
+
Ruby-owned predicates execute after structural processing and are skipped when
|
|
109
|
+
the same field already has a type/structural error.
|
|
110
|
+
|
|
111
|
+
## Contract rules
|
|
112
|
+
|
|
113
|
+
| Surface | Status | Notes |
|
|
114
|
+
| ------------------------------------ | ------ | ------------------------------------------ |
|
|
115
|
+
| `rule(:key) { ... }` | ✅ | Ordered |
|
|
116
|
+
| Multi-key rules | ✅ | |
|
|
117
|
+
| Dot-string nested paths | ✅ | |
|
|
118
|
+
| Array path form | ✅ | |
|
|
119
|
+
| Simple/multi hash path form | ✅ | |
|
|
120
|
+
| Skip when schema dependency fails | ✅ | Prefix-aware |
|
|
121
|
+
| `value` / `values` | ✅ | |
|
|
122
|
+
| `key?` | ✅ | Nested and indexed paths |
|
|
123
|
+
| `key.failure` / `key(path).failure` | ✅ | |
|
|
124
|
+
| `base.failure` | ✅ | Base key is nil in `to_h` |
|
|
125
|
+
| Explicit String failures | ✅ | |
|
|
126
|
+
| Hash failure metadata | ✅ | |
|
|
127
|
+
| Symbol/localized failure identifiers | 🟡 | Small built-in fallback, no locale backend |
|
|
128
|
+
| `schema_error?` | ✅ | |
|
|
129
|
+
| `rule_error?` / `base_rule_error?` | ✅ | |
|
|
130
|
+
| `rule(:array).each` | ✅ | Provides `index:` |
|
|
131
|
+
| Rule context | ✅ | Hash rather than Concurrent::Map |
|
|
132
|
+
| Delegate to contract methods | ✅ | Includes private methods |
|
|
133
|
+
| Rule block exceptions | ✅ | Propagate as Ruby exceptions |
|
|
134
|
+
|
|
135
|
+
## Options and macros
|
|
136
|
+
|
|
137
|
+
| Surface | Status | Notes |
|
|
138
|
+
| ----------------------------------- | ------ | -------------------------------------------- |
|
|
139
|
+
| Required `option :name` | ✅ | |
|
|
140
|
+
| Optional option | ✅ | |
|
|
141
|
+
| Static/callable default | ✅ | Callable receives no instance |
|
|
142
|
+
| dry-auto_inject integration | 🟡 | Likely works through keywords; not certified |
|
|
143
|
+
| Global macros | ✅ | |
|
|
144
|
+
| Per-contract macros and inheritance | ✅ | |
|
|
145
|
+
| Macro arguments and `macro:` | ✅ | |
|
|
146
|
+
| `rule.validate` | ✅ | |
|
|
147
|
+
| Predicate-as-macro extension | 🟡 | Common numeric/size/format cases only |
|
|
148
|
+
| Full localized macro templates | ❌ | |
|
|
149
|
+
|
|
150
|
+
## Results and messages
|
|
151
|
+
|
|
152
|
+
| Surface | Status | Notes |
|
|
153
|
+
| ------------------------------------- | ------ | ---------------------------------------------------------------------- |
|
|
154
|
+
| `success?` / `failure?` | ✅ | |
|
|
155
|
+
| `to_h` / `[]` / `key?` / `values` | ✅ | |
|
|
156
|
+
| `errors.to_h` | ✅ | Nested hashes and integer indexes |
|
|
157
|
+
| Enumerable errors | ✅ | |
|
|
158
|
+
| Mutable `errors.messages` collection | 🟡 | Read-only view; mutate a set through `#add` |
|
|
159
|
+
| `errors[:path]` | ✅ | Prefix filter |
|
|
160
|
+
| `errors.filter(:base?)` | ✅ | Also `schema?` and `rule?` |
|
|
161
|
+
| `errors(full: true)` | 🟡 | Simple humanized paths |
|
|
162
|
+
| Error `text`, `path`, `meta`, `code` | ✅ | |
|
|
163
|
+
| Hash pattern matching | ✅ | |
|
|
164
|
+
| Tuple values/context pattern matching | ✅ | |
|
|
165
|
+
| YAML messages/configured load paths | ✅ | Supports localized schema templates and `%{token}` interpolation |
|
|
166
|
+
| I18n backend/locales | ✅ | Delegates to the optional `i18n` gem; add it to the application bundle |
|
|
167
|
+
| Exact upstream message wording | 🟡 | Common English messages only |
|
|
168
|
+
| Hints/info message extensions | ❌ | |
|
|
169
|
+
| Monads extension | ❌ | `load_extensions` raises explicitly |
|
|
170
|
+
|
|
171
|
+
## Runtime and platform
|
|
172
|
+
|
|
173
|
+
| Property | Status |
|
|
174
|
+
| ---------------- | ---------------------------------------- |
|
|
175
|
+
| CRuby 3.3 | ✅ compiled/tested |
|
|
176
|
+
| Ruby 3.4/current | 🟡 expected, not verified here |
|
|
177
|
+
| Linux x86-64 | ✅ |
|
|
178
|
+
| Linux arm64/musl | 🟡 source design supports it; not tested |
|
|
179
|
+
| macOS | 🟡 source design supports it; not tested |
|
|
180
|
+
| Windows | ❌ untested |
|
|
181
|
+
| JRuby | ❌ |
|
|
182
|
+
| TruffleRuby | ❌ |
|
|
183
|
+
| Ruby threads | ✅ call isolation tested |
|
|
184
|
+
| Ractors | ❌ no compatibility promise |
|
|
185
|
+
| GVL release | ❌ native Ruby-object path holds GVL |
|
|
186
|
+
|
|
187
|
+
## Migration guidance
|
|
188
|
+
|
|
189
|
+
1. Start with side-by-side mode and a small contract.
|
|
190
|
+
2. Build a fixture corpus from production-shaped valid and invalid payloads.
|
|
191
|
+
3. Run upstream and Rust contracts in separate processes.
|
|
192
|
+
4. Compare output values, classes, errors, paths, metadata, and exceptions.
|
|
193
|
+
5. Do not switch exact mode until every used feature is ✅ or explicitly
|
|
194
|
+
adapted.
|
|
195
|
+
6. Benchmark only after semantic parity.
|
|
196
|
+
|
|
197
|
+
Any unsupported feature should fail loudly. A silent approximation is treated
|
|
198
|
+
as a bug in this prototype.
|
data/docs/FEASIBILITY.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Feasibility study
|
|
2
|
+
|
|
3
|
+
## Executive conclusion
|
|
4
|
+
|
|
5
|
+
Yes: the declarative execution logic behind the common `dry-validation` path
|
|
6
|
+
can be implemented in Rust and exposed as another Ruby gem while preserving
|
|
7
|
+
most application-facing syntax.
|
|
8
|
+
|
|
9
|
+
No: a complete Rust-only rewrite cannot transparently preserve every behavior,
|
|
10
|
+
because contracts intentionally contain arbitrary Ruby code, Ruby objects,
|
|
11
|
+
custom dry-types, message backends, extension hooks, and dependency-injected
|
|
12
|
+
services. The sound target is a native schema engine with a Ruby compatibility
|
|
13
|
+
shell.
|
|
14
|
+
|
|
15
|
+
This prototype demonstrates that boundary with a compiled extension and
|
|
16
|
+
executable API, not just a design sketch.
|
|
17
|
+
|
|
18
|
+
## Material inspected
|
|
19
|
+
|
|
20
|
+
The study used public repositories directly without a GitHub connector, fork,
|
|
21
|
+
pull request, or other public mutation.
|
|
22
|
+
|
|
23
|
+
| Project | Inspected revision | Version in source | Role |
|
|
24
|
+
| -------------- | -------------------------------------------------------------- | ----------------- | ----------------------------------------------- |
|
|
25
|
+
| dry-validation | `e7dff1eddfa98a2bab3acd895535c29b1e0b294c` (2026-05-10) | 1.11.1 | Contract orchestration, rules, messages, result |
|
|
26
|
+
| dry-schema | `9e17659aa2fe6629f770b2d02703663f2330ea76` (2026-05-10) | 1.16.0 | Input processing, types, predicates, errors |
|
|
27
|
+
| dry-logic | `3326aefba07b6faa1535d823a22ef83dca92df85` (2026-05-10) | 1.6.0 | Predicate AST and operations |
|
|
28
|
+
| dry-types | `f47a790d6319759237a59247f74e1072ed5098d1` (2026-05-04) | 1.9.1 | Types, coercions, constraints |
|
|
29
|
+
| Magnus | `4e46772050e47cd6cd988fa935263cc5c583e388` plus released 0.8.2 | 0.8.2 used | High-level Rust/CRuby binding |
|
|
30
|
+
| rb-sys | `94b205cd8425ebdc09d5682f14503b3f3756fa9b` | 0.9.128 used | CRuby API and extension build |
|
|
31
|
+
|
|
32
|
+
At the time of inspection, the latest published `dry-validation` version was
|
|
33
|
+
1.11.1 (2025-01-21), while current main had already raised its Ruby requirement
|
|
34
|
+
to 3.3. `dry-schema` 1.16.0 was released on 2026-03-03 and current main also
|
|
35
|
+
required Ruby 3.3.
|
|
36
|
+
|
|
37
|
+
Primary references:
|
|
38
|
+
|
|
39
|
+
- https://github.com/dry-rb/dry-validation
|
|
40
|
+
- https://github.com/dry-rb/dry-schema
|
|
41
|
+
- https://hanakai.org/learn/dry/dry-validation/v1.11
|
|
42
|
+
- https://github.com/matsadler/magnus
|
|
43
|
+
- https://github.com/oxidize-rb/rb-sys
|
|
44
|
+
|
|
45
|
+
## What upstream actually does
|
|
46
|
+
|
|
47
|
+
`dry-validation` itself is relatively small. Its essential call sequence is:
|
|
48
|
+
|
|
49
|
+
1. assert that input is a Hash;
|
|
50
|
+
2. create the mutable per-call context;
|
|
51
|
+
3. call a `dry-schema` processor;
|
|
52
|
+
4. create a result from processed values and schema errors;
|
|
53
|
+
5. run declared rules in order;
|
|
54
|
+
6. skip a rule when any declared dependency failed schema processing;
|
|
55
|
+
7. resolve rule failures through the message backend;
|
|
56
|
+
8. freeze and return the result.
|
|
57
|
+
|
|
58
|
+
The larger surface lives under `dry-schema`. Its processor has four core
|
|
59
|
+
stages:
|
|
60
|
+
|
|
61
|
+
1. key coercion and selection;
|
|
62
|
+
2. optional pre-coercion filtering;
|
|
63
|
+
3. value coercion through dry-types;
|
|
64
|
+
4. application of a dry-logic predicate tree and message compilation.
|
|
65
|
+
|
|
66
|
+
This dependency map matters. Rewriting only the thin `Contract#call` loop would
|
|
67
|
+
probably make the system slower after native-boundary overhead. The schema
|
|
68
|
+
processor, type/coercion work, and predicate traversal are the meaningful
|
|
69
|
+
native target.
|
|
70
|
+
|
|
71
|
+
## Chosen boundary
|
|
72
|
+
|
|
73
|
+
### Compiled into Rust
|
|
74
|
+
|
|
75
|
+
- schema mode and field topology;
|
|
76
|
+
- required/optional presence;
|
|
77
|
+
- primitive/member/nested type descriptions;
|
|
78
|
+
- nullable and filled semantics;
|
|
79
|
+
- key lookup and Params/JSON normalization;
|
|
80
|
+
- Params scalar coercion;
|
|
81
|
+
- recursive hash and array processing;
|
|
82
|
+
- output filtering;
|
|
83
|
+
- native numeric and size predicates;
|
|
84
|
+
- structural error paths and codes.
|
|
85
|
+
|
|
86
|
+
The Ruby DSL emits JSON only once, when the contract class is defined. Rust
|
|
87
|
+
deserializes it into an immutable typed plan wrapped as a Ruby typed-data
|
|
88
|
+
object. Individual calls do not parse the DSL or plan again.
|
|
89
|
+
|
|
90
|
+
### Kept in Ruby
|
|
91
|
+
|
|
92
|
+
- class definition and inheritance;
|
|
93
|
+
- arbitrary `rule` blocks;
|
|
94
|
+
- injected repository/client/service objects;
|
|
95
|
+
- global and class macros;
|
|
96
|
+
- rule context;
|
|
97
|
+
- Ruby Regexp matching;
|
|
98
|
+
- collection inclusion and Ruby `eql?` semantics;
|
|
99
|
+
- result/message compatibility objects.
|
|
100
|
+
|
|
101
|
+
These are not accidental leftovers. Ruby is the correct semantic owner for
|
|
102
|
+
behavior that can invoke arbitrary Ruby methods.
|
|
103
|
+
|
|
104
|
+
## Alternatives considered
|
|
105
|
+
|
|
106
|
+
### 1. Full Rust rewrite with a new Rust-only rule language
|
|
107
|
+
|
|
108
|
+
This could release the GVL and maximize native execution, but would break the
|
|
109
|
+
most valuable compatibility property. Existing rule blocks and injected Ruby
|
|
110
|
+
objects would need rewriting. Rejected as the primary migration path.
|
|
111
|
+
|
|
112
|
+
### 2. Rust rewrite of only `Contract#call`
|
|
113
|
+
|
|
114
|
+
The loop is small and mostly dispatches Ruby objects. Native crossings would
|
|
115
|
+
dominate. Rejected because it targets the wrong hot path.
|
|
116
|
+
|
|
117
|
+
### 3. Keep upstream dry-schema and move only rules to Rust
|
|
118
|
+
|
|
119
|
+
Rules are arbitrary Ruby and often perform I/O, so they are the least
|
|
120
|
+
mechanically translatable part. Rejected.
|
|
121
|
+
|
|
122
|
+
### 4. Compile declarative schema, retain Ruby rules
|
|
123
|
+
|
|
124
|
+
This preserves familiar contracts and moves repeated structural work to Rust.
|
|
125
|
+
Chosen and implemented.
|
|
126
|
+
|
|
127
|
+
## Performance expectations
|
|
128
|
+
|
|
129
|
+
Rust is not automatically faster at a Ruby boundary.
|
|
130
|
+
|
|
131
|
+
Likely wins:
|
|
132
|
+
|
|
133
|
+
- wide or deeply nested payloads;
|
|
134
|
+
- large arrays of homogeneous members;
|
|
135
|
+
- repeated calls through a reused contract;
|
|
136
|
+
- coercion/type-heavy schemas;
|
|
137
|
+
- fewer temporary Ruby executor/AST objects per call.
|
|
138
|
+
|
|
139
|
+
Likely neutral or negative cases:
|
|
140
|
+
|
|
141
|
+
- one- or two-field schemas;
|
|
142
|
+
- contracts dominated by Ruby rule blocks;
|
|
143
|
+
- rules dominated by database/network calls;
|
|
144
|
+
- workloads where message localization is the main cost;
|
|
145
|
+
- one-shot schemas where compilation cannot amortize.
|
|
146
|
+
|
|
147
|
+
The current engine still manipulates Ruby objects and therefore retains the
|
|
148
|
+
GVL. It is thread-safe for concurrent Ruby calls, but it does not execute Ruby
|
|
149
|
+
object access in parallel. A future batch API could copy supported input into
|
|
150
|
+
Rust-owned values, release the GVL, validate, then materialize results. That
|
|
151
|
+
must be benchmarked against copy/serialization cost.
|
|
152
|
+
|
|
153
|
+
No performance claim should be published until a parity corpus and comparative
|
|
154
|
+
benchmarks against upstream are available.
|
|
155
|
+
|
|
156
|
+
## Feasibility by feature family
|
|
157
|
+
|
|
158
|
+
| Family | Feasibility | Reason |
|
|
159
|
+
| ---------------------------- | ---------------------- | ------------------------------------------------ |
|
|
160
|
+
| Key coercion/filtering | High | Deterministic tree processing |
|
|
161
|
+
| Built-in scalar coercion | High | Clear conversion table |
|
|
162
|
+
| Nested hashes/arrays | High | Natural typed Rust plan |
|
|
163
|
+
| Built-in predicates | High | Deterministic operations |
|
|
164
|
+
| Error paths/codes | High | Native structured accumulation |
|
|
165
|
+
| Ruby rule blocks | Hybrid only | Arbitrary Ruby |
|
|
166
|
+
| Injected dependencies | Ruby | Arbitrary object protocols and I/O |
|
|
167
|
+
| Custom dry-types | Medium/low | Constructor may be arbitrary Ruby |
|
|
168
|
+
| Custom predicates | Medium | Callback preserves behavior but not native speed |
|
|
169
|
+
| YAML messages | Medium | Reimplementable with parity work |
|
|
170
|
+
| I18n backend | Medium | Best delegated to Ruby initially |
|
|
171
|
+
| Extensions/monads/hints | Medium | Separate compatibility adapters |
|
|
172
|
+
| Processor before/after hooks | Ruby callback | Arbitrary transforms |
|
|
173
|
+
| JRuby/TruffleRuby | Low for this extension | Magnus/rb-sys targets CRuby |
|
|
174
|
+
|
|
175
|
+
## Legal and project identity
|
|
176
|
+
|
|
177
|
+
The inspected dry-rb projects use the MIT license, which permits use,
|
|
178
|
+
modification, distribution, sublicensing, and sale subject to retaining the
|
|
179
|
+
copyright/license notice in copied or substantial source portions.
|
|
180
|
+
|
|
181
|
+
This prototype is an independent implementation of a public interface and does
|
|
182
|
+
not copy upstream implementation source. It uses:
|
|
183
|
+
|
|
184
|
+
- a distinct gem name;
|
|
185
|
+
- an explicit non-affiliation notice;
|
|
186
|
+
- its own MIT license;
|
|
187
|
+
- upstream references and notices in `NOTICE.md`.
|
|
188
|
+
|
|
189
|
+
If future work copies upstream tests or implementation, preserve their license
|
|
190
|
+
headers/notices and track provenance per file. Before a public release, also
|
|
191
|
+
ask the maintainers whether they have naming or ecosystem-integration
|
|
192
|
+
preferences. That is a relationship and trademark courtesy, not a condition
|
|
193
|
+
imposed by the MIT license.
|
|
194
|
+
|
|
195
|
+
## Final determination
|
|
196
|
+
|
|
197
|
+
The project is technically viable. The prototype proves:
|
|
198
|
+
|
|
199
|
+
- a real CRuby/Rust extension can own an immutable schema plan;
|
|
200
|
+
- the normal contract syntax can be kept exactly in replacement mode;
|
|
201
|
+
- arbitrary Ruby rules can operate on natively coerced output;
|
|
202
|
+
- nested error paths and rule-skipping semantics can be retained;
|
|
203
|
+
- a safe namespace can coexist for migration tests.
|
|
204
|
+
|
|
205
|
+
Production viability remains unproven until the compatibility corpus,
|
|
206
|
+
message/configuration surfaces, packaging matrix, fuzzing, and comparative
|
|
207
|
+
benchmarks described in `ARCHITECTURE.md` are completed.
|