SPARQL:查询
PurRDF 的 SPARQL 栈为原生实现,分为三层,并以 W3C SPARQL 1.1 与 1.2 一致性测试套件把关:
purrdf-sparql-algebra—— 把查询与更新文本解析为由 PurRDF 自有、原生支持 RDF 1.2 的查询代数(Query/GraphPattern、Update/GraphUpdateOperation)。仅负责解析与代数。purrdf-sparql-eval—— 基于冻结 IR 的DatasetView的多重集求值器,完全在驻留的TermId空间中运行。purrdf-sparql-results—— 结果边界(下一章)。
三者都在 purrdf::sparql 下重新导出。
第一个查询
#![allow(unused)]
fn main() {
use purrdf::{RdfDatasetBuilder, RdfLiteral, SparqlEngine, SparqlRequest, SparqlResult};
use purrdf::sparql::NativeSparqlEngine;
// 驻留 TermId 空间中的一个微型数据集。
let mut b = RdfDatasetBuilder::new();
let cat = b.intern_iri("https://example.org/cat").expect("subject");
let says = b.intern_iri("https://example.org/says").expect("predicate");
let meow = b.intern_literal(RdfLiteral::simple("meow")).expect("object");
b.push_quad(cat, says, meow, None).expect("quad");
let ds = b.freeze().expect("freeze");
// 通过 SparqlEngine 扩展点求值;解析后的计划会被记忆化缓存。
let engine = NativeSparqlEngine::new();
let result = engine.query(&ds, SparqlRequest {
query: "SELECT ?what WHERE { <https://example.org/cat> <https://example.org/says> ?what }",
base_iri: None,
substitutions: &[],
}).expect("evaluates");
if let SparqlResult::Solutions { rows, .. } = result {
assert_eq!(rows.len(), 1);
}
}
SparqlEngine trait 本身位于 purrdf-core 中,因此宿主可以在同一个扩展点背后替换引擎;NativeSparqlEngine 是随附的实现。
前端覆盖的范围
- 查询 —— 全部四种查询形式(SELECT/ASK/CONSTRUCT/DESCRIBE)、基本图模式、
OPTIONAL、UNION、MINUS、GRAPH、FILTER/BIND/VALUES、属性路径、GROUP BY/聚合、EXISTS/NOT EXISTS、解修饰符、RDF 1.2 引用三元组项(<<( s p o )>>)、LATERAL(SEP-0006 扩展,见下文),以及 SEP-0009 复合数据类型及其FOLD聚合与UNFOLD图模式(见下文)。 - 更新 ——
INSERT DATA/DELETE DATA、DELETE/INSERT … WHERE系列(WITH/USING、DELETE WHERE)、LOAD,以及CLEAR/DROP/CREATE/ADD/MOVE/COPY。 - 语法之外 —— 其余一切都经由本页后文描述的、由调用方提供键的扩展点进入,绝不以新语法的形式出现:路径见证、全文检索、GeoSPARQL 与嵌入最近邻,各自都是注册在调用方所提供 IRI 下的一个属性函数(对
geof:系列而言,则是标量函数)。
超出这一接口的任何内容——以及每一个格式错误的查询——都是带类型的 ParseError,绝不会是静默降级的解析结果。
命名图的生命周期
默认策略 GraphExistenceMode::RememberEmpty 会从命名图创建起一直记住它,直到它被删除,无论它是否包含行。CREATE 注册一个空槽位,并拒绝重复创建,除非使用 SILENT;CLEAR 以及移除图的最后一行都会保留槽位,DROP 则移除槽位。缺失的 CLEAR 或 DROP 目标图,或缺失的 ADD、COPY 或 MOVE 源图,会在任何更改发生之前被拒绝,除非使用 SILENT。常量和变量形式的 GRAPH 查询都能看到声明的空图。默认图始终存在。
GraphExistenceMode::Implicit 是退出选项,保留 3.x 基于行的生命周期:CREATE 不注册槽位,CLEAR 和 DROP 都会移除命名图。通过 MutableDataset::new_with_graph_existence 或 QueryOptions::with_graph_existence 选择它。
Python 通过 Store(remember_empty_graphs=False) 或 MutableDataset(remember_empty_graphs=False) 选择同一策略;该关键字参数默认为 True。两者共享的 add_graph 和 named_graphs 方法使用原生命名节点或空白节点图名称。UPDATE、受治理的 UPDATE、Store.checkpoint 和 MutableDataset.compact 都保留该选择。WebAssembly 通过 new Dataset(false) 或 Dataset.parse(input, format, base, false) 选择它;C ABI 通过 purrdf_graph_from_dataset、purrdf_update_governed、purrdf_update_governed_xpath_regex 或 purrdf_update_governed_with_geo 的 graph_existence 参数传入 PURRDF_GRAPH_EXISTENCE_IMPLICIT 选择;命令行则通过 purrdf update --graph-existence implicit 选择。通过 TriG、JSON-LD 和 TriX 加载及导出声明的空图,在两种模式下都能工作;模式是变更策略,而非序列化数据。停止的 UPDATE 不发布任何内容,包括此前操作建立的声明。
求值器的工作方式
- 多重集(bag)语义 —— 按照 SPARQL 代数,解是一个多重集,在
DISTINCT/REDUCED之前一直保持。 - 驻留求值 —— 常量只解析一次,得到数据集中的
TermId;解的比较是整数比较;计算出的 FILTER/BIND 值若已存在于数据集中,会在生成时提升为驻留 id。 - 引擎内属性路径 —— 完整的路径代数(
* + ? / | ^ !())在同一个带索引的接口上求值,且 wasm 安全。 - 基于代价的 BGP 规划 —— 连接顺序由代价模型选定;
NativeSparqlEngine::explain_query以三元组模式字符串的有序列表形式公开所选顺序,从而无需运行查询即可审查规划器的决策。 EXISTS/NOT EXISTS,有据可依 —— 采用单一的、基于替换的定义(SEP-0007),其应答要么来自记忆化的存在性探测,要么来自逐行定义本身,二者按调用位置由预备阶段的可采纳性证明选定——见下文。- SERVICE 扩展点——
SERVICE联邦查询通过一个宿主可注入的ServiceResolver求值:引擎本身不执行任何 I/O,因此联邦查询可以移植到 wasm,并由宿主决定如何(以及是否)到达远程端点——细到逐服务的请求头、凭据与能力(见下文)。止步于何处:PurRDF 不附带 HTTP 客户端(交换是一个由 Rust 宿主实现的HttpTransporttrait)。CLI、Python 与 C 接口不安装解析器,wasm 包的同步方法同样不安装,因此那里的SERVICE或LOAD会按名称失败,除非写了SILENT。wasm 包的异步方法接受宿主提供的解析器——作业经由 JSPI 挂起、等待其应答的 JavaScript 处理函数(见入门:JavaScript)。联邦查询是宿主的组合。全部七个 W3Cservice联邦查询用例都经由这个扩展点通过。被转发的主体经由确定性的序列化器重新输出——这就是联邦查询的线上格式——其解析 → 序列化 → 再解析的保真度在 823 项语料上做了扫描(每一个随库固化的 W3C 与第一方查询和更新文本,外加本书自己的示例),例外台账为空。 - 受治理的孪生入口与 explain 回执 —— 每个查询/更新入口都有一个受治理的对应入口,在调用方设定的上限(燃料(fuel)、答案行数、中间单元格数、暂存字节数、远程请求数、截止时间)下运行,触发时给出经认证的行,而不是错误答案;
explain_query返回一个QueryExplanation,其台账按代数节点与计费点分解所消耗的燃料,并附上代价规划器对每个基本图模式的估计。规范性计费表与冻结的 54 例 governor(执行调控器)语料记录在docs/SPARQL-GOVERNOR-PROFILE.md中。 - 硬失败 —— 超出范围的代数节点或未实现的内置函数都是带类型的
EvalError::Unsupported,绝不会是部分或错误的答案。
数值限制
PurRDF 对任意大小的 xsd:integer 与 xsd:decimal 都做精确计算。XSD 1.1 允许处理器限制这两个值空间,前提是至少支持 16 位十进制数字,并把限制写进文档。PurRDF 不限制它们:
| 数据类型 | 取值 |
|---|---|
xsd:integer、xsd:nonNegativeInteger、xsd:positiveInteger、xsd:nonPositiveInteger、xsd:negativeInteger | 该类型符号范围内的每个整数,长度不限 |
xsd:long、xsd:int、xsd:short、xsd:byte 以及各无符号类型 | 该类型自身的范围 |
xsd:decimal | 每个十进制小数,长度不限,小数点后最多 4,294,967,295 位 |
放得进有符号 128 位整数、或放得进小数点后最多 18 位的有符号 128 位尾数的值,用机器算术计算;其余的值用任意精度表示计算,结果相同。+、- 与 * 都是精确的:"170141183460469231731687303715884105727"^^xsd:integer + 1 是 170141183460469231731687303715884105728,0.1 * 0.1 * 0.1 是 0.001。
除法与数值类型转换遵循以下精度规则:
- 除法。 F&O 把十进制商的精度留给实现决定。默认情况下,PurRDF 保留有限小数商的全部位数,即使超过 18 位:
1 / 1048576是0.00000095367431640625。无限循环小数商则保留 18 位,按最近偶数舍入:2 / 3是0.666666666666666667。调用方可以选择别的精度:Rust 中用QueryOptions::with_division,命令行中用--division,C、WebAssembly 与 Python 绑定中用各自的除法设置。设置写作exact、N(N 位,向零截断)、N:ROUNDING(例如5:half-even)或exact-or-N[:ROUNDING]。使用exact时,所有有限小数商均保持精确;无限循环小数商(例如1 / 3)会产生表达式错误(err:FOAR0002),结果保持未绑定。AVG在同一设置下做除法。 - 转换为
xsd:float或xsd:double。 无论大小,都从精确值出发只舍入一次,取最接近的值,平局取偶。 - 从
xsd:float或xsd:double转换为xsd:decimal。 每个二进制值都是有限小数,所以转换结果就是它的精确值:xsd:decimal("0.1"^^xsd:double)是0.1000000000000000055511151231257827021181583404541015625。
其余情况都是错误,表达式无绑定:1 / 0 是 err:FOAR0001;超出派生类型的值(xsd:byte(300))或格式错误的词法形式是 err:FORG0001;把 NaN 或无穷大转换为 xsd:decimal 或整数类型是 err:FOCA0002;非数值操作数是 err:XPTY0004。受调控的查询会在其证据(GovernorEvidence::expression_errors;受调控的 purrdf query 会把这些计数写到 stderr)中按代码计数它吸收的每个错误,purrdf_xsd 报告的每条错误消息都以其代码结尾,例如 (err:FORG0001)。派生整数类型的取负与绝对值是 xsd:integer,所以 -("5"^^xsd:unsignedByte) 是类型为 xsd:integer 的 -5。小数点后超过 4,294,967,295 位的十进制数是 err:FOAR0002;这样的值任何内存都放不下。
任意大小的比较
任意长度的数值都精确比较:比较运算符、=、!=、IN、ORDER BY、MIN、MAX,以及 cdt:List 的元素。数值与 xsd:float 或 xsd:double 比较时,遵循 SPARQL 向该类型的提升;ORDER BY 例外,它比较精确值,以保证排序可传递。
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?big WHERE {
VALUES ?big { "100000000000000000000000000000000000000001"^^xsd:integer }
FILTER(?big > "100000000000000000000000000000000000000000"^^xsd:integer)
}
这个查询会绑定 ?big。SHACL 范围约束、ShEx 数值 facet、CSVW 数据类型 facet,以及 OWL 2 的值同一性,都以同样精确的方式比较数值。ShEx 按 facet 边界的原始写法精确比较,所以 MININCLUSIVE 100000000000000000001 和 MAXEXCLUSIVE 0.30000000000000001 的每一位都会保留。OWL 2 RL 的值同一性在精确数值与 double 之间也精确比较,所以 100000000000000000000000000000000000000000 与 100000000000000000000000000000000000000001 都不同于 double 1.0E41。
SUM 与 AVG
SUM 精确累加其值,无论总和多大都返回精确总和。AVG 在每个分组上都是 SUM 在除法设置下除以 COUNT,所以两者总是一致。整数 170141183460469231731687303715884105727 与 170141183460469231731687303715884105726 的平均值是 "170141183460469231731687303715884105726.5"^^xsd:decimal。每个结果都是其数据类型的值,并能原样读回。
资源调控
长数值的计算比短数值更费资源,而一个很短的查询就能造出长数值:把 0.1 平方二十次,得到的十进制数超过一百万位。对超出机器字表示的值所做的每次运算,都会在运行前计入 governor(执行调控器):其工作量计入 fuel 上限,其工作内存计入 scratch 字节上限。结果的渲染也会计入,所以百万位的平方在写出时就要付费。所有数值都放得进机器字表示的查询不会多付任何代价。参见 docs/SPARQL-GOVERNOR-PROFILE.md。
预绑定变量
预备执行的参数和请求的替换会在求值前绑定变量。预绑定变量在整个求值过程中、在每一层深度上都是同一个值:OPTIONAL 或 MINUS 的右分支、子 SELECT 以及 EXISTS 体都能看到它,并且它会作为常量越过 GROUP BY,因此 SELECT $this (COUNT(*) AS ?c) 返回所绑定的节点。由此得出两条规则。
- 赋值与所绑定的值连接。 若
?p已在作用域内,BIND(… AS ?p)就不是合法的 SPARQL 查询(§18.2.1),无论是否预绑定都会解析失败。其他每个赋值都在赋值之处与所绑定的值连接(§18.5),在每一层深度上都是同一条规则,未投影?p的子SELECT和EXISTS主体也不例外:没有任何作用域拥有自己的?p。当?p绑定为:a时,赋值:z使该赋值所在的模式没有任何行,因此?x :p ?o BIND(:z AS ?p)和{ SELECT ?o WHERE { ?x :p ?o BIND(:z AS ?p) } }无论单独出现还是与其他模式并列都不返回任何结果,而OPTIONAL { BIND(:z AS ?p) }让每个左侧行保持未扩展。赋值:a本身则保留这些行;表达式出错的赋值使?p保持所绑定的值。 MINUS的两侧都能看到所绑定的值,无论该侧是否提及?p,因此右侧每一行都与左侧每一行共享?p并将其减去:只要:q有一个三元组,?x :p ?o MINUS { ?s :q ?w }就不返回任何行,与?x :p ?o MINUS { ?p :q ?w }相同;右侧没有行时则不减去任何行。
rdflib 7.6 的 initBindings 不是这一行为的参照:查询在何处为预绑定名称赋值,它就在何处重新绑定该名称,因此那里的赋值会覆盖所绑定的值,而不是与之连接。原生查询 API 在每一层深度上都遵循上述单一的连接规则。
VALUES ?p { … } 在其书写之处与所绑定的值做连接,且在任何深度都遵循同一规则:它只保留与所绑定值一致的行,因此在 $this 绑定为 ex:a 时,VALUES $this { ex:b } 没有任何行。在查询的 WHERE 组中,这意味着不返回任何行;在 OPTIONAL 分支中,它使左侧每一行保持未扩展;在 MINUS 操作数中,它不减去任何行;在未投影该名称的子 SELECT 中,它使该子 SELECT 没有任何行,无论单独出现还是与其他任何模式并列。子 SELECT 产生的每一行在去重或分组之前都带有所绑定的值,因此其中的 SELECT DISTINCT 和 GROUP BY ?p 无论与其他模式并列还是单独出现,答案都相同。
SHACL 更为严格。SHACL 1.2 SPARQL Extensions 附录 A 禁止在以预绑定变量执行的查询中使用 MINUS、提及预绑定名称的 VALUES 以及针对预绑定名称的 AS,SHACL 通道会在加载形状图时拒绝它们。VALUES 规则因通道而异:
- 节点表达式和
sh:expression,与sh:SPARQLFunction函数体和sh:SPARQLTargetType查询一样,只拒绝提及预绑定名称的VALUES,与附录 A 的措辞一致:VALUES ?v { true }可以加载。 sh:sparql约束,与基于 SPARQL 的组件验证器、sh:SPARQLTarget的sh:ask以及 SPARQL 规则一样,拒绝所有VALUES。随库固化的 W3C SHACL 用例unsupported-sparql-002要求拒绝VALUES ?any { true },而其中的?any并非预绑定变量。
参见 SHACL 验证。
rdflib 兼容层在 Rust 中编译上下文映射,并通过共享的原生求值器执行。purrdf.compat.rdflib.Graph.query(..., initBindings=...) 将初始绑定与返回映射分开,允许 RDFLib 的变量重新赋值,包括嵌套组、OPTIONAL、MINUS、EXISTS 和分组。公开结果保留原始变量名,并不迭代空的投影映射。Store.query、Store.prepare 和 SHACL 保留其原生预绑定和赋值规则。
数值转换
xsd:double(?x) 这类 XSD 构造函数遵循 XPath and XQuery Functions and Operators 3.1 §19 的转换规则。SPARQL 1.1 §17.5 的转换表引用的是更早的 XQuery 1.0 and XPath 2.0 Functions and Operators;PurRDF 采用 3.1 的文本。参数是数值或布尔字面量时,转换的是它的值,而不是把字面量的数字按新类型重新读取一遍。写作 0.1 的 float 的值是 0.100000001490116119384765625,所以 xsd:double("0.1"^^xsd:float) 是 1.0000000149011612E-1。字符串参数按文本解析:xsd:double("0.1") 是 1.0E-1。
| 源类型 | 目标类型 | 结果 |
|---|---|---|
| float | double | 同一个值,精确无误 |
| double | float | 最接近的 float,平局取偶(见下文) |
| integer、decimal | float、double | 最接近的值,只舍入一次 |
| float、double | decimal | 精确值;NaN 与 INF 为错误 |
| float、double、decimal | 整数类型 | 舍去小数部分,与大小无关;NaN、INF 以及超出目标类型范围的值为错误 |
| boolean | 数值 | 1 或 0 |
| 数值 | boolean | 零和 NaN 为 false,其余为 true |
把 float 或 double 转换为 xsd:string 时,绝对值从 0.000001(含)到 1000000(不含)的数用普通记数法("0.1"、"123456.5"、"100")。其余的值用科学记数法,小数点后至少一位数字("1.0E7"、"1.0E-7")。特殊值写作 "NaN"、"INF"、"-INF" 和 "-0"。数字位数取按源类型自身精度读回同一个值所需的最少位数,因此 xsd:string("0.1"^^xsd:float) 是 "0.1"。STR 不做转换:它原样返回字面量的词法形式。
double 转 float:舍入到最近
PurRDF 在这里有意不遵循 F&O 3.1 §19.1.2.1 的文字。该文字通过截断二进制尾数把 double 收窄为 float。PurRDF 则像 IEEE 754 一样舍入到最近的 float,平局取偶数值。理由如下:
- 精度。 结果与 double 的差距不超过末位的半个单位,且误差无偏。截断的误差可接近一个完整单位,而且总是偏向零。
- 处处同一种舍入。 XSD 1.1 用这种舍入(
floatingPointRound)把词法形式映射为 float,PurRDF 对字符串、decimal 和 integer 也已经采用它。若用截断,一个 double 得到的 float 会与写成字符串的同一个数得到的 float 不同。 - 与常见引擎一致。 常见的 SPARQL 和 XPath 引擎都采用舍入。
- 该文字对极小值有缺陷。 按其规则,凡低于最小正规指数的结果都变为零,即使有更接近的 float 次正规数。在这里
xsd:float("1e-40"^^xsd:double)是1.0E-40,而不是零。
下面两个值显示了两种规则的区别:
| 表达式 | PurRDF(舍入到最近) | F&O 3.1 文字(截断) |
|---|---|---|
xsd:float("1.0000000894069671630859375"^^xsd:double) | 1.0000001E0 | 1.0E0 |
xsd:float("3.4028235677973366e38"^^xsd:double) | INF | 3.4028235E38 |
第二个 double 恰好位于最大的 float 与下一个二的幂之间的正中,因此平局取偶得到 INF。
计算所得数值的拼写
规范只确定计算所得数值的值及其数据类型,并不确定承载该数值的字面量的词法形式。PurRDF 的拼写方式如下。
- 由运算符、函数或聚合计算得出的十进制数带有小数点和至少一位小数:
84 / 2为42.0,CEIL(3.2)为4.0,对4.2与37.8做SUM为42.0,对2与4做AVG为3.0。带小数部分的十进制数保持不变(2.5)。 - 类型转换与构造函数、
xsd:integer结果以及xsd:float/xsd:double结果保持 XSD 1.1 的规范文本:xsd:decimal(3)为3,对整数做CEIL(3)为3。 - 一元加号是恒等运算,按原样返回其操作数,因此保持操作数的拼写:存储的
"4.0"^^xsd:decimal仍为4.0,+(1.5 + 2.5)仍为4.0,因为被计算的操作数就是4.0。一元减号会计算出新的数,因此对十进制数4取负为-4.0。 STR原样返回字面量的词法形式,因此STR(1.5 + 2.5)为"4.0"。xsd:string(...)及其他转为字符串的类型转换使用该值的 XPathfn:string文本,因此xsd:string(1.5 + 2.5)为"4"。当存储或计算所得字面量的拼写很重要时,请使用STR。SECONDS按 SPARQL 文本的定义,原样返回参数词法形式中的秒字段:对"2023-12-25T10:30:00"^^xsd:dateTime为00,对"2023-12-25T10:30:05.500"^^xsd:dateTime为05.500,对"10:30:00.250"^^xsd:time为00.250。每个参数都是 RDF 项,因而都有词法形式:由表达式计算得出的 dateTime(例如 dateTime 加上时长)是词法形式为规范形式的字面量,SECONDS读取的就是该形式。SPARQL 1.1 的seconds-01测试对秒字段01、02、00、03期望"1"、"2"、"0"、"3",并按值判分;没有任何拼写能同时让它与 SHACL 节点表达式的seconds-example(期望"00")逐项一致。- 以上规则适用于
purrdf-xsd中的算术与 SPARQL 求值器。其他计算所得的十进制数有各自的拼写:文本检索与检索扩展(purrdf-text、purrdf-retrieval)的得分是带固定 12 位小数的xsd:decimal字面量。
比较数值结果时应按值而不是按拼写:"3"^^xsd:decimal 与 "3.0"^^xsd:decimal 是同一个数,却是不同的 RDF 项。
SPARQL 1.2 时间运算与调整
+、-、*、/ 以及一元 - 的适用范围超出数值类型体系,扩展到 xsd:dateTime/xsd:date/xsd:time(时刻)、xsd:duration 及其两个子类型 xsd:dayTimeDuration/xsd:yearMonthDuration,以及五种公历部分日期类型(xsd:gYearMonth、xsd:gYear、xsd:gMonth、xsd:gMonthDay、xsd:gDay)。SPARQL 1.2 Query 规范自身的正文未定义数值类型体系之外的任何运算;唯一有文档记载的表格来自 SEP-0002,本节的覆盖范围即依照该表。ADJUST(见下文)是 SEP-0002 其余的、非运算类的新增内容。
时间值中的秒保留全部小数位。例如,把 "PT0.000000000000000000009S"^^xsd:dayTimeDuration 加到 "2024-01-02T03:04:05.123456789012345678901Z"^^xsd:dateTime,结果是 "2024-01-02T03:04:05.12345678901234567891Z"^^xsd:dateTime;SECONDS 返回 05.12345678901234567891。结果不会截断到 18 位小数。即使时长中的整天数超出机器整数范围,time 按整天平移后仍会回到同一日内时刻。
运算符表
SEP-0002 的表格共有 24 行:其中 11 行是比较(两个 yearMonthDuration 之间或两个 dayTimeDuration 之间的 </>,两个 duration 之间的 =,以及两个 date 之间或两个 time 之间的 =/</>),已由普通的值比较覆盖。其余 13 行是运算:
| 操作数 | 结果 |
|---|---|
date - date | dayTimeDuration |
date + yearMonthDuration | date |
date - yearMonthDuration | date |
date + dayTimeDuration | date |
date - dayTimeDuration | date |
time - time | dayTimeDuration |
time + dayTimeDuration | time |
time - dayTimeDuration | time |
dateTime - dateTime | dayTimeDuration |
dateTime + yearMonthDuration | dateTime |
dateTime - yearMonthDuration | dateTime |
dateTime + dayTimeDuration | dateTime |
dateTime - dayTimeDuration | dateTime |
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?diff WHERE {
BIND("2002-01-02T10:00:00"^^xsd:dateTime - "2001-01-01T10:00:00"^^xsd:dateTime AS ?diff)
}
除这张表外,purrdf 还在上表每一行接受一般的 xsd:duration(SEP-0002 把 xsd:duration 列为运算符表所覆盖的操作数类型之一,且这一一般类型自身的值空间包含两个子类型),接受 duration ± duration、duration ×/÷ 精确数,以及对全部五种公历类型的公历值 ± duration——前提是结果无需捏造缺失的字段(见下文分歧):
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?next WHERE {
BIND("2012-10"^^xsd:gYearMonth + "P1Y1M"^^xsd:yearMonthDuration AS ?next)
}
结果数据类型
两个 duration 之间的 +/-,其结果的数据类型由操作数的声明标签决定,绝不由计算出的分量值决定:当且仅当每个 duration 操作数都声明为 dayTimeDuration 时,结果为 dayTimeDuration;当且仅当每个 duration 操作数都声明为 yearMonthDuration 时,结果为 yearMonthDuration;其余情况下为一般的 xsd:duration。以下两个用例使这条规则具体化,因为任意一个单独拿出来,也同样能被一条恰好在那一点上结论一致的、基于分量的规则满足:
零值结果保留其操作数声明的子类型,而不是塌缩为一个泛化的零:
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?zero WHERE {
BIND("P1Y"^^xsd:yearMonthDuration - "P1Y"^^xsd:yearMonthDuration AS ?zero)
}
?zero 为 "P0M"^^xsd:yearMonthDuration——一条只看分量、检查(为零的)结果而非(一致的)声明标签的规则,在这里也会得出同样的答案,这正是需要第二个用例的原因。反过来,一个和的_分量_即便看起来与纯粹的 yearMonthDuration 完全相同,只要两个操作数的声明标签不是同一种 duration 子类型,结果仍会拓宽为一般类型:
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?mixed WHERE {
BIND("P1M"^^xsd:yearMonthDuration + "PT0S"^^xsd:dayTimeDuration AS ?mixed)
}
?mixed 为 "P1M"^^xsd:duration,而不是 "P1M"^^xsd:yearMonthDuration——值为零的 dayTimeDuration 操作数对分量毫无贡献,却仍会拓宽结果的声明类型,因为这条规则读取的是标签,而不是值。
与其他实现的分歧
公历值 ± duration 可能要求一个操作数类型并不携带的字段——把含月份分量的 duration 加到 xsd:gDay 上,需要一个月份来施加该分量,而 xsd:gDay 没有月份。xsd:gMonthDay 会把移位后月份中不存在的日期向下钳制到该月的实际天数,这与 date/dateTime 已遵循的规则相同(XML Schema 附录 E)——但 xsd:gMonthDay 不携带年份,钳制无从依据,因此 purrdf 通过以下方式判断是否需要捏造年份:把_完整的_「先月后日」计算锚定在一个完整的 400 年公历周期中的每一年上(历法的闰年规则恰好以这一长度为周期,因此一个周期就涵盖了所有可能相关的锚点),并检查所有锚点的答案是否一致。当且仅当所有锚点一致时,purrdf 给出答案;当且仅当它们不一致时,返回带类型的错误——对任意大小的 duration 都是如此,而不仅限于有界的/普通的 duration:在任何锚点的运算开始之前,月份与日期的进位先按历法的精确周期(400 年,146,097 天)约简,因此天文数字般巨大的 yearMonthDuration 或 dayTimeDuration 分量,会以同样有界的工作量、按与较小分量相同的方式得出判定。计算是整体评判的,而非逐分量评判:duration 的月份部分可能落在某个中间日期上,其钳制本身依赖于年份,尽管在日期部分也执行之后,_最终_答案并不依赖年份——"--01-31"^^xsd:gMonthDay + "P1M1D"^^xsd:duration 在每个锚点下都是 "--03-01"(2 月 28 日或 2 月 29 日的次日总是 3 月 1 日),尽管 "--01-31"^^xsd:gMonthDay + "P1M"^^xsd:yearMonthDuration 单独来看确实有歧义。被拒绝类别中反复出现的例子是二月:其余每个月在每一年的天数都相同,因此落在这些月份的移位总是安全的;而落在二月、且被钳制的日期为 29 日或更晚的移位,其答案可能取决于一个 xsd:gMonthDay 并不携带的年份——这是被拒绝类别的一个例子,而不是规则本身。RDF4J 对这些情况的应答方式是:借助其底层的 JAXP 日历捏造缺失的字段(第 0 年、一月,或第 1 日),并返回建立在这一捏造之上的值——例如 "---31"^^xsd:gDay + "P1M"^^xsd:yearMonthDuration 的答案是 "---29",这是针对一个捏造出的闰年钳制的结果。在每一个答案不依赖于捏造字段的用例上,purrdf 都与 RDF4J 一致——包括 "2012-10"^^xsd:gYearMonth + P1Y1M = "2013-11",这是 RDF4J 自身测试套件所固定的唯一一个公历用例——而恰恰在答案会取决于 RDF4J 日历碰巧选取了哪个捏造值的地方,返回带类型的错误:
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?bound WHERE {
BIND("---31"^^xsd:gDay + "P1M"^^xsd:yearMonthDuration AS ?maybe)
BIND(BOUND(?maybe) AS ?bound)
}
?bound 为 false:?maybe 的 BIND 引发的带类型错误被毒化(poison)为未绑定,这是引擎普通的类型错误处理规则。这种毒化也正是这一分歧的界限:在 SPARQL 接口上,带类型的错误,与一个转而_丢弃_自身捏造答案(而非返回它)的参考实现,都会让同一个变量保持未绑定——两者完全相同。「拒绝捏造」与「捏造并返回一个值」之间的可见分歧是真实存在的,但它存在于值空间 API 的边界(一个 Err,对比一个携带具体答案的 Ok),而不在 SPARQL 查询自身的结果中;在那里,拒绝与假想的丢弃呈现方式相同。
超出 SEP-0002 与 F&O 的扩展
purrdf 新增了三个 SEP-0002 以及 XPath and XQuery Functions and Operators(F&O)均未定义的运算符,每一个都立足于一条既有规则,并把它扩展到 F&O 遗漏的类型上:
-
针对 duration 的
SUM/AVG。 SPARQL 1.1 §18.5.1.3 通过反复应用op:numeric-add来定义SUM,其定义域仅为数值类型体系;purrdf把同样的折叠扩展到 duration 群上,该群在逐分量加法下是精确且满足结合律的。PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> SELECT (SUM(?d) AS ?total) WHERE { VALUES ?d { "P1M"^^xsd:yearMonthDuration "P2M"^^xsd:yearMonthDuration } }?total为"P3M"^^xsd:yearMonthDuration。混合了数值与 duration 值的分组保持未绑定——该扩展并不拓宽SUM既有的仅限数值的接受范围,只是新增了第二个互不相交的接受范围。 -
duration 的一元负号。 F&O 的一元负号(§4.2.8)仅限数值,没有定义 duration 形式。
purrdf对 duration 的两个分量同时取负,因此-(?duration)绝不会产生该类型无法表示的混合符号值。一元正号刻意保持仅限数值,因此+(?duration)是类型错误,而-(?duration)不是:PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> SELECT ?neg WHERE { BIND(-("P1Y2M"^^xsd:yearMonthDuration) AS ?neg) }?neg为"-P1Y2M"^^xsd:yearMonthDuration。 -
按值可公度性进行的
duration ÷ duration。 F&O 只定义了两种同子类型的形式(op:divide-yearMonthDuration-by-yearMonthDuration、op:divide-dayTimeDuration-by-dayTimeDuration)。purrdf还接受一般的xsd:duration,并依据两个操作数的_值_是否可公度(均为纯月份,或均为纯秒数)来分派,而不是依据其声明标签,因此一个dayTimeDuration与一个恰好纯粹由天构成的一般xsd:duration仍然可以相除:PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> SELECT ?ratio WHERE { BIND("P30D"^^xsd:duration / "P1D"^^xsd:dayTimeDuration AS ?ratio) }?ratio为30,类型为xsd:decimal。两个值不可公度的操作数(一个纯月份值对一个纯秒数值,即使声明标签一致)是带类型的错误,而不是随意给出的答案。
ADJUST
ADJUST(value, timezone) 把一个 xsd:dateTime/xsd:date/xsd:time 值移到给定的时区偏移,或为不带时区的值附加一个时区。SPARQL 1.2 Query 规范自身的正文没有 ADJUST 一节;该函数唯一有文档记载的定义是 SEP-0002 的双参数签名,它对应 XPath and XQuery Functions and Operators §9.6 的 fn:adjust-*-to-timezone 系列(purrdf-xsd 为其他每一个 SPARQL 1.2 时间内置函数实现的也是同一张表)。
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?adjusted WHERE {
BIND(ADJUST("2011-01-10T14:45:13Z"^^xsd:dateTime,
"-PT05H"^^xsd:dayTimeDuration) AS ?adjusted)
}
timezone 是位于 [-PT14H, PT14H] 内(仅限整分钟)的 xsd:dayTimeDuration,或空的简单字面量 ""——它是 SPARQL 对 XPath 中表示「移除时区」的空序列参数的替代,因为 SPARQL 本身没有空序列;其他每一个已知的 SEP-0002 实现(例如 Apache Jena 的 E_AdjustToTimezone)得出的也是同一解决方式。参数个数固定为二,并在解析时强制执行——ADJUST(?x) 与 ADJUST(?x, ?tz, ?extra) 都会在求值前被拒绝。每一种定义域或类型违规(非整分钟的偏移、越界的偏移、非时间类型的第一个参数)都是 SPARQL 类型错误;按照引擎普通的类型错误处理规则,它在 BIND、FILTER 或聚合参数内部会被毒化为未绑定,而不会中止查询。
LATERAL(SEP-0006)
SPARQL 1.2 Query 规范自身的正文没有 LATERAL 产生式;唯一有文档记载的定义是 SEP-0006 的定义,已在 Apache Jena 4.7.0 中实现,本节即依照该定义。LATERAL 为 GroupGraphPatternSub 新增一个产生式,与 OPTIONAL/MINUS/ GRAPH 处于同一位置,并以同样的方式左结合:
GroupGraphPatternSub ::= ... | 'LATERAL' GroupGraphPattern
与普通连接不同,LATERAL 的右侧针对其左侧的每一个解各求值一次(ONCE PER SOLUTION),且该解的绑定在右侧内部可见——这与 SQL 中 LATERAL/CROSS APPLY 子查询同其外层查询的关系相同。这使得按分组取「前 N 项」的查询可以用纯 SPARQL 表达:左侧的每个主语在右侧挑选自己最小的标签,而不是只计算一次全局最小的标签、再与每一行连接。
PREFIX : <https://example.org/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT * WHERE {
?s :p ?o
LATERAL {
SELECT * WHERE { ?s rdfs:label ?label }
ORDER BY ?label LIMIT 1
}
}
关联会把左侧任意(ANY)RDF 1.2 项类型的绑定——包括空节点或引用三元组——带入右侧,即使该变量在右侧唯一的出现位置是在表达式中(一个 FILTER、一个 BIND、一次 BOUND(?s) 调用),而不是作为三元组模式的叶子:对于左侧绑定的空节点或引用三元组,BOUND(?s) 的应答与对 IRI 或字面量一样正确,而这样的行也不会因为缺少可承载它的叶子出现位置而被静默丢弃。
作用域限制
LATERAL 的右侧可以自由地复用(REUSE)左侧已绑定的变量(这正是其全部意义所在——关联就是这样发生的),但不得为其引入(INTRODUCE)新的绑定:在右侧自身的作用域层级上,BIND 的变量目标、子 SELECT 的 (expr AS ?v) 投影、GROUP BY 聚合的输出、表达式值的 GROUP BY (expr AS ?v) 分组条件,以及 VALUES 列,都不得与左侧已可见的变量冲突。仅仅指名一个已绑定变量的裸 GROUP BY ?v 分组键是一次使用(USE),而不是引入,永远不会冲突。唯一开启新作用域层级的构造是子 SELECT 自身的投影——OPTIONAL/UNION/GRAPH/嵌套分组/嵌套 LATERAL 对此均是透明的。以下是该 SEP 自己给出的一对合法与非法示例:
PREFIX : <https://example.org/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
# 合法:子 SELECT 只投影 ?label,因此它自身(未投影的)对 ?s 的复用
# 属于关联,而不是引入,不可能发生冲突。
SELECT * WHERE {
?s a :T
LATERAL { SELECT ?label WHERE { ?s rdfs:label ?label } LIMIT 1 }
}
# Illegal — refused with a typed ParseError naming ?o: ?o is already bound by
# the left-hand triple pattern, and BIND tries to give it a NEW value at the
# LATERAL right-hand side's own scope level.
SELECT * WHERE {
?s ?p ?o
LATERAL { BIND(123 AS ?o) }
}
与 Jena 的分歧之处
该限制有两处边角与 Jena 自身的 SyntaxVarScope 检查不一致,每一处都由一个具名测试固定下来,而不是任其漂移:
- 更宽松。 仅限于
MINUS右操作数内的BIND/VALUES/聚合引入,在这里无论处于何种深度都会被接受(ACCEPTED)——无论是直接位于LATERAL关键字之下,还是嵌套在SELECT *子查询之下——而 Jena 会拒绝它。SPARQL §18.2.1 把MINUS-右侧的变量置于作用域之外,§18.5 的求值说明了原因:右操作数仅用于构建与左操作数各行之间的兼容性测试,之后其绑定即被丢弃,因此在MINUS之上构建的任何东西——SELECT *投影或其他任何东西——都永远无法观察到该操作数引入的值。这条规则没有任何可观察的东西可以拒绝。(在这样的右侧上使用SELECT *只是通往同一形态的一条路径,而非另一种形态。) - 更严格。
SERVICE ?g { ... }的端点变量在这里计为左侧作用域;Jena 则忽略它。带变量端点的SERVICE ?g { ... }要求?g在传入的解中已被绑定——端点 IRI 在发起远程调用之前由该绑定解析——因此当LATERAL右侧运行时,?g已持有一个来自左侧、可观察的逐行值,与其他任何左侧绑定的变量无异。因此,右侧用BIND/VALUES赋予它一个新(NEW)值,恰恰是这条规则所要拒绝的那种可观察的重新绑定。
在 UPDATE 中
INSERT/DELETE ... WHERE 与 WITH ... WHERE 与 SELECT 的 WHERE 子句经由同一套分组图模式语法,因此 LATERAL——以完全相同的方式做作用域检查——在那里同样合法。DELETE WHERE 的四元组模板是另一条语法产生式(TriplesTemplate,而非 GroupGraphPattern),不含任何种类的分组模式运算符,因此 LATERAL 出现在那里会被点名拒绝,而不会被误解析为一个主语项。
SERVICE 转发
包含书面 LATERAL 子句的模式——位于转发查询体中的任何位置,包括嵌套在另一个 SERVICE 内部——无论是否写了 SILENT,都会原样带着 LATERAL { … } 文本转发,因此端点的实际裁决——来自支持 LATERAL 的端点的答案(LATERAL 是 Jena 自己的扩展,因此以 Jena 为后端的端点会应答它),或来自未实现它的端点的失败——会以与其他任何转发构造被拒绝时相同的方式呈现:一个错误,或者在 SILENT 下成为连接的单位元并记录在证据中。自定义标量函数调用也以同样的方式转发。变量端点 SERVICE ?g 仅在没有任何东西提供 ?g 的绑定时被拒绝——即传入的解中没有任何东西为远程求值器指名一个可解析的 IRI——而 SERVICE SILENT 不会改变这一点,因为根本没有发出任何调用可供 SILENT 吸收。当外围模式绑定了 ?g——同一分组中位于其前的三元组模式,或 LATERAL 左侧的逐行关联——端点会在发起远程调用之前解析为该 IRI(与上文「与 Jena 的分歧之处」中描述的逐行替换相同),查询照常分派,与带固定 IRI 的 SERVICE 无异:每个传入的解一个请求,因此指明同一 IRI 的两个解各自发出自己的请求。当包含该子句的 OPTIONAL、MINUS 或组连接的左侧在每个解中都绑定了 ?g 时,右侧仍然独立求值,而该子句只向左侧为 ?g 绑定的每个不同 IRI 各发送一次。连接满足交换律,因此当组连接的另一侧绑定 ?g 时——{ SERVICE ?g { … } ?s ex:endpoint ?g }——同样成立;而 OPTIONAL 或 MINUS 只从其左侧获取端点。
逐服务上下文:ServiceResolver 扩展点
SERVICE 通过单一 trait ServiceResolver 应答,它以 ServiceRequest 的形式接收整个请求——端点、转发的查询文本、SILENT 标志、正在执行的查询的停止信号,以及中间单元格上限。随附两个实现:HttpRemoteQuerySource,它通过宿主注入的 HttpTransport 构建并解码 SPARQL Protocol 请求;以及 InProcessServiceResolver,它从已在内存中的数据集作答。
解析器需要了解的关于_单个_服务的一切——额外的请求头、凭据、超时,以及允许它做什么——都存放在解析器上的 ServiceCatalog 中,以服务 IRI 为键。刻意不放在 IRI 本身中:服务 IRI 是一个名称,而不是凭据存储;把上下文编码在那里会使其进入查询文本,从而对编写查询的任何人可见,被序列化进计划与回执,并随嵌套的 SERVICE 查询体一同传播。
ServiceCatalog 把每个服务 IRI 映射到一个 ServiceProfile,每个 profile 授予一组显式的能力:
| 能力 | 授予 |
|---|---|
Query | 解析该服务本身 |
Network | 为解析它而执行网络 I/O |
Credentials | 把该 profile 的凭据附加到请求上 |
目录默认拒绝:没有条目、也没有显式配置回退 profile 的服务会被拒绝。管控是选择加入的——没有目录的解析器,其行为与目录出现之前完全相同,会联系交给它的任何端点,且不添加任何请求头——而配置目录不会改变 profile 未添加任何内容的请求中的任何字节。
扣留 Network 正是使进程内门面_可证明_而非仅仅承诺的关键:InProcessServiceResolver 只拥有一个数据集映射,别无他物——没有传输层,没有套接字,没有注入的回调——因此不存在经由它执行 I/O 的代码路径。于是,宿主可以提供 SERVICE 形态的组合,而无需承担 SERVICE 形态的风险。ServiceRouter 把二者组合起来,将部分服务发往进程内、其余发往网络,由路由表而非查询文本决定哪个去往哪里。每一次解析都会查阅目录,嵌套解析也包括在内,因此埋在转发查询体中的 SERVICE 无法到达目录在顶层拒绝的端点。
该策略在调用传输层_之前_施加,这正是闸门与审计日志之间的全部区别:被拒绝的服务从不会先打开套接字再被丢弃。
服务 IRI 与其他任何 IRI 一样,因此它由工作区唯一的 RFC 3986 基准 IRI 层解析(见 基准 IRI),而不是由该扩展点自创的规则解析。序言中没有 BASE、也没有向 API 传入 base 的 SERVICE <sparql>,会触发共享的 iri-relative-no-base 硬错误,该错误在解析查询时抛出——早于查阅任何解析器,且不会被 SILENT 软化,因为它是针对不应答的端点的承诺,而不是针对无法构成的端点 IRI 的承诺。在有 base 的作用域中,解析器收到的是绝对 IRI,这正是 ServiceCatalog 用作键的形式。
SILENT 契约
SPARQL 1.1 Federated Query §3.2 将 SERVICE 子句定义为端点的应答:“in case of a successful service invocation according to the SPARQL protocol, and otherwise Ω0 in case SilentOp is true, and otherwise error”(依照 SPARQL 协议成功调用服务时为其应答,否则若 SilentOp 为真则为 Ω0,否则为错误)。Ω0 是唯一的空解——连接的单位元,因此外围查询照常进行、不受影响。PurRDF 按字面理解这里的「否则」:
| 结果 | SERVICE | SERVICE SILENT |
|---|---|---|
| 调用因任何原因失败:端点不可达或其响应无法解码;没有任何来源能到达它;某项能力被拒绝或宿主拒绝了该请求;宿主的处理函数发生故障;变量端点绑定到了不是 IRI 的项 | 查询错误 | 连接的单位元,并记录在证据中 |
| 本引擎自身的 governor 被触发,或其栈已耗尽 | 截断,或栈拒绝 | 截断,或栈拒绝 |
SILENT 是关于调用的声明,绝不是关于调用方自身预算的声明,因此经由 SERVICE 子句到达的 governor 触发,无论是否写了 SILENT,都会作为截断向外传播。拒绝是一次没有成功的调用,而查询的作者写下 SILENT 时就已知道该服务可能不会应答。
SILENT 给出的应答与一个没有任何内容可补充的端点的应答无法区分,因此每一次被静默的调用都会被记录下来:GovernorEvidence::silenced 为每一次失败列出一条 SilencedInvocation——端点、失败的种类(transport、decode、disabled、unconfigured、denied、host-denied、not-an-iri、fault),以及该错误本应携带的消息。每一个受 governor 管控的查询都携带它;不受管控的调用只返回裸结果,因此要读取这份记录,请在 QueryGovernors::METERED 下运行。由 InProcessServiceResolver 求值的查询体中嵌套的 SERVICE 所引发的拒绝,会作为其本来面目——结构化的拒绝——传回外层,因此外围子句在每一层深度上都会把它作为拒绝报告出来,或者记录下来。
LOAD SILENT 遵循 SPARQL 1.1 Update §3.1.4:按照该节,操作失败时 “will still return success”(仍将返回成功):无法抓取或无法解析的文档、一次拒绝、一次故障,以及缺少 GraphResolver,都各自是一次不加载任何内容的成功,并在更新的证据中记录为一条指名来源 IRI 的 SilencedInvocation。GraphResolver 以带类型的 LoadError 报告它为何没有产出文档,其 code() 就是不写 SILENT 的 LOAD 失败时所用的诊断代码。
在 MINUS 下,变量端点的左侧解按端点划分,每一部分都由只列出其自身端点而求值的右侧来相减,因此被静默的端点——Ω0,不绑定任何变量——不会移除自己的任何行,而每个应答了的端点仍会移除与之匹配的行。
SEP-0007 下的 EXISTS
SPARQL 1.1/1.2 §18.6 通过两个函数定义 EXISTS/NOT EXISTS:substitute,它依据被过滤的行改写模式中的变量;以及 evalExists,它判断替换后的模式的求值结果是否非空。若按字面的项改写来理解,substitute 在几个确切的点上存在缺陷:
- 仅限变量的位置。
substitute是针对模式中的变量位置(POSITIONS)来表述的,对于Bgp/Path叶子项或GRAPH ?g名称,除了将其视为表达式之外,没有定义任何动作——因此一个仅出现在三元组位置或图名位置的关联变量,没有可以施加的替换。 MINUS定义域翻转。 把MINUS的共享变量作为常量改写掉,恰恰抹去了其定义域连接用以比较两个操作数的那些列,从而把一个关联的MINUS变成无条件不相交的形式——这与代入该行本应保持的东西正好相反。- 作为变量的空节点。 任何 SPARQL 表层语法都无法把空节点或引用三元组写成改写后的常量,因此在字面的项改写下,关联的空节点绑定或引用三元组绑定没有合法的替换形式。
- 不相连的变量。 一个仅(ONLY)能经由嵌套
EXISTS内部的表达式位置到达的关联变量——从不经由该内层自身的三元组模式叶子——在substitute的模式改写解读中,没有任何东西能把它带到那里。 - 赋值限制。 §18.6 自身的正文对于
EXISTS主体重新绑定其所过滤的行上已处于作用域中的变量,没有规定任何规则。
SEP-0007——一份 SPARQL-dev 提案,尚未并入 SPARQL 1.1/1.2 REC 正文——通过把 substitute 重新表述为 Replace(一次连接(JOIN),而不是改写)修复了前四项,并把第五项作为第 3 部分新增为一条限制。purrdf 完整实现了 SEP-0007 的各项修复,包括第 3 部分。
唯一的定义
exists(X, μ) ⟺ eval(D(G), Replace(PrjMap(X), μ)) is non-empty
Replace 即值插入(Values Insertion):当前行 μ 在每个 Bgp/Path/Graph(Var, ·) 位置上,作为单行 VALUES 表连接进 X,而不是作为语法常量拼接进去——它对每一种 RDF 1.2 项类型都是全定义的,包括空节点与引用三元组,因为它经由真实 VALUES 数据所用的同一求值路径到达数据集;并且对于仅能经由表达式到达的变量,它不需要任何特例,因为无论关联是由哪个位置引入的,连接都会到达每一个叶子。PrjMap 即**Project-边界收窄**:μ 在连接进 Project 节点之下之前,先被限制到该节点自身的变量列表——这是表层语言唯一的作用域边界,也是子 SELECT 自身的投影可以合理遮蔽外层变量的原因。
SEP-0007 以 toMultiSet(μ) 表述 Replace——把行转换为连接所需的多重集单元。purrdf 的代数中没有需要这一转换来弥合的模式/查询类型之分:解行本身就是(IS)那个多重集单元,因此 Replace 在结构上直接落在包裹其目标叶子的任何解修饰符之下,无需额外构造或裁定任何东西。
HAVING 位置不在 SEP-0007 所声明的覆盖范围之内——SEP-0007 只为 Bgp/Path/Graph 值插入位置与普通表达式位置规定了 Replace,没有为子 SELECT 的 HAVING 子句作出规定。purrdf 遵循字面的 PrjMap 解读:子 SELECT 的 HAVING 仅过滤该子 SELECT 自身的作用域,与其内部的其他每个表达式完全相同——不会为它自创任何特殊的关联通道。
存在范式(Existential Normal Form)
在 EXISTS 之下,每一行恰好只观察一个比特:内层模式的求值结果是否为空。以下改写律把一个节点替换为可证明在空性上等价、但代价更低的东西,凡在内层顶部出现之处均会应用(绝不在 Join/Filter/Extend/ Graph/Minus 的操作数内部应用,因为它们从子节点读取的不只是空性):
LeftJoin(A, B, c) → A。OPTIONAL无条件地为每个左侧行至少输出一行——一个填充后的左侧行,或每个兼容匹配各一行——且从不移除任何一行,因此当且仅当A为空时,LeftJoin(A, B, c)为空,与B或其连接条件无关。OrderBy(P) → P。 排序是一种置换,绝不是过滤:一旦唯一的问题是空性,排序键根本不会被求值。Distinct(P) → P、Reduced(P) → P。 去重只会移除行,而且只在更早的某一行已携带相同的值时才移除,因此只要P非空,第一行的值总会保留下来。Slice(0, len ≥ 1)(P) → P。 偏移为零、至少容纳一行的LIMIT只从P的多重集末尾丢弃行,因此只要P非空,第零行总在窗口之内。Slice(_, Some(0))(P) → false。 长度为零的LIMIT对每个P、每一行都为空——整个EXISTS折叠为常量false(NOT EXISTS折叠为true),完全不触及数据集。
上述每条定律都是在行集合(SETS)上证明空性等价的,绝不涉及副作用,因此每条定律仅在其所抹去的部分(对 LeftJoin 而言是 B 与连接条件,对 OrderBy 而言是排序键,对长度为零的 Slice 折叠而言是整个内层)无副作用时才会触发——即其中不可能到达任何 SERVICE 调用、属性函数或自定义/heldIn/rdf:List 函数——因此,本会传播到 EXISTS 之外的硬错误或远程副作用仍会传播,而不会仅仅因为写在其内部就消失。
两种策略,一个答案
对于上述唯一的定义,purrdf 恰好携带两种实现:
- 定义本身 —— 经由
Replace的逐行替换,外包一个Slice{0, Some(1)}首个见证即停(这是可靠的,因为「这个多重集有没有第一行」恰恰就是EXISTS所问的问题),并由一个以限制为键的记忆表支撑:记忆键是被限制到内层自身关联变量列上的外层行,因此N个外层行中k个不同的限制,恰好对内层求值k次,绝不是N次。对任何内层模式都始终正确。 - 记忆化探测 —— 对内层不加约束地恰好求值一次,在其与外层模式共享的列上建立索引,然后针对该索引对每一行的
μ做存在性探测。仅当预备阶段的可采纳性证明表明,对该位置可见的每一行,它都与定义等价时,才是正确的。
预备阶段的分析在每次求值中对每个位置决定一次,由哪种策略应答每个 EXISTS/NOT EXISTS。--explain 的逐代数节点计费台账通过三个证据计数器报告该决定:exists-probe-answered(一次记忆化探测求值)、exists-definition-answered(一次逐行定义求值)以及 exists-inner-solutions-consumed(定义路径的内层实际物化出的一行——受首个见证即停约束,每次求值最多为 1)。EXISTS/NOT EXISTS 内层自身的计划节点——一个 MINUS、一个 Bgp、一个嵌套的 EXISTS(无论嵌套多深),主体所含的任何东西——同样各自带有台账行,在两种(EITHER)策略下,都与普通节点一样精确分解,而不是折叠进外围的 FILTER/BIND:记忆化探测每个位置仅一次的那次求值,其归属方式与逐行定义完全相同,只是只计费一次,而不是按每个不同的限制各计费一次。一个合理地显示 fuel=0 rows=0 的节点,是被存在范式从所求值的树中彻底抹去的节点(一个被保持空性的定律证明为透明的 OPTIONAL/ORDER BY/DISTINCT/LIMIT 包装——见上文「存在范式」),因此它从未运行,而不是一笔遗失的计费。
唯一具名的例外:若 EXISTS/NOT EXISTS 内层自身(OWN)经 ENF 规范化后的顶层形态是 Project(一个子 SELECT)或 Union(顶层的 { A } UNION { B },而非嵌套在更深处的那种),则不(NOT)进行归属——其中的每个节点,无论多深,在任一策略下都会以 fuel=0 rows=0 折叠进外围的 FILTER/BIND,与主体本身包含什么无关。Project/Union 由不止一个子节点合成其输出,而不是原始树中任何东西的 1:1 结构克隆;为它们重建同样的原始地址对应关系,需要 LATERAL 值插入机制已在做的那种包装者/被包装者 counts_rows 仲裁——这是刻意不去尝试的,因此对于这一个位置,对应关系干脆不存在,而不是靠猜测给出。这并非倒退:这恰恰是在逐节点归属出现之前,每个 EXISTS/NOT EXISTS 内层原有的行为,如今只局限于这一种形态,而不是整个功能。
性能特征,直白地说:一个不关联的内层,或一个仅由 Bgp/Path/Values/Graph/Join/ Union/OrderBy/Project/Distinct/Reduced 以及只读取内层中确定已绑定列的 Filter/Extend 表达式构建的关联内层,由探测来服务——总共只求值一次,无论有多少外层行经过它的过滤。探测无法服务的形态——MINUS、具有限制作用的 Slice(任何偏移或上限,不仅是越过第一行的那种)、GROUP BY、LATERAL、属性函数调用、SERVICE 调用,或读取了内层并不确定绑定的关联变量的 FILTER/BIND 表达式(例如仅在某个 OPTIONAL 分支下可见的变量)——则经由定义逐行求值,由上述以限制为键的记忆表与首个见证即停来约束代价。
第 3 部分的赋值限制
在 EXISTS/NOT EXISTS 主体内部,无论是 BIND/子 SELECT 的 (expr AS ?v) 投影目标/GROUP BY (expr AS ?v) 分组目标,还是 VALUES 列,都不得重新绑定在被过滤的行上已处于作用域中的变量。NOT EXISTS 与 EXISTS 共享完全相同的语法产生式——并不存在单独的「NOT EXISTS」表述——因此该限制同等适用于二者,也适用于嵌套 EXISTS 自身的主体(相对于紧邻其外的 EXISTS 已处于作用域中的变量)。局限于主体内 MINUS 右操作数中的重新绑定,在任何深度上都豁免,理由与 LATERAL 自身的作用域限制相同(§18.2.1:MINUS-右侧的引入永远不会逃逸出去,因此永远无法可观察地重新绑定该行)。
PREFIX : <https://example.org/>
# 合法:?fresh 不在 FILTER EXISTS 所测试的行的作用域中,因此 BIND
# 为它赋值是一次普通的新绑定,而不是重新绑定。
SELECT ?s WHERE {
?s :p ?o .
FILTER EXISTS { BIND(1 AS ?fresh) }
}
# Illegal — refused with a typed ParseError naming ?o: ?o is already bound by
# the row FILTER EXISTS is testing, and BIND tries to give it a NEW value at
# the EXISTS body's own scope level.
SELECT ?s WHERE {
?s :p ?o .
FILTER EXISTS { BIND(1 AS ?o) }
}
该限制是 SEP-0007 自己的新增内容,在此采纳,是因为正是它使 EXISTS 的替换语义在每个位置上都有据可依——SPARQL 1.1/1.2 自身的 §18.6 正文并不要求这样的规则。
SHA-3 哈希(SEP-0008)
SPARQL 1.1 §17.4.6 提供五个哈希内置函数——MD5、SHA1、SHA256、SHA384、SHA512。purrdf 新增了 SEP-0008 所提议的四个 SHA-3(FIPS 202 Keccak)函数,调用约定完全相同。SEP-0008 是一份提案,不属于 SPARQL 1.1 或 1.2 推荐标准——这四个函数是第一方扩展,且无法移植到其他引擎:
| 调用 | 摘要 | 十六进制字符数 |
|---|---|---|
SHA3-224(string) | SHA3-224 | 56 |
SHA3-256(string) | SHA3-256 | 64 |
SHA3-384(string) | SHA3-384 | 96 |
SHA3-512(string) | SHA3-512 | 128 |
SELECT ?s (SHA3-256(?label) AS ?fingerprint)
WHERE { ?s <http://example.org/label> ?label }
参数契约
每个函数接受一个参数,对其词法形式的 UTF-8 字节求哈希,并以小写十六进制 xsd:string 返回摘要——与 SHA256 的契约相同,因此查询可以把二者互换,而不必改动该行的任何其他内容。
可接受的参数是简单字面量和显式标注为 xsd:string 类型的字面量,这正是 SPARQL 1.1 §17.4.6 对每个哈希内置函数的要求:SHA3-256("abc") 与 SHA3-256("abc"^^xsd:string) 的摘要相同。
其他任何参数都是表达式错误,这是 SPARQL 普通的「该行不产生值」结果,而不是查询失败:
- 未绑定的变量(
SHA3-256(?missing)), - IRI 或空节点,
- 带语言标签的字符串或 RDF 1.2 带方向的字符串(
SHA3-256("abc"@en)), - 非字符串字面量(
SHA3-256(7))。
在 SELECT 投影中,出错的调用会使被投影的变量在该行上未绑定;在 FILTER 下,它使约束为假;对它做 BIND 则不绑定任何东西。当意图是「对这个项看起来的样子求哈希」时,请用 STR(…) 包裹参数——SHA3-256(STR(?anything))——因为 STR 才是把项转为字符串的函数,而这些函数不会隐式地这样做。
连字符是名称的一部分
这些是语言中仅有的名称含 - 的内置函数,因此值得把空白规则明确说明:
| 文本 | 解读 |
|---|---|
SHA3-256(?o) | 内置函数调用——一个记号,含连字符 |
SHA3 - 256 | 解析错误:单独的 SHA3 既不是函数也不是关键字 |
STRLEN(SHA3-256(?o)) - 4 | 减法——- 跟在 ) 之后,而不是跟在单词字符之后 |
词法分析器的 PN_PREFIX 扫描把 - 视为名称字符,因此 SHA3-256 作为单个单词到达解析器。连字符两侧有空白时,它又成为减法运算符;而由于 SHA3 本身不是函数,带空格的形式会显式报错失败,而不是被理解为别的含义。名称与其他每个内置函数一样不区分大小写(sha3-256 是同一个调用)。
SEP-0008 自身的正文用下划线拼写这四个函数(sha3_256),因此 SHA3_256(?o) 被接受为 SHA3-256(?o) 的别名:从提案中复制出的查询可以解析。该别名仅是一种输入拼写——代数中每种摘要长度只有一个函数,因此序列化后的查询总是携带规范的连字符名称,无论键入的是哪种拼写,都保持字节级确定性。
把 SHA-3 查询带到另一个引擎
预计会得到解析错误。SHA3-256 在这里是内置名称,但 SPARQL 1.1/1.2 语法恰好只提供两种命名函数的方式——其自身内置列表中的关键字,或 iri 为 IRIREF 或前缀名的 FunctionCall ::= iri ArgList。在尚未采纳 SEP-0008 的引擎上,SHA3-256 不在内置列表中,而不带冒号的裸单词也不是 iri,因此 SHA3-256(?o) 根本无法解析。带下划线的 SHA3_256(?o) 拼写因同样的原因失败。因此,这种失败是显式且即时的,而不是一个悄无声息地未绑定的列。
可移植的替代是 SPARQL 1.1 §17.4.6 中的某个哈希函数。SHA256 接受同样的单个参数,返回同样的小写十六进制 xsd:string,因此改名就是全部修改——它改变摘要,查询的其他一切均不变。当引擎由调用方掌控且明确需要 Keccak 构造时,选用 SHA-3 名称;当查询文本必须能在任何地方运行时,选用 SHA256。
从其他宿主到达它
无需任何配置:与扩展函数和自定义聚合的扩展点不同,这些是内置函数,因此每个接受查询文本的接口都具备它们——purrdf query、Python 中的 Store.query / MutableDataset.query、WebAssembly 中的 Dataset.query / QueryEngine.select,以及 C ABI 上的 purrdf_query / purrdf_query_json。
复合数据类型(SEP-0009)
SPARQL 1.1 与 1.2 没有可容纳若干其他项的项。purrdf 实现了 SEP-0009 的两种复合数据类型 cdt:List 与 cdt:Map,连同该 SEP 定义的十五个函数、由分组构建复合值的 FOLD 聚合,以及将复合值拆开的 UNFOLD 图模式。SEP-0009 是一份提案,不属于 SPARQL 1.1 或 1.2 推荐标准——本节中的一切都是扩展,使用它们的查询无法原样移植到其他引擎。
命名空间是该 SEP 自己的,仅被识别,绝不自创——下面的每个示例都写在这段序言之下:
PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
复合值是一个普通的 RDF 字面量,其数据类型为 cdt:List 或 cdt:Map,其词法形式逐一写出其内容。以下是项,而不是查询:
"[1,2,3]"^^cdt:List
"[1,null,<http://example.org/s>]"^^cdt:List
"[[1,2],[3]]"^^cdt:List
"{1:'a', 2:'b'}"^^cdt:Map
"[]"^^cdt:List "{}"^^cdt:Map
列表元素可以是 IRI、空节点、字面量、null 或嵌套的复合值。映射的键可以是 IRI 或字面量——绝不能是空节点、绝不能是 null、绝不能是嵌套的复合值——并且必须按项两两不同,因此 {1: "a", "1"^^xsd:integer: "b"} 是同一个键写了两次,会被拒绝。映射的值没有这样的限制。复合值词法形式中的每个 IRI 都必须是绝对的:复合值的内容属于它自己,不会依据外围文档恰好声明的任何 base 进行解析。
函数库
该集合是封闭的。没有需要配置的注册表,调用方也无法遮蔽这些名称中的任何一个,因此同一个查询在每个宿主上含义相同,已配置的扩展命名空间也无法截获 CDT 名称。参数个数在解析时强制执行,早于任何求值。
| 调用 | 参数个数 | 结果 |
|---|---|---|
cdt:List(…) | 任意 | 由各参数项构成的 cdt:List |
cdt:Map(…) | 偶数 | 由交替的键/值参数构成的 cdt:Map |
cdt:concat(…) | 任意 | 将各参数列表首尾相接 |
cdt:contains(list, term) | 2 | xsd:boolean —— 列表是否包含相等的项 |
cdt:get(coll, n_or_key) | 2 | 按从 1 开始的位置取一个元素,或按键取一个映射值 |
cdt:head(list) | 1 | 第一个元素 |
cdt:tail(list) | 1 | 除第一个元素之外的全部元素 |
cdt:reverse(list) | 1 | 按相反顺序排列的元素 |
cdt:size(coll) | 1 | xsd:integer 元素数或条目数——唯一一个两种数据类型都接受的函数 |
cdt:subseq(list, start[, len]) | 2–3 | 一段连续的元素,start 从 1 开始 |
cdt:containsKey(map, key) | 2 | xsd:boolean —— 这是否是映射的一个键 |
cdt:keys(map) | 1 | 映射的键,以 cdt:List 形式给出 |
cdt:merge(…) | ≥ 2 | 各参数映射的并集;被不止一个映射持有的键,以携带它的第一个映射为准 |
cdt:put(map, key[, value]) | 2–3 | 设置了一个条目的映射;省略的值或出错的值会存入 null 条目 |
cdt:remove(map, key) | 2 | 去掉该条目的映射;键不存在时映射保持不变 |
把映射交给列表函数(或反之)会抛出错误——这是类型错误,而不是未绑定的列。按照该 SEP,索引始终从 1 开始。
PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT ?s ?first
WHERE {
?s <http://example.org/tags> ?tags
FILTER(cdt:size(?tags) > 1)
BIND(cdt:head(?tags) AS ?first)
}
FOLD:一个分组变成一个项
FOLD 是聚合,因此凡是 SUM 或 GROUP_CONCAT 可以出现的地方它都可以出现——在 SELECT 列表中、在 HAVING 中、与其他聚合并列、在 GROUP BY 之下。
Aggregate ::= … | 'FOLD' '(' 'DISTINCT'? Expression ( ',' Expression )?
( 'ORDER' 'BY' OrderCondition+ )? ')'
一个表达式构建 cdt:List;两个表达式构建 cdt:Map,第一个为键。可选的 ORDER BY 属于聚合本身,跟在最后一个表达式之后,且不加逗号——正是这一扩展点使 FOLD 成为引擎中第一个依赖顺序的聚合,这也是为什么 FOLD 可以携带 ORDER BY,而其他每个聚合都会拒绝它。
可接受的写法,汇总如下:
FOLD(?v) FOLD(DISTINCT ?v)
FOLD(?v ORDER BY DESC(?v)) FOLD(DISTINCT ?v ORDER BY ASC(?a) ASC(?b))
FOLD(?k, ?v) FOLD(?k, ?v ORDER BY ?ord)
以及一个完整的查询:
PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT ?s (FOLD(?tag ORDER BY ?tag) AS ?tags)
WHERE { ?s <http://example.org/tag> ?tag }
GROUP BY ?s
在依赖以下任何一项之前,有四种行为值得了解:
- 空分组折叠为一个已绑定的空复合值——
"[]"^^cdt:List或"{}"^^cdt:Map——绝不会是未绑定的列。对空集的SUM为0,对空集的GROUP_CONCAT为"";FOLD遵循的是这种形态,而不是MIN的形态。 - 元素表达式未绑定或出错的行会贡献一个
null元素,这与其他所有聚合相反,它们都会跳过这样的行。该 SEP 要求列表的元数与分组的元数相符。未绑定或出错的映射键会使整个条目被丢弃,因为没有可以用来归档该值的键。 - 重复的映射键以最后一个绑定为准。
DISTINCT依据的是 RDF 项的同一性,而不是值相等,因此"1"^^xsd:integer与"01"^^xsd:integer作为两个元素保留下来。
FOLD 没有 ; SEPARATOR=——这个标量参数只属于 GROUP_CONCAT——而 FOLD(*) 不合语法。
UNFOLD:一个项变成若干行
UNFOLD 是一个图模式,而不是函数。它是一个独立的 GraphPatternNotTriples 备选项——在结构上与 LATERAL 所处的位置相同——并叠放在迄今已解析的模式之上:
Unfold ::= 'UNFOLD' '(' Expression 'AS' Var ( ',' Var )? ')'
| 操作数 | 第一个变量 | 第二个变量 |
|---|---|---|
cdt:List | 每个元素,按列表顺序,保留重复 | 从 1 开始的 xsd:integer 索引 |
cdt:Map | 每个条目的键 | 该条目的值 |
SELECT ?elem ?i
WHERE {
?s <http://example.org/tags> ?tags
UNFOLD(?tags AS ?elem, ?i)
FILTER(?i < 4)
}
两个目标变量都遵守 BIND 的 §19.6 作用域规则:已在分组图模式中处于作用域的变量是语法错误,而不是连接;两次指名同一个变量同样会被拒绝。
决定 UNFOLD 外围的 OPTIONAL 是否在做预期之事的两个边界情形:
- 良构但为空的复合值(
"[]"^^cdt:List、cdt:Map())产生零行。该行消失。 - 根本不是复合值的操作数——未绑定、出错、
xsd:integer、普通字符串,或词法形式无法解析的cdt:-类型字面量——会原样放行该行,两个目标变量均未绑定。这逐字遵循 SEP-0009 §12.3,并且刻意不作为无行结果,因此UNFOLD绝不会静默删除一个它仅仅是未能理解的行。
FOLD 与 UNFOLD 可以组合,查询正是借此在不离开查询语言的情况下对列表重新排序:
PREFIX cdt: <http://w3id.org/awslabs/neptune/SPARQL-CDTs/>
SELECT (FOLD(?e ORDER BY ?e) AS ?sorted)
WHERE { BIND("[3,1,2]"^^cdt:List AS ?l) UNFOLD(?l AS ?e) }
排序、MIN 与 MAX
ORDER BY、MIN、MAX 以及 FOLD 自身的 ORDER BY,都按复合字面量所表示的值对其排序,而不是按其词法形式,途径是 SPARQL §15.1 的同一个共享投影。复合值的排位介于普通字面量与 RDF 1.2 三元组项之间:unbound < blank < IRI < literal < composite < triple term。SEP-0009 并未固定这一位置;这是 purrdf 有文档记载的选择。
purrdf 为复合值导出的顺序是语法性的,这并非疏忽。SEP-0009 的值关系是偏序,并在不可比较的操作数上抛出错误,而显而易见的修补方式——按值排序、以语法打破平局——在 SPARQL 自身的类型格上是非传递的("9"^^xsd:double < "P1D"^^xsd:duration < "8"^^xsd:float < "9"^^xsd:double)。Rust 的排序在这样的比较器上可能会 panic,因此在这里,全序是正确性要求,而不是锦上添花。
MEDIAN 与 PERCENTILE 是数值折叠,不感知复合值:复合值不在其定义域内,聚合结果为未绑定。
复合值内部的空节点
写在复合值词法形式内部的 _:b,在每个编解码器上——Turtle、TriG、N-Triples、N-Quads、RDF/XML、TriX、HexTuples、JSON-LD,以及两条 GTS 导入路径——都经由与裸 _:b 记号相同的入口规则绑定。因此,在同一文档中,作为主语写出的 _:b 与写在 cdt:List 内部的 _:b 是同一个节点,而同一标签出现在两个不同文档中则仍是两个节点,这完全符合 RDF 1.1 §4.1 的要求。嵌入复合值中的空节点同样参与规范化与 skolem 化(skolemization)。
出于同样的原因,查询文本拥有自己的空节点作用域,因此在查询中写在复合字面量内部的 _:b 永远不是数据中的 _:b。
限制,以及复合值拒绝什么
复合值的嵌套只受内存限制,另有两个界限是值的不变量——程序化构造函数与解析器以完全相同的方式强制执行它们,并且每一层嵌套都计为一个元素:
| 界限 | 值 |
|---|---|
| 元素总数(所有层级) | 2²⁰ (1 048 576) |
| 词法字节数 | 64 MiB |
在求值时越过任一界限都是硬性查询失败,并指名所越过的界限,绝不会是一个悄无声息地未绑定的答案。
文档中格式错误的复合字面量会使整个文档被拒绝(cdt-literal-malformed),而格式错误的 "abc"^^xsd:integer 则不会。这种不对称是刻意为之,并已完整记录在案:嵌入式空节点扫描器是词法层面的,因此若不透明地接纳一个无法解析的形式,就会让空节点作用域一半已绑定、一半仍为原始形式。在查询文本中,同一个字面量可以解析——在那里,类型是否不良是求值时的问题,作用于它的函数结果为未绑定,与它的比较则会抛出错误。
把复合值查询带到另一个引擎
对于 FOLD 与 UNFOLD,预计会得到解析错误,因为它们是任何未扩展语法都不接纳的关键字;对于函数调用,则会悄无声息地得到不同的答案,因为它们是普通的 iri ArgList 调用,没有 SEP-0009 的引擎会把它们报告为未知函数。从宿主到达其中任何一项都无需配置:与 SHA-3 内置函数一样,复合值是无条件提供的,因此 purrdf query、Python 的 Store.query / MutableDataset.query、WebAssembly 的 Dataset.query / QueryEngine.select,以及 C ABI 的 purrdf_query / purrdf_query_json 都具备它们。
有一处分歧方向相反,这里只作陈述,不作论证。purrdf 的读取器接纳两种已发布的 SEP-0009 词法空间所不包含的元素形式——RDF 1.2 三元组项 <<( s p o )>>,以及带方向的语言标签字面量("x"@en--ltr)——作为列表元素与映射值,因为在同样由数据模型定义的容器内部,拒绝 RDF 1.2 数据模型所定义的项类型,对本工具包而言不是可接受的结果。仅当值根本无法用已发布的语法表达时,才会输出这些形式。**把这样的字面量交给符合规范的 SEP-0009 读取器,它会将其判定为格式错误。**关于这一分歧以及限定其影响范围的扫描,见 docs/CONFORMANCE.md。
四元组模板:CONSTRUCT 到命名图中
SPARQL 1.1 的 CONSTRUCT 模板是一组三元组,结果是一个图。purrdf 还接受四元组模板,从而模板可以指名每条语句所落入的图,单个结果也可以跨越多个命名图。
溯源:purrdf 的扩展,并非 SPARQL 1.2 特性
SPARQL 1.2 并未定义四元组模板。 1.1 与 1.2 的语法均不接纳 CONSTRUCT 模板内部的 GRAPH 块(ConstructTemplate ::= '{' ConstructTriples? '}',而 ConstructTriples 仅含三元组),也都没有定义 CONSTRUCT GRAPH … 简写。下文记录的两种写法都是本引擎随附的第一方扩展。让 CONSTRUCT 产生四元组,是 SPARQL 社区提案流程中一项由来已久的诉求,其他引擎——包括 Jena 与 Stardog——已随附了它的某种形式,但并不存在标准化的写法,因此这里描述的写法是 purrdf 自己的。
声明 VERSION "1.2" 不会去掉该扩展:版本声明选择的是语义,而不是功能白名单。见 VERSION 声明。
把这类查询带到另一个引擎
预计会得到解析错误,而不是不同的答案。没有该扩展的引擎一旦在 CONSTRUCT 模板内部遇到 GRAPH 关键字,就会拒绝它,因为其语法中没有在那里接纳它的产生式——查询在求值之前就会失败,因此不存在静默得到错误图的风险。随附了自己形式的该功能的引擎,可能只接受两种写法中的一种,因为二者都未标准化。
两种可移植的改写:
-
如果结果要写入存储,请使用 SPARQL 1.1 Update 而不是
CONSTRUCT。Update 的模板向来就是四元组模板,因此INSERT { GRAPH … { … } } WHERE { … }是标准的、被普遍实现的,并能提供同样的逐解图定位——包括逐行绑定的图名:PREFIX ex: <http://example.org/> INSERT { GRAPH ?g { ?s ex:friend ?o } } WHERE { GRAPH ?g { ?s ex:knows ?o } } -
如果结果必须以文档形式返回,请针对每个目标图各发出一个普通的、产生三元组的
CONSTRUCT,并在客户端组装数据集。这样每个图都要付出一次往返的代价,且无法表达逐解行计算出的图名,而这正是四元组模板所填补的空白。
停留在三元组形式内的查询在两个方向上都不受影响:没有 GRAPH 槽位的模板在这里就是普通的 SPARQL 1.1 CONSTRUCT,输出逐字节相同,因此只有真正指名了图的模板才无法移植。
模板内部的 GRAPH 块
PREFIX ex: <http://example.org/>
CONSTRUCT { GRAPH ex:derived { ?s ex:friend ?o } }
WHERE { ?s ex:knows ?o }
变量图名
图槽位既接受 IRI,也接受变量,因此语句所落入的图可以逐解行决定:
PREFIX ex: <http://example.org/>
CONSTRUCT { GRAPH ?g { ?s ex:friend ?o } }
WHERE { GRAPH ?g { ?s ex:knows ?o } }
多个图,以及混合的默认图三元组
一个模板可以写入不止一个图,并且可以把限定于图的四元组与落入默认图的未限定三元组混合在一起:
PREFIX ex: <http://example.org/>
CONSTRUCT {
?s ex:seen true .
GRAPH ex:people { ?s ex:friend ?o }
GRAPH ex:reverse { ?o ex:friend ?s }
}
WHERE { ?s ex:knows ?o }
整模板简写
CONSTRUCT GRAPH <iri> { … } 把整个模板限定到一个图,而无需在外面包一个 GRAPH 块。它同样适用于简短形式(CONSTRUCT GRAPH <iri> WHERE { … }),并接受变量(CONSTRUCT GRAPH ?g { … })、前缀名或相对于 BASE 的 IRI:
PREFIX ex: <http://example.org/>
CONSTRUCT GRAPH ex:derived { ?s ex:friend ?o }
WHERE { ?s ex:knows ?o }
该简写是默认值,而不是覆盖:它为每个自身未指名图的模板槽位提供图,因此内部的 GRAPH 块仍然优先于它。不带名称的 CONSTRUCT GRAPH { … } 是语法错误,而不是一个被静默取消限定的模板。
跳过语义
SPARQL §16.2 已规定跳过变量未绑定或实例化结果格式错误的模板语句。格式错误意为「不是合法的 RDF 1.2 语句」,逐位置而言:
- 主语是 IRI 或空节点——字面量在那里是非法的,三元组项也是如此(引用三元组是一个值;断言的语句是关于具体化节点的,而不是关于被引用的三元组本身)。二者都可能来自普通数据:对于 RDF 1.2 输入,
CONSTRUCT { ?o ?p ?s } WHERE { ?s ?p ?o }把?o绑定到三元组项,就像绑定到字面量一样轻而易举; - 谓语是 IRI,别无其他;
- 宾语可以是任何项,但当它是三元组项时,该三元组项自身的各分量递归地遵守同样的规则(其主语不得为字面量,其谓语必须是 IRI)。
图槽位遵循同样的规则,这一点值得明确说明:
无法解析的图名会使其语句被跳过。 这不是错误,也_不是_回退到默认图。图槽位最先解析,因此,图名为未绑定变量、或被绑定到任何非 IRI 之物(字面量、空节点、三元组项)的语句,根本不会被实例化——它也不会生成任何空节点标签,因此结果的其余部分与模板中不存在该四元组时完全相同。
跳过是逐语句的,而不是逐行的:对于同一个解,自身图槽位可以解析的同级模板四元组仍会被输出。
哪些输出格式可以承载该结果
命名图需要一种有地方放置图名的语法。九种 RDF 语法中有六种具备这一点:
| 承载命名图 | 不承载 |
|---|---|
| TriG, N-Quads, TriX, HexTuples, JSON-LD, YAML-LD | Turtle, N-Triples, RDF/XML |
单图序列化器会丢弃限定于图的语句——不会把它们折叠进默认图——因此把承载图的结果写入其中之一,会产生一个良构、却恰好静默缺失了查询所要求内容的文档。没有任何宿主会不加提示地放行这种情况。出口为_文档_的三个宿主会直接拒绝,各自指名本会丢弃的图、所要求的格式,以及可承载四元组的替代格式;出口为数据集本身的 C ABI 则改为以计数形式报告损失:
-
CLI ——
purrdf query … --results-format turtle以 2 退出(这是用法层面的拒绝,有别于求值错误),并在 stderr 上打印拒绝信息,指向--results-format trig/nquads/trix/hextuples/jsonld/yamlld。 -
Python —— 承载图的
CONSTRUCT的结果是QueryQuads(其成员是带有有效graph_name的Quad),而不是QueryTriples。QueryQuads.serialize(RdfFormat.TURTLE)会抛出ValueError,并指名RdfFormat.N_QUADS/TRIG/TRIX/HEXTUPLES/JSON_LD/YAML_LD。 -
WebAssembly —— 显式的
serialize("turtle")/queryRaw(…, {format: "turtle"})会抛出异常,给出同样的说明与同样的替代格式。当调用方未(NO)指名格式时,默认格式由turtle拓宽为trig,而不是抛出异常,因为并不存在可被违背的请求,而一个空文档会是错误的答案;不含命名图的结果仍然得到turtle,逐字节相同。 -
C ABI —— 形态不同,且是刻意如此。
purrdf_query返回一个PurrdfDataset句柄,它就是冻结 IR 本身,有地方放置图名,因此图在查询边界上永远不会丢失。序列化该句柄是一次单独的调用,而purrdf_serialize序列化为单图媒体类型时会成功,同时通过out_named_graph_rows_dropped输出参数报告其丢弃的内容——以计数而非异常的形式,因为这是 C 所具备的信号机制。该参数可独立地为空;为它传入 null 的调用方表示不希望被告知,因此只要结果可能承载图,就应读取它——或序列化为可承载四元组的媒体类型。便捷路径
purrdf_query_json两者都不需要:它在其{"graph": "..."}信封内把CONSTRUCT/DESCRIBE结果呈现为 N-Quads,因此图名、基础四元组与 RDF 1.2 陈述层全部得以保留,没有需要报告的损失。该成员是purrdf自己的信封,而不是由调用方选择的 RDF 语法,这正是它像 WebAssembly 未指定格式时的默认值那样拓宽、而不像显式serialize("turtle")那样拒绝的原因。仅含默认图的结果,与该成员过去所存放的 N-Triples 逐字节相同——不含图项的 N-Quads 行就是 N-Triples 行。
仅承载默认图语句的结果在任何地方都不受影响:每一个 SPARQL 1.1 CONSTRUCT 与每一个 DESCRIBE 都与以往完全一样序列化为 Turtle。CLI、Python 与 WebAssembly 在被明确要求使用单图语法时,会整体拒绝混合结果,因为输出默认图那一半会把部分答案报告为完整答案。C ABI 则改为报告丢弃的行数,如上所述。
VERSION 声明
查询或更新的序言可以声明 VERSION "<string>"(SPARQL 1.2 Query 规范 §4.4)。解析仅涉及语法——接受任何字符串,序言重复声明时以最后一个为准——但声明的值不再被丢弃:Query::version() / Update::version() 以带类型的 SparqlVersion 公开它,与既有的 dataset()/base_iri() 访问器并列。
求值是准入边界。VERSION "1.2" 与 VERSION "1.2-basic" 可被识别;其他任何声明的字符串——VERSION "1.1"、拼写错误、本构建之后才出现的未来版本——都会在求值准入时被拒绝,给出指名所声明字符串的带类型错误,且早于任何工作的开销。
VERSION "1.2" 在完整引擎上正常求值。VERSION "1.2-basic" 作为一个更窄的 profile 强制执行:SPARQL 1.2 Query 规范 §4.3.1 的「Version Labels」表把 1.2-basic 定义为完整的 1.2 语法,但「不含三元组项,也不含主语或宾语位置上带有三元组模式的三元组模式」——即 RDF 1.2 的三元组项/具体化功能领域。使用了引用三元组项(<<( s p o )>>)、具体化三元组或注解(<< s p o >>、{| ... |})、VALUES 中不含变量的(ground)三元组项,或某个「Functions on Triple Terms」(TRIPLE、isTRIPLE、SUBJECT、PREDICATE、OBJECT,§17.4.6)的 1.2-basic 查询或更新,会在求值准入时被拒绝,给出指名违规构造的带类型错误——对于更新,不会施加任何变更。未使用上述任何构造的 1.2-basic 请求,其求值与 1.2 请求完全相同。
聚合的确定性:行顺序、DISTINCT 与 GROUP_CONCAT
SPARQL 1.1/1.2 有意对 GROUP BY/聚合求值的若干边角不作完整规定——符合规范的引擎可以在规范允许的范围内选择任何答案。purrdf-sparql-eval 为每一处选定唯一(ONE)一个确定性的答案,并在此记录(同时反映在 purrdf_sparql_eval::modifier 的 crate 文档中),从而「GROUP_CONCAT 返回什么」具有单一、可测试的含义,而不是「引擎碰巧产生的任何顺序」。
行顺序与分组顺序(§18.6.1「Aggregate Algebra」)。 GROUP BY 把内层解序列划分为若干分组;本 crate 按首次出现顺序保持分组(即每个分组的键在内层解序列中首次出现的顺序),并按内层算子顺序保持每个分组自身的行(即未分组的输入产生它们的顺序)。每一个对顺序敏感的折叠——GROUP_CONCAT 的拼接、SAMPLE 的「第一个值胜出」、自定义聚合的 OrderDependent 折叠——都严格按这一顺序对各行折叠。
「内层算子顺序」是可复现(REPRODUCIBLE)的(同一查询针对同一数据集每次都产生相同的顺序),但就其本身而言,对于普通的三元组模式扫描,它并不是查询文本可以依赖的有文档保证的不变量:裸 BGP 的解顺序取决于本存储的内部索引布局(目前按规划器所选索引上的驻留项 id 排序),这是实现细节,而非承诺。查询能够(CAN)依赖的唯一行顺序是显式的 ORDER BY:当聚合的直接输入是(或来自)按 ORDER BY 排序的解序列时——例如为外层聚合提供输入的子查询 { SELECT ?v WHERE { ... } ORDER BY ?key }——「内层算子顺序」恰恰就是该 ORDER BY 的 SPARQL 全序(§15.1),而这是规范本身所固定的。
DISTINCT(聚合调用内部)。 依照 §18.6.1 的 Aggregation 定义,DISTINCT 折叠的是 Dedup(M(Ψ)) 而不是 M(Ψ)——一个保持顺序、无重复的视图,其中各元素首次出现的相对顺序得以保持。本 crate 的去重保留按值相等的元组的第一次(FIRST)出现(按上述行顺序);之后的每次出现都不会到达折叠的 step。
GROUP_CONCAT 的排序
§18.6.1.7 把 GroupConcat 定义为用 sep 连接序列的各元素,但明确对序列自身的顺序不作规定(「字符串的顺序未作规定」)——这恰恰就是上述段落所固定下来的自由度。本 crate 按上述行顺序拼接:分组按首次出现,行按内层算子顺序,DISTINCT 保留第一次出现——产生一个由各词法形式以 sep 连接而成的普通 xsd:string(在没有显式 SEPARATOR 时,依照 §18.6.1.7 默认为 " ")。没有词法形式的项(空节点或三元组项)会把折叠毒化为未绑定,这与 SUM/AVG 对非数值累计值的解读相同。
由于普通 BGP 自身的扫描顺序是实现细节而非有文档的保证(见上文),一个想用精确字符串固定来展示这一确定性解读的 GROUP_CONCAT 测试夹具,不能把证明建立在直接扫描三元组模式之上——那样固定的将是当前索引布局的一个偶然属性,而不是本节所记录的、有规范支撑的排序。本项目自己的一致性测试夹具 crates/sparql-conformance/suite/purrdf-extend/group-concat-order.rq,用一个为外层 GROUP_CONCAT 提供输入的 ORDER BY DESC(?s) 子查询来驱动其行顺序——把固定锚定在 SPARQL 自身针对互不相同的 IRI 的 ORDER BY 全序(§15.1)上,并使用 DESC 而不是 ASC,这样一个静默忽略子查询 ORDER BY(并退回到存储偶然的扫描顺序)的回归,就会产生一个可以察觉的不同的错误字符串,而不是碰巧通过。
扩展求值器:自定义聚合
在 SPARQL 1.1 内置聚合(COUNT/SUM/AVG/MIN/MAX/ SAMPLE/GROUP_CONCAT)之外,Rust 宿主还可以注册额外的 GROUP BY 归约,并在查询文本中以 AGG(<iri>, [DISTINCT] arg, arg, …) 到达它们——这是规范性的位置参数写法(刻意有别于 Jena ARQ 的 AGG <iri>(args))。purrdf_sparql_eval::property_fn 注入的是一个_关系_(图模式位置上的行来源),user_fn 注入的是一个_标量函数_(每次调用一个值),而 agg_fn 注入的是一个折叠:分组中的各行经由调用方提供的累加器归约为一个值,与内置聚合的做法完全相同。
注册的聚合实现两个 trait——CustomAggregate(逐 IRI 的工厂:声明的参数个数、Volatility、AlgebraicClass 以及声明的状态界限)与 AggregateAccumulator(逐调用的折叠:step 每次处理一个已求值的参数元组,combine 按源顺序合并两个部分折叠,finish 得出分组的答案)——并以调用方选定的 IRI 注册到 AggregateRegistry 中:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use purrdf::sparql::{
AggregateAccumulator, AggregateRegistry, AlgebraicClass, Arity, CustomAggregate, EvalError,
ExtensionEnv, NativeSparqlEngine, QueryOptions, Volatility,
};
use purrdf::{RdfDatasetBuilder, SparqlRequest, SparqlResult, TermValue};
use purrdf::xsd::datatype::XSD_INTEGER;
/// 经检查的非负整数累计求和(不超过 i64::MAX)——`example.org` 自己的
/// `AGG(<https://example.org/agg#total>, ?x)`。
struct TotalAccumulator {
sum: i64,
}
impl AggregateAccumulator for TotalAccumulator {
fn step(&mut self, args: &[TermValue]) -> Result<(), EvalError> {
let [TermValue::Literal { lexical_form, datatype, .. }] = args else {
return Err(EvalError::function("total requires one xsd:integer"));
};
if datatype != XSD_INTEGER {
return Err(EvalError::function("total requires one xsd:integer"));
}
let n = lexical_form.parse::<i64>()
.map_err(|_| EvalError::function("total requires a signed-64-bit integer"))?;
if n < 0 {
return Err(EvalError::function("total requires a nonnegative integer"));
}
self.sum = self.sum.checked_add(n)
.ok_or_else(|| EvalError::function("total overflow"))?;
Ok(())
}
fn combine(&mut self, other: Box<dyn AggregateAccumulator>) -> Result<(), EvalError> {
// 通过 `into_any` 恢复 `other` 的真实状态,而不是
// 从 `finish()` 的词法形式重新推导:`finish()` 是一个
// 有损的、字符串类型的答案,重新解析它恰恰是本 crate 自己的
// `agg_fn`/`stat_agg` 合并所避免的模式(见其
// 「Real merges via `into_any`」文档)。累计求和碰巧能够
// 无损地经受这一往返,但下面这种恢复类型的合并,
// 才是在折叠的最终答案本身并非(NOT)充分的可合并状态时
// 应当照搬的模式。
let other = other.into_any().downcast::<Self>().map_err(|_| {
EvalError::function(
"combine received a partial accumulator of a different concrete type",
)
})?;
self.sum = self.sum.checked_add(other.sum)
.ok_or_else(|| EvalError::function("total overflow"))?;
Ok(())
}
fn into_any(self: Box<Self>) -> Box<dyn std::any::Any + Send> {
self // 累计求和本身就是(IS)其充分的合并状态
}
fn finish(self: Box<Self>) -> Result<Option<TermValue>, EvalError> {
Ok(Some(TermValue::typed_literal(
self.sum.to_string(),
XSD_INTEGER,
)))
}
}
struct TotalAggregate;
impl CustomAggregate for TotalAggregate {
fn arity(&self) -> Arity {
Arity::Exact(1)
}
fn volatility(&self) -> Volatility {
Volatility::Stable // 可参与分组内的并行折叠
}
fn algebraic_class(&self) -> AlgebraicClass {
AlgebraicClass::Commutative
}
fn state_bound(&self) -> u64 {
0 // 除累计求和外无其他状态
}
fn init(&self, _scalarvals: &[(String, TermValue)]) -> Box<dyn AggregateAccumulator> {
Box::new(TotalAccumulator { sum: 0 })
}
}
let mut registry = AggregateRegistry::default();
registry.register(
"https://example.org/agg#total",
Arc::new(TotalAggregate),
);
// 读取查询文本所处的环境:`AGG(<iri>, …)` 依据这个
// 注册表被接纳。
let env = ExtensionEnv::over_aggregates(registry).expect("aggregate environment");
let ds = RdfDatasetBuilder::new().freeze().expect("empty dataset");
let engine = NativeSparqlEngine::new();
let result = engine.query_with_options_view(
&ds,
SparqlRequest {
query: "SELECT (AGG(<https://example.org/agg#total>, ?x) AS ?t) WHERE { VALUES ?x { 1 2 3 } }",
base_iri: None,
substitutions: &[],
},
QueryOptions::EMPTY.with_env(&env),
).expect("custom total");
let SparqlResult::Solutions { rows, .. } = result else {
panic!("SELECT result");
};
assert_eq!(rows, vec![vec![Some(TermValue::typed_literal("6", XSD_INTEGER))]]);
}
对未注册 IRI 的 AGG(<iri>, …) 调用、参数个数错误的调用,或带有无效 ; NAME=value scalarval 子句的调用(名称无法识别、名称重复、缺少必需的名称,或值的类型错误——见下文),都会在查询预备时被拒绝——早于任何预算单位的开销——并且预备好的计划携带注册表的指纹,因此在一个注册表下获准的计划,永远不会静默地在另一个注册表下运行(完整的信任边界与确定性契约,包括 into_any 在合并折叠最终答案本身无法重建的结构化状态时所起的作用,见 purrdf-sparql-eval 的 agg_fn 模块文档)。
具名标量值参数
除位置参数 args 外,AGG(<iri>, …) 还接受零个或多个尾随的 ; NAME=value 子句——一个具名的、逐聚合的标量参数,把 GROUP_CONCAT 自身的 ; SEPARATOR="…"(SPARQL 中具名标量聚合参数的既有先例)推广到任何自定义聚合:AGG(<{NS}PERCENTILE>, ?x; P=0.95)、AGG(<{NS}TOPK>, ?x; K=3)。NAME 不区分大小写匹配,并以大写形式存储;value 是任何 SPARQL 字面量——包括带符号形式在内的完整数值类型体系(Q=-1、P=+0.5)、布尔字面量(B=true)以及字符串——因此数值型 scalarval 保持其自然的数据类型。与位置参数不同,scalarval 对整个聚合只求值一次,绝不逐行求值——这对百分位秩或「前 k 项」计数这类参数而言是正确的语义,它们必须是整个分组中一个固定的值,而不是查询作者仅仅打算保持不变的逐行表达式。
注册的聚合通过 CustomAggregate::scalarvals 声明其接受哪些名称,返回一个 ScalarvalSpec { name, kind } 切片(kind 为 ScalarvalKind::Numeric 或 ScalarvalKind::String);每个声明的名称都是必需的。CustomAggregate::init 收到的调用点 scalarval 已被解析为 TermValue,并已依据该声明验证过,因此累加器可以按名称读回它们(scalarvals.iter().find(|(k, _)| k == "P")),而无需重新检查类型或是否存在。
AGG(<iri>, …) 的执行与其他任何折叠一样受治理:profile v6 新增了两个计费点——aggregate-invocation(每个分组、每个聚合表达式一次)与 aggregate-accumulation(每检查一个值一次)——由内置聚合与自定义聚合共同使用,因此在给定的分组形态上,注册的聚合所消耗的燃料与内置聚合完全相同。见 docs/SPARQL-GOVERNOR-PROFILE.md。
统计聚合集
purrdf-sparql-eval 随附十个精确的统计聚合——MEDIAN、PERCENTILE、STDDEV、STDDEV_POP、VARIANCE、VAR_POP、MODE、FIRST、LAST、TOPK——它们是位于同一个 agg_fn 扩展点背后的第一方 CustomAggregate 实例,是封闭的(没有供调用方扩展的扩展点),并通过一次调用到达:AggregateRegistry::register_statistical_aggregates:
#![allow(unused)]
fn main() {
use purrdf::sparql::AggregateRegistry;
let mut registry = AggregateRegistry::default();
registry.register_statistical_aggregates("https://example.org/agg#");
assert_eq!(registry.len(), 10);
// 现已可到达:AGG(<https://example.org/agg#MEDIAN>, ?x), …STDDEV…, …TOPK…
}
与 PurRDF 涉及的每个词汇表一样,namespace 是调用方提供的配置,没有捏造的默认值——从不调用该方法的宿主,十个 IRI 一个也不会被注册,这与从不配置 ParserOptions::extension_fn_namespaces 的宿主得不到任何内置标量扩展函数完全一样。
各成员的语义:
- 数值型成员(
STDDEV*/VARIANCE*、MEDIAN、PERCENTILE)遵循内置SUM/AVG折叠所用的同一个integer ⊂ decimal ⊂ float ⊂ double提升体系;非数值输入会把折叠毒化为未绑定,而不是抛出硬错误,与SUM自身的处理规则一致。只有STDDEV/STDDEV_POP最后的平方根会离开精确的 decimal 体系;VARIANCE/VAR_POP从不离开。 STDDEV/VARIANCE是样本统计量(分母为n - 1,少于两个值时为未绑定);STDDEV_POP/VAR_POP是总体统计量(分母为n,一个值时即有定义)——这是 SQL 自身的命名约定。MEDIAN即p = 0.5时的PERCENTILE,在两个最接近的秩之间做线性插值——对于大小为偶数的分组,这恰好是两个中间值的平均值。PERCENTILE接受一个具名 scalarval,AGG(<{NS}PERCENTILE>, ?x; P=0.95):P对整个聚合只解析一次(绝不逐行——见上文「具名标量值参数」);位于[0, 1]之外的P会毒化为未绑定,这与每个数值型成员所用的「毒化,而非中止」规则相同。缺失或非数值的P会在预备时被拒绝。MODE适用于任何项类型,按 RDF 项的同一性计数(而不是值相等——"5"^^xsd:integer与"05"^^xsd:integer算作两个不同的项)。平局时取MIN/ORDER BY所用的同一全序下最小的项。FIRST/LAST适用于任何项类型,按输入行顺序。TOPK接受一个具名 scalarval,AGG(<{NS}TOPK>, ?x; K=3):K必须是正的xsd:integer,对整个聚合只解析一次(K ≤ 0会毒化为未绑定;缺失或非整数的K会在预备时被拒绝)。由于每个 SPARQL 聚合恰好产生一个 RDF 项,TOPK的应答方式与GROUP_CONCAT应答多值问题的方式相同——前k个值,按降序排列,其词法形式以单个固定的空格分隔符连接。
全部十个成员都声明 Volatility::Stable,并通过真实的、恢复了类型的结构化状态(AggregateAccumulator::into_any)合并部分折叠,而不是从最终答案做有损的重新推导——因此单个大分组可以分块并行折叠(见 crate::modifier::eval_custom_aggregate),其结果与顺序折叠逐字节相同。
路径见证:绑定推导过程,而不是端点
核心语法的属性路径回答的是可达性问题。?s ex:p+ ?o 表示存在某个由 ex:p 边构成的序列从 ?s 通往 ?o,答案是端点对;哪些边则无法表达,因此需要解释、加权、过滤或重新连接该路线的查询无处可查。
路径见证属性函数回答的则是推导问题。调用形式如下
?start <caller-iri> ( ?end ?pathId ?len ?step ?node ?edge )
并每跳输出一行。对于一次 k 跳的游走,第 i 行把 ?len = k、?step = i 绑定好,把 ?node 绑定到该跳到达的节点,把 ?edge 绑定到它所经过的语句——一个以断言方向给出的一等 RDF 1.2 三元组项,因此它可以通过普通的基本图模式直接连接回数据集。?step 与 ?len 是 xsd:integer 字面量,正是为了让 ORDER BY ?step 按数值排序,而不是把 "10" 排在 "2" 之前。
没有默认的关系 IRI,也没有默认的遍历范围。PurRDF 不生成任何词汇表 IRI,因此查询在谓语位置写出的名称由调用方提供;而零跳路径没有见证,无界的游走则没有尽头,因此最小与最大跳数以及两个资源守卫每次都要写明,而不是由库自行臆造。u32 能容纳的任何 max_hops 都会被接受:遍历把每一层深度的状态都保存在堆上,而两个资源守卫限定一次游走所做的工作量。
两种关系类型(TYPES),而不是一种带模式开关的类型:PathWitnessRelation 枚举每一条简单前缀游走(最坏情况下为指数级),ShortestPathWitnessRelation 为每个可达对产生一个最短见证(多项式级)。规划器从注册信息中读取基数,因此由二者中的哪一个应答某个 IRI,是注册的属性,而不是规划器无法看到的运行时值的属性。
一个完整示例
在一条三边链 ex:a → ex:b → ex:c → ex:d 上:
purrdf query --data chain.ttl --results-format csv \
--path-relation 'iri=http://example.org/pf#walk;forward=http://example.org/p;min-hops=1;max-hops=4;max-paths-per-seed=1024;max-expansions=100000;mode=walk' \
'SELECT ?end ?len ?step ?node
WHERE { <http://example.org/a> <http://example.org/pf#walk>
( ?end ?pathId ?len ?step ?node ?edge ) }
ORDER BY ?len ?step'
end,len,step,node
http://example.org/b,1,1,http://example.org/b
http://example.org/c,2,1,http://example.org/b
http://example.org/c,2,2,http://example.org/c
http://example.org/d,3,1,http://example.org/b
http://example.org/d,3,2,http://example.org/c
http://example.org/d,3,3,http://example.org/d
GROUP BY ?pathId 正是把各跳的行重新组成游走的方式。该标识符在一次游走的各行中保持不变,在不同游走之间互不相同,因此每个分组就是(IS)一次游走;?start、?end 与 ?len 在分组内保持不变,因此同时按它们分组仍保持相同的分组,并使它们能够被投影出来:
purrdf query --data chain.ttl --results-format csv \
--path-relation 'iri=http://example.org/pf#walk;forward=http://example.org/p;min-hops=1;max-hops=4;max-paths-per-seed=1024;max-expansions=100000;mode=walk' \
'SELECT ?end ?len (GROUP_CONCAT(?node; separator="->") AS ?route)
WHERE { <http://example.org/a> <http://example.org/pf#walk>
( ?end ?pathId ?len ?step ?node ?edge ) }
GROUP BY ?pathId ?end ?len ORDER BY ?len'
end,len,route
http://example.org/b,1,http://example.org/b
http://example.org/c,2,http://example.org/b->http://example.org/c
http://example.org/d,3,http://example.org/b->http://example.org/c->http://example.org/d
把 ?node 换成 ?edge,同样的分组就会重建语句(STATEMENT)序列,而这正是针对游走的聚合应在之处:对经由 ?edge 连接进来的逐跳权重求 SUM、对逐跳置信度求 MIN、用 HAVING 只保留每一跳都带有注解的路线。这些都无法从一个返回端点的关系中得到,而且都不需要列表项。
在 Python 中进行同样的注册:
store.query(
"SELECT ?end ?len ?step ?node WHERE { <http://example.org/a> "
"<http://example.org/pf#walk> ( ?end ?pathId ?len ?step ?node ?edge ) } "
"ORDER BY ?len ?step",
path_relations={
"http://example.org/pf#walk": (
[(purrdf.NamedNode("http://example.org/p"), "forward")],
1, 4, 1024, 100000, "walk",
)
},
)
在蕴涵机制(entailment regime)下,游走读取的是推理闭包
路径关系是步骤所含边的快照,而它必须据以建立快照的数据集,是查询所依据作答的那个数据集。在 --entailment(或 Python 的 query_entailment_governed)下,那就是推理闭包,而不是源图——因此,在步骤谓语下推导出四元组的蕴涵机制,会拓宽游走,与它拓宽写在旁边的 p+ 完全一样。以如下数据为例:
ex:sub rdfs:subPropertyOf ex:p .
ex:a ex:p ex:b .
ex:b ex:sub ex:c .
在 rdfs 下,SELECT ?end WHERE { ex:a ex:p+ ?end } 的答案是 ex:b, ex:c,而以 ex:a 为起点注册的游走同样会到达 ex:c。没有该蕴涵机制时,二者都止于 ex:b,因为推导出的 ex:b ex:p ex:c 并不存在,无从游走。
有一种组合会被拒绝,而不是作答:在受限 chase 会生成存在性见证的本体上使用 --entailment owl-direct。这些见证是不属于作用域图的项的空节点,而该蕴涵机制的见证过滤无法到达属性函数的输出——因此在该推理闭包上的游走可能会把其中一个作为绑定交还。拒绝携带代码 reasoning-closure-relation-witness,并指名确实接受这种组合的蕴涵机制。不生成任何见证的 owl-direct 运行——TBox 位于组合方法的 Horn 片段之外,或其 chase 未触发任何存在性规则——不会被拒绝。
Rust 接口把同样的事情明确写出:query_with_entailment 与 query_with_entailment_governed 接受一个 ClosureRelations 参数,它要么是 ClosureRelations::NONE(每个注册的关系都与数据集无关,因此调用方的注册表原样使用),要么是 ClosureRelations::rebuilt_by(&f),其中 f 接收物化后的推理闭包,并返回用于作答的注册表。
从其他宿主到达扩展
SHACL-AF 函数注册表与一般(GENERAL)的自定义聚合注册表是 Rust 闭包扩展点:注册的函数或聚合是任意的宿主 Rust 代码(对聚合而言是 init/step/combine/finish 闭包),因此注册它们是仅限 Rust 宿主的操作。它确实无法以字符串或任何其他 FFI 形态的值跨越 Python、WebAssembly 或 C 边界——本项目不愿为此自创任何回调协议,下述四个宿主接口也都没有公开它。
属性函数注册表在同样的意义上也是闭包扩展点——PropertyFunction 是任意的宿主实现——但其数据形态(DATA-SHAPED)的成员不是,这些成员可以跨越边界。冻结的项表、以 rdf:List 的 rdf:List 形式从存储自身数据集中读出的表,以及路径见证遍历,各自都完全由自有数据描述,因此每一种都可以从 Python 注册(每个 Store / MutableDataset 查询与更新入口上的 relations、relations_from_graph、path_relations),路径关系还可以从 CLI 注册(purrdf query --path-relation、purrdf update --path-relation)。它们都不是回调:引擎在释放 GIL 期间调用的任何东西都无法重新进入解释器,这正是它们能够跨越边界的原因。WebAssembly 与 C-ABI 接口完全不公开属性函数注册。
关系对自身作出的证明,以及唯一能够记录它的通道
关于一次调用,有两项事实只有关系的游标知道,其他任何东西都不知道:由其索引的哪一代作答,以及该索引是否完整。因此,每个 Python 关系声明都接受一个尾随位置 (generation, incompleteness),二者各为字符串或 None,并各自被逐字记录。两者都不声明是默认情况,意味着该关系什么也没说——这是沉默,而不是声称索引完整。
声明自身不完整的关系,会在任何无处存放该声明的入口被拒绝:不受治理的 query 与每一条 update 通道都会抛出 native-sparql-relation-incomplete,且更新不施加任何内容。这一拒绝正是受治理通道存在的原因,而不是它的障碍——一个无人能读取其回执的不完整(被截短)的答案,恰恰是硬失败原则所禁止的。因此,受治理通道会作答,其结果携带:
outcome.relation_witness
# {relation_iri: {"invocations": int,
# "generations": [str | None],
# "incompleteness": [str]}}
该映射始终存在,且可以为空——未调用任何关系的受治理查询报告 {},这是一项事实,而不是缺失。generations 中的 None 表示一次什么也没证明的调用,它刻意作为集合的成员保留,而不被丢弃:没有它,「每次都声明了一代」与「有时声明、其余时候什么也没说」读起来会完全相同。键与两个列表都按引擎自身的排序顺序输出,因此在同一快照上两次运行的声明(DECLARATIONS)可以逐字节比较。已触发的 governor 仍会报告各关系在触发之前所证明的内容。
invocations 是唯一不具备这一性质的字段:它统计进入宿主代码的次数,这是关于调度的事实,而不是关于索引的事实。在 FILTER EXISTS 之下,并行行循环在工作线程上对每一块驱动行求值,而工作线程的 EXISTS 记忆表从冷状态开始,因此内层模式——以及其中的关系——每一块都会被重新进入一次,而块数取决于运行时的线程数。因此,同一份数据上的同一个查询可能报告不同的计数,而旁边的每一项声明都完全相同,包括在两次仅所给预算不同的运行之间。应把它读作「该关系是否运行过」(0 对比非零),或作为日志行中的粗略量级;绝不要在两份回执之间比较它。
PurRDF 的第一方统计集则不同,恰恰因为它不是(NOT)任意闭包:AggregateRegistry::register_statistical_aggregates 只接受一个命名空间字符串,并在内部连接十个预构建的 Rust 实例,因此它能跨越本 crate 随附的每一个宿主边界,方式与 property_fn_namespaces 完全相同——没有回调,没有逐聚合的封送。每个宿主接口都以名为 aggregate_namespace(在采用 camelCase 拼写的 JavaScript 中为 aggregateNamespace)的关键字参数 / 标志 / 参数传递它,与该接口传递 property_fn_namespaces 或最接近的等价可选字符串配置的方式保持一致:
-
Rust(直接嵌入引擎):
QueryOptions.aggregates,如本页各处所示。 -
Python(
purrdf.Store/purrdf.MutableDataset):query/query_governed/update/update_governed上的aggregate_namespace关键字参数。store.query( "PREFIX ex: <https://ex.example/> " "SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?v) AS ?m) " "WHERE { ?s ex:value ?v }", aggregate_namespace="https://ex.example/agg#", ) -
CLI(
purrdf query/purrdf update):--aggregate-namespace IRI标志。purrdf query --data data.ttl --aggregate-namespace 'https://ex.example/agg#' \ 'SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?v) AS ?m) WHERE { ?s ?p ?v }' -
WebAssembly(
QueryEngine.queryGoverned/updateGoverned):aggregateNamespace选项,与其他受治理调用选项并列。const outcome = engine.queryGoverned(dataset, sparql, { aggregateNamespace: "https://ex.example/agg#", }); -
C ABI(
purrdf_query_governed/purrdf_update_governed):一个可为空的const char *aggregate_namespace参数,与该 ABI 上其他每个可为空字符串所用的可选 C 字符串约定相同。purrdf_query_governed(dataset, query, /* base_iri */ NULL, "https://ex.example/agg#", &governors, /* … */);
在上述每一个接口上,namespace 都保持为调用方提供的配置,没有捏造的默认值:省略它(None / 不传该标志 / undefined / 空指针)会使十个名称中的每一个都成为普通的未注册自定义聚合 IRI,与该参数出现之前完全一样被拒绝。
感知蕴涵的查询通道(query_with_entailment/ query_with_entailment_governed,以及围绕受治理入口的每一个 Python/CLI/WebAssembly/C 包装)与 aggregate_namespace 的组合方式,与原始视图通道完全相同:二者都接受引擎的 QueryOptions,并将其贯穿推理闭包查询的解析与求值,因此 AGG(<{NS}MEDIAN>, …) 在蕴涵得出的推理闭包上的解析方式,与在原始视图上相同。
purrdf query --data ent.ttl --entailment rdfs \
--aggregate-namespace 'https://ex.example/agg#' \
'SELECT (AGG(<https://ex.example/agg#MEDIAN>, ?w) AS ?m)
WHERE { ?x <http://example.org/measure> ?w }'
purrdf.Store.query_entailment_governed(..., aggregate_namespace=NS) 以同样的方式回答同一个查询,WebAssembly 与 C-ABI 的受治理蕴涵入口也接受同一个参数。
在统计集可到达的所有地方,都适用一个结构性限制:SPARQL UPDATE 的语法只在 WHERE 子句中嵌套的 SELECT … GROUP BY 内部接纳聚合(普通的 DELETE/INSERT WHERE 基本图模式自身没有 GROUP BY)——每个宿主的 update 入口都恰好通过这种嵌套子查询形态到达统计集。
带日期的 XPath 正则法则
REGEX 与 REPLACE 运行在兼容正则表达式之上,除非调用方按稳定名称选择一种带日期的原生 XPath 法则:
| 名称 | 法则 |
|---|---|
xpath-2.0-2010-12-14 | XPath and XQuery Functions and Operators 2.0(第二版),2010 年 12 月 14 日 |
xpath-3.1-2017-03-21 | XPath and XQuery Functions and Operators 3.1,2017 年 3 月 21 日 |
两种法则都接受反向引用,而兼容正则表达式拒绝反向引用。只有 3.1 接受 (?:a) 这样的非捕获组和 q 标志。在任一法则下,下面的查询保留前两个字符相同的每个 ?code:
SELECT ?code WHERE {
?item <http://example.org/code> ?code
FILTER(REGEX(?code, "^(.)\\1"))
}
每个宿主都以自己的写法选择法则:
| 宿主 | 写法 |
|---|---|
| Rust | NativeSparqlEngine::with_xpath_regex(profile, limits) 作用于该引擎运行的每个请求,QueryOptions::with_xpath_regex(profile, limits) 则只作用于一个请求,其中 Profile 与 Limits 来自 purrdf_core::xsd_regex::xpath;purrdf_validate::xpath_regex::{parse_profile, sparql_engine, query_options} 按名称选择,并使用生产限制 |
| CLI | query 与 update 上的 --xpath-regex <NAME> |
| Python | query、query_governed、query_entailment_governed、update、update_governed 与 Store.prepare 上,以及 rdflib 层的 Graph.query 与 Graph.update 上的 xpath_regex=;purrdf.XPATH_REGEX_PROFILES 列出这些名称 |
| WebAssembly | 每个求值型 QueryEngine 方法及其异步孪生方法上的 xpathRegex 选项,以及 Dataset.query、queryRawConfigured 与 queryRawWithContext 的尾随参数 xpathRegex |
| C | purrdf_query_xpath_regex、purrdf_query_json_xpath_regex、purrdf_query_governed_xpath_regex、purrdf_query_entailment_governed_xpath_regex 与 purrdf_update_governed_xpath_regex 上的 regex_profile |
名称须精确匹配,不做大小写折叠,也没有不带日期的别名,因此像 xpath-3.1 这样的名称会被拒绝,并附上可接受名称的列表。未作选择时(没有选项、没有标志,或 regex_profile 为 NULL),兼容正则表达式的行为与以往完全一样。预备好的查询在每次运行中都保留其法则,而未设置法则的请求继承引擎的选择。
所选法则未定义的模式或标志是普通的表达式错误,因此 FILTER 会剔除该行,BIND 会使其变量保持未绑定。所选法则还在有限的资源上限下运行,其中包括 64 KiB 的模式源文本。超出任一上限都是运行层面的失败,并携带该资源的 xpath-* 代码,该页面列出了触及每个上限的、经过测量的几类工作。它绝不会成为部分答案或 false 的过滤结果,被拒绝的更新也不会改变任何内容。
蕴涵机制
先用 purrdf-entail 物化数据集,即可在蕴涵机制下回答 SPARQL 查询——Regime::from_iri 把 sparql:entailmentRegime IRI 映射到相应的引擎。
一致性
完整的 W3C SPARQL 1.1 查询 + 更新求值测试套件以及 SPARQL 1.2 测试套件都被原样随库固化,并由 purrdf-sparql-conformance 运行;每一个未通过项都是带类型、记入台账的预期失败。实时矩阵见 一致性与测试 与 docs/CONFORMANCE.md。