Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ struct RegistrationView: View {
| `IBANValidationRule` | Validates that a string is a valid IBAN (International Bank Account Number) | `IBANValidationRule(error: "Invalid IBAN")`
| `IPAddressValidationRule` | Validates that a string is a valid IPv4 or IPv6 address | `IPAddressValidationRule(version: .v4, error: ValidationError("Invalid IPv4"))`
| `PostalCodeValidationRule` | Validates postal/ZIP codes for different countries | `PostalCodeValidationRule(country: .uk, error: "Invalid post code")`
| `DependentValidationRule` | Validates another field's value to determine which rule to apply | ``DependentValidationRule<String, String>(dependsOn: countryField, error: "Invalid", ruleProvider: { $0 == "US" ? USPhoneRule() : InternationalPhoneRule() })``

## Custom Validators

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// Validator
// Copyright © 2025 Space Code. All rights reserved.
//

/// A validation rule that depends on another field's value to determine which rule to apply.
///
/// # Example:
/// ```swift
/// let countryField = FormField(value: "US")
/// let phoneField = FormField(
/// value: "",
/// rules: [
/// DependentValidationRule(
/// dependsOn: countryField,
/// error: "Invalid phone number",
/// ruleProvider: { country in
/// if country == "US" {
/// return USPhoneRule()
/// } else {
/// return InternationalPhoneRule()
/// }
/// }
/// )
/// ]
/// )
/// ```
public struct DependentValidationRule<DependentInput, Input>: IValidationRule {
// MARK: Properties

/// The error message or error object to return if validation fails.
public let error: IValidationError

/// A closure that returns the value of the field this rule depends on.
private let dependentField: () -> DependentInput

/// A closure that takes the dependent field's value and returns the appropriate validation rule.
private let ruleProvider: (DependentInput) -> any IValidationRule<Input>

// MARK: Initialization

/// Creates a dependent validation rule.
///
/// - Parameters:
/// - dependsOn: A closure that returns the value of the field this rule depends on.
/// - error: The error message or error object to return if validation fails.
/// - ruleProvider: A closure that takes the dependent field's value and returns the appropriate validation rule.
public init(
dependsOn: @escaping @autoclosure () -> DependentInput,
error: IValidationError,
ruleProvider: @escaping (DependentInput) -> any IValidationRule<Input>
) {
dependentField = dependsOn
self.error = error
self.ruleProvider = ruleProvider
}

// MARK: IValidationRule

public func validate(input: Input) -> Bool {
let dependentValue = dependentField()
let rule = ruleProvider(dependentValue)
return rule.validate(input: input)
}
}
1 change: 1 addition & 0 deletions Sources/ValidatorCore/Validator.docc/Overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ ValidatorCore contains all core validation rules, utilities, and mechanisms for
- ``IBANValidationRule``
- ``IPAddressValidationRule``
- ``PostalCodeValidationRule``
- ``DependentValidationRule``

### Articles

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// Validator
// Copyright © 2025 Space Code. All rights reserved.
//

@testable import ValidatorCore
import XCTest

final class DependentValidationRuleTests: XCTestCase {
// MARK: - Mock Validation Rules

struct AlwaysPassRule<T>: IValidationRule {
let error: IValidationError = "Should not fail"
func validate(input _: T) -> Bool { true }
}

struct AlwaysFailRule<T>: IValidationRule {
let error: IValidationError = "Failed"
func validate(input _: T) -> Bool { false }
}

// MARK: - Tests

func test_dependentValidationRuleUsesCorrectValidationBasedOnDependentValue() {
// given
let country = "US"

let rule = DependentValidationRule<String, String>(
dependsOn: country,
error: "Invalid",
ruleProvider: { value in
if value == "US" {
AlwaysPassRule<String>()
} else {
AlwaysFailRule<String>()
}
}
)

// when
let isValid = rule.validate(input: "12345")

// then
XCTAssertTrue(isValid)
}

func test_dependentValidationRuleSwitchesBehaviorWhenDependentValueChanges() {
// given
var country = "US"

let rule = DependentValidationRule(
dependsOn: country,
error: "Invalid",
ruleProvider: { value in
if value == "US" {
AlwaysPassRule<String>()
} else {
AlwaysFailRule<String>()
}
}
)

// then
XCTAssertTrue(rule.validate(input: "value"))

country = "FR"

XCTAssertFalse(rule.validate(input: "value"))
}

func test_validationFails_whenInnerRuleFails() {
// given
let rule = DependentValidationRule(
dependsOn: "ANY",
error: "Invalid",
ruleProvider: { _ in AlwaysFailRule<String>() }
)

// when
let result = rule.validate(input: "test")

// then
XCTAssertFalse(result)
}

func test_validationPasses_whenInnerRulePasses() {
// given
let rule = DependentValidationRule(
dependsOn: "ANY",
error: "Invalid",
ruleProvider: { _ in AlwaysPassRule<String>() }
)

// when
let result = rule.validate(input: "test")

// then
XCTAssertTrue(result)
}

func test_canWorkWithDifferentInputTypes() {
// given
let rule = DependentValidationRule(
dependsOn: true,
error: "Invalid",
ruleProvider: { condition in
if condition {
AlwaysPassRule<Int>()
} else {
AlwaysFailRule<Int>()
}
}
)

// then
XCTAssertTrue(rule.validate(input: 123))
}
}