Skip to content

refactor: implement new validation engine infrastructure#1397

Merged
h2zi merged 11 commits intomasterfrom
haozi/validation-core
Mar 16, 2026
Merged

refactor: implement new validation engine infrastructure#1397
h2zi merged 11 commits intomasterfrom
haozi/validation-core

Conversation

@h2zi
Copy link
Contributor

@h2zi h2zi commented Mar 7, 2026

Replace gookit/validate with a native validation engine that is fully compatible with Laravel's validation API. This PR introduces the core infrastructure without builtin rule/filter implementations and some tests (to be added in a follow-up PR).

Key changes:

  • New DataBag for unified data access (JSON, form, multipart, query)
  • New Engine as the core validation orchestrator
  • New RuleParser for parsing rule strings and slice syntax
  • New filter infrastructure with custom filter support via reflection
  • New message system with type-specific error templates
  • Contracts updated: map[string]string -> map[string]any for rules/filters
  • Remove gookit/validate dependency

📑 Description

Closes https://github.com/goravel/goravel/issues/

Summary by CodeRabbit

Release Notes

  • New Features

    • Added comprehensive validation engine with wildcard rule expansion and cross-field value tracking.
    • Introduced flexible data handling supporting nested access via dot notation and HTTP request ingestion with multipart/form data support.
    • Added Validated() method to expose validated field data post-validation.
  • Improvements

    • Validation rules and filters now accept flexible data types for enhanced extensibility.
    • Enhanced error reporting with additional error type coverage.
  • Removals

    • Removed external validation library dependency for improved maintainability.

✅ Checks

  • Added test cases for my code

Replace gookit/validate with a native validation engine that is fully
compatible with Laravel's validation API. This PR introduces the core
infrastructure without builtin rule/filter implementations (to be added
in a follow-up PR).

Key changes:
- New DataBag for unified data access (JSON, form, multipart, query)
- New Engine as the core validation orchestrator
- New RuleParser for parsing rule strings and slice syntax
- New filter infrastructure with custom filter support via reflection
- New message system with type-specific error templates
- Contracts updated: map[string]string -> map[string]any for rules/filters
- Remove gookit/validate dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@h2zi h2zi requested a review from a team as a code owner March 7, 2026 10:12
@codecov
Copy link

codecov bot commented Mar 7, 2026

Codecov Report

❌ Patch coverage is 64.70588% with 312 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.95%. Comparing base (0cb7658) to head (a71205f).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
validation/filters.go 0.00% 99 Missing ⚠️
validation/validation.go 0.00% 76 Missing ⚠️
validation/engine.go 67.47% 63 Missing and 4 partials ⚠️
validation/utils.go 85.92% 21 Missing and 8 partials ⚠️
validation/validator.go 0.00% 26 Missing ⚠️
validation/data_bag.go 88.54% 7 Missing and 4 partials ⚠️
validation/options.go 85.00% 3 Missing ⚠️
validation/errors.go 96.42% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1397      +/-   ##
==========================================
- Coverage   67.18%   65.95%   -1.24%     
==========================================
  Files         348      353       +5     
  Lines       25609    26083     +474     
==========================================
- Hits        17206    17203       -3     
- Misses       7674     8140     +466     
- Partials      729      740      +11     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

hwbrzzl

This comment was marked as outdated.

hwbrzzl

This comment was marked as outdated.

@hwbrzzl
Copy link
Contributor

hwbrzzl commented Mar 7, 2026

Thanks, awesome. Could you list the break changes in the PR description? And list the to do items as well.

@hwbrzzl
Copy link
Contributor

hwbrzzl commented Mar 7, 2026

@CodeRabbit review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 7, 2026

📝 Walkthrough

Walkthrough

This PR refactors the validation system from an external library dependency (gookit/validate) to an internal implementation. Rule and filter types shift from map[string]string to map[string]any. New infrastructure includes DataBag for flexible data handling with dot-notation access, a validation Engine for rule evaluation and expansion, and comprehensive filter/message/error management.

Changes

Cohort / File(s) Summary
Contract interface updates
contracts/http/request.go, contracts/validation/validation.go
Updated Validate, Rules, and Filters methods to accept/return map[string]any instead of map[string]string. Introduced new Options struct with Filters, CustomFilters, Messages, Attributes, PrepareForValidation, and MaxMultipartMemory fields. Added Validated() and All() methods to expose validated data.
HTTP stubs and mock implementations
http/console/stubs.go, mocks/http/ContextRequest.go, mocks/http/FormRequest.go, mocks/http/FormRequestWithFilters.go
Updated DummyRequest and mock method signatures to return map[string]interface{} for Filters and Rules. Adjusted mock invocation and return handling to reflect new type signatures across multiple call wrappers.
Validation interface mocks
mocks/validation/Data.go, mocks/validation/Option.go, mocks/validation/Validation.go, mocks/validation/Validator.go
Added All() and Validated() mock methods; updated Option to work with *validation.Options; updated Make to accept rules as map[string]interface{}; extended mock call types with Run and RunAndReturn helpers.
Dependency and error definitions
go.mod, errors/list.go, tests/go.mod
Removed gookit/validate and related dependencies; bumped golang.org/x/net to v0.51.0; added ValidationInvalidFilterType and ValidationInvalidRuleType error definitions.
DataBag implementation
validation/data_bag.go
New comprehensive data handling with constructor supporting maps, URL values, structs, and HTTP requests. Implements Get/Set/Has/All/Keys with dot-notation nested access, caching, and wildcard expansion for arrays and nested fields.
Validation engine and rule processing
validation/engine.go, validation/rule_parser.go, validation/rules.go
New validation orchestration engine handling rule expansion, field-level validation, control flow (bail/nullable/sometimes), exclusion logic, distinct value tracking, and error formatting. Rule parser supports pipe-separated rules with parameters and escape sequences; RuleContext carries evaluation state.
Filter system and message handling
validation/filters.go, validation/messages.go
New filter application system with reflection-based custom filter dispatch; built-in filter support; message resolution with type-specific variants (size.string, min.file); placeholder formatting with optimization for common cases.
Error and validator refactoring
validation/errors.go, validation/errors_test.go, validation/validator.go, validation/validator_test.go
Replaced external Errors wrapper with internal map-based storage (messages map[string]map[string]string); simplified Errors API with Add/All/Get/Has/IsEmpty methods. Refactored Validator to use DataBag and simplified Bind logic. Removed large test suite; added focused unit tests for error handling.
Options and validation core
validation/options.go, validation/options_test.go, validation/validation.go, validation/utils.go
Centralized option handling through *validation.Options struct with helper functions (Filters, Messages, Attributes, CustomFilters, MaxMultipartMemory, PrepareForValidation). Refactored Make to build DataBag, apply options, parse rules, and orchestrate validation via Engine. Added utility helpers for empty-value detection and attribute type inference. Removed old validation/data.go wrapper.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Validation
    participant DataBag
    participant Engine
    participant Rules
    participant ErrorBag

    Client->>Validation: Make(ctx, data, rules, options)
    Validation->>DataBag: NewDataBag/NewDataBagFromRequest
    DataBag-->>Validation: *DataBag instance
    
    Validation->>Validation: Parse rules (ParseRules)
    Validation->>Engine: NewEngine(ctx, data, parsedRules, options)
    Engine-->>Validation: *Engine instance
    
    Validation->>Engine: Validate()
    
    Engine->>Engine: expandWildcardRules
    Engine->>Engine: validateField (for each field)
    
    Engine->>Rules: executeRule
    Rules->>DataBag: Get value
    DataBag-->>Rules: field value
    Rules-->>Engine: validation result
    
    alt Validation fails
        Engine->>ErrorBag: Add error
    end
    
    Engine->>Engine: ValidatedData()
    Engine-->>Validation: *Errors
    
    Validation-->>Client: Validator instance with errors
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 The rules now dance in maps so vast,
From strings we freed, to any at last!
A DataBag holds nested keys,
The Engine validates with gentle ease,
No library chains—just fluffy hops and pleas! 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor: implement new validation engine infrastructure' clearly and concisely summarizes the main change—implementing a new native validation engine to replace gookit/validate, which is the primary objective of this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch haozi/validation-core

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
mocks/validation/Option.go (1)

10-26: ⚠️ Potential issue | 🟠 Major

Remove or fix the invalid mock generation for Option function type.

The contract defines Option as a function type (type Option func(*Options), line 14 in contracts/validation/validation.go), not an interface. The generated mock implements it as a struct with an Execute method, which cannot satisfy the function type expected by the validation code.

The .mockery.yaml configuration's all: True setting causes mockery to generate mocks for non-interface types like function aliases. Either exclude function types from mock generation or configure mockery to only mock actual interfaces. This mock struct is not used anywhere in the codebase and represents dead code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mocks/validation/Option.go` around lines 10 - 26, The generated mock for the
function type Option is invalid: Option is declared as a function alias (type
Option func(*Options)) in contracts/validation/validation.go but
mocks/validation/Option.go contains a struct Option with method Execute, which
cannot satisfy a function type; fix by removing this mock file or reconfiguring
mock generation to exclude non-interface types (adjust .mockery.yaml to not use
all: true or add an exclude for function aliases), and if a mock is still needed
create a proper function-typed mock helper (or wrap it with a variable of type
Option that calls a mocked function) instead of the struct
Option/Option_Expecter/Execute pattern so code expecting
Option(func(*validation.Options)) compiles.
🧹 Nitpick comments (6)
validation/errors_test.go (1)

9-87: Use a testify/suite for these unit tests.

The coverage is fine, but the repo’s unit-test convention prefers suites over standalone test functions.

As per coding guidelines "Co-locate unit tests with source files using *_test.go naming convention and testify suite and mock libraries".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@validation/errors_test.go` around lines 9 - 87, Convert the standalone table
tests into a testify/suite-based test file: create a test suite type (e.g.,
ErrorsTestSuite) that embeds suite.Suite, move the setup (instantiating
NewErrors) into a SetupTest method if needed, and convert each TestErrors_*
function into a suite method (e.g., TestOne, TestGet, TestAll, TestHas,
TestIsEmpty) that uses s.Require()/s.Assert() instead of t; register the suite
with suite.Run in a single TestErrorsSuite entrypoint. Ensure all assertions
still exercise NewErrors and its methods Add, One, Get, All, Has, and IsEmpty
and that the file keeps the *_test.go naming convention and imports
testify/suite.
validation/options_test.go (1)

12-111: Use a testify/suite for these option unit tests.

The test coverage is fine, but the repository test guidelines prefer suites for unit tests.

As per coding guidelines "Co-locate unit tests with source files using *_test.go naming convention and testify suite and mock libraries".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@validation/options_test.go` around lines 12 - 111, Convert the standalone
unit tests into a testify suite: create a suite struct (e.g., OptionsTestSuite)
that embeds suite.Suite, move the logic from TestFiltersOption,
TestCustomFiltersOption, TestMessagesOption, TestAttributesOption,
TestPrepareForValidationOption and the subtests in TestApplyOptions into suite
methods (e.g., TestFilters, TestCustomFilters, TestMessages, TestAttributes,
TestPrepareForValidation, TestApplyOptions) that use
suite.Require()/suite.Assert(), and add a top-level TestOptionsTestSuite
function that calls suite.Run(t, new(OptionsTestSuite)); keep assertions the
same and reference existing helpers/constructors (Filters, CustomFilters,
Messages, Attributes, PrepareForValidation, applyOptions) inside the suite
methods.
validation/rule_parser_test.go (1)

9-401: Use a testify/suite for the parser unit tests.

The cases are useful, but the repository test guidelines prefer suites for unit tests.

As per coding guidelines "Co-locate unit tests with source files using *_test.go naming convention and testify suite and mock libraries".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@validation/rule_parser_test.go` around lines 9 - 401, Replace the
free-standing test functions (TestParseRules, TestParseRuleSlice,
TestSplitRules, TestExtractRuleName, TestParseOneRule, TestSplitParameters) with
a testify suite: define a ParserTestSuite struct embedding suite.Suite, convert
each Test* function into a method on *ParserTestSuite (e.g., func (s
*ParserTestSuite) TestParseRules() { ... } ) using s.Equal / s.Require as
assertions, and add a top-level TestParserTestSuite(t *testing.T) that calls
suite.Run(t, new(ParserTestSuite)); keep the existing test cases and helper
calls (ParseRules, ParseRuleSlice, splitRules, extractRuleName, parseOneRule,
splitParameters) intact inside the suite methods and import
github.com/stretchr/testify/suite.
contracts/http/request.go (1)

97-113: Clarify the allowed map[string]any value shapes in the contract.

This public API now accepts map[string]any, but the new validation errors only describe string and []string as valid rule/filter values. Without that constraint here, callers can satisfy the type checker and still hit runtime type errors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/http/request.go` around lines 97 - 113, The contract currently
accepts map[string]any but doesn't specify allowed value shapes, which lets
callers pass unsupported types and causes runtime validation errors; update the
doc comments for Validate, ValidateRequest, FormRequest.Rules and
FormRequestWithFilters.Filters to explicitly state that map values must be
either string or []string (the shapes the validator supports) and callers must
use those shapes (or a clearly documented nested rule structure if you support
it) so the type checker plus the docs prevent runtime type errors; reference the
methods Validate, ValidateRequest and the interfaces FormRequest and
FormRequestWithFilters in the comments to make the constraint explicit.
validation/filters.go (2)

123-137: Extra parameters silently dropped for non-variadic filter functions.

When more parameters are provided than the function accepts, they are silently ignored (line 127-129). This is acceptable behavior, but consider documenting this in the function comment for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@validation/filters.go` around lines 123 - 137, Update the function comment
for the filter invocation logic to explicitly state that when calling a
non-variadic filter, any extra parameters in params beyond the function's
declared inputs are silently ignored; mention the behavior seen in the loop that
checks fnType.NumIn(), uses argIdx, calls convertToType and appends to args, and
breaks when argIdx >= fnType.NumIn(). Make the comment concise and placed above
the function (or the relevant block) so callers/readers know that extra params
are dropped for non-variadic filter functions.

72-72: Consider handling bag.Set error.

The error from bag.Set is discarded. While unlikely to fail for in-memory operations, ignoring errors makes debugging harder if the DataBag implementation changes.

♻️ Proposed fix
-		_ = bag.Set(field, val)
+		if err := bag.Set(field, val); err != nil {
+			return fmt.Errorf("failed to set filtered value for %s: %w", field, err)
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@validation/filters.go` at line 72, The discarded error from the call `_ =
bag.Set(field, val)` should be handled: capture the returned error from
`bag.Set(field, val)` and either return it to the caller or wrap it with context
(e.g. using fmt.Errorf) and return, or log it with the function's logger if the
surrounding function cannot return errors; include `field` and `val` in the
error context to aid debugging. Locate the occurrence of `bag.Set`, replace the
discard with error handling (`err := bag.Set(field, val)` ...), and ensure the
surrounding function's signature and control flow propagate or surface the error
appropriately.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@validation/data_bag.go`:
- Around line 79-133: The handler in validation/data_bag.go is swallowing
request parse errors (io.ReadAll, supportjson.Unmarshal, r.ParseMultipartForm,
r.ParseForm) causing truncated or partial data to be validated; update the JSON
branch (strings.HasPrefix(contentType, "application/json")) to return the
read/unmarshal error instead of returning bag, nil when io.ReadAll or
supportjson.Unmarshal fail, and update the multipart and urlencoded branches to
return the parse error when r.ParseMultipartForm or r.ParseForm return non-nil
errors (i.e., do not ignore those errors in the multipart/form-data and
application/x-www-form-urlencoded cases); keep restoring r.Body only after a
successful read. Ensure the function returns (bag, err) consistently so
validation.Make can propagate parse failures.
- Around line 206-227: struct-backed nested values are left as raw Go types so
dotGet and collectKeys only see map[string]any, []any and []map[string]any and
therefore fail for nested structs, typed slices or maps; fix by recursively
normalizing nested values in structToMap (or adding reflective traversal used by
structToMap) so that any nested struct becomes map[string]any, any typed slice
becomes []any and any typed map becomes map[string]any before returning; update
structToMap to call a helper normalizeValue (or inline reflection) for fields,
and ensure dotGet and collectKeys continue to operate on the normalized
map[string]any / []any forms (also apply the same normalization logic to places
where structToMap output is consumed).

In `@validation/engine.go`:
- Around line 196-208: The current branch for unknown rules returns false which
treats typos/unregistered rules as normal validation failures; change this to
propagate an internal error instead: when rule.Name is not found in
builtinRules, return an explicit error (e.g., fmt.Errorf("unknown rule: %s",
rule.Name)) from the rule evaluation function rather than false, and update the
function signature and its callers (the methods that invoke this block and
consume its result) to accept and propagate that error so it surfaces as a
validator configuration/internal error (not added to the error bag). Ensure
references to builtinRules, rule.Name, e.ruleCtx remain intact and that callers
handle the new error return path.
- Around line 109-147: Run all exclude_* rules in a pre-pass before executing
any non-control validation rules to avoid order-dependency; specifically, after
collecting control rules (bail/nullable/sometimes) and after the "Sometimes"
presence check, iterate fieldRules and for each rule where
excludeRules[rule.Name] is true call e.handleExcludeRule(field, rule) and if
e.isExcluded(field) return/skip validating this field entirely. Remove or skip
exclude rule handling from the later per-rule loop so only non-exclude rules run
there.
- Around line 91-96: The expandWildcardRules method must merge expanded rule
slices instead of letting later entries overwrite earlier ones and must drop any
leftover unmatched wildcard entries; change Engine.expandWildcardRules to call
expandWildcardFields(e.rules, e.data.Keys(), true), iterate its result and for
each key append/merge the []ParsedRule into e.expandedRules (instead of
assigning/replacing), and skip any returned keys that still contain a wildcard
(e.g., strings.Contains(key, "*")) so unmatched wildcard paths are not kept.

In `@validation/errors.go`:
- Around line 5-7: The Errors type currently uses unordered maps (messages
map[string]map[string]string) so Errors.One and One(field) return
non-deterministic results; change the storage to preserve insertion order (e.g.,
messages map[string][]string or keep an ordered slice of field keys plus
per-field ordered slice of messages) and update all methods that add/read errors
(e.g., the constructor, Add/Append methods, One, All, and Any) to push messages
into the ordered lists and iterate that order when returning One() or One(field)
so the first-added message is deterministic.

In `@validation/filters.go`:
- Around line 63-69: The current block in applyFilters silently ignores errors
from callFilterFunc when applying customFilterMap[pf.Name]; change this so any
non-nil err from callFilterFunc is returned (with contextual info including
pf.Name) instead of swallowing it, and update applyFilters' signature/Callers if
necessary to propagate the error upward; alternatively, if you prefer to keep
processing, log the error via the existing logger and continue — but do not
ignore the error silently from callFilterFunc in the custom filter handling.

In `@validation/rule_parser_test.go`:
- Around line 60-65: The test shows the parser incorrectly consuming the rest of
the rule string after a regex/not_regex (e.g.,
"required|regex:^(foo|bar)$|string") making "string" part of the regex; update
the rule parser logic (the code that parses rules into ParsedRule entries) so
that when encountering "regex" or "not_regex" it does not silently swallow
trailing pipe-delimited rules — either: 1) reject the ambiguous form and return
an error for patterns containing unescaped/unquoted pipes after regex/not_regex,
or 2) require an explicit slice/array parameter syntax for regex/not_regex;
change the parser branch that handles rule names "regex" and "not_regex" to
detect and enforce the chosen behavior and adjust the tests (ParsedRule
expectations) accordingly to assert rejection or the slice format instead of
consuming the remainder.

In `@validation/rule_parser.go`:
- Around line 60-72: The parser in rule_parser.go currently treats any rule
identified as "regex" or "not_regex" by appending the rest of ruleString into
the same rule (using current.WriteByte and current.WriteString), which swallows
subsequent rules like "max:10"; update the logic in the parsing loop (the branch
that checks ruleName via extractRuleName(rule)) so that instead of
unconditionally consuming the remainder it (a) detects a properly delimited
regex parameter (e.g., a starting delimiter and a matching end delimiter or
explicit anchors) and only consumes up to that delimiter, or (b) if no clear
delimiter/end is found, rejects the ambiguous single-string form with a parse
error instructing callers to supply rules as a []string when the regex contains
pipes; ensure this change is made where ruleString, current, and extractRuleName
are used so subsequent rules are not swallowed.

In `@validation/utils.go`:
- Around line 29-48: getAttributeType currently only returns "numeric" when
numericRuleNames[r.Name] is true (and numericRuleNames is initialized empty), so
numeric runtime values slip through as "string"; update getAttributeType to also
inspect the runtime value kind when no numeric rule is present: if the value's
reflected kind is one of int/int8/.../uint/uint8/.../float32/float64 (or a
pointer/nullable to those) return "numeric". Reference getAttributeType,
numericRuleNames and the rules[attribute] loop to add this fallback check before
returning "string".
- Around line 14-23: The isValueEmpty function only checks concrete types
(string, []any, []string, map[string]any) so typed slices/maps (e.g., []int,
[]MyStruct, map[string]string) are treated as non-empty; update isValueEmpty to
use reflection: detect if val is nil, if it's a string trim and check empty, and
for any kind.Kind() == reflect.Slice || reflect.Array || reflect.Map return true
when reflect.Value.Len() == 0; keep existing cases for direct string handling
but replace the concrete []any/[]string/map checks with the generic
reflect-based length check so required/filled works for typed collections.

In `@validation/validation.go`:
- Around line 107-116: The new code routes all validation through NewEngine
(constructed with NewEngine(ctx, bag, parsedRules, engineOptions{...})) but
NewEngine currently lacks builtin rule/filter parity and returns false for
unknown rules, causing regressions; revert to preserving the old backend as a
fallback or gate the NewEngine path behind a feature flag: detect the flag (or
absence of builtinRules) before calling NewEngine, and if not enabled, call the
previous Make/engine creation path that produced the prior
errorBag/validatedData, otherwise use NewEngine and continue to call
engine.Validate() and engine.ValidatedData() and then return NewValidator(bag,
errorBag, validatedData). Ensure the check references NewEngine,
engine.Validate, engine.ValidatedData and NewValidator so the fallback/flag is
applied exactly where the switch currently happens.

In `@validation/validator.go`:
- Around line 33-42: The merge currently copies v.validated entries directly
into a flat map built from v.data.All(), which leaves dotted keys like
"user.name" as literal keys and doesn't update nested structures for Bind();
instead iterate v.validated and apply each entry through the DataBag.Set method
(or reconstruct a nested map by splitting dot-paths and creating nested maps) so
nested fields overwrite correctly, then decode merged.All() (or the rebuilt map)
into the target; update the merge logic that uses v.data.All() and v.validated
to call DataBag.Set (or build nested maps) before decoding.

---

Outside diff comments:
In `@mocks/validation/Option.go`:
- Around line 10-26: The generated mock for the function type Option is invalid:
Option is declared as a function alias (type Option func(*Options)) in
contracts/validation/validation.go but mocks/validation/Option.go contains a
struct Option with method Execute, which cannot satisfy a function type; fix by
removing this mock file or reconfiguring mock generation to exclude
non-interface types (adjust .mockery.yaml to not use all: true or add an exclude
for function aliases), and if a mock is still needed create a proper
function-typed mock helper (or wrap it with a variable of type Option that calls
a mocked function) instead of the struct Option/Option_Expecter/Execute pattern
so code expecting Option(func(*validation.Options)) compiles.

---

Nitpick comments:
In `@contracts/http/request.go`:
- Around line 97-113: The contract currently accepts map[string]any but doesn't
specify allowed value shapes, which lets callers pass unsupported types and
causes runtime validation errors; update the doc comments for Validate,
ValidateRequest, FormRequest.Rules and FormRequestWithFilters.Filters to
explicitly state that map values must be either string or []string (the shapes
the validator supports) and callers must use those shapes (or a clearly
documented nested rule structure if you support it) so the type checker plus the
docs prevent runtime type errors; reference the methods Validate,
ValidateRequest and the interfaces FormRequest and FormRequestWithFilters in the
comments to make the constraint explicit.

In `@validation/errors_test.go`:
- Around line 9-87: Convert the standalone table tests into a
testify/suite-based test file: create a test suite type (e.g., ErrorsTestSuite)
that embeds suite.Suite, move the setup (instantiating NewErrors) into a
SetupTest method if needed, and convert each TestErrors_* function into a suite
method (e.g., TestOne, TestGet, TestAll, TestHas, TestIsEmpty) that uses
s.Require()/s.Assert() instead of t; register the suite with suite.Run in a
single TestErrorsSuite entrypoint. Ensure all assertions still exercise
NewErrors and its methods Add, One, Get, All, Has, and IsEmpty and that the file
keeps the *_test.go naming convention and imports testify/suite.

In `@validation/filters.go`:
- Around line 123-137: Update the function comment for the filter invocation
logic to explicitly state that when calling a non-variadic filter, any extra
parameters in params beyond the function's declared inputs are silently ignored;
mention the behavior seen in the loop that checks fnType.NumIn(), uses argIdx,
calls convertToType and appends to args, and breaks when argIdx >=
fnType.NumIn(). Make the comment concise and placed above the function (or the
relevant block) so callers/readers know that extra params are dropped for
non-variadic filter functions.
- Line 72: The discarded error from the call `_ = bag.Set(field, val)` should be
handled: capture the returned error from `bag.Set(field, val)` and either return
it to the caller or wrap it with context (e.g. using fmt.Errorf) and return, or
log it with the function's logger if the surrounding function cannot return
errors; include `field` and `val` in the error context to aid debugging. Locate
the occurrence of `bag.Set`, replace the discard with error handling (`err :=
bag.Set(field, val)` ...), and ensure the surrounding function's signature and
control flow propagate or surface the error appropriately.

In `@validation/options_test.go`:
- Around line 12-111: Convert the standalone unit tests into a testify suite:
create a suite struct (e.g., OptionsTestSuite) that embeds suite.Suite, move the
logic from TestFiltersOption, TestCustomFiltersOption, TestMessagesOption,
TestAttributesOption, TestPrepareForValidationOption and the subtests in
TestApplyOptions into suite methods (e.g., TestFilters, TestCustomFilters,
TestMessages, TestAttributes, TestPrepareForValidation, TestApplyOptions) that
use suite.Require()/suite.Assert(), and add a top-level TestOptionsTestSuite
function that calls suite.Run(t, new(OptionsTestSuite)); keep assertions the
same and reference existing helpers/constructors (Filters, CustomFilters,
Messages, Attributes, PrepareForValidation, applyOptions) inside the suite
methods.

In `@validation/rule_parser_test.go`:
- Around line 9-401: Replace the free-standing test functions (TestParseRules,
TestParseRuleSlice, TestSplitRules, TestExtractRuleName, TestParseOneRule,
TestSplitParameters) with a testify suite: define a ParserTestSuite struct
embedding suite.Suite, convert each Test* function into a method on
*ParserTestSuite (e.g., func (s *ParserTestSuite) TestParseRules() { ... } )
using s.Equal / s.Require as assertions, and add a top-level
TestParserTestSuite(t *testing.T) that calls suite.Run(t, new(ParserTestSuite));
keep the existing test cases and helper calls (ParseRules, ParseRuleSlice,
splitRules, extractRuleName, parseOneRule, splitParameters) intact inside the
suite methods and import github.com/stretchr/testify/suite.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 77694720-f5bf-418b-a4ae-fb35f544815f

📥 Commits

Reviewing files that changed from the base of the PR and between 7dc0da2 and f970ed9.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • tests/go.sum is excluded by !**/*.sum
📒 Files selected for processing (30)
  • contracts/http/request.go
  • contracts/validation/validation.go
  • errors/list.go
  • go.mod
  • http/console/stubs.go
  • mocks/http/ContextRequest.go
  • mocks/http/FormRequest.go
  • mocks/http/FormRequestWithFilters.go
  • mocks/validation/Data.go
  • mocks/validation/Option.go
  • mocks/validation/Validation.go
  • mocks/validation/Validator.go
  • tests/go.mod
  • validation/data.go
  • validation/data_bag.go
  • validation/engine.go
  • validation/errors.go
  • validation/errors_test.go
  • validation/filters.go
  • validation/messages.go
  • validation/options.go
  • validation/options_test.go
  • validation/rule_parser.go
  • validation/rule_parser_test.go
  • validation/rules.go
  • validation/utils.go
  • validation/validation.go
  • validation/validation_test.go
  • validation/validator.go
  • validation/validator_test.go
💤 Files with no reviewable changes (3)
  • go.mod
  • validation/data.go
  • validation/validator_test.go

@devhaozi
Copy link
Member

devhaozi commented Mar 7, 2026

@copilot 检查所有未解决的AI回复,判断哪些是真实存在的问题,解决它们并关闭无效的问题。

Copy link
Contributor

Copilot AI commented Mar 7, 2026

@devhaozi I've opened a new pull request, #1398, to work on those changes. Once the pull request is ready, I'll request review from you.

- isValueEmpty: use reflection to handle typed slices/maps ([]int{}, map[string]string{}, etc.)
- validateField: run exclude_* rules in pre-pass to ensure order-independence
- Bind: deep merge validated data to preserve unvalidated nested fields
- Make: reject unknown rule names early with ValidationUnknownRule error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest leaving the original test cases here to avoid break changes for users. We can comment the unimplemented test cases temporarily.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest leaving the original test cases here to avoid break changes for users. We can comment the unimplemented test cases temporarily.

下一个PR加回去,现在没有规则基本所有用例都测不了

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok,主要是想通过测试用例检查修改前后的区别,之后的添加的用例最好能直接使用原始的用例。

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

}

// ParseRules parses a pipe-separated rule string into a slice of ParsedRule.
// Example: "required|string|max:255|in:a,b,c" -> [{required []}, {string []}, {max [255]}, {in [a b c]}]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does all validation styles keep the same as the original?

Copy link
Contributor Author

@h2zi h2zi Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does all validation styles keep the same as the original?

大多数如此,但是有一些变化,例如min_len->min,Laravel没有min_len这种

Copy link
Contributor Author

@h2zi h2zi Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

还有一点是required规则,gookit对于Go类型的空值(默认值)会拒绝,重构之后会允许通过(更接近Laravel的情况)。

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以为这样的差异列一个表,如果很多的话,我们可能需要考虑保留 min_len 等以向后兼容,以后慢慢废弃。如果不多,可以考虑直接切换。

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不知道有没有可能以多驱动的形式来实现验证功能的替换?在 service provider 中注入不同的驱动。当前直接替换的方式怕对用户的项目产生很大的影响。
例如在1.18中默认使用新驱动,对于老项目用户可以添加一个配置指定使用 gookit。
这样的话 gookit 的功能我们也不需要一次做到平替,可以先实现基础的基座,然后一个pr添加一个rule的形式小步快跑。

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

可以为这样的差异列一个表,如果很多的话,我们可能需要考虑保留 min_len 等以向后兼容,以后慢慢废弃。如果不多,可以考虑直接切换。

Claude Code 生成的对比:

验证规则差异对比

1. 原版有但新版命名不同(需要添加别名或确认语义差异)

原版规则 新版对应 差异说明
eq 检查值等于给定值。新版没有 eq,但有 same(比较两个字段)
ne 检查值不等于给定值。新版没有 ne,但有 different(比较两个字段)
len size 原版 len 检查长度等于给定值,新版 size 是 Laravel 风格
min_len min 原版 min_len 只检查长度,原版 min 只检查数值;新版 min 根据类型自动选择检查数值还是长度
max_len max 同上
eq_field same 原版 eq_field:field 比较两字段相等;新版 same:field
ne_field different 原版 ne_field:field;新版 different:field
gt_field gt 原版 gt_field:field 比较两字段;新版 gt:field 支持字段比较(Laravel 风格)
gte_field gte 同上
lt_field lt 同上
lte_field lte 同上
gt_date after 原版 gt_date:value;新版 after:date(Laravel 命名)
gte_date after_or_equal 原版 gte_date:value;新版 after_or_equal:date
lt_date before 原版 lt_date:value;新版 before:date
lte_date before_or_equal 原版 lte_date:value;新版 before_or_equal:date
full_url url 原版 full_url 要求 http/https 开头;新版 url 也检查 scheme
number numeric 原版 number 检查数字 >= 0;新版 numeric 检查是否为数字
uint 原版检查 uintX 类型,新版无对应(可用 integer + min:0
uuid3 新版只有通用 uuid,无 uuid3/4/5 变体
uuid4 同上
uuid5 同上

2. 新版有但原版没有(Laravel 标准规则)

新版规则 说明
required_if_accepted 当另一字段为 accepted 时必填
required_if_declined 当另一字段为 declined 时必填
present_if/unless/with/with_all 字段存在性条件检查
missing/missing_if/missing_unless/missing_with/missing_with_all 字段不应存在的条件检查
accepted_if, declined_if 条件接受/拒绝
prohibited/prohibited_if/prohibited_unless/prohibited_if_accepted/prohibited_if_declined/prohibits 禁止规则
filled 不为空(如果存在)
ascii 仅 ASCII 字符
active_url DNS 可解析 URL
mac_address MAC 地址
ulid ULID 格式
hex_color 十六进制颜色
not_regex 正则不匹配
lowercase/uppercase 大小写检查
doesnt_start_with/doesnt_end_with/doesnt_contain 反向字符串检查
contains 包含子串
confirmed 确认字段匹配
in_array/in_array_keys 数组内检查
date_format/date_equals 日期格式/相等
exclude/exclude_if/exclude_unless/exclude_with/exclude_without 排除规则
mimes/mimetypes/extensions/dimensions/encoding 文件规则
digits/digits_between/decimal/multiple_of/min_digits/max_digits 数字规则
distinct/required_array_keys 数组规则
bail/nullable/sometimes 控制规则
list 列表类型
exists/unique 数据库规则

3. 关于 int 规则的注意事项

新版已经正确处理了 float64 -> int 的问题。ruleInteger 在 line 745-749 检查 float64 时,会判断:

v == float64(int64(v))

也就是说,只要 float64 的值本质上是整数,例如 42.0,就会通过验证。

同样,新版还支持:

  • json.Number 类型(line 750-752)
  • 字符串形式的整数(line 753-755)

因此,原版里关于 int 规则的那个注意事项,在新版中已经解决。

总结

新版验证器的设计理念是:从 gookit/validate 的 Go 本地风格命名,全面迁移到 Laravel 标准命名,同时保留 Go 特有的别名:

  • int = integer
  • bool = boolean
  • slice = list

需要关注的差异主要有这几类:

  1. 原版独有规则,新版缺少:
    eqneuintuuid3uuid4uuid5

    其中 eq/ne 可以被 same/different 部分替代,但语义并不相同:

    • same 比较字段
    • eq 比较固定值

    uint 则可以通过 integer|min:0 组合实现。

  2. 命名变更:
    例如 eq_field -> samene_field -> differentgt_date -> afterlt_date -> before,整体都改成了 Laravel 风格命名。

  3. 语义合并:
    原版的 min(只检查数值)和 min_len(只检查长度),在新版里统一为 min,由规则根据类型自动判断是比较数值还是长度。

  4. intfloat64 问题:
    新版已经修复。json.Unmarshal 产生的 float64,只要数值本身是整数,也能正确通过 integer 验证。

Copy link
Contributor Author

@h2zi h2zi Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不知道有没有可能以多驱动的形式来实现验证功能的替换?在 service provider 中注入不同的驱动。当前直接替换的方式怕对用户的项目产生很大的影响。 例如在1.18中默认使用新驱动,对于老项目用户可以添加一个配置指定使用 gookit。 这样的话 gookit 的功能我们也不需要一次做到平替,可以先实现基础的基座,然后一个pr添加一个rule的形式小步快跑。

感觉有点复杂,目前看主要是规则的命名有变更,语义上没有太大问题(我在gin/fiber上通过了所有测试用例),考虑先保留gookit的大部分别名?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good news 👍 如果我们考虑直接替换掉 gookit 的话,需要尽可能的实现 gookit 目前支持的 rule、filter、语法等,让用户可以较为顺畅的过度,即使在这个过程中我们添加一些 Laravel 本身不支持的语法也是可以接受的,这些语法我们可以标注为废弃,慢慢移除。

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Available Validation Rules 逐一对比

# 文档原版规则 新版对应 状态 说明
1 required required ✅ 保留 语义变更:原版对 Go 零值(false/0/"")拒绝,新版允许通过(更接近 Laravel)
2 required_if required_if ✅ 保留 -
3 required_unless required_unless ✅ 保留 -
4 required_with required_with ✅ 保留 -
5 required_with_all required_with_all ✅ 保留 -
6 required_without required_without ✅ 保留 -
7 required_without_all required_without_all ✅ 保留 -
8 int int / integer ✅ 保留 新增 integer 别名;修复 JSON float64 问题(42.0 可通过验证)
9 uint ❌ 移除 可用 integer|min:0 替代
10 bool bool / boolean ✅ 保留 新增 boolean 别名
11 string string ✅ 保留 -
12 float float ✅ 保留 -
13 slice slice ✅ 保留 同时新增 list 别名
14 in in ✅ 保留 -
15 not_in not_in ✅ 保留 -
16 starts_with starts_with ✅ 保留 -
17 ends_with ends_with ✅ 保留 -
18 between between ✅ 保留 语义扩展:原版仅检查数值,新版同时支持字符串长度/数组数量/文件大小(类型自动判断)
19 max max ✅ 保留 语义扩展:原版仅检查数值,新版根据类型自动判断(同上)
20 min min ✅ 保留 语义扩展:原版仅检查数值,新版合并了 min_len 的功能
21 eq ❌ 移除 检查值等于固定值;新版 same 是字段间比较,语义不同
22 ne ❌ 移除 检查值不等于固定值;新版 different 是字段间比较,语义不同
23 lt lt ✅ 保留 语义扩展:合并了原版 lt_fieldlt:field 支持跨字段比较)
24 gt gt ✅ 保留 语义扩展:合并了原版 gt_field
25 len size ⚠️ 更名 lensize(Laravel 命名)
26 min_len 合并入 min ⚠️ 合并 min 根据字段类型自动选择检查数值还是长度
27 max_len 合并入 max ⚠️ 合并 同上
28 email email ✅ 保留 -
29 array array ✅ 保留 -
30 map map ✅ 保留 -
31 eq_field same ⚠️ 更名 eq_field:fieldsame:field
32 ne_field different ⚠️ 更名 ne_field:fielddifferent:field
33 gt_field 合并入 gt ⚠️ 合并 gt_field:fieldgt:field
34 gte_field 合并入 gte ⚠️ 合并 gte_field:fieldgte:field
35 lt_field 合并入 lt ⚠️ 合并 lt_field:fieldlt:field
36 lte_field 合并入 lte ⚠️ 合并 lte_field:fieldlte:field
37 file file ✅ 保留 -
38 image image ✅ 保留 -
39 date date ✅ 保留 -
40 gt_date after ⚠️ 更名 gt_date:valueafter:date
41 lt_date before ⚠️ 更名 lt_date:valuebefore:date
42 gte_date after_or_equal ⚠️ 更名 gte_date:valueafter_or_equal:date
43 lte_date before_or_equal ⚠️ 更名 lte_date:valuebefore_or_equal:date
44 alpha alpha ✅ 保留 -
45 alpha_num alpha_num ✅ 保留 -
46 alpha_dash alpha_dash ✅ 保留 -
47 json json ✅ 保留 -
48 number numeric ⚠️ 更名 原版 number 检查 >= 0 的数字字符串;新版 numeric 检查是否为数值
49 full_url url ⚠️ 更名 原版 full_url 要求 http/https 开头;新版 url 同样检查 scheme
50 ip ip ✅ 保留 -
51 ipv4 ipv4 ✅ 保留 -
52 ipv6 ipv6 ✅ 保留 -
53 regex regex ✅ 保留 -
54 uuid uuid ✅ 保留 -
55 uuid3 ❌ 移除 新版只有通用 uuid,不区分版本
56 uuid4 ❌ 移除 同上
57 uuid5 ❌ 移除 同上

Available Validation Filters 逐一对比

新版 builtinFilters 为空 map,所有内置 filter 均尚未实现,待后续 PR 添加。
Filter 基础设施已就绪(applyFilters / callFilterFunc 已实现),缺的是 builtinFilters map 中的具体注册。

# 文档原版 Filter 新版状态 说明
1 int / toInt ❌ 未实现 转换为 int
2 uint / toUint ❌ 未实现 转换为 uint
3 int64 / toInt64 ❌ 未实现 转换为 int64
4 float / toFloat ❌ 未实现 转换为 float
5 bool / toBool ❌ 未实现 转换为 bool
6 trim / trimSpace ❌ 未实现 去除两端空白
7 ltrim / trimLeft ❌ 未实现 去除左侧空白
8 rtrim / trimRight ❌ 未实现 去除右侧空白
9 lower / lowercase ❌ 未实现 转小写
10 upper / uppercase ❌ 未实现 转大写
11 lcFirst / lowerFirst ❌ 未实现 首字母小写
12 ucFirst / upperFirst ❌ 未实现 首字母大写
13 ucWord / upperWord ❌ 未实现 每个单词首字母大写
14 camel / camelCase ❌ 未实现 驼峰命名
15 snake / snakeCase ❌ 未实现 下划线命名
16 escapeJs / escapeJS ❌ 未实现 JS 转义
17 escapeHtml / escapeHTML ❌ 未实现 HTML 转义
18 str2ints / strToInts ❌ 未实现 字符串转 []int
19 str2time / strToTime ❌ 未实现 字符串转 time.Time
20 str2arr / str2array / strToArray ❌ 未实现 字符串转 []string

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @h2zi 新版中可否将除了 uuid3、uuid4、uuid5 的 rules 进行适配?之后我们可以在文档中标注要废弃的 rules。

return
}
if len(remaining) == 1 {
v[idx] = map[string]any{"": val}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why "": val?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The map[string]any{"": val} was a hack for assigning a non-map value into a []map[string]any slot. Fixed in 5bfd772 — now only assigns if val is map[string]any, otherwise skips the invalid operation.

}

// structToMap converts a struct to map using "form" tags.
func structToMap(rv reflect.Value) map[string]any {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a similar function in database/gorm, can they be merged?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They serve different purposes with different tag conventions — validation uses form/json tags while gorm uses gorm tag with column: format. The tag parsing logic and field handling (e.g. pointer/nil semantics) differ significantly. Merging would require an abstraction layer that adds complexity without clear benefit. Keeping them separate for now.

if r.Body != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
return bag, nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about returning err here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional graceful degradation — body parse failures (io.ReadAll, JSON unmarshal, multipart parse) should not block validation. The bag already has query parameters parsed, and returning an error would lose those. This matches the existing gin/fiber HTTP driver behavior where malformed bodies fall back to query-only data.

func getAttributeType(attribute string, value any, rules map[string][]ParsedRule) string {
if fieldRules, ok := rules[attribute]; ok {
for _, r := range fieldRules {
if numericRuleNames[r.Name] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doen't work, numericRuleNames is always nil.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern. Fixed in 5bfd772 — added a runtime value type fallback using reflection. When numericRuleNames has no match (empty in PR 1, will be populated in PR 2), the function now checks the actual value'''s reflect.Kind for int/uint/float types and returns "numeric". Also added "array" fallback for slice/array/map kinds.

// Check custom filters
if customFilter, ok := customFilterMap[pf.Name]; ok {
result, err := callFilterFunc(ctx, customFilter, val, pf.Parameters)
if err == nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not return the err?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Fixed in 5bfd772applyFilters now propagates custom filter errors with context (filter name + field name) instead of silently ignoring them.

continue
}

result[tag] = val.Interface()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the struct:

type User struct {
    Name    string  `form:"name"`
    Address Address `form:"address"`  // Nest
}

After calling NewDataBag(&User{Name: "Alice", Address: Address{City: "Beijing"}}), the data will be :

map[string]any{
    "name":    "Alice",
    "address": Address{City: "Beijing"},  // struct, not map
}

The result of bag.Get("address.city") is unexpected.

The similar situation:

type Form struct {
    Tags []string `form:"tags"`
    Agents []Agents `form:agents`
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern. Fixed in 5bfd772 — added normalizeValue() helper that recursively converts nested structs to map[string]any, typed slices ([]string, []Agent) to []any, and typed maps (map[string]string) to map[string]any. Now bag.Get("address.city") and bag.Get("tags.0") work correctly for struct-backed data.

h2zi added 2 commits March 10, 2026 01:19
- dotSet: fix "": val hack in []map[string]any case, use direct map assignment
- structToMap: recursively normalize nested structs/slices/maps via normalizeValue
- getAttributeType: add runtime value type fallback when numericRuleNames is empty
- applyFilters: propagate custom filter errors instead of silently ignoring
Copy link
Contributor

@hwbrzzl hwbrzzl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, awesome PR. Only a few concerns about the test. It's good to go once the test is optimized.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some test cases for this file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 8427dd8data_bag_test.go covers NewDataBag (map/url.Values/struct/nil/unsupported), NewDataBagFromRequest (JSON/form/multipart/query/graceful degradation), Get/Set (dot notation, array index, missing keys), Has, All, Keys (sort, cache).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add test for this file as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 8427dd8utils_test.go covers isValueEmpty, getAttributeType, matchesOtherValue, isControlRule, dotGet/dotSet, collectKeys, expandWildcardFields, urlValuesToMap, structToMap (form/json/embedded/nested), normalizeValue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add test for this file as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 8427dd8messages_test.go covers getMessage (custom field+rule > custom rule > type-specific default > generic default > fallback), formatMessage (placeholder replacement, length-based sorting, fast path vs sort path), getDisplayableAttribute.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add test for this file in the next PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good — will add filter tests in the builtin rules PR.

if customFilter, ok := customFilterMap[pf.Name]; ok {
result, err := callFilterFunc(ctx, customFilter, val, pf.Parameters)
if err != nil {
return fmt.Errorf("filter %s on field %s: %w", pf.Name, field, err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error messages can be moved to errors/list.go in another PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — will move the filter error messages to errors/list.go in a follow-up PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add test for this file as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 8427dd8engine_test.go covers Validate (custom rules pass/fail, unknown rule, missing field skip), ValidatedData (ruled fields only, excludes), handleExcludeRule (all 5 variants), expandWildcardRules (wildcard expansion + caching), trackDistinct (duplicate detection), formatErrorMessage (custom rule, custom message, parameter replacement, custom attributes), executeRule (custom/unknown), and control rules (bail, nullable, sometimes).

}

// formatErrorMessage creates the error message for a rule failure.
func (e *Engine) formatErrorMessage(field string, rule ParsedRule, attrType string) string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small suggestion on formatErrorMessage: since it currently relies on a switch statement for rule-specific preprocessing before formatting, would it be worth extracting that part into an interface such as ErrorMessageFormatter?

That way, each rule could handle its own preprocessing logic (for example, preparing replacements etc.), while formatErrorMessage would only need to call the interface when implemented and otherwise fall back to the default replacements (attribute and value). This could make the code cleaner and easier to extend.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! The current switch-based approach is intentional for this stage — with all rules being static and finite, a switch gives the compiler full optimization (inlining, jump tables) and keeps the code straightforward.

An interface like ErrorMessageFormatter would add a per-rule abstraction layer that's not needed when the rule set is fixed and all formatting logic is co-located. In Laravel, formatErrorMessage is similarly centralized rather than delegated to individual rule classes.

If we later support user-defined builtin-style rules (beyond the current custom rule API which already handles its own messages), we can revisit this. For now the switch keeps it simple and fast.

h2zi and others added 3 commits March 15, 2026 22:23
Add comprehensive test coverage for core validation infrastructure:
- data_bag_test.go: NewDataBag, NewDataBagFromRequest, Get/Set/Has/All/Keys
- utils_test.go: dotGet/dotSet, collectKeys, expandWildcardFields, structToMap, normalizeValue, isValueEmpty
- messages_test.go: getMessage priority, formatMessage, getDisplayableAttribute
- engine_test.go: Validate, ValidatedData, handleExcludeRule, trackDistinct, formatErrorMessage, bail/nullable/sometimes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@hwbrzzl hwbrzzl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome

@h2zi h2zi merged commit c0f099a into master Mar 16, 2026
16 of 18 checks passed
@h2zi h2zi deleted the haozi/validation-core branch March 16, 2026 05:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants