实现带有嵌入式错误消息/用户反馈的可组合谓词类型

Implementing a composable predicate type with embedded error messages / user feedback

我有一个 "Swifty" 版本的 NSPredicate,它基于一个简单的闭包。这使其可组合,但我想找到一种实现错误消息的方法,以便在 UI 中为用户提供反馈。

当我尝试用逻辑 AND 组合两个谓词时出现问题 - 在我当前的实现中(使谓词非常简单),我找不到从组件谓词生成错误消息的有意义的方法.一个明显的解决方案是将计算的 属性 添加到将重新评估谓词的谓词和 return 一个错误(如果适用),但这似乎非常低效。

我开始研究通过 Combine Publisher 公开错误消息,但这很快就失控了,而且看起来不必要地复杂。我得出的结论是,我现在只见树木不见森林,可以做一点引导。代码库如下...

谓词:

public struct Predicate<Target> {
    // MARK: Public roperties
    var matches: (Target) -> Bool
    var error: String

    // MARK: Init
    init(_ matcher: @escaping (Target) -> Bool, error: String = "") {
        self.matches = matcher
        self.error = error
    }

    // MARK: Factory methods
    static func required<LosslessStringComparabke: Collection>() -> Predicate<LosslessStringComparabke> {
        .init( { ![=10=].isEmpty }, error: "Required field")
    }

    static func characterCountMoreThan<LosslessStringComparable: Collection>(count: Int) -> Predicate<LosslessStringComparable> {
        .init({ [=10=].count >= count }, error: "Length must be at least \(count) characters")
    }

    static func characterCountLessThan<LosslessStringComparable: Collection>(count: Int) -> Predicate<LosslessStringComparable> {
        .init( { [=10=].count <= count }, error: "Length must be less than \(count) characters")
    }

    static func characterCountWithin<LosslessStringComparable: Collection>(range: Range<Int>) -> Predicate<LosslessStringComparable> {
        .init({ ([=10=].count >= range.lowerBound) && ([=10=].count <= range.upperBound) }, error: "Length must be between \(range.lowerBound) and \(range.upperBound) characters")
    }
}


// MARK: Overloads

// e.g. let uncompletedItems = list.items(matching: \.isCompleted == false)
func ==<T, V: Equatable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    Predicate { [=10=][keyPath: lhs] == rhs }
}

// r.g. let uncompletedItems = list.items(matching: !\.isCompleted)
prefix func !<T>(rhs: KeyPath<T, Bool>) -> Predicate<T> {
    rhs == false
}


func ><T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    Predicate { [=10=][keyPath: lhs] > rhs }
}


func <<T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    //    Predicate { [=10=][keyPath: lhs] < rhs }
    Predicate({ [=10=][keyPath: lhs] < rhs }, error: "\(rhs) must be less than \(lhs)")
}


func &&<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
    return Predicate({ lhs.matches([=10=]) && rhs.matches([=10=]) }, error: "PLACEHOLDER: One predicate failed")
}

func ||<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
    Predicate({ lhs.matches([=10=]) || rhs.matches([=10=]) }, error: "PLACEHOLDER: Both predicates failed")
}

验证器(使用谓词):

public enum ValidationError: Error, CustomStringConvertible {
    case generic(String)

    public var description: String {
        switch self {
        case .generic(let error): return error
        }
    }
}

public struct Validator<ValueType> {
    private var predicate: Predicate<ValueType>

    func validate(_ value: ValueType) -> Result<ValueType, ValidationError> {
        switch predicate.matches(value) {
        case true:
            return .success(value)
        case false:
            return .failure(.generic(predicate.error)) // TODO: placeholder
        }
    }

    init(predicate: Predicate<ValueType>) {
        self.predicate = predicate
    }
}

验证器结构由 属性 包装器使用:

@propertyWrapper
public class ValidateAndPublishOnMain<ValueType> where ValueType: LosslessStringConvertible { // Type constraint specifically for SwiftUI text controls
   @Published private var value: ValueType

    private var validator: Validator<ValueType>

    public var wrappedValue: ValueType {
        get { value }
        set { value = newValue }
    }

    // need to also force validation to execute when the textfield loses focus
    public var projectedValue: AnyPublisher<Result<ValueType, ValidationError>, Never> {
        return $value
            .receive(on: DispatchQueue.main)
            .map { value in
                self.validator.validate(value)
        }
        .eraseToAnyPublisher()
    }

    public init(wrappedValue initialValue: ValueType, predicate: Predicate<ValueType>) {
        self.value = initialValue
        self.validator = Validator(predicate: predicate)
    }
}

...最后,在 SwiftUI(和关联的视图模型)中使用 属性 包装器

public class ViewModel: ObservableObject {
    @ValidateAndPublishOnMain(predicate: .required() && .characterCountLessThan(count: 5))
    var validatedData = "" {
        willSet { objectWillChange.send() }
    }

    var errorMessage: String = ""
    private var cancellables = Set<AnyCancellable>()

    init() {
        setupBindings()
    }

    private func setupBindings() {
        $validatedData
            .map { value in
                switch value {
                case .success: return ""
                case .failure(let error): return error.description
                }
        }
        .assign(to: \.errorMessage, on: self)
        .store(in: &cancellables)
    }
}

struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()
    @State private var error = ""

    var body: some View {
        VStack {
            HStack {
                Text("Label")
                TextField("Data here", text: $viewModel.validatedData)
                    .textFieldStyle(RoundedBorderTextFieldStyle())

            }.padding()

            Text("Result: \(viewModel.validatedData)")
            Text("Errors: \(viewModel.errorMessage)")
        }
        .onAppear {
            self.viewModel.objectWillChange.send() // ensures UI shows requirements immediately
        }
    }
}

你有歧义的主要原因是错误信息 "set in stone" 太早了。对于 && 操作,在计算表达式之前您不知道错误消息。

因此,您不应存储 error 属性。相反,仅在 matches return 时输出错误消息,即作为其 return 值。当然,您还需要处理没有错误消息的成功状态。

Swift 提供了很多方法来对此进行建模 - 您可以 return 表示错误消息的 String?,或者 Result<(), ValidationError>,甚至是 Result<Target, ValidationError>.

并且只要您将错误消息设为 matches 的 return 值(无论您选择哪种类型),就不会有这种歧义问题。

在这里,我用 Result<(), ValidationError> 完成了。老实说,代码本身我很直接:

public struct ValidationError: Error {
    let message: String
}

public struct Predicate<Target> {
    var matches: (Target) -> Result<(), ValidationError>

    // MARK: Factory methods
    static func required<T: Collection>() -> Predicate<T> {
        .init { ![=10=].isEmpty ? .success(()) : .failure(ValidationError(message: "Required field")) }
    }

    static func characterCountMoreThan<T: StringProtocol>(count: Int) -> Predicate<T> {
        .init { [=10=].count > count ? .success(()) : .failure(ValidationError(message: "Length must be more than \(count) characters")) }
    }

    static func characterCountLessThan<T: StringProtocol>(count: Int) -> Predicate<T> {
        .init { [=10=].count < count ? .success(()) : .failure(ValidationError(message: "Length must be less than \(count) characters")) }
    }

    static func characterCountWithin<T: StringProtocol>(range: Range<Int>) -> Predicate<T> {
        .init {
            ([=10=].count >= range.lowerBound) && ([=10=].count <= range.upperBound) ?
                .success(()) :
                .failure(ValidationError(message: "Length must be between \(range.lowerBound) and \(range.upperBound) characters")) }
    }
}

func ==<T, V: Equatable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    Predicate {
        [=10=][keyPath: lhs] == rhs ?
            .success(()) :
            .failure(ValidationError(message: "Must equal \(rhs)"))
    }
}

// r.g. let uncompletedItems = list.items(matching: !\.isCompleted)
prefix func !<T>(rhs: KeyPath<T, Bool>) -> Predicate<T> {
    rhs == false
}

func ><T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    Predicate {
        [=10=][keyPath: lhs] > rhs ?
            .success(()) :
        .failure(ValidationError(message: "Must be greater than \(rhs)"))
    }
}

func <<T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    Predicate {
        [=10=][keyPath: lhs] < rhs ?
            .success(()) :
        .failure(ValidationError(message: "Must be less than \(rhs)"))
    }
}


func ||<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
    // short-circuiting version, needs a nested switch
//    Predicate {
//        target in
//        switch lhs.matches(target) {
//        case .success:
//            return .success(())
//        case .failure(let leftError):
//            switch rhs.matches(target) {
//            case .success:
//                return .success(())
//            case .failure(let rightError):
//                return .failure(ValidationError(message: "\(leftError.message) AND \(rightError.message)"))
//            }
//        }
//    }

    // without a nested switch, not short-circuiting
    Predicate {
        target in
        switch (lhs.matches(target), rhs.matches(target)) {
        case (.success, .success), (.success, .failure), (.failure, .success):
            return .success(())
        case (.failure(let leftError), .failure(let rightError)):
            return .failure(ValidationError(message: "\(leftError.message) AND \(rightError.message)"))
        }
    }
}

func &&<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
    Predicate {
        target in
        switch (lhs.matches(target), rhs.matches(target)) {
        case (.success, .success):
            return .success(())
        case (.success, let rightFail):
            return rightFail
        case (let leftFail, .success):
            return leftFail
        case (.failure(let leftError), .failure(let rightError)):
            return .failure(ValidationError(message: "\(leftError.message) AND \(rightError.message)"))
        }
    }
}

@propertyWrapper
public class ValidateAndPublishOnMain<ValueType> where ValueType: LosslessStringConvertible { // Type constraint specifically for SwiftUI text controls
   @Published private var value: ValueType

    private var validator: Predicate<ValueType>

    public var wrappedValue: ValueType {
        get { value }
        set { value = newValue }
    }

    // need to also force validation to execute when the textfield loses focus
    public var projectedValue: AnyPublisher<Result<ValueType, ValidationError>, Never> {
        return $value
            .receive(on: DispatchQueue.main)
            .map { value in
                // mapped the Result' Success type
                self.validator.matches(value).map { _ in value }
        }
        .eraseToAnyPublisher()
    }

    public init(wrappedValue initialValue: ValueType, predicate: Predicate<ValueType>) {
        self.value = initialValue
        self.validator = predicate
    }
}

请注意,我已将您的 ValidationError 更改为结构而不是枚举。如果您不喜欢 ValidationError(message: ...).

的冗长,您可以使其符合 ExpressibleByStringLiteral

您可能要考虑的另一件事是涉及关键路径的谓词的消息。关键路径没有人类可读的字符串表示形式,因此您不能将 "isCompleted must equal false" 作为 \.isCompleted == false.

的消息