Реализация составного типа предиката со встроенными сообщениями об ошибках / отзывами пользователей - PullRequest
2 голосов
/ 18 апреля 2020

У меня есть «Swifty» версия NSPredicate, основанная на простом закрытии. Это делает его составным, но я хотел бы найти способ реализации сообщений об ошибках, чтобы предоставить обратную связь с пользователем в пользовательском интерфейсе.

Проблема возникает, когда я пытаюсь составить два предиката с логическим И - с моей текущей реализацией (в которой предикат был очень прост), я не могу найти осмысленный способ генерирования сообщения об ошибке из компонента предикаты. Очевидным решением было бы добавить вычисляемое свойство к предикату, которое будет переоценивать предикат и возвращать ошибку (если применимо), но это кажется очень неэффективным.

Я начал изучать сообщения об ошибках через 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( { !$0.isEmpty }, error: "Required field")
    }

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

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

    static func characterCountWithin<LosslessStringComparable: Collection>(range: Range<Int>) -> Predicate<LosslessStringComparable> {
        .init({ ($0.count >= range.lowerBound) && ($0.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 { $0[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 { $0[keyPath: lhs] > rhs }
}


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


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

func ||<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
    Predicate({ lhs.matches($0) || rhs.matches($0) }, 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
        }
    }
}

1 Ответ

1 голос
/ 18 апреля 2020

Основная причина, по которой вы испытываете двусмысленность, заключается в том, что сообщения об ошибках слишком рано «появляются» Для операции && сообщение об ошибке не известно до тех пор, пока выражение не будет оценено.

Следовательно, не следует хранить свойство error. Вместо этого сообщение об ошибке выводится только при возврате matches, то есть в качестве возвращаемого значения. Конечно, вам также необходимо обработать состояние успеха, когда сообщение об ошибке отсутствует.

Swift предоставляет множество способов смоделировать это - вы можете вернуть String?, представляющее сообщение об ошибке, или Result<(), ValidationError> или даже Result<Target, ValidationError>.

И если вы сделали сообщение об ошибке, возвращающее значение matches (какой бы тип вы ни выбрали), у вас не должно быть этой проблемы неоднозначности.

Здесь я сделал это с 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 { !$0.isEmpty ? .success(()) : .failure(ValidationError(message: "Required field")) }
    }

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

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

    static func characterCountWithin<T: StringProtocol>(range: Range<Int>) -> Predicate<T> {
        .init {
            ($0.count >= range.lowerBound) && ($0.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 {
        $0[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 {
        $0[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 {
        $0[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 на struct, а не на enum. Вы можете сделать это в соответствии с ExpressibleByStringLiteral, если вам не нравится многословие ValidationError(message: ...).

Еще одна вещь, которую вы можете рассмотреть, - это сообщения для предикатов, включающие ключевые пути. Ключевые пути не имеют удобочитаемого строкового представления, поэтому в качестве сообщения для \.isCompleted == false.

нельзя указывать «isCompleted must равно false».
...