diff --git a/.gitignore b/.gitignore index 4c741c0..7941e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ /.swiftpm /Packages /*.xcodeproj +/docs xcuserdata/ Package.resolved \ No newline at end of file diff --git a/Sources/JMESPath/Ast.swift b/Sources/JMESPath/Ast.swift index 908adef..e2c185c 100644 --- a/Sources/JMESPath/Ast.swift +++ b/Sources/JMESPath/Ast.swift @@ -1,31 +1,44 @@ -// -// File.swift -// -// -// Created by Adam Fowler on 27/05/2021. -// - +/// JMES expression abstract syntax tree public indirect enum Ast: Equatable { + /// compares two nodes using a comparator case comparison(comparator: Comparator, lhs: Ast, rhs: Ast) + /// if `predicate` evaluates to a truthy value returns result from `then` case condition(predicate: Ast, then: Ast) + /// returns the current node case identity + /// used by functions to dynamically evaluate argument values case expRef(ast: Ast) + /// evaluates nodes and then flattens it one level case flatten(node: Ast) + /// function name and a vector of function argument expressions case function(name: String, args: [Ast]) + /// extracts a key value from an object case field(name: String) + /// extracts an indexed value from an array case index(index: Int) + /// resolves to a literal value case literal(value: JMESVariable) + /// resolves to a list of evaluated expressions case multiList(elements: [Ast]) + /// resolves to a map of key/evaluated expression pairs case multiHash(elements: [String: Ast]) + /// evaluates to true/false based on expression case not(node: Ast) + /// evalutes `lhs` and pushes each value through to `rhs` case projection(lhs: Ast, rhs: Ast) + /// evaluates expression and if result is an object then return array of its values case objectValues(node: Ast) + /// evaluates `lhs` and if not truthy returns, otherwise evaluates `rhs` case and(lhs: Ast, rhs: Ast) + /// evaluates `lhs` and if truthy returns, otherwise evaluates `rhs` case or(lhs: Ast, rhs: Ast) + /// returns a slice of an array case slice(start: Int?, stop: Int?, step: Int) + /// evalutes `lhs` and then provides that value to `rhs` case subExpr(lhs: Ast, rhs: Ast) } +/// Comparator used in comparison AST nodes public enum Comparator: Equatable { case equal case notEqual @@ -34,6 +47,7 @@ public enum Comparator: Equatable { case greaterThan case greaterThanOrEqual + /// initialise `Comparator` from `Token` init(from token: Token) throws { switch token { case .equals: self = .equal diff --git a/Sources/JMESPath/Error.swift b/Sources/JMESPath/Error.swift index a65e5a3..31df0da 100644 --- a/Sources/JMESPath/Error.swift +++ b/Sources/JMESPath/Error.swift @@ -3,9 +3,9 @@ /// Provides two errors, compile time and run time errors public struct JMESPathError: Error, Equatable { /// Error that occurred while compiling JMESPath - public static func compileTime(_ message: String) -> Self { .init(value: .compileTime(message))} + public static func compileTime(_ message: String) -> Self { .init(value: .compileTime(message)) } /// Error that occurred while running a search - public static func runtime(_ message: String) -> Self { .init(value: .runtime(message))} + public static func runtime(_ message: String) -> Self { .init(value: .runtime(message)) } private enum Internal: Equatable { case compileTime(String) diff --git a/Sources/JMESPath/Expression.swift b/Sources/JMESPath/Expression.swift index 7be703f..229ce9b 100644 --- a/Sources/JMESPath/Expression.swift +++ b/Sources/JMESPath/Expression.swift @@ -23,7 +23,7 @@ public struct Expression { /// - Throws: JMESPathError /// - Returns: Search result public func search(json: String, as: Value.Type = Value.self, runtime: JMESRuntime = .init()) throws -> Value? { - return try search(json: json, runtime: runtime) as? Value + return try self.search(json: json, runtime: runtime) as? Value } /// Search Swift type @@ -35,7 +35,7 @@ public struct Expression { /// - Throws: JMESPathError /// - Returns: Search result public func search(_ any: Any, as: Value.Type = Value.self, runtime: JMESRuntime = .init()) throws -> Value? { - return try search(any, runtime: runtime) as? Value + return try self.search(any, runtime: runtime) as? Value } /// Search JSON diff --git a/Sources/JMESPath/Functions.swift b/Sources/JMESPath/Functions.swift index f2c0e05..bb5589b 100644 --- a/Sources/JMESPath/Functions.swift +++ b/Sources/JMESPath/Functions.swift @@ -1,22 +1,61 @@ import Foundation -/// Function argument used in function signature to verify arguments -public indirect enum FunctionArgumentType { - case any - case null - case string - case number - case boolean - case object - case array - case expRef - case typedArray(FunctionArgumentType) - case union([FunctionArgumentType]) +/// Used to validate arguments of a function before it is run +public struct FunctionSignature { + /// Function argument used in function signature to verify arguments + public indirect enum ArgumentType { + case any + case null + case string + case number + case boolean + case object + case array + case expRef + case typedArray(ArgumentType) + case union([ArgumentType]) + } + + let inputs: [ArgumentType] + let varArg: ArgumentType? + + /// Initialize function signature + /// - Parameters: + /// - inputs: Function parameters + /// - varArg: Additiona variadic parameter + public init(inputs: ArgumentType..., varArg: ArgumentType? = nil) { + self.inputs = inputs + self.varArg = varArg + } + + /// Validate list of arguments, match signature + /// - Parameter args: Array of arguments + /// - Throws: JMESPathError.runtime + func validateArgs(_ args: [JMESVariable]) throws { + guard args.count == self.inputs.count || + (args.count > self.inputs.count && self.varArg != nil) + else { + throw JMESPathError.runtime("Invalid number of arguments") + } + + for i in 0.. self.inputs.count { + for i in self.inputs.count.. Bool { + func isType(_ type: FunctionSignature.ArgumentType) -> Bool { switch (self, type) { case (_, .any), (.string, .string), @@ -29,7 +68,7 @@ extension JMESVariable { return true case (.array(let array), .typedArray(let elementType)): - let childElementsAreType = (array.first { !$0.isType(elementType) } == nil) + let childElementsAreType = (array.first { !JMESVariable(from: $0).isType(elementType) } == nil) return childElementsAreType case (_, .union(let types)): @@ -42,46 +81,35 @@ extension JMESVariable { } } -/// Used to validate arguments of a function before it is run -public struct FunctionSignature { - let inputs: [FunctionArgumentType] - let varArg: FunctionArgumentType? - - init(inputs: FunctionArgumentType..., varArg: FunctionArgumentType? = nil) { - self.inputs = inputs - self.varArg = varArg - } - - func validateArgs(_ args: [JMESVariable]) throws { - guard args.count == self.inputs.count || - (args.count > self.inputs.count && self.varArg != nil) else { - throw JMESPathError.runtime("Invalid number of arguments") - } - - for i in 0.. self.inputs.count { - for i in self.inputs.count.. JMESVariable { +/// return args[0] +/// } +/// } +/// let runtime = JMESRuntime() +/// runtime.registerFunction("identity", function: IdentityFunction.self) +/// // compile expression and run search +/// let expression = try Expression.compile(myExpression) +/// let result = try expression.search(json: myJson, runtime: runtime) +/// ``` +public protocol JMESFunction { /// function signature static var signature: FunctionSignature { get } /// Evaluate function static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable } -protocol NumberFunction: Function { +/// Protocl for JMESPath function that takes a single number +protocol NumberFunction: JMESFunction { static func evaluate(_ number: NSNumber) -> JMESVariable } @@ -97,8 +125,9 @@ extension NumberFunction { } } -protocol ArrayFunction: Function { - static func evaluate(_ array: [JMESVariable]) -> JMESVariable +/// Protocl for JMESPath function that takes a single array +protocol ArrayFunction: JMESFunction { + static func evaluate(_ array: JMESArray) -> JMESVariable } extension ArrayFunction { @@ -113,18 +142,25 @@ extension ArrayFunction { } } +// MARK: Functions + +/// `number abs(number $value)` +/// Returns the absolute value of the provided argument. The signature indicates that a number is returned, and that the +/// input argument must resolve to a number, otherwise a invalid-type error is triggered. struct AbsFunction: NumberFunction { static func evaluate(_ number: NSNumber) -> JMESVariable { return .number(.init(value: abs(number.doubleValue))) } } +/// `number avg(array[number] $elements)` +/// Returns the average of the elements in the provided array. An empty array will produce a return value of null. struct AvgFunction: ArrayFunction { static var signature: FunctionSignature { .init(inputs: .typedArray(.number)) } - static func evaluate(_ array: [JMESVariable]) -> JMESVariable { + static func evaluate(_ array: JMESArray) -> JMESVariable { guard array.count > 0 else { return .null } let total = array.reduce(0.0) { - if case .number(let number) = $1 { + if case .number(let number) = JMESVariable(from: $1) { return $0 + number.doubleValue } else { preconditionFailure() @@ -134,18 +170,22 @@ struct AvgFunction: ArrayFunction { } } +/// `number ceil(number $value)` +/// Returns the next highest integer value by rounding up if necessary. struct CeilFunction: NumberFunction { static func evaluate(_ number: NSNumber) -> JMESVariable { return .number(.init(value: ceil(number.doubleValue))) } } -struct ContainsFunction: Function { +/// `boolean contains(array|string $subject, any $search)` +/// Returns true if the given $subject contains the provided $search string. +struct ContainsFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .union([.array, .string]), .any) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch (args[0], args[1]) { case (.array(let array), _): - let result = array.firstIndex(of: args[1]) != nil + let result = array.first { args[1] == JMESVariable(from: $0) } != nil return .boolean(result) case (.string(let string), .string(let string2)): @@ -161,7 +201,9 @@ struct ContainsFunction: Function { } } -struct EndsWithFunction: Function { +/// `boolean ends_with(string $subject, string $prefix)` +/// Returns true if the $subject ends with the $prefix, otherwise this function returns false. +struct EndsWithFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .string, .string) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch (args[0], args[1]) { @@ -173,19 +215,24 @@ struct EndsWithFunction: Function { } } +/// `number floor(number $value)` +/// Returns the next lowest integer value by rounding down if necessary. struct FloorFunction: NumberFunction { static func evaluate(_ number: NSNumber) -> JMESVariable { return .number(.init(value: floor(number.doubleValue))) } } -struct JoinFunction: Function { +/// `string join(string $glue, array[string] $stringsarray)` +/// Returns all of the elements from the provided $stringsarray array joined together using the +/// $glue argument as a separator between each. +struct JoinFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .string, .typedArray(.string)) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch (args[0], args[1]) { case (.string(let separator), .array(let array)): let strings: [String] = array.map { - if case .string(let s) = $0 { + if case .string(let s) = JMESVariable(from: $0) { return s } else { preconditionFailure() @@ -198,19 +245,28 @@ struct JoinFunction: Function { } } -struct KeysFunction: Function { +/// `array keys(object $obj)` +/// Returns an array containing the keys of the provided object. Note that because JSON hashes are +/// inheritently unordered, the keys associated with the provided object obj are inheritently unordered. +/// Implementations are not required to return keys in any specific order. +struct KeysFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .object) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { case .object(let object): - return .array(object.map { .string($0.key) }) + return .array(object.keys.map { $0 }) default: preconditionFailure() } } } -struct LengthFunction: Function { +/// `number length(string|array|object $subject)` +/// Returns the length of the given argument using the following types rules: +/// 1. string: returns the number of code points in the string +/// 2. array: returns the number of elements in the array +/// 3. object: returns the number of key-value pairs in the object +struct LengthFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .union([.array, .object, .string])) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { @@ -226,12 +282,18 @@ struct LengthFunction: Function { } } -struct MapFunction: Function { +/// `array[any] map(expression->any->any expr, array[any] elements)` +/// Apply the expr to every element in the elements array and return the array of results. An elements +/// of length N will produce a return array of length N. +/// +/// Unlike a projection, `([*].bar)`, map will include the result of applying the expr for every +/// element in the elements array, even if the result if null. +struct MapFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .expRef, .array) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable { switch (args[0], args[1]) { case (.expRef(let ast), .array(let array)): - let results = try array.map { try runtime.interpret($0, ast: ast) } + let results = try array.map { try runtime.interpret(JMESVariable(from: $0), ast: ast).collapse() ?? NSNull() } return .array(results) default: preconditionFailure() @@ -239,16 +301,19 @@ struct MapFunction: Function { } } -struct MaxFunction: Function { +/// `number max(array[number]|array[string] $collection)` +/// Returns the highest found number in the provided array argument. +/// An empty array will produce a return value of null. +struct MaxFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .union([.typedArray(.string), .typedArray(.number)])) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { case .array(let array): - if array.count == 0 { return .null } - switch array[0] { + guard let first = array.first else { return .null } + switch JMESVariable(from: first) { case .string(var max): for element in array.dropFirst() { - if case .string(let string) = element { + if case .string(let string) = JMESVariable(from: element) { if string > max { max = string } @@ -258,7 +323,7 @@ struct MaxFunction: Function { case .number(var max): for element in array.dropFirst() { - if case .number(let number) = element { + if case .number(let number) = JMESVariable(from: element) { if number.compare(max) == .orderedDescending { max = number } @@ -276,18 +341,21 @@ struct MaxFunction: Function { } } -struct MaxByFunction: Function { +/// `max_by(array elements, expression->number|expression->string expr)` +/// Return the maximum element in an array using the expression expr as the comparison key. +/// The entire maximum element is returned. +struct MaxByFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .array, .expRef) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable { switch (args[0], args[1]) { case (.array(let array), .expRef(let ast)): - if array.count == 0 { return .null } - let firstValue = try runtime.interpret(array.first!, ast: ast) - var maxElement: JMESVariable = array.first! + guard let first = array.first else { return .null } + let firstValue = try runtime.interpret(JMESVariable(from: first), ast: ast) + var maxElement: Any = first switch firstValue { case .string(var maxValue): for element in array.dropFirst() { - let value = try runtime.interpret(element, ast: ast) + let value = try runtime.interpret(JMESVariable(from: element), ast: ast) if case .string(let string) = value { if string > maxValue { maxValue = string @@ -297,11 +365,11 @@ struct MaxByFunction: Function { throw JMESPathError.runtime("Invalid argment") } } - return maxElement + return JMESVariable(from: maxElement) case .number(var maxValue): for element in array.dropFirst() { - let value = try runtime.interpret(element, ast: ast) + let value = try runtime.interpret(JMESVariable(from: element), ast: ast) if case .number(let number) = value { if number.compare(maxValue) == .orderedDescending { maxValue = number @@ -311,7 +379,7 @@ struct MaxByFunction: Function { throw JMESPathError.runtime("Invalid argment") } } - return maxElement + return JMESVariable(from: maxElement) default: throw JMESPathError.runtime("Invalid argment") @@ -322,16 +390,18 @@ struct MaxByFunction: Function { } } -struct MinFunction: Function { +/// `number min(array[number]|array[string] $collection)` +/// Returns the lowest found number in the provided $collection argument. +struct MinFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .union([.typedArray(.string), .typedArray(.number)])) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { case .array(let array): - if array.count == 0 { return .null } - switch array[0] { + guard let first = array.first else { return .null } + switch JMESVariable(from: first) { case .string(var min): for element in array { - if case .string(let string) = element { + if case .string(let string) = JMESVariable(from: element) { if string < min { min = string } @@ -341,7 +411,7 @@ struct MinFunction: Function { case .number(var min): for element in array { - if case .number(let number) = element { + if case .number(let number) = JMESVariable(from: element) { if number.compare(min) == .orderedAscending { min = number } @@ -359,18 +429,21 @@ struct MinFunction: Function { } } -struct MinByFunction: Function { +/// min_by(array elements, expression->number|expression->string expr) +/// Return the minimum element in an array using the expression expr as the comparison key. +/// The entire maximum element is returned. +struct MinByFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .array, .expRef) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable { switch (args[0], args[1]) { case (.array(let array), .expRef(let ast)): - if array.count == 0 { return .null } - let firstValue = try runtime.interpret(array.first!, ast: ast) - var minElement: JMESVariable = array.first! + guard let first = array.first else { return .null } + let firstValue = try runtime.interpret(JMESVariable(from: first), ast: ast) + var minElement: Any = first switch firstValue { case .string(var minValue): for element in array.dropFirst() { - let value = try runtime.interpret(element, ast: ast) + let value = try runtime.interpret(JMESVariable(from: element), ast: ast) if case .string(let string) = value { if string < minValue { minValue = string @@ -380,11 +453,11 @@ struct MinByFunction: Function { throw JMESPathError.runtime("Invalid argment") } } - return minElement + return JMESVariable(from: minElement) case .number(var minValue): for element in array.dropFirst() { - let value = try runtime.interpret(element, ast: ast) + let value = try runtime.interpret(JMESVariable(from: element), ast: ast) if case .number(let number) = value { if number.compare(minValue) == .orderedAscending { minValue = number @@ -394,7 +467,7 @@ struct MinByFunction: Function { throw JMESPathError.runtime("Invalid argment") } } - return minElement + return JMESVariable(from: minElement) default: throw JMESPathError.runtime("Invalid argment") @@ -405,7 +478,13 @@ struct MinByFunction: Function { } } -struct MergeFunction: Function { +/// `object merge([object *argument, [, object $...]])` +/// Accepts 0 or more objects as arguments, and returns a single object with subsequent objects +/// merged. Each subsequent object’s key/value pairs are added to the preceding object. This +/// function is used to combine multiple objects into one. You can think of this as the first object +/// being the base object, and each subsequent argument being overrides that are applied to +/// the base object. +struct MergeFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .object, varArg: .object) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { @@ -424,7 +503,11 @@ struct MergeFunction: Function { } } -struct NotNullFunction: Function { +/// `any not_null([any $argument [, any $...]])` +/// Returns the first argument that does not resolve to null. This function accepts one or more +/// arguments, and will evaluate them in order until a non null argument is encounted. If all +/// arguments values resolve to null, then a value of null is returned. +struct NotNullFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .any, varArg: .any) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { for arg in args { @@ -436,7 +519,9 @@ struct NotNullFunction: Function { } } -struct ReverseFunction: Function { +/// `array reverse(string|array $argument)` +/// Reverses the order of the $argument. +struct ReverseFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .union([.array, .string])) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { @@ -450,29 +535,44 @@ struct ReverseFunction: Function { } } -struct SortFunction: Function { +/// `array sort(array[number]|array[string] $list)` +/// This function accepts an array $list argument and returns the sorted elements of the $list +/// as an array. +/// +/// The array must be a list of strings or numbers. Sorting strings is based on code points. +/// Locale is not taken into account. +struct SortFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .union([.typedArray(.number), .typedArray(.string)])) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { case .array(let array): - return .array(array.sorted { $0.compare(.lessThan, value: $1) == true }) + let jmesArray = array.map { JMESVariable(from: $0) } + let sorted = jmesArray.sorted { $0.compare(.lessThan, value: $1) == true } + return .array(sorted.map { $0.collapse() }) default: preconditionFailure() } } } -struct SortByFunction: Function { +/// `sort_by(array elements, expression->number|expression->string expr)` +/// Sort an array using an expression expr as the sort key. For each element in the array of +/// elements, the expr expression is applied and the resulting value is used as the key used +/// when sorting the elements. +/// +/// If the result of evaluating the expr against the current array element results in type other +/// than a number or a string, a type error will occur. +struct SortByFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .array, .expRef) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable { struct ValueAndSortKey { - let value: JMESVariable + let value: Any let sortValue: JMESVariable } switch (args[0], args[1]) { case (.array(let array), .expRef(let ast)): guard let first = array.first else { return .array(array) } - let firstSortValue = try runtime.interpret(first, ast: ast) + let firstSortValue = try runtime.interpret(JMESVariable(from: first), ast: ast) switch firstSortValue { case .string, .number: break @@ -481,7 +581,7 @@ struct SortByFunction: Function { } let restOfTheValues = try array.dropFirst().map { element -> ValueAndSortKey in - let sortValue = try runtime.interpret(element, ast: ast) + let sortValue = try runtime.interpret(JMESVariable(from: element), ast: ast) guard sortValue.isSameType(as: firstSortValue) else { throw JMESPathError.runtime("Sort arguments all have to be the same type") } @@ -489,14 +589,16 @@ struct SortByFunction: Function { } let values = [ValueAndSortKey(value: first, sortValue: firstSortValue)] + restOfTheValues let sorted = values.sorted(by: { $0.sortValue.compare(.lessThan, value: $1.sortValue) == true }) - return .array(sorted.map { $0.value} ) + return .array(sorted.map { $0.value }) default: preconditionFailure() } } } -struct StartsWithFunction: Function { +/// `boolean starts_with(string $subject, string $prefix)` +/// Returns true if the $subject starts with the $prefix, otherwise this function returns false. +struct StartsWithFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .string, .string) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch (args[0], args[1]) { @@ -508,11 +610,14 @@ struct StartsWithFunction: Function { } } +/// `number sum(array[number] $collection)` +/// Returns the sum of the provided array argument. +/// An empty array will produce a return value of 0. struct SumFunction: ArrayFunction { static var signature: FunctionSignature { .init(inputs: .typedArray(.number)) } - static func evaluate(_ array: [JMESVariable]) -> JMESVariable { + static func evaluate(_ array: JMESArray) -> JMESVariable { let total = array.reduce(0.0) { - if case .number(let number) = $1 { + if case .number(let number) = JMESVariable(from: $1) { return $0 + number.doubleValue } else { preconditionFailure() @@ -522,19 +627,29 @@ struct SumFunction: ArrayFunction { } } -struct ToArrayFunction: Function { +/// `array to_array(any $arg)` +/// - array - Returns the passed in value. +/// - number/string/object/boolean - Returns a one element array containing the passed in argument. +struct ToArrayFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .any) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { - case .array(let array): - return .array(array) + case .array: + return args[0] default: - return .array([args[0]]) + return .array([args[0].collapse() ?? NSNull()]) } } } -struct ToNumberFunction: Function { +/// `number to_number(any $arg)` +/// - string - Returns the parsed number. Any string that conforms to the json-number +/// production is supported. Note that the floating number support will be implementation +/// specific, but implementations should support at least IEEE 754-2008 binary64 +/// (double precision) numbers, as this is generally available and widely used. +/// - number - Returns the passed in value. +/// - Everything else - null +struct ToNumberFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .any) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable { switch args[0] { @@ -552,7 +667,11 @@ struct ToNumberFunction: Function { } } -struct ToStringFunction: Function { +/// `string to_string(any $arg)` +/// - string - Returns the passed in value. +/// - number/array/object/boolean - The JSON encoded value of the object. The JSON encoder +/// should emit the encoded JSON value without adding any additional new lines. +struct ToStringFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .any) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable { switch args[0] { @@ -564,22 +683,28 @@ struct ToStringFunction: Function { } } -struct TypeFunction: Function { +/// `string type(array|object|string|number|boolean|null $subject)` +/// Returns the JavaScript type of the given $subject argument as a string value. +/// The return value MUST be one of the following: number, string, boolean, array, object, null +struct TypeFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .any) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) throws -> JMESVariable { return .string(args[0].getType()) } } -struct ValuesFunction: Function { +/// `array values(object $obj)` +/// Returns the values of the provided object. Note that because JSON hashes are inheritently +/// unordered, the values associated with the provided object obj are inheritently unordered. +/// Implementations are not required to return values in any specific order. +struct ValuesFunction: JMESFunction { static var signature: FunctionSignature { .init(inputs: .object) } static func evaluate(args: [JMESVariable], runtime: JMESRuntime) -> JMESVariable { switch args[0] { case .object(let object): - return .array(object.map { $0.value }) + return .array(object.values.map { $0 }) default: preconditionFailure() } } } - diff --git a/Sources/JMESPath/Interpreter.swift b/Sources/JMESPath/Interpreter.swift index bac88ad..8cf0651 100644 --- a/Sources/JMESPath/Interpreter.swift +++ b/Sources/JMESPath/Interpreter.swift @@ -1,5 +1,12 @@ +import Foundation extension JMESRuntime { + /// Interpret Ast given object to search + /// - Parameters: + /// - data: Object to search + /// - ast: AST of search + /// - Throws: JMESPathError.runtime + /// - Returns: Result of search func interpret(_ data: JMESVariable, ast: Ast) throws -> JMESVariable { switch ast { case .field(let name): @@ -67,11 +74,11 @@ extension JMESRuntime { case .projection(let lhs, let rhs): let leftResult = try self.interpret(data, ast: lhs) if case .array(let array) = leftResult { - var collected: [JMESVariable] = [] + var collected: JMESArray = [] for element in array { - let currentResult = try interpret(element, ast: rhs) + let currentResult = try interpret(.init(from: element), ast: rhs) if currentResult != .null { - collected.append(currentResult) + collected.append(currentResult.collapse() ?? NSNull()) } } return .array(collected) @@ -82,9 +89,9 @@ extension JMESRuntime { case .flatten(let node): let result = try self.interpret(data, ast: node) if case .array(let array) = result { - var collected: [JMESVariable] = [] + var collected: JMESArray = [] for element in array { - if case .array(let array2) = element { + if let array2 = element as? JMESArray { collected += array2 } else { collected.append(element) @@ -99,9 +106,9 @@ extension JMESRuntime { if data == .null { return .null } - var collected: [JMESVariable] = [] + var collected: JMESArray = [] for node in elements { - collected.append(try self.interpret(data, ast: node)) + collected.append(try self.interpret(data, ast: node).collapse() ?? NSNull()) } return .array(collected) @@ -109,10 +116,10 @@ extension JMESRuntime { if data == .null { return .null } - var collected: [String: JMESVariable] = [:] + var collected: JMESObject = [:] for element in elements { let valueResult = try self.interpret(data, ast: element.value) - collected[element.key] = valueResult + collected[element.key] = valueResult.collapse() ?? NSNull() } return .object(collected) diff --git a/Sources/JMESPath/Lexer.swift b/Sources/JMESPath/Lexer.swift index 731106b..56adbd4 100644 --- a/Sources/JMESPath/Lexer.swift +++ b/Sources/JMESPath/Lexer.swift @@ -2,7 +2,7 @@ import Foundation /// Lexer object /// -/// Parses raw test to create an array of tokens +/// Parses raw text to create an array of tokens class Lexer { var index: String.Index let text: String diff --git a/Sources/JMESPath/Parser.swift b/Sources/JMESPath/Parser.swift index 0e0772f..ba75f23 100644 --- a/Sources/JMESPath/Parser.swift +++ b/Sources/JMESPath/Parser.swift @@ -12,7 +12,7 @@ class Parser { func parse() throws -> Ast { let result = try self.expression(rbp: 0) - guard case .eof = peek() else { + guard case .eof = self.peek() else { throw JMESPathError.compileTime("Did you not parse the complete expression") } return result diff --git a/Sources/JMESPath/Runtime.swift b/Sources/JMESPath/Runtime.swift index 62020ee..07c4f9e 100644 --- a/Sources/JMESPath/Runtime.swift +++ b/Sources/JMESPath/Runtime.swift @@ -2,47 +2,50 @@ /// /// Holds list of functions available to JMESPath expression public class JMESRuntime { + /// Initialize `JMESRuntime` public init() { - self.functions = [:] - self.registerBuiltInFunctions() + self.functions = Self.builtInFunctions } - public func registerFunction(_ name: String, function: Function.Type) { + /// Register new function with runtime + /// - Parameters: + /// - name: Function name + /// - function: Function object + public func registerFunction(_ name: String, function: JMESFunction.Type) { self.functions[name] = function } - func getFunction(_ name: String) -> Function.Type? { + func getFunction(_ name: String) -> JMESFunction.Type? { return self.functions[name] } - func registerBuiltInFunctions() { - self.registerFunction("abs", function: AbsFunction.self) - self.registerFunction("avg", function: AvgFunction.self) - self.registerFunction("ceil", function: CeilFunction.self) - self.registerFunction("contains", function: ContainsFunction.self) - self.registerFunction("ends_with", function: EndsWithFunction.self) - self.registerFunction("floor", function: FloorFunction.self) - self.registerFunction("join", function: JoinFunction.self) - self.registerFunction("keys", function: KeysFunction.self) - self.registerFunction("length", function: LengthFunction.self) - self.registerFunction("map", function: MapFunction.self) - self.registerFunction("max", function: MaxFunction.self) - self.registerFunction("max_by", function: MaxByFunction.self) - self.registerFunction("min", function: MinFunction.self) - self.registerFunction("min_by", function: MinByFunction.self) - self.registerFunction("merge", function: MergeFunction.self) - self.registerFunction("not_null", function: NotNullFunction.self) - self.registerFunction("reverse", function: ReverseFunction.self) - self.registerFunction("sort", function: SortFunction.self) - self.registerFunction("sort_by", function: SortByFunction.self) - self.registerFunction("starts_with", function: StartsWithFunction.self) - self.registerFunction("sum", function: SumFunction.self) - self.registerFunction("to_array", function: ToArrayFunction.self) - self.registerFunction("to_number", function: ToNumberFunction.self) - self.registerFunction("to_string", function: ToStringFunction.self) - self.registerFunction("type", function: TypeFunction.self) - self.registerFunction("values", function: ValuesFunction.self) - } - - var functions: [String: Function.Type] + private var functions: [String: JMESFunction.Type] + private static var builtInFunctions: [String: JMESFunction.Type] = [ + "abs": AbsFunction.self, + "avg": AvgFunction.self, + "ceil": CeilFunction.self, + "contains": ContainsFunction.self, + "ends_with": EndsWithFunction.self, + "floor": FloorFunction.self, + "join": JoinFunction.self, + "keys": KeysFunction.self, + "length": LengthFunction.self, + "map": MapFunction.self, + "max": MaxFunction.self, + "max_by": MaxByFunction.self, + "min": MinFunction.self, + "min_by": MinByFunction.self, + "merge": MergeFunction.self, + "not_null": NotNullFunction.self, + "reverse": ReverseFunction.self, + "sort": SortFunction.self, + "sort_by": SortByFunction.self, + "starts_with": StartsWithFunction.self, + "sum": SumFunction.self, + "to_array": ToArrayFunction.self, + "to_number": ToNumberFunction.self, + "to_string": ToStringFunction.self, + "type": TypeFunction.self, + "values": ValuesFunction.self, + ] } diff --git a/Sources/JMESPath/Variable.swift b/Sources/JMESPath/Variable.swift index 8eb86ce..f40d0b1 100644 --- a/Sources/JMESPath/Variable.swift +++ b/Sources/JMESPath/Variable.swift @@ -1,14 +1,17 @@ import CoreFoundation import Foundation +public typealias JMESArray = [Any] +public typealias JMESObject = [String: Any] + /// Internal representation of a variable -public enum JMESVariable: Equatable { +public enum JMESVariable { case null case string(String) case number(NSNumber) case boolean(Bool) - case array([JMESVariable]) - case object([String: JMESVariable]) + case array(JMESArray) + case object(JMESObject) case expRef(Ast) /// initialize JMESVariable from a swift type @@ -17,40 +20,40 @@ public enum JMESVariable: Equatable { case let string as String: self = .string(string) case let number as NSNumber: + // both booleans and integer/float point types can be converted to a `NSNumber` + // We have to check to see the type id to see if it is a boolean if CFGetTypeID(number) == CFBooleanGetTypeID() { self = .boolean(number.boolValue) } else { self = .number(number) } case let array as [Any]: - self = .array(array.map { .init(from: $0)}) + self = .array(array) case let set as Set: - self = .array(set.map { .init(from: $0)}) + self = .array(set.map { $0 }) case let dictionary as [String: Any]: - self = .object(dictionary.mapValues { .init(from: $0)} ) + self = .object(dictionary) default: if any is NSNull { self = .null return } + // use Mirror to build JMESVariable.object let mirror = Mirror(reflecting: any) guard mirror.children.count > 0 else { - self = .null + self = .object([:]) return } - var dictionary: [String: JMESVariable] = [:] + var object: JMESObject = [:] for child in mirror.children { guard let label = child.label else { self = .null return } - guard let unwrapValue = unwrap(child.value) else { - self = .null - return - } - dictionary[label] = JMESVariable(from: unwrapValue) + let unwrapValue = Self.unwrap(child.value) ?? NSNull() + object[label] = unwrapValue } - self = .object(dictionary) + self = .object(object) } } @@ -63,20 +66,13 @@ public enum JMESVariable: Equatable { /// Collapse JMESVariable back to its equivalent Swift type public func collapse() -> Any? { switch self { - case .null: - return nil - case .string(let string): - return string - case .number(let number): - return number - case .boolean(let bool): - return bool - case .array(let array): - return array.map { $0.collapse() } - case .object(let map): - return map.mapValues { $0.collapse() } - case .expRef: - return nil + case .null: return nil + case .string(let string): return string + case .number(let number): return number + case .boolean(let bool): return bool + case .array(let array): return array + case .object(let map): return map + case .expRef: return nil } } @@ -90,14 +86,12 @@ public enum JMESVariable: Equatable { case .boolean(let bool): return String(describing: bool) case .array(let array): - let collapsed = array.map { $0.collapse() } - guard let jsonData = try? JSONSerialization.data(withJSONObject: collapsed, options: [.fragmentsAllowed]) else { + guard let jsonData = try? JSONSerialization.data(withJSONObject: array, options: [.fragmentsAllowed]) else { return nil } return String(decoding: jsonData, as: Unicode.UTF8.self) case .object(let object): - let collapsed = object.mapValues { $0.collapse() } - guard let jsonData = try? JSONSerialization.data(withJSONObject: collapsed, options: [.fragmentsAllowed]) else { + guard let jsonData = try? JSONSerialization.data(withJSONObject: object, options: [.fragmentsAllowed]) else { return nil } return String(decoding: jsonData, as: Unicode.UTF8.self) @@ -137,7 +131,7 @@ public enum JMESVariable: Equatable { /// Get variable for field from object type public func getField(_ key: String) -> JMESVariable { if case .object(let object) = self { - return object[key] ?? .null + return object[key].map { JMESVariable(from: $0) } ?? .null } return .null } @@ -147,7 +141,7 @@ public enum JMESVariable: Equatable { if case .array(let array) = self { let index = array.calculateIndex(index) if index >= 0, index < array.count { - return array[index] + return JMESVariable(from: array[index]) } } return .null @@ -164,6 +158,11 @@ public enum JMESVariable: Equatable { } } + /// Compare JMESVariable with another using supplied comparator + /// - Parameters: + /// - comparator: Comparison operation + /// - value: Other value + /// - Returns: True/False or nil if variables cannot be compared public func compare(_ comparator: Comparator, value: JMESVariable) -> Bool? { switch comparator { case .equal: return self == value @@ -193,27 +192,96 @@ public enum JMESVariable: Equatable { return nil } - func slice(start: Int?, stop: Int?, step: Int) -> [JMESVariable]? { + /// Generate Array slice if variable is an array + func slice(start: Int?, stop: Int?, step: Int) -> JMESArray? { if case .array(let array) = self, step != 0 { - return array.slice( - start: start.map { array.calculateIndex($0) }, - stop: stop.map { array.calculateIndex($0) }, - step: step - ) + var start2 = start.map { array.calculateIndex($0) } ?? (step > 0 ? 0 : array.count - 1) + var stop2 = stop.map { array.calculateIndex($0) } ?? (step > 0 ? array.count : -1) + + if step > 0 { + start2 = Swift.min(Swift.max(start2, 0), array.count) + stop2 = Swift.min(Swift.max(stop2, 0), array.count) + } else { + start2 = Swift.min(Swift.max(start2, -1), array.count - 1) + stop2 = Swift.min(Swift.max(stop2, -1), array.count - 1) + } + if start2 <= stop2, step > 0 { + let slice = array[start2.. 0 else { return [] } + return slice.skipElements(step: step) + } else if start2 > stop2, step < 0 { + let slice = array[(stop2 + 1)...start2].reversed().map { $0 } + guard step < 0 else { return [] } + return slice.skipElements(step: -step) + } else { + return [] + } } return nil } + + /// unwrap optional + private static func unwrap(_ any: Any) -> Any? { + let mirror = Mirror(reflecting: any) + guard mirror.displayStyle == .optional else { return any } + guard let first = mirror.children.first else { return nil } + return first.value + } } -/// unwrap optional -func unwrap(_ any: Any) -> Any? { - let mirror = Mirror(reflecting: any) - guard mirror.displayStyle == .optional else { return any } - guard let first = mirror.children.first else { return nil } - return first.value +extension JMESVariable: Equatable { + /// extend JMESVariable to be `Equatable`. Need to write custom equals function + /// as it needs the custom `equalTo` functions for arrays and objects + public static func == (lhs: JMESVariable, rhs: JMESVariable) -> Bool { + switch (lhs, rhs) { + case (.null, .null): + return true + case (.string(let lhs), .string(let rhs)): + return lhs == rhs + case (.boolean(let lhs), .boolean(let rhs)): + return lhs == rhs + case (.number(let lhs), .number(let rhs)): + return lhs == rhs + case (.array(let lhs), .array(let rhs)): + return lhs.equalTo(rhs) + case (.object(let lhs), .object(let rhs)): + return lhs.equalTo(rhs) + case (.expRef(let lhs), .expRef(let rhs)): + return lhs == rhs + default: + return false + } + } +} + +extension JMESArray { + /// return if arrays are equal by converting entries to `JMESVariable` + fileprivate func equalTo(_ rhs: JMESArray) -> Bool { + guard self.count == rhs.count else { return false } + for i in 0.. Bool { + guard self.count == rhs.count else { return false } + for element in self { + guard let rhsValue = rhs[element.key], JMESVariable(from: rhsValue) == JMESVariable(from: element.value) else { + return false + } + } + return true + } } extension Array { + /// calculate actual index. Negative indices read backwards from end of array func calculateIndex(_ index: Int) -> Int { if index >= 0 { return index @@ -221,38 +289,12 @@ extension Array { return count + index } } - - /// Slice implementation - func slice(start: Int?, stop: Int?, step: Int) -> [Element] { - var start2 = start ?? (step > 0 ? 0 : self.count - 1) - var stop2 = stop ?? (step > 0 ? self.count : -1) - - if step > 0 { - start2 = Swift.min(Swift.max(start2, 0), count) - stop2 = Swift.min(Swift.max(stop2, 0), count) - } else { - start2 = Swift.min(Swift.max(start2, -1), count-1) - stop2 = Swift.min(Swift.max(stop2, -1), count-1) - } - if start2 <= stop2, step > 0 { - let slice = self[start2.. 0 else { return [] } - return slice.everyOther(step: step) - } else if start2 > stop2, step < 0 { - let slice = self[(stop2+1)...(start2)].reversed().map { $0 } - guard step < 0 else { return [] } - return slice.everyOther(step: -step) - } else { - return [] - } - } } extension RandomAccessCollection { - func everyOther(step: Int) -> [Element] { - if step == 0 { - return [] - } + /// return array where we skip so many elements between each entry. + func skipElements(step: Int) -> [Element] { + precondition(step > 0, "Cannot have non-zero or negative step") if step == 1 { return self.map { $0 } } diff --git a/Tests/JMESPathTests/ComplianceTests.swift b/Tests/JMESPathTests/ComplianceTests.swift index 80247a3..014f654 100644 --- a/Tests/JMESPathTests/ComplianceTests.swift +++ b/Tests/JMESPathTests/ComplianceTests.swift @@ -67,15 +67,15 @@ final class ComplianceTests: XCTestCase { func run() throws { for c in self.cases { if let _ = c.bench { - testBenchmark(c) + self.testBenchmark(c) } else if let error = c.error { - testError(c, error: error) + self.testError(c, error: error) } else { - testResult(c, result: c.result?.value) + self.testResult(c, result: c.result?.value) } } } - + func testBenchmark(_ c: Case) { do { let expression = try Expression.compile(c.expression) @@ -102,7 +102,7 @@ final class ComplianceTests: XCTestCase { func testResult(_ c: Case, result: Any?) { do { let expression = try Expression.compile(c.expression) - + let resultJson: String? = try result.map { let data = try JSONSerialization.data(withJSONObject: $0, options: [.fragmentsAllowed, .sortedKeys]) return String(decoding: data, as: Unicode.UTF8.self) @@ -118,7 +118,7 @@ final class ComplianceTests: XCTestCase { XCTFail("\(error)") } } - + func output(_ c: Case, expected: String?, result: String?) { if expected != result { let data = try! JSONSerialization.data(withJSONObject: self.given.value, options: [.fragmentsAllowed, .sortedKeys]) @@ -130,7 +130,6 @@ final class ComplianceTests: XCTestCase { print("Given: \(givenJson)") print("Expected: \(expected ?? "nil")") print("Result: \(result ?? "nil")") - } } } diff --git a/Tests/JMESPathTests/MirrorTests.swift b/Tests/JMESPathTests/MirrorTests.swift index 9371350..e073df5 100644 --- a/Tests/JMESPathTests/MirrorTests.swift +++ b/Tests/JMESPathTests/MirrorTests.swift @@ -35,7 +35,7 @@ final class MirrorTests: XCTestCase { let f: Float let b: Bool } - let test = TestNumbers(i:34, d: 1.4, f: 2.5, b: true) + let test = TestNumbers(i: 34, d: 1.4, f: 2.5, b: true) self.testInterpreter("i", data: test, result: 34) self.testInterpreter("d", data: test, result: 1.4) self.testInterpreter("f", data: test, result: 2.5) @@ -46,7 +46,7 @@ final class MirrorTests: XCTestCase { struct TestArray { let a: [Int] } - let test = TestArray(a: [1,2,3,4,5]) + let test = TestArray(a: [1, 2, 3, 4, 5]) self.testInterpreter("a[2]", data: test, result: 3) self.testInterpreter("a[-2]", data: test, result: 4) self.testInterpreter("a[1]", data: test, result: 2) @@ -57,6 +57,7 @@ final class MirrorTests: XCTestCase { struct TestSubObject { let a: String } + let sub: TestSubObject } let test = TestObject(sub: .init(a: "hello"))