Skip to content

Commit

Permalink
Value predicate in listPaths function
Browse files Browse the repository at this point in the history
- Specify a predicate on the value. Possibility to specify several predicates to target different types of values (e.g. string, int).
- Optimisation to cache a mismatched type if an expression already mismatched it.
  • Loading branch information
Alexis Bridoux committed Feb 3, 2021
1 parent bd4e640 commit 6e2da96
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 73 deletions.
36 changes: 20 additions & 16 deletions Sources/Scout/Definitions/KeyAllowedType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@
import Foundation

/// A value can take a type conforming to this protocol
public protocol KeyAllowedType: LosslessStringConvertible, Equatable {
public protocol KeyAllowedType: LosslessStringConvertible, Hashable {

static var typeDescription: String { get }

init(value: Any) throws
}

public extension KeyAllowedType {
extension KeyAllowedType {

/// Try to instantiate a type with the given value
init(value: Any) throws {
Expand All @@ -22,21 +20,22 @@ public extension KeyAllowedType {
return
}

if let stringValue = (value as? CustomStringConvertible)?.description {
guard let stringValue = (value as? CustomStringConvertible)?.description else {
throw PathExplorerError.valueConversionError(value: String(describing: value), type: String(describing: Self.typeDescription))
}

if Self.self == Bool.self {
// specific case for Bool values as we allow other string than "true" or "false"
if Bool.trueSet.contains(stringValue) {
self = try Self(value: true)
return
} else if Bool.falseSet.contains(stringValue) {
self = try Self(value: false)
return
}
} else if let convertedValue = Self(stringValue) {
self = convertedValue
if Self.self == Bool.self {
// specific case for Bool values as we allow other string than "true" or "false"
if Bool.trueSet.contains(stringValue) {
self = try Self(value: true)
return
} else if Bool.falseSet.contains(stringValue) {
self = try Self(value: false)
return
}
} else if let convertedValue = Self(stringValue) {
self = convertedValue
return
}

throw PathExplorerError.valueConversionError(value: String(describing: value), type: String(describing: Self.typeDescription))
Expand All @@ -45,23 +44,28 @@ public extension KeyAllowedType {

extension String: KeyAllowedType {
public static let typeDescription = "String"
static var valueType: PathsFilter.Predicate.ValueType { .string }
}

extension Int: KeyAllowedType {
public static let typeDescription = "Integer"
static var valueType: PathsFilter.Predicate.ValueType { .int }
}

extension Double: KeyAllowedType {
public static let typeDescription = "Real"
static var valueType: PathsFilter.Predicate.ValueType { .double }
}

extension Bool: KeyAllowedType {
public static let typeDescription = "Boolean"
static var valueType: PathsFilter.Predicate.ValueType { .bool }

static let trueSet: Set<String> = ["y", "yes", "Y", "Yes", "YES", "t", "true", "T", "True", "TRUE"]
static let falseSet: Set<String> = ["n", "no", "N", "No", "NO", "f", "false", "F", "False", "FALSE"]
}

extension AnyHashable: KeyAllowedType {
public static let typeDescription = "Automatic"
static var valueType: PathsFilter.Predicate.ValueType { .string }
}
4 changes: 4 additions & 0 deletions Sources/Scout/Definitions/PathExplorerError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public enum PathExplorerError: LocalizedError, Equatable {
case csvExportWrongGroupValue
case csvExportAmbiguous(expected: String, path: Path)

case predicateError(description: String)

public var errorDescription: String? {
switch self {
case .invalidData(let type): return "Cannot intialize a \(String(describing: type)) object with the given data"
Expand Down Expand Up @@ -77,6 +79,8 @@ public enum PathExplorerError: LocalizedError, Equatable {
case .groupSampleConversionError(let path): return "Internal error. Group sample conversion error in '\(path.description)'"
case .csvExportWrongGroupValue: return "CSV export requires either first object to be an array or a dictionary of arrays"
case .csvExportAmbiguous(let expectedType, let path): return "Ambiguous type for value at '\(path.description). Expected \(expectedType) as the first value is of type \(expectedType)"

case .predicateError(let description): return description
}
}
}
100 changes: 76 additions & 24 deletions Sources/Scout/Definitions/PathsFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import Foundation
import BooleanExpressionEvaluation

public enum PathsFilter {
/// No filter on key or value.
case targetOnly(ValueTarget)

/// Filter the keys based on a regular expression
case key(regex: NSRegularExpression, target: ValueTarget)
case value(Predicate)
case keyAndValue(keyRegex: NSRegularExpression, valuePredicate: Predicate)

/// Filter the value based on predicates. The value is valid when one of the predicates validates it.
case value([Predicate])

/// Filter the keys based on a regular expression and the value based on predicates. The value is valid when one of the predicates validates it.
case keyAndValue(keyRegex: NSRegularExpression, valuePredicates: [Predicate])

/// Allows group values (array, dictionaries)
var groupAllowed: Bool {
Expand All @@ -28,32 +35,57 @@ public enum PathsFilter {
}
}

/// Validate a key when the filter has a key regex
/// Validate a key when the filter has a key regex. `true` otherwise
func validate(key: String) -> Bool {
switch self {
case .key(let regex, _), .keyAndValue(let regex, _): return regex.validate(key)
case .value, .targetOnly: return true
}
}

/// Validate an index when the filter is has no key regex
/// Validate an index when the filter is has no key regex.
func validate(index: Int) -> Bool {
switch self {
case .key: return false
case .value, .keyAndValue, .targetOnly: return true
}
}

/// Validate a value when the filter has a value predicate
func validate(value: Any) -> Bool {
/// Validate a value when the filter has a value predicate. `true` otherwise
func validate(value: Any) throws -> Bool {
switch self {
case .value(let predicate), .keyAndValue(_, let predicate): return predicate.evaluate(with: value)
case .value(let predicates), .keyAndValue(_, let predicates):
for predicate in predicates {
if try predicate.evaluate(with: value) {
return true
}
}
return false
case .key, .targetOnly:
return true
}
}
}

extension PathsFilter {

/// No filter with target `singleAndGroup`
public static var noFilter: PathsFilter { .targetOnly(.singleAndGroup) }

/// Key filter with `singleAndGroup` target
public static func key(regex: NSRegularExpression) -> PathsFilter { .key(regex: regex, target: .singleAndGroup) }

public static func value(_ predicate: Predicate, _ additionalPredicates: Predicate...) -> Self {
.value([predicate] + additionalPredicates)
}

public static func keyAndValue(keyRegex: NSRegularExpression, valuePredicates: Predicate, _ additionalPredicates: Predicate...) -> Self {
.keyAndValue(keyRegex: keyRegex, valuePredicates: [valuePredicates] + additionalPredicates)
}
}

// MARK: - Structs

extension PathsFilter {

/// Specifies if group (array, dictionary) values, single (string, bool...) values or both should be targeted
Expand All @@ -75,37 +107,57 @@ extension PathsFilter {

extension PathsFilter {

/// Allow to specify a bollean expression to filter the value
/// Allow to specify a boleean expression to filter the value
///
/// The value is specified as the variable 'value' in the expression.
/// - `value > 10`
/// - `value hasPrefix 'Lou' && value hasSuffix 'lou'`
///
///
/// - note: Public wrapper around BoleeanExpressionEvaluation.Expression
public struct Predicate {
var expression: Expression

init(expression: Expression) {
self.expression = expression
}
public final class Predicate {
private(set) var expression: Expression
private(set) var mismatchedTypes: Set<ValueType> = []

/// Specify a predicate with a 'value' variable that will be replaced with a concrete value during evaluation
public init(format: String) throws {
expression = try Expression(format)
}

public func evaluate(with value: Any) -> Bool {
let result = try? expression.evaluate(with: ["variable": String(describing: value)])
return result ?? true //ignore the error of wrong value type
/// Evaluate the predicate with the value.
///
/// Ignore the error of mismatching type between the value and an operand and retruen `false
public func evaluate(with value: Any) throws -> Bool {
// if the type has already be invalidated, return false immediately
if mismatchedTypes.contains(type(of: value)) { return false }

do {
return try expression.evaluate(with: ["value": String(describing: value)])
} catch ExpressionError.mismatchingType {
mismatchedTypes.insert(type(of: value))
return false //ignore the error of wrong value type
} catch {
throw PathExplorerError.predicateError(description: error.localizedDescription)
}
}

func type(of value: Any) -> ValueType {
// use the initialisation from any allowing a string value
if let _ = try? Int(value: value) {
return .int
} else if let _ = try? Double(value: value) {
return .double
} else if let _ = try? Bool(value: value) {
return .bool
} else {
return .string
}
}
}
}

extension PathsFilter {

/// No filter with target `singleAndGroup`
public static var noFilter: PathsFilter { .targetOnly(.singleAndGroup) }
extension PathsFilter.Predicate {

/// Key filter with `singleAndGroup` target
public static func key(regex: NSRegularExpression) -> PathsFilter { .key(regex: regex, target: .singleAndGroup) }
enum ValueType: Hashable {
case string, int, double, bool
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,35 +23,36 @@ extension PathExplorerSerialization {
}
var paths = [Path]()

explorer.collectKeysPaths(in: &paths, filter: filter)
try explorer.collectKeysPaths(in: &paths, filter: filter)
return paths.map { $0.flattened() }.sortedByKeysAndIndexes()
}

func collectKeysPaths(in paths: inout [Path], filter: PathsFilter) {
func collectKeysPaths(in paths: inout [Path], filter: PathsFilter) throws {
switch value {

case let dict as DictionaryValue:
dict.forEach { (key, value) in
try dict.forEach { (key, value) in
if filter.groupAllowed, filter.validate(key: key), isGroup(value: value) {
paths.append(readingPath.appending(key))
}

let explorer = PathExplorerSerialization(value: value, path: readingPath.appending(key))
explorer.collectKeysPaths(in: &paths, filter: filter)
try explorer.collectKeysPaths(in: &paths, filter: filter)
}

case let array as ArrayValue:
array.enumerated().forEach { (index, value) in
try array.enumerated().forEach { (index, value) in
if filter.groupAllowed, isGroup(value: value), filter.validate(index: index) {
paths.append(readingPath.appending(index))
}

let explorer = PathExplorerSerialization(value: value, path: readingPath.appending(index))
explorer.collectKeysPaths(in: &paths, filter: filter)
try explorer.collectKeysPaths(in: &paths, filter: filter)
}

default:
guard filter.singleAllowed else { break }
guard try filter.validate(value: value) else { return }
guard let name = readingPath.lastKeyElementName else {
paths.append(readingPath)
return
Expand Down
12 changes: 6 additions & 6 deletions Sources/Scout/Implementations/XML/PathExplorerXML+Paths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,32 @@ extension PathExplorerXML {
}
}
var paths = [Path]()
explorer.collectKeysPaths(in: &paths, filter: filter)
try explorer.collectKeysPaths(in: &paths, filter: filter)

return paths.map { $0.flattened() }.sortedByKeysAndIndexes()
}

func collectKeysPaths(in paths: inout [Path], filter: PathsFilter) {
func collectKeysPaths(in paths: inout [Path], filter: PathsFilter) throws {

if filter.singleAllowed,
let value = element.value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty,
filter.validate(key: element.name) {
filter.validate(key: element.name), try filter.validate(value: value) {
paths.append(readingPath)
}

element.children.enumerated().forEach { (index, child) in
try element.children.enumerated().forEach { (index, child) in
let newElement: PathElement = element.differentiableChildren ? .key(child.name) : .index(index)

if child.children.isEmpty {
if filter.singleAllowed, filter.validate(key: child.name) {
if filter.singleAllowed, filter.validate(key: child.name), try filter.validate(value: child.string) {
paths.append(readingPath.appending(newElement))
}
} else {
if filter.groupAllowed, filter.validate(key: child.name) {
paths.append(readingPath.appending(newElement))
}
let explorer = PathExplorerXML(element: child, path: readingPath.appending(newElement))
explorer.collectKeysPaths(in: &paths, filter: filter)
try explorer.collectKeysPaths(in: &paths, filter: filter)
}
}
}
Expand Down
Loading

0 comments on commit 6e2da96

Please sign in to comment.