From e58ddd1882890904d2d2d006c5285e03aff456b4 Mon Sep 17 00:00:00 2001 From: Thomas Rasch Date: Mon, 9 Sep 2024 13:31:14 +0200 Subject: [PATCH] #25: Filter by property (#32) --- README.md | 150 ++++- Sources/MVTCLI/Query.swift | 4 +- .../Extensions/DoubleExtensions.swift | 7 + .../MVTTools/Extensions/IntExtensions.swift | 9 + .../Extensions/StringExtensions.swift | 26 + Sources/MVTTools/Query.swift | 15 +- Sources/MVTTools/QueryParser.swift | 626 ++++++++++++++++++ Tests/MVTToolsTests/QueryParserTests.swift | 200 ++++++ 8 files changed, 1021 insertions(+), 16 deletions(-) create mode 100644 Sources/MVTTools/Extensions/DoubleExtensions.swift create mode 100644 Sources/MVTTools/Extensions/IntExtensions.swift create mode 100644 Sources/MVTTools/Extensions/StringExtensions.swift create mode 100644 Sources/MVTTools/QueryParser.swift create mode 100644 Tests/MVTToolsTests/QueryParserTests.swift diff --git a/README.md b/README.md index aa8d185..8ced33f 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,19 @@ # MVTTools -MapLibre/Mapbox vector tiles (MVT) reader/writer library for Swift, together with a tool for working with vector tiles from the command line. +MapLibre/Mapbox vector tiles (MVT) reader/writer library for Swift, together with a powerful tool for working with vector tiles and GeoJSONs from the command line. ## Features -- Load and write Mapnik Vector Tiles from/to disk, data objects or URLs (also handles gzipped input) -- Export options: Zipped, buffered (in pixels or extents), simplified (in meters or extents) -- Can dump a tile as a GeoJSON object -- Supported projections: EPSG:4326, EPSG:3857 or none (uses the tile's coordinate space) -- Fast search (supports indexing), either within a bounding box or with center and radius -- Extract selected layers into a new tile -- Merge two tiles into one +- Load and write MapLibre/Mapbox Vector Tiles from/to disk, data objects or URLs (also handles gzipped input). +- Export options: Zipped, buffered (in pixels or extents), simplified (in meters or extents). +- Can dump a tile as a GeoJSON object. +- Supported projections: EPSG:4326, EPSG:3857 or none (uses the tile's coordinate space). +- Fast search (supports indexing), either within a bounding box or with center and radius. +- Extract selected layers into a new tile. +- Merge tiles into one. - Can extract some infos from tiles like feature count, etc. -- Powerful command line tool (via [Homebrew](#command-line-tool), documentation below) for working with vector tiles and GeoJSON files +- Powerful command line tool (via [Homebrew](#command-line-tool), documentation below) for working with vector tiles and GeoJSON files. ## Requirements @@ -176,7 +176,10 @@ mvt dump Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt --- ### mvt info -Print some informations about vector tiles/GeoJSONs. +Print some informations about vector tiles/GeoJSONs: +- The number of features, points, linestrings, polygons per layer +- The properties for each layer +- Counts of specific properties **Example 1**: Print information about the MVTTools test vector tile at zoom 14, at Yaoundé, Cameroon. @@ -207,10 +210,37 @@ mvt info https://demotiles.maplibre.org/tiles/2/2/1.pbf countries | 113 | 0 | 0 | 113 | 0 | 2 geolines | 4 | 0 | 4 | 0 | 0 | 2 ``` +--- + +**Example 3**: Print information about the properties for each layer. + +```bash +mvt info Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt + + Name | area | class | group | layer | ldir | len | name | name_de | name_en | name_es | name_fr | network | oneway | ref | reflen | scalerank | type +--------------------+------+-------+-------+-------+------+-----+------+---------+---------+---------+---------+---------+--------+-----+--------+-----------+----- + airport_label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 + area_label | 55 | 55 | 0 | 0 | 0 | 0 | 55 | 55 | 55 | 55 | 55 | 0 | 0 | 0 | 0 | 0 | 0 + barrier_line | 0 | 4219 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 + bridge | 0 | 14 | 0 | 13 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 14 | 0 | 0 | 0 | 0 +... +``` +--- + +**Example 4**: Print information about specific properties. + +```bash +mvt info -p class Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt + + Name | cemetery | driveway | fence | hedge | hospital | industrial | main | major_rail | mini_roundabout | minor_rail | motorway | park | parking | path | pitch | rail | school | service | street | street_limited | wetland | wood +-------+----------+----------+-------+-------+----------+------------+------+------------+-----------------+------------+----------+------+---------+------+-------+------+--------+---------+--------+----------------+---------+----- + class | 4 | 36 | 3895 | 324 | 9 | 2 | 113 | 21 | 1 | 13 | 30 | 95 | 59 | 46 | 21 | 2 | 59 | 187 | 376 | 4 | 4 | 12 +``` + --- ### mvt query -Query a vector tile or GeoJSON file with a search term. +**Example 1**: Query a vector tile or GeoJSON file with a search term. ```bash mvt query Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt "École" @@ -246,7 +276,7 @@ mvt query Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt "École" } ``` --- -Query a tile with `latitude,longitude,radius`. +**Example 2**: Query a tile with `latitude,longitude,radius`. ```bash mvt query Tests/MVTToolsTests/TestData/14_8716_8015.geojson "3.87324,11.53731,1000" @@ -276,6 +306,102 @@ mvt query Tests/MVTToolsTests/TestData/14_8716_8015.geojson "3.87324,11.53731,10 ... } ``` +--- +**Example 3**: Query a tile for properties. + +```bash +mvt query -p Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt ".area > 40000 and .class == 'hospital'" + +{ + "features" : [ + { + "bbox" : [ + 11.510410308837876, + 3.871287406415171, + 11.510410308837876, + 3.871287406415171 + ], + "geometry" : { + "coordinates" : [ + 11.510410308837876, + 3.871287406415171 + ], + "type" : "Point" + }, + "id" : 2, + "properties" : { + "area" : 48364.9375, + "class" : "hospital", + "name" : "Hopital Central de Yaoundé", + "name_de" : "Hopital Central de Yaoundé", + "name_en" : "Hopital Central de Yaoundé", + "name_es" : "Hopital Central de Yaoundé", + "name_fr" : "Hopital Central de Yaoundé", + "vt_layer" : "area_label" + }, + "type" : "Feature" + } + ], + "type" : "FeatureCollection" +} +``` + +The query language is loosely modeled after the jq query language. Here is an overview. + +Example: +``` +"properties": { + "foo": {"bar": 1}, + "some": ["a", "b"], + "value": 1, + "string": "Some name" +} +``` + +Values are retrieved by putting a `.` in front of the property name. The property name must be quoted +if it is a number or contains any non-alphabetic characters. Elements in arrays can be +accesses either by simply using the array index after the dot, or by wrapping it in brackets. + +``` +.foo // true, property "foo" exists +.foo.bar // true, property "foo" is a dictionary containing "bar" +."foo"."bar" // true, same as above but quoted +.foo.x // false, "foo" doesn't contain "x" +."foo.bar" // false, property "foo.bar" doesn't exist +.foo.[0] // false, "foo" is not an array +.some.[0] // true, "some" is an array and has an element at index "0" +.some.0 // true, same as above but without brackets +.some."0" // false, "0" is a string key but "some" is not a dictionary +``` + +Comparisons can be expressed like this: + +``` +.value == "bar" // false +.value == 1 // true +.value != 1 // false +.value > 1 // false +.value >= 1 // true +.value < 1 // false +.value <= 1 // true +.string =~ /[Ss]ome/ // true +.string =~ /some/ // false +.string =~ /some/i // true, case insensitive +.string =~ "^Some" // true +``` + +Conditions (evaluated left to right): + +``` +.foo.bar == 1 and .value == 1 // true +.foo == 1 or .bar == 2 // false +.foo == 1 or .value == 1 // true +.foo not // true if foo does not exist +.foo and .bar not // true if foo and bar don't exist together +.foo or .bar not // true if neither foo nor bar exist +.foo.bar not // true if "bar" in dictionary "foo" doesn't exist +``` + --- ### mvt merge diff --git a/Sources/MVTCLI/Query.swift b/Sources/MVTCLI/Query.swift index e3aabd3..a2443ec 100644 --- a/Sources/MVTCLI/Query.swift +++ b/Sources/MVTCLI/Query.swift @@ -77,7 +77,7 @@ extension CLI { var path: String @Argument(help: "Search term, can be a string or a coordinate in the form 'latitude,longitude,tolerance(meters)'.") - var searchTerm: String + var searchTerms: [String] mutating func run() async throws { if let outputFile { @@ -94,6 +94,8 @@ extension CLI { } } + let searchTerm = searchTerms.joined(separator: " ") + var coordinate: Coordinate3D? var tolerance: CLLocationDistance? diff --git a/Sources/MVTTools/Extensions/DoubleExtensions.swift b/Sources/MVTTools/Extensions/DoubleExtensions.swift new file mode 100644 index 0000000..39ddaa5 --- /dev/null +++ b/Sources/MVTTools/Extensions/DoubleExtensions.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Float { + + var asDouble: Double { Double(self) } + +} diff --git a/Sources/MVTTools/Extensions/IntExtensions.swift b/Sources/MVTTools/Extensions/IntExtensions.swift new file mode 100644 index 0000000..e1fce3b --- /dev/null +++ b/Sources/MVTTools/Extensions/IntExtensions.swift @@ -0,0 +1,9 @@ +import Foundation + +extension BinaryInteger { + + var asInt: Int { Int(self) } + + var asUInt: UInt { UInt(self) } + +} diff --git a/Sources/MVTTools/Extensions/StringExtensions.swift b/Sources/MVTTools/Extensions/StringExtensions.swift new file mode 100644 index 0000000..e3e208a --- /dev/null +++ b/Sources/MVTTools/Extensions/StringExtensions.swift @@ -0,0 +1,26 @@ +import Foundation + +extension String { + + var isNotEmpty: Bool { !isEmpty } + + func matches(_ regex: String) -> Bool { + var options: String.CompareOptions = .regularExpression + + var regex = regex + if regex.hasPrefix("/") { + regex.removeFirst() + + if regex.hasSuffix("/i") { + options.insert(.caseInsensitive) + regex.removeLast(2) + } + else if regex.hasSuffix("/") { + regex.removeLast() + } + } + + return self.range(of: regex, options: options) != nil + } + +} diff --git a/Sources/MVTTools/Query.swift b/Sources/MVTTools/Query.swift index c815463..7508931 100644 --- a/Sources/MVTTools/Query.swift +++ b/Sources/MVTTools/Query.swift @@ -51,15 +51,24 @@ extension VectorTile { layerNames } + let queryParser = QueryParser(string: term) + var result: [QueryResult] = [] for layerName in queryLayerNames { guard let layerFeatureContainer = layers[layerName] else { continue } let resultFeatures: [Feature] = layerFeatureContainer.features.filter({ feature in - for value in feature.properties.values.compactMap({ $0 as? String }) { - if value.contains(term) { - return true + if let queryParser, + let properties = feature.properties as? [String: AnyHashable] + { + return queryParser.evaluate(on: properties) + } + else { + for value in feature.properties.values.compactMap({ $0 as? String }) { + if value.contains(term) { + return true + } } } diff --git a/Sources/MVTTools/QueryParser.swift b/Sources/MVTTools/QueryParser.swift new file mode 100644 index 0000000..2b9e008 --- /dev/null +++ b/Sources/MVTTools/QueryParser.swift @@ -0,0 +1,626 @@ +import Foundation + +public struct QueryParser { + + public enum Expression: Equatable { + // Comparisons + public enum Comparison: Equatable { + case equals + case notEquals + case greaterThan + case greaterThanOrEqual + case lessThan + case lessThanOrEqual + case regex + } + + // Conditions + public enum Condition: Equatable { + case and + case or + case not + } + + // Key or index + public enum KeyOrIndex: Equatable { + case key(String) + case index(Int) + } + + case comparison(Comparison) + case condition(Condition) + case literal(AnyHashable) + case value([KeyOrIndex]) + } + + private let reader: Reader? + private(set) var pipeline: [Expression]? + + public init?(string: String) { + guard string.hasPrefix(".") else { return nil } + + self.reader = Reader(characters: Array(string.utf8)) + self.parseQuery() + } + + public init(pipeline: [Expression]) { + self.reader = nil + self.pipeline = pipeline + } + + // Works in a reverse polish notation + public func evaluate(on properties: [String: AnyHashable]) -> Bool { + guard let pipeline else { return false } + + var stack: [AnyHashable?] = [] + + for expression in pipeline { + switch expression { + case let .literal(value): + stack.insert(value, at: 0) + + case let .value(keys): + var current: AnyHashable? = properties + + for keyOrIndex in keys { + switch keyOrIndex { + case let .key(key): + if let object = current as? [String: AnyHashable] { + current = object[key] + } + else { + current = nil + break + } + + case let .index(index): + if let array = current as? [AnyHashable] { + current = array.get(at: index) + } + else { + current = nil + break + } + } + } + + stack.insert(current, at: 0) + + case let .comparison(comparison): + switch comparison { + case .equals, .notEquals: + guard stack.count >= 2, + let second = stack.removeFirst(), + let first = stack.removeFirst() + else { return false } + + if comparison == .equals { + stack.insert(first == second, at: 0) + } + else { + stack.insert(first != second, at: 0) + } + + case .greaterThan, .greaterThanOrEqual, .lessThan, .lessThanOrEqual: + guard stack.count >= 2, + let second = stack.removeFirst(), + let first = stack.removeFirst() + else { return false } + + stack.insert(compare(first: first, second: second, condition: comparison), at: 0) + + case .regex: + guard stack.count >= 2, + let regex = stack.removeFirst() as? String, + let value = stack.removeFirst() as? String + else { return false } + + stack.insert(value.matches(regex), at: 0) + } + + case let .condition(condition): + switch condition { + case .and, .or: + guard stack.count >= 2 else { return false } + + let second = stack.removeFirst() + let first = stack.removeFirst() + let firstIsTrue = if let bool = first as? Bool { bool } else { first != nil } + let secondIsTrue = if let bool = second as? Bool { bool } else { second != nil } + + if condition == .and { + stack.insert(firstIsTrue && secondIsTrue, at: 0) + } + else { + stack.insert(firstIsTrue || secondIsTrue, at: 0) + } + + case .not: + guard stack.isNotEmpty else { return false } + + let value = stack.removeFirst() + let valueIsTrue = if let bool = value as? Bool { bool } else { value != nil } + + stack.insert(!valueIsTrue, at: 0) + } + } + } + + // The stack should contain the result now + guard stack.count == 1, + let result = stack.first + else { return false } + + if let bool = result as? Bool { + return bool + } + + return result != nil + } + + // This needs improvement - can this be done in a more generic way? + // Only the most common cases covered for now + private func compare( + first: AnyHashable, + second: AnyHashable, + condition: QueryParser.Expression.Comparison) + -> Bool + { + if let left = first as? Int { + if let right = second as? Int { + return compare(left: left, right: right, condition: condition) + } + else if let right = second as? UInt { + return compare(left: UInt(left), right: right, condition: condition) + } + else if let right = second as? Double { + return compare(left: Double(left), right: right, condition: condition) + } + } + else if let left = first as? Double { + if let right = second as? Double { + return compare(left: left, right: right, condition: condition) + } + else if let right = second as? Int { + return compare(left: left, right: Double(right), condition: condition) + } + else if let right = second as? UInt { + return compare(left: left, right: Double(right), condition: condition) + } + } + else if let left = first as? UInt { + if let right = second as? UInt { + return compare(left: left, right: right, condition: condition) + } + else if let right = second as? Int { + return compare(left: left, right: UInt(right), condition: condition) + } + else if let right = second as? Double { + return compare(left: Double(left), right: right, condition: condition) + } + } + else if let left = first as? String, let right = second as? String { + return compare(left: left, right: right, condition: condition) + } + + return false + } + + private func compare( + left: T, + right: T, + condition: QueryParser.Expression.Comparison) + -> Bool + { + switch condition { + case .equals: + return left == right + case .notEquals: + return left != right + case .greaterThan: + return left > right + case .greaterThanOrEqual: + return left >= right + case .lessThan: + return left < right + case .lessThanOrEqual: + return left <= right + case .regex: + guard let value = left as? String, let regex = right as? String else { return false } + return value.matches(regex) + } + } + + private mutating func parseQuery() { + // skipWhitespace returns the first non-whitespace character, + // which must be a '.' + guard var reader, + let firstCharacter = reader.skipWhitespace(), + firstCharacter == UInt8(ascii: ".") + else { return } + + pipeline = [] + + var terms: [Expression] = [] + var comparison: Expression? + var condition: Expression? + var isBeginningOfTerm = false + + outer: while let char = reader.peek() { + // Check for: + // - and, or, not + // - ==, !=, >, >=, <, <=, =~ + if isBeginningOfTerm { + let hasAnd = reader.peekString("and", caseInsensitive: true) + let hasOr = reader.peekString("or", caseInsensitive: true) + let hasNot = reader.peekString("not", caseInsensitive: true) + + if hasAnd || hasOr || hasNot { + pipeline?.append(contentsOf: terms) + if let comparison { + pipeline?.append(comparison) + } + if let condition { + pipeline?.append(condition) + } + terms = [] + comparison = nil + condition = nil + isBeginningOfTerm = false + + if hasAnd { + condition = .condition(.and) + reader.moveIndex(by: 3) + } + else if hasOr { + condition = .condition(.or) + reader.moveIndex(by: 2) + } + else { + pipeline?.append(.condition(.not)) + reader.moveIndex(by: 3) + } + + continue + } + + // Must be in the middle, otherwise it's just some literal value + if terms.count == 1, + let term = reader.readComparisonExpression() + { + isBeginningOfTerm = false + comparison = term + continue + } + } + + switch char { + case UInt8(ascii: " "): + reader.skipWhitespace() + isBeginningOfTerm = true + continue + + case UInt8(ascii: "."): + guard let term = reader.readValueExpression() else { return } + isBeginningOfTerm = false + terms.append(term) + + default: + guard let term = reader.readLiteralExpression() else { return } + isBeginningOfTerm = false + terms.append(term) + } + } + + pipeline?.append(contentsOf: terms) + if let comparison { + pipeline?.append(comparison) + } + if let condition { + pipeline?.append(condition) + } + } + + // MARK: - Reader + + struct Reader { + + let characters: [UInt8] + + private var index: Int = 0 + + init(characters: [UInt8]) { + self.characters = characters + } + + mutating func readNextCharacter() -> UInt8? { + guard index < characters.endIndex else { + index = characters.endIndex + return nil + } + + defer { index += 1 } + + return characters[index] + } + + mutating func moveIndex(by offset: Int) { + index += offset + } + + func peek(withOffset offset: Int = 0) -> UInt8? { + guard index + offset < characters.endIndex else { return nil } + + return characters[index + offset] + } + + func peekString(_ string: String, caseInsensitive: Bool) -> Bool { + guard index + string.count <= characters.endIndex else { return false } + + let peekString = caseInsensitive ? string.lowercased() : string + + for (offset, char) in peekString.utf8.enumerated() { + var c = characters[index + offset] + if caseInsensitive, c >= 65, c <= 90 { + c += 32 + } + + if c != char { return false } + } + + return true + } + + @discardableResult + mutating func skipWhitespace() -> UInt8? { + var offset = 0 + + while let char = peek(withOffset: offset) { + if char == UInt8(ascii: " ") { + offset += 1 + continue + } + + moveIndex(by: offset) + return char + } + + return nil + } + + mutating func readValueExpression() -> Expression? { + guard readNextCharacter() == UInt8(ascii: ".") else { return nil } + + var startIndex = index + var offset = 0 + var parts: [QueryParser.Expression.KeyOrIndex] = [] + + outer: while let char = peek(withOffset: offset) { + switch char { + case UInt8(ascii: " "): + break outer + + case UInt8(ascii: "."): + if let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8), + current.isNotEmpty + { + if let index = Int(current) { + parts.append(.index(index)) + } + else { + parts.append(.key(current)) + } + } + + moveIndex(by: offset + 1) + + startIndex = index + offset = 0 + + case UInt8(ascii: "\""): + guard let quotedString = readQuotedString(UInt8(ascii: "\"")) else { return nil } + + parts.append(.key(quotedString)) + startIndex = index + offset = 0 + + case UInt8(ascii: "'"): + guard let quotedString = readQuotedString(UInt8(ascii: "'")) else { return nil } + + parts.append(.key(quotedString)) + startIndex = index + offset = 0 + + case UInt8(ascii: "["): + guard let arrayIndex = readArrayIndex() else { return nil } + + parts.append(.index(arrayIndex)) + startIndex = index + offset = 0 + + default: + offset += 1 + } + } + + moveIndex(by: offset) + + if let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8), + current.isNotEmpty + { + if let index = Int(current) { + parts.append(.index(index)) + } + else { + parts.append(.key(current)) + } + } + + return .value(parts) + } + + mutating func readLiteralExpression() -> Expression? { + var startIndex = index + var offset = 0 + var result = "" + + outer: while let char = peek(withOffset: offset) { + switch char { + case UInt8(ascii: " "): + break outer + + case UInt8(ascii: "\""): + guard let quotedString = readQuotedString(UInt8(ascii: "\"")) else { return nil } + + result += quotedString + startIndex = index + offset = 0 + + case UInt8(ascii: "'"): + guard let quotedString = readQuotedString(UInt8(ascii: "'")) else { return nil } + + result += quotedString + startIndex = index + offset = 0 + + default: + offset += 1 + } + } + + moveIndex(by: offset) + + if let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8) { + result += current + } + + guard result.isNotEmpty else { return nil } + + if let int = Int(result) { + return .literal(int) + } + else if let double = Double(result) { + return .literal(double) + } + + return .literal(result) + } + + mutating func readComparisonExpression() -> Expression? { + let firstChar = peek() + + guard firstChar == UInt8(ascii: "=") + || firstChar == UInt8(ascii: "!") + || firstChar == UInt8(ascii: ">") + || firstChar == UInt8(ascii: "<") + else { return nil } + + if let secondChar = peek(withOffset: 1), + secondChar != UInt8(ascii: " ") + { + if secondChar == UInt8(ascii: "=") { + if firstChar == UInt8(ascii: "=") { + moveIndex(by: 2) + return .comparison(.equals) + } + else if firstChar == UInt8(ascii: "!") { + moveIndex(by: 2) + return .comparison(.notEquals) + } + else if firstChar == UInt8(ascii: ">") { + moveIndex(by: 2) + return .comparison(.greaterThanOrEqual) + } + else if firstChar == UInt8(ascii: "<") { + moveIndex(by: 2) + return .comparison(.lessThanOrEqual) + } + } + else if secondChar == UInt8(ascii: "~") { + moveIndex(by: 2) + return .comparison(.regex) + } + } + else { + if firstChar == UInt8(ascii: ">") { + moveIndex(by: 1) + return .comparison(.greaterThan) + } + else if firstChar == UInt8(ascii: "<") { + moveIndex(by: 1) + return .comparison(.lessThan) + } + } + + return nil + } + + mutating func readQuotedString(_ quotationCharacter: UInt8) -> String? { + guard readNextCharacter() == quotationCharacter else { return nil } + + var startIndex = index + var offset = 0 + var result = "" + + while let char = peek(withOffset: offset) { + switch char { + case quotationCharacter: + moveIndex(by: offset + 1) + + guard let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8) else { return nil } + + result += current + return result + + case UInt8(ascii: "\\"): + moveIndex(by: offset) + + guard let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8), + readNextCharacter() == UInt8(ascii: "\\") + else { return nil } + + result += current + + guard let escaped = readNextCharacter() else { return nil } + + result += String(cString: [escaped, 0]) + + startIndex = index + offset = 0 + + default: + offset += 1 + } + } + + return nil + } + + private mutating func readArrayIndex() -> Int? { + guard readNextCharacter() == UInt8(ascii: "[") else { return nil } + + let startIndex = index + var offset = 0 + + while let char = peek(withOffset: offset) { + switch char { + case UInt8(ascii: "]"): + moveIndex(by: offset + 1) + + guard let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8) else { + return nil + } + + return Int(current) + + default: + offset += 1 + } + } + + return nil + } + + } + +} diff --git a/Tests/MVTToolsTests/QueryParserTests.swift b/Tests/MVTToolsTests/QueryParserTests.swift new file mode 100644 index 0000000..84b5909 --- /dev/null +++ b/Tests/MVTToolsTests/QueryParserTests.swift @@ -0,0 +1,200 @@ +import XCTest + +@testable import MVTTools + +final class QueryParserTests: XCTestCase { + + private static let properties: [String: Sendable] = [ + "foo": [ + "bar": 1, + "baz": UInt8(10) + ], + "some": [ + "a", + "b", + ], + "value": 1, + "string": "Some name" + ] + + private func result(for pipeline: [QueryParser.Expression]) -> Bool { + QueryParser(pipeline: pipeline).evaluate(on: QueryParserTests.properties as! [String: AnyHashable]) + } + + private func pipeline(for query: String) -> [QueryParser.Expression] { + QueryParser(string: query)?.pipeline ?? [] + } + + func testValues() throws { + XCTAssertTrue(result(for: [.value([.key("foo")])])) + XCTAssertTrue(result(for: [.value([.key("foo"), .key("bar")])])) + XCTAssertFalse(result(for: [.value([.key("foo"), .key("x")])])) + XCTAssertFalse(result(for: [.value([.key("foo.bar")])])) + XCTAssertFalse(result(for: [.value([.key("foo"), .index(0)])])) + XCTAssertTrue(result(for: [.value([.key("some"), .index(0)])])) + } + + func testComparisons() throws { + XCTAssertFalse(result(for: [.value([.key("value")]), .literal("bar"), .comparison(.equals)])) + XCTAssertTrue(result(for: [.value([.key("value")]), .literal(1), .comparison(.equals)])) + XCTAssertTrue(result(for: [.value([.key("value")]), .literal(1.0), .comparison(.equals)])) + XCTAssertFalse(result(for: [.value([.key("value")]), .literal(1), .comparison(.notEquals)])) + XCTAssertFalse(result(for: [.value([.key("value")]), .literal(1), .comparison(.greaterThan)])) + XCTAssertTrue(result(for: [.value([.key("value")]), .literal(1), .comparison(.greaterThanOrEqual)])) + XCTAssertTrue(result(for: [.value([.key("value")]), .literal(0.5), .comparison(.greaterThanOrEqual)])) + XCTAssertFalse(result(for: [.value([.key("value")]), .literal(1), .comparison(.lessThan)])) + XCTAssertTrue(result(for: [.value([.key("value")]), .literal(1), .comparison(.lessThanOrEqual)])) + XCTAssertTrue(result(for: [.value([.key("value")]), .literal(1.5), .comparison(.lessThanOrEqual)])) + XCTAssertTrue(result(for: [.value([.key("foo"), .key("baz")]), .literal(10), .comparison(.equals)])) + XCTAssertFalse(result(for: [.value([.key("x")]), .literal(1), .comparison(.equals)])) + XCTAssertTrue(result(for: [.value([.key("string")]), .literal("name$"), .comparison(.regex)])) + XCTAssertTrue(result(for: [.value([.key("string")]), .literal("/[Ss]ome/"), .comparison(.regex)])) + XCTAssertFalse(result(for: [.value([.key("string")]), .literal("^some"), .comparison(.regex)])) + XCTAssertTrue(result(for: [.value([.key("string")]), .literal("/^some/i"), .comparison(.regex)])) + } + + func testConditions() throws { + XCTAssertTrue(result(for: [ + .value([.key("foo"), .key("bar")]), + .literal(1), + .comparison(.equals), + .value([.key("value")]), + .literal(1), + .comparison(.equals), + .condition(.and), + ])) + XCTAssertFalse(result(for: [ + .value([.key("foo")]), + .literal(1), + .comparison(.equals), + .value([.key("bar")]), + .literal(2), + .comparison(.equals), + .condition(.or), + ])) + XCTAssertTrue(result(for: [ + .value([.key("foo")]), + .literal(1), + .comparison(.equals), + .value([.key("value")]), + .literal(1), + .comparison(.equals), + .condition(.or), + ])) + XCTAssertFalse(result(for: [ + .value([.key("foo")]), + .condition(.not), + ])) + XCTAssertTrue(result(for: [ + .value([.key("foo")]), + .value([.key("bar")]), + .condition(.and), + .condition(.not), + ])) + XCTAssertFalse(result(for: [ + .value([.key("foo")]), + .value([.key("some")]), + .condition(.and), + .condition(.not), + ])) + XCTAssertFalse(result(for: [ + .value([.key("foo")]), + .value([.key("bar")]), + .condition(.or), + .condition(.not), + ])) + XCTAssertTrue(result(for: [ + .value([.key("x")]), + .value([.key("y")]), + .condition(.or), + .condition(.not), + ])) + XCTAssertFalse(result(for: [ + .value([.key("foo"), .key("bar")]), + .condition(.not), + ])) + XCTAssertTrue(result(for: [ + .value([.key("foo"), .key("x")]), + .condition(.not), + ])) + } + + func testValueQueries() throws { + XCTAssertEqual(pipeline(for: ".foo"), [.value([.key("foo")])]) + XCTAssertEqual(pipeline(for: ".foo.bar"), [.value([.key("foo"), .key("bar")])]) + XCTAssertEqual(pipeline(for: ".foo.x"), [.value([.key("foo"), .key("x")])]) + XCTAssertEqual(pipeline(for: ".\"foo\".\"bar\""), [.value([.key("foo"), .key("bar")])]) + XCTAssertEqual(pipeline(for: ".\"foo.bar\""), [.value([.key("foo.bar")])]) + XCTAssertEqual(pipeline(for: ".foo.[0]"), [.value([.key("foo"), .index(0)])]) + XCTAssertEqual(pipeline(for: ".some.0"), [.value([.key("some"), .index(0)])]) + } + + func testComparisonQueries() throws { + XCTAssertEqual(pipeline(for: ".value == \"bar\""), [.value([.key("value")]), .literal("bar"), .comparison(.equals)]) + XCTAssertEqual(pipeline(for: ".value == 'bar'"), [.value([.key("value")]), .literal("bar"), .comparison(.equals)]) + XCTAssertEqual(pipeline(for: ".value == 'bar\"baz'"), [.value([.key("value")]), .literal("bar\"baz"), .comparison(.equals)]) + + XCTAssertEqual(pipeline(for: ".value == 1"), [.value([.key("value")]), .literal(1), .comparison(.equals)]) + XCTAssertEqual(pipeline(for: ".value != 1"), [.value([.key("value")]), .literal(1), .comparison(.notEquals)]) + XCTAssertEqual(pipeline(for: ".value > 1"), [.value([.key("value")]), .literal(1), .comparison(.greaterThan)]) + XCTAssertEqual(pipeline(for: ".value >= 1"), [.value([.key("value")]), .literal(1), .comparison(.greaterThanOrEqual)]) + XCTAssertEqual(pipeline(for: ".value < 1"), [.value([.key("value")]), .literal(1), .comparison(.lessThan)]) + XCTAssertEqual(pipeline(for: ".value <= 1"), [.value([.key("value")]), .literal(1), .comparison(.lessThanOrEqual)]) + + XCTAssertEqual(pipeline(for: ".string =~ /[Ss]ome/"), [.value([.key("string")]), .literal("/[Ss]ome/"), .comparison(.regex)]) + XCTAssertEqual(pipeline(for: ".string =~ /some/"), [.value([.key("string")]), .literal("/some/"), .comparison(.regex)]) + XCTAssertEqual(pipeline(for: ".string =~ /some/i"), [.value([.key("string")]), .literal("/some/i"), .comparison(.regex)]) + XCTAssertEqual(pipeline(for: ".string =~ \"^Some\""), [.value([.key("string")]), .literal("^Some"), .comparison(.regex)]) + } + + func testConditionQueries() throws { + XCTAssertEqual(pipeline(for: ".foo.bar == 1 and .value == 1"), [ + .value([.key("foo"), .key("bar")]), + .literal(1), + .comparison(.equals), + .value([.key("value")]), + .literal(1), + .comparison(.equals), + .condition(.and), + ]) + XCTAssertEqual(pipeline(for: ".foo == 1 or .bar == 2"), [ + .value([.key("foo")]), + .literal(1), + .comparison(.equals), + .value([.key("bar")]), + .literal(2), + .comparison(.equals), + .condition(.or), + ]) + XCTAssertEqual(pipeline(for: ".foo == 1 or .value == 1"), [ + .value([.key("foo")]), + .literal(1), + .comparison(.equals), + .value([.key("value")]), + .literal(1), + .comparison(.equals), + .condition(.or), + ]) + XCTAssertEqual(pipeline(for: ".foo not"), [ + .value([.key("foo")]), + .condition(.not), + ]) + XCTAssertEqual(pipeline(for: ".foo and .bar not"), [ + .value([.key("foo")]), + .value([.key("bar")]), + .condition(.and), + .condition(.not), + ]) + XCTAssertEqual(pipeline(for: ".foo or .bar not"), [ + .value([.key("foo")]), + .value([.key("bar")]), + .condition(.or), + .condition(.not), + ]) + XCTAssertEqual(pipeline(for: ".foo.bar not"), [ + .value([.key("foo"), .key("bar")]), + .condition(.not), + ]) + } + +}