Sextant

Sextant 是一个完整的、高性能的 JSONPath 实现,使用 Swift 编写。它最初是从 SMJJSONPath 移植而来,而 SMJJSONPath 本身又是对 Jayway JsonPath 实现的紧密改编。Sextant 后来经过更新,以使其与其他 JSONPath 实现保持一致(请参阅 issue),因此这个特定实现现在与 SMJJSONPath/Jayway 实现有所不同。

入门指南

目标

用法

import Sextant

/// Each call to Sextant's query(values: ) will return an array on success and nil on failure
func testSimple0() {
    let json = #"["Hello","World"]"#
    guard let results = json.query(values: "$[0]") else { return XCTFail() }
    XCTAssertEqualAny(results[0], "Hello")
}
/// Works with any existing JSON-like structure
func testSimple2() {
    let data = [ "Hello", "World" ]
    guard let results = data.query(values: "$[0]") else { return XCTFail() }
    XCTAssertEqualAny(results[0], "Hello")
}
/// Automatically covert to simple tuples
func testSimple3() {
    let json = #"{"name":"Rocco","age":42}"#
    
    guard let person: (name: String?, age: Int?) = json.query("$.['name','age']") else { return XCTFail() }
    XCTAssertEqual(person.name, "Rocco")
    XCTAssertEqual(person.age, 42)
}
/// Supports Decodable structs
func testSimple4() {
    let json = #"{"data":{"people":[{"name":"Rocco","age":42},{"name":"John","age":12},{"name":"Elizabeth","age":35},{"name":"Victoria","age":85}]}}"#
    
    class Person: Decodable {
        let name: String
        let age: Int
    }
    
    guard let persons: [Person] = json.query("$..[?(@.name)]") else { return XCTFail() }
    XCTAssertEqual(persons[0].name, "Rocco")
    XCTAssertEqual(persons[0].age, 42)
    XCTAssertEqual(persons[2].name, "Elizabeth")
    XCTAssertEqual(persons[2].age, 35)
}
/// Easily combine results from multiple queries
func testSimple5() {
    let json1 = #"{"error":"Error format 1"}"#
    let json2 = #"{"errors":[{"title:":"Error!","detail":"Error format 2"}]}"#
            
    let queries: [String] = [
        "$.error",
        "$.errors[0].detail",
    ]
    
    XCTAssertEqualAny(json1.query(string: queries), "Error format 1")
    XCTAssertEqualAny(json2.query(string: queries), "Error format 2")
}
/// High performance JSON processing by using .parsed() to get
/// a quick view of the raw json to execute on paths on
func testSimple9() {
    let data = #"{"DtCutOff":"2018-01-01 00:00:00","ServiceGroups":[{"ServiceName":"Service1","DtUpdate":"2021-11-22 00:00:00","OrderNumber":"123456","Active":"true"},{"ServiceName":"Service2","DtUpdate":"2021-11-20 00:00:00","OrderNumber":"123456","Active":true},{"ServiceName":"Service3","DtUpdate":"2021-11-10 00:00:00","OrderNumber":"123456","Active":false}]}"#
        
    data.parsed { json in
        guard let json = json else { XCTFail(); return }
    
        guard let isActive: Bool = json.query("$.ServiceGroups[*][?(@.ServiceName=='Service1')].Active") else { XCTFail(); return }
        XCTAssertEqual(isActive, true)
        
        guard let date: Date = json.query("$.ServiceGroups[*][?(@.ServiceName=='Service1')].DtUpdate") else { XCTFail(); return }
        XCTAssertEqual(date, "2021-11-22 00:00:00".date())
    }
}
/// Use replace, map, filter, remove and forEach to perform mofications to your json
func testSimple10() {
    let json = #"{"data":{"people":[{"name":"Rocco","age":42,"gender":"m"},{"name":"John","age":12,"gender":"m"},{"name":"Elizabeth","age":35,"gender":"f"},{"name":"Victoria","age":85,"gender":"f"}]}}"#

    let modifiedJson: String? = json.parsed { root in
        guard let root = root else { XCTFail(); return nil }
        
        // Remove all females
        root.query(remove: "$..people[?(@.gender=='f')]")
        
        // Incremet all ages by 1
        root.query(map: "$..age", {
            guard let age = $0.intValue else { return $0 }
            return age + 1
        })
        
        // Lowercase all names
        root.query(map: "$..name", { $0.hitchValue?.lowercase() })
        
        return root.description
    }
    
    XCTAssertEqual(modifiedJson, #"{"data":{"people":[{"name":"rocco","age":43,"gender":"m"},{"name":"john","age":13,"gender":"m"}]}}"#)
}
/// Use a single map to accomplish the same task as above but with only one pass through the data
func testSimple11() {
    let json = #"{"data":{"people":[{"name":"Rocco","age":42,"gender":"m"},{"name":"John","age":12,"gender":"m"},{"name":"Elizabeth","age":35,"gender":"f"},{"name":"Victoria","age":85,"gender":"f"}]}}"#
    
    let modifiedJson: String? = json.query(map: "$..people[*] ", { person in
        // Remove all females, increment age by 1, lowercase all names
        guard person["gender"]?.stringValue == "m" else {
            return nil
        }
        if let age = person["age"]?.intValue {
            person.set(key: "age", value: age + 1)
        }
        if let name = person["name"]?.hitchValue {
            person.set(key: "name", value: name.lowercase())
        }
        return person
    }) { root in
        return root.description
    }

    XCTAssertEqual(modifiedJson, #"{"data":{"people":[{"name":"rocco","age":43,"gender":"m"},{"name":"john","age":13,"gender":"m"}]}}"#)
}
/// You are not bound to just modify existing elements in your JSON,
/// you can return any json-like structure in your mapping
func testSimple12() {
    let oldJson = #"{"someValue": ["elem1", "elem2", "elem3"]}"#
    let newJson: String? = oldJson.query(map: "$.someValue", {_ in
        return ["elem4", "elem5"]
    } ) { root in
        return root.description
    }
    XCTAssertEqual(newJson, #"{"someValue":["elem4","elem5"]}"#)
}
/// For the performance minded, your maps should do as little work as possible
/// per replacement. To improve on the previous example, we could create our
/// replacement element outside of the mapping to reduce unnecessary work.
func testSimple13() {
    let oldJson = #"{"someValue": ["elem1", "elem2", "elem3"]}"#
    let replacementElement = JsonElement(unknown: ["elem4", "elem5"])
    let newJson: String? = oldJson.query(map: "$.someValue", {_ in
        return replacementElement
    } ) { root in
        return root.description
    }
    XCTAssertEqual(newJson, #"{"someValue":["elem4","elem5"]}"#)
}
/// Example of handling an heterogenous array. The task is to iterate over all
/// operations and perform a dynamic lookup to the operation function, perform
/// the task and coallate the results.
func testSimple14() {
    let json = #"[{"name":"add","inputs":[3,4]},{"name":"subtract","inputs":[6,3]},{"echo":"Hello, world"},{"name":"increment","input":41},{"echo":"Hello, world"}]"#
    
    let operations: [HalfHitch: (JsonElement) -> (Int?)] = [
        "add": { input in
            guard let values = input[element: "inputs"] else { return nil }
            guard let lhs = values[int: 0] else { return nil }
            guard let rhs = values[int: 1] else { return nil }
            return lhs + rhs
        },
        "subtract": { input in
            guard let values = input[element: "inputs"] else { return nil }
            guard let lhs = values[int: 0] else { return nil }
            guard let rhs = values[int: 1] else { return nil }
            return lhs - rhs
        },
        "increment": { input in
            guard let value = input[int: "input"] else { return nil }
            return value + 1
        }
    ]
    
    var results = [Int]()
    
    json.query(forEach: #"$[?(@.name)]"#) { operation in
        if let opName = operation[halfHitch: "name"],
           let opFunc = operations[opName] {
            results.append(opFunc(operation) ?? 0)
        }
    }
            
    XCTAssertEqual(results, [7,3,42])
}
/// You can test a json path for validity by calling .query(validate)
func testSimple17() {
    let json = #"[{"title":"Post 1","timestamp":1},{"title":"Post 2","timestamp":2}]"#

    XCTAssertEqual(json.query(validate: "$"), nil)
    XCTAssertEqual(json.query(validate: ""), "Path must start with $ or @")
    XCTAssertEqual(json.query(validate: "$."), "Path must not end with a \'.\' or \'..\'")
    XCTAssertEqual(json.query(validate: "$.."), "Path must not end with a \'.\' or \'..\'")
    XCTAssertEqual(json.query(validate: "$.store.book[["), "Could not parse token starting at position 12.")
}

性能

Sextant 利用 Hitch(高性能字符串)和 Spanker(高性能、低开销 JSON 反序列化)为 Swift 提供一流的 JSONPath 实现。Hitch 允许快速、utf8 共享内存字符串。Spanker 生成 JSON blob 的低成本视图,Sextant 随后针对该视图查询 JSONPath。在它们作为查询结果返回之前,不会反序列化任何内容,也不会从源 JSON blob 复制任何内存。在您有大量 JSON 和/或大量查询需要对其运行时,Sextant 真正发挥作用。

安装

Sextant 完全兼容 Swift Package Manager

dependencies: [
    .package(url: "https://github.com/KittyMac/Sextant.git", .upToNextMinor(from: "0.4.0"))
],

什么是 JSONPath

最初的 Stefan Goessner JsonPath 实现于 2007 年发布,并由此衍生出数十种不同的实现。此 JSONPath 比较图表显示了各种可用的实现,并且在撰写本文时,Swift 实现尚不存在(请注意,存在 SwiftPath 项目,但由于 在 Linux 上运行时出现严重错误,因此未包含在该图表中)。

本节的其余部分主要改编自 Jayway JsonPath 入门指南部分。

运算符

运算符 描述
$ 要查询的根元素。这将启动所有路径表达式。
@ 过滤器谓词正在处理的当前节点。
* 通配符。在需要名称或数字的任何地方都可用。
.. 深度扫描。在需要名称的任何地方都可用。
.<name> 点表示法子级
['<name>' (, '<name>')] 括号表示法子级或子级们
[<number> (, <number>)] 数组索引或索引们
[start:end] 数组切片运算符
[?(<expression>)] 过滤器表达式。表达式必须求值为布尔值。

函数

函数可以在路径的末尾调用 - 函数的输入是路径表达式的输出。函数输出由函数本身决定。

函数 描述 输出类型
min() 提供数字数组的最小值 Double
max() 提供数字数组的最大值 Double
avg() 提供数字数组的平均值 Double
stddev() 提供数字数组的标准偏差值 Double
length() 提供数组的长度 Integer
sum() 提供数字数组的总和值 Double
keys() 提供属性键(终端波浪号 ~ 的替代方案) Set<E>
concat(X) 提供路径输出与新项的连接版本 类似输入
append(X) 向 jsonpath 输出数组添加一个项目 类似输入

过滤器运算符

过滤器是用于过滤数组的逻辑表达式。典型的过滤器是 [?(@.age > 18)],其中 @ 表示正在处理的当前项。可以使用逻辑运算符 &&|| 创建更复杂的过滤器。字符串文字必须用单引号或双引号括起来([?(@.color == 'blue')][?(@.color == "blue")])。

运算符 描述
== 左侧等于右侧(请注意 1 不等于 '1')
!= 左侧不等于右侧
< 左侧小于右侧
<= 左侧小于或等于右侧
> 左侧大于右侧
>= 左侧大于或等于右侧
=~ 左侧匹配正则表达式 [?(@.name =~ /foo.*?/i)]
in 左侧存在于右侧 [?(@.size in ['S', 'M'])]
nin 左侧不存在于右侧
subsetof 左侧是右侧的子集 [?(@.sizes subsetof ['S', 'M', 'L'])]
anyof 左侧与右侧有交集 [?(@.sizes anyof ['M', 'L'])]
noneof 左侧与右侧没有交集 [?(@.sizes noneof ['M', 'L'])]
size 左侧(数组或字符串)的大小应与右侧匹配
empty 左侧(数组或字符串)应为空

路径示例

给定 json

{
  "store": {
    "book": [
      {
        "category": "reference",
        "author": "Nigel Rees",
        "title": "Sayings of the Century",
        "display-price": 8.95,
        "bargain": true
      },
      {
        "category": "fiction",
        "author": "Evelyn Waugh",
        "title": "Sword of Honour",
        "display-price": 12.99,
        "bargain": false
      },
      {
        "category": "fiction",
        "author": "Herman Melville",
        "title": "Moby Dick",
        "isbn": "0-553-21311-3",
        "display-price": 8.99,
        "bargain": true
      },
      {
        "category": "fiction",
        "author": "J. R. R. Tolkien",
        "title": "The Lord of the Rings",
        "isbn": "0-395-19395-8",
        "display-price": 22.99,
        "bargain": false
      }
    ],
    "bicycle": {
      "color": "red",
      "display-price": 19.95,
      "foo:bar": "fooBar",
      "dot.notation": "new",
      "dash-notation": "dashes"
    }
  }
}
JsonPath(点击链接尝试) 结果
$.store.book[*].author 所有书籍的作者
$..['author','title'] 所有作者和标题
$.store.* 所有事物,包括书籍和自行车
$.store..display-price 所有事物的价格
$..book[2] 第三本书
$..book[-2] 倒数第二本书
$..book[0,1] 前两本书
$..book[:2] 从索引 0(包含)到索引 2(不包含)的所有书籍
$..book[1:2] 从索引 1(包含)到索引 2(不包含)的所有书籍
$..book[-2:] 最后两本书
$..book[2:] 从末尾开始的第二本书
$..book[?(@.isbn)] 所有带有 ISBN 编号的书籍
$.store.book[?(@.display-price < 10)] 商店中所有价格低于 10 的书籍
$..book[?(@.bargain == true)] 商店中所有特价书籍
$..book[?(@.author =~ /.*REES/i)] 所有匹配正则表达式的书籍(忽略大小写)
$..* 给我一切东西
$..book.length() 书籍的数量

许可证

Sextant 是根据 MIT 许可证条款分发的免费软件,以下为许可证内容。Sextant 可用于任何目的,包括商业目的,且完全免费。无需文书工作、无需版税、没有 GNU 式的“著作权共享”限制。只需下载并享受即可。

版权所有 (c) 2021 Chimera Software, LLC

特此授予许可,免费向任何获得本软件和相关文档文件(“软件”)副本的人员授予许可,以不受限制地处理本软件,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或销售软件副本的权利,并允许向已获得本软件的人员提供软件,但须符合以下条件

上述版权声明和本许可声明应包含在软件的所有副本或主要部分中。

本软件按“原样”提供,不提供任何形式的明示或暗示的保证,包括但不限于适销性、特定用途的适用性和不侵权的保证。在任何情况下,作者或版权持有人均不对任何索赔、损害或其他责任负责,无论是在合同诉讼、侵权诉讼或其他诉讼中,因本软件或本软件的使用或其他交易而产生、出于或与之相关。