Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions crates/utopia-ingest/src/ontology_rdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ pub struct OwlProperty {
/// `author rangeIncludes Organization, Person` 就会被读成
/// "必须既是组织又是人",然后降级成 text——一条边就这么没了
pub ranges_union: bool,
/// `utopia:temporal` 的值:`state` / `event` / `eternal`(0031)。导出端早就写这一条,
/// 导入端从前不读——一份本体自己说「这条关系是一个时刻」,导进来还是 `state`。
/// 没写或写了别的值都是 `None`,落库时按 `state`;数据属性读了也不用
pub temporal: Option<String>,
}

/// 一次解析的结果。`unprojected` 是**报告**不是错误——见模块文档。
Expand All @@ -85,6 +89,8 @@ pub type VocabDatatypes = BTreeMap<String, &'static str>;
const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const RDFS: &str = "http://www.w3.org/2000/01/rdf-schema#";
const OWL: &str = "http://www.w3.org/2002/07/owl#";
/// 我们自己的注解命名空间,与导出端(`utopia-server::rdf`)同一个
const UTOPIA_NS: &str = "urn:utopia:ns:";

/// schema.org 自己那套 domain/range。**不是标准词汇**,所以写死在这里,
/// 但值得认:schema.org 及派生词汇表一个 `rdfs:domain` 都没有,
Expand Down Expand Up @@ -342,6 +348,9 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
let mut ranges: BTreeMap<String, Vec<String>> = BTreeMap::new();
// rangeIncludes 用过的属性,它的 range 按并集读
let mut union_ranged: BTreeSet<String> = BTreeSet::new();
// `utopia:temporal` 说的时间语义;只认三个值
let mut temporal: BTreeMap<String, String> = BTreeMap::new();
let utopia_temporal = format!("{UTOPIA_NS}temporal");
let known = |p: &str| {
p == RDF_TYPE
|| p == format!("{RDFS}label")
Expand All @@ -365,6 +374,12 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
};
for t in &triples {
let p = t.predicate.as_str();
// 导出端写的就是这三个字面值(0031)。别的值不猜:落到下面按谓词报告,落库按 `state`
let declared_temporal = t
.object_lit
.as_ref()
.map(|(v, _)| v.as_str())
.filter(|v| p == utopia_temporal && matches!(*v, "state" | "event" | "eternal"));
if p == format!("{RDFS}label") {
if let Some(l) = &t.object_lit {
labels.entry(t.subject.clone()).or_default().push(l.clone());
Expand Down Expand Up @@ -444,6 +459,8 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
ranges.entry(t.subject.clone()).or_default().push(o.clone());
union_ranged.insert(t.subject.clone());
}
} else if let Some(v) = declared_temporal {
temporal.entry(t.subject.clone()).or_insert(v.to_string());
} else if !known(p) {
// 报告而不是丢弃:预览页要能说清"这个文件里还有什么我们没消费"
*proj.unprojected.entry(p.to_string()).or_insert(0) += 1;
Expand Down Expand Up @@ -582,6 +599,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
domains: domains.get(iri).cloned().unwrap_or_default(),
ranges: rs,
ranges_union: union_ranged.contains(iri),
temporal: temporal.get(iri).cloned(),
iri: iri.clone(),
});
}
Expand Down Expand Up @@ -1716,6 +1734,42 @@ schema:employees a rdf:Property ; rdfs:label "employees" ;
"**认了就不该再算进「暂未投影」**"
);
}

/// `utopia:temporal` 要被读出来(0031)。导出端一直写它,导入端从前不读:
/// 一份本体自己说「这条关系是一个时刻」,导进来还是 `state`,#1242 的那条
/// 「非 state 的关系不经模型就能绑」对它永远不成立。
#[test]
fn a_property_annotated_with_utopia_temporal_keeps_the_value_it_declared() {
let ttl = r#"
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix utopia: <urn:utopia:ns:> .
@prefix ex: <http://example.org/> .

ex:Person a owl:Class .
ex:attended a owl:ObjectProperty ; rdfs:label "attended" ; utopia:temporal "event" .
ex:born_of a owl:ObjectProperty ; rdfs:label "born of" ; utopia:temporal "eternal" .
ex:works_at a owl:ObjectProperty ; rdfs:label "works at" .
ex:met a owl:ObjectProperty ; rdfs:label "met" ; utopia:temporal "sometimes" .
"#;
let p = project(ttl.as_bytes(), RdfFormat::Turtle).expect("解析");
let by = |k: &str| {
p.properties
.iter()
.find(|x| x.key == k)
.unwrap_or_else(|| panic!("没有 {k}"))
};
assert_eq!(by("attended").temporal.as_deref(), Some("event"));
assert_eq!(by("born_of").temporal.as_deref(), Some("eternal"));
assert_eq!(
by("works_at").temporal,
None,
"没写就是没写,落库再按 state"
);
// 三个值以外的不猜:留空并按谓词报告出来,只有 `met` 那一条,预览页能说清它没进来
assert_eq!(by("met").temporal, None);
assert_eq!(p.unprojected.get("urn:utopia:ns:temporal"), Some(&1));
}
}

#[cfg(test)]
Expand Down
94 changes: 94 additions & 0 deletions crates/utopia-server/src/api/token_setup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,97 @@ async fn importing_an_ontology_queues_alignment_behind_the_index_refresh() -> an
f.cleanup().await?;
result
}

/// 导入读 `utopia:temporal`(0031)。导出端一直写这一条,导入端从前不读:一份本体
/// 说「这条关系是一个时刻」,导进来照样 `state`,#1242 的「非 state 的关系不经模型
/// 就能绑」对导入的本体永远不成立。新建的照本体建;重导入时本体说了的改过来,
/// 没说的不动——与 domain/range 同一个道理。真改了推 `updated_at`,与页面上改一样
#[tokio::test]
async fn an_imported_property_keeps_the_temporal_it_declares() -> anyhow::Result<()> {
let Some(f) = Fixture::new().await? else {
return Ok(());
};
let result = async {
let kb = f.seeded_kb("temporal").await?;
let token = f.token(f.admin, "write", None).await;
const PREFIXES: &str = "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
@prefix utopia: <urn:utopia:ns:> .\n\
@prefix ex: <http://example.org/> .\n";
let works_at = || {
let pool = f.pool.clone();
async move {
sqlx::query_as::<_, (String, chrono::DateTime<chrono::Utc>)>(
"SELECT temporal, updated_at FROM relation_types
WHERE kb_id = $1 AND iri = 'http://example.org/worksAt'",
)
.bind(kb)
.fetch_one(&pool)
.await
}
};

// 第一版没有注解:worksAt 建成 state
let first =
format!("{PREFIXES}ex:worksAt a owl:ObjectProperty ; rdfs:label \"works at\" .\n");
let (status, body) = f.import_turtle(kb, &token, &first).await?;
anyhow::ensure!(status == StatusCode::OK, "{status} {body}");
let (temporal, created) = works_at().await?;
anyhow::ensure!(temporal == "state");

// 第二版:worksAt 加了注解,attended 是带注解新建的
let second = format!(
"{PREFIXES}ex:worksAt a owl:ObjectProperty ; rdfs:label \"works at\" ; \
utopia:temporal \"eternal\" .\n\
ex:attended a owl:ObjectProperty ; rdfs:label \"attended\" ; \
utopia:temporal \"event\" .\n"
);
let (status, body) = f.import_turtle(kb, &token, &second).await?;
anyhow::ensure!(status == StatusCode::OK, "{status} {body}");
let (temporal, changed) = works_at().await?;
anyhow::ensure!(
temporal == "eternal",
"a re-import writes the temporal the file now declares"
);
anyhow::ensure!(
changed > created,
"a temporal changed by import moves updated_at"
);
let attended: String = sqlx::query_scalar(
"SELECT temporal FROM relation_types
WHERE kb_id = $1 AND iri = 'http://example.org/attended'",
)
.bind(kb)
.fetch_one(&f.pool)
.await?;
anyhow::ensure!(
attended == "event",
"a fresh import creates the relation with the temporal it declares"
);

// 第三版把注解去掉:没说就不动,人在页面上改过的也不会被重导入抹掉
let (status, body) = f.import_turtle(kb, &token, &first).await?;
anyhow::ensure!(status == StatusCode::OK, "{status} {body}");
anyhow::ensure!(
works_at().await? == ("eternal".to_string(), changed),
"an unannotated re-import leaves temporal and updated_at alone"
);

// 第四版说回 state:导出端写 state,所以一份导出重导入能把关系带回 state
let back = format!(
"{PREFIXES}ex:worksAt a owl:ObjectProperty ; rdfs:label \"works at\" ; \
utopia:temporal \"state\" .\n"
);
let (status, body) = f.import_turtle(kb, &token, &back).await?;
anyhow::ensure!(status == StatusCode::OK, "{status} {body}");
let (temporal, returned) = works_at().await?;
anyhow::ensure!(
temporal == "state" && returned > changed,
"a re-import declaring state returns the relation to state"
);
anyhow::Ok(())
}
.await;
f.cleanup().await?;
result
}
5 changes: 5 additions & 0 deletions crates/utopia-server/src/owl_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ pub async fn apply(
&p.description,
&domains,
&ranges,
p.temporal.as_deref(),
)
.await?
{
Expand All @@ -631,6 +632,8 @@ pub async fn apply(
symmetric: p.symmetric,
asymmetric: p.asymmetric,
irreflexive: p.irreflexive,
// 本体用 `utopia:temporal` 说了的照它说的(0031);没说的是 state
temporal: p.temporal.as_deref().unwrap_or("state").to_string(),
});
pending_links.push((p.key.clone(), domains, ranges));
}
Expand Down Expand Up @@ -719,6 +722,8 @@ pub async fn apply(
symmetric: false,
asymmetric: false,
irreflexive: false,
// 属性建成 state,页面也不给别的选(0031 的「未决」里那一条)
temporal: "state".to_string(),
});
pending_attr_domains.push((p.key.clone(), domain_ids));
}
Expand Down
18 changes: 14 additions & 4 deletions crates/utopia-server/src/rdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,8 @@ pub fn emit_relation(
}
// 时间语义也照抄(0031):一个 event 谓词的事实两端是同一刻,一个 eternal 谓词的
// 事实没有日期——读的人不看这一条,会把前者读成一天的状态、后者读成从不知何时起。
// 状态是默认,不写
if r.temporal != "state" {
sink.l(&iri, &utopia("temporal"), &text(r.temporal.clone()))?;
}
// `state` 也写:导入把「没写」读成「不动」,不写它,改回 state 的关系重导入后还是 event
sink.l(&iri, &utopia("temporal"), &text(r.temporal.clone()))?;
for d in &r.domains {
if let Some(c) = vocab.class(*d) {
let c = c.clone();
Expand Down Expand Up @@ -1600,6 +1598,18 @@ mod tests {
));
}

/// 改回 state 的关系重导入也得是 state:导入把没写的 `utopia:temporal` 读成「不动」
#[test]
fn a_state_relation_exports_its_temporal() {
let quads = export(Format::Turtle, |_, _, _| {});
assert!(has(
&quads,
&format!("<{WORKS_FOR}>"),
"urn:utopia:ns:temporal",
"\"state\""
));
}

#[test]
fn a_live_fact_is_both_a_statement_and_a_triple() {
let quads = export(Format::Turtle, |sink, names, vocab| {
Expand Down
53 changes: 37 additions & 16 deletions crates/utopia-store/src/ontology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,16 @@ pub fn lower_camel(label: &str) -> String {
out
}

/// `temporal` 只有三个值(0031):单条建、单条改、批量建、重导入更新都走这一句
fn validate_temporal(temporal: &str) -> AppResult<()> {
if !matches!(temporal, "state" | "event" | "eternal") {
return Err(AppError::Validation(
"temporal must be state / event / eternal".into(),
));
}
Ok(())
}

fn validate_key(key: &str) -> AppResult<()> {
let ok = !key.is_empty()
&& key.len() <= 40
Expand Down Expand Up @@ -608,11 +618,7 @@ pub async fn create_relation_type<'a>(
unit: Option<&str>,
) -> AppResult<Uuid> {
validate_key(key)?;
if !matches!(temporal, "state" | "event" | "eternal") {
return Err(AppError::Validation(
"temporal must be state / event / eternal".into(),
));
}
validate_temporal(temporal)?;
validate_attribute_fields(kind, domains, datatype)?;
let mut tx = pool.begin().await?;
let label = &lower_camel(label);
Expand Down Expand Up @@ -826,11 +832,7 @@ pub async fn update_relation_type(
}
// 改名也归一:不然界面上改一次就能把小驼峰改回 "access to"
let label = &lower_camel(label);
if !matches!(temporal, "state" | "event" | "eternal") {
return Err(AppError::Validation(
"temporal must be state / event / eternal".into(),
));
}
validate_temporal(temporal)?;
if datatype.is_some() && !matches!(datatype, Some("text" | "number" | "date" | "bool")) {
return Err(AppError::Validation(
"datatype must be text / number / date / bool".into(),
Expand Down Expand Up @@ -1133,6 +1135,11 @@ pub async fn create_relation_with_iri(
///
/// **key 不动**(它可能已被事实引用),**空描述不覆盖**(人写过的比上游的准),
/// **domain/range 整体重写**——它们是上游声明的结构,不是人调过的措辞。
/// **`temporal` 只在本体说了才写**(`Some`):没写 `utopia:temporal` 的本体不动它,
/// 人在页面上改过的就留着;写了的照本体,与 domain/range 同一个道理。
/// 真改了就推 `updated_at`,与页面上改一样:0053 的短语绑定依据读它,
/// state 与非 state 决定短语怎么绑,旧值下做的决定要能被重审。
#[allow(clippy::too_many_arguments)]
pub async fn update_relation_from_import(
pool: &PgPool,
kb_id: Uuid,
Expand All @@ -1141,18 +1148,25 @@ pub async fn update_relation_from_import(
description: &str,
domains: &[Uuid],
ranges: &[Uuid],
temporal: Option<&str>,
) -> AppResult<bool> {
if let Some(t) = temporal {
validate_temporal(t)?;
}
let row: Option<(Uuid,)> = sqlx::query_as(
"UPDATE relation_types
SET label = $3,
description = CASE WHEN $4 = '' THEN description ELSE $4 END
description = CASE WHEN $4 = '' THEN description ELSE $4 END,
temporal = COALESCE($5::text, temporal),
updated_at = CASE WHEN $5::text <> temporal THEN now() ELSE updated_at END
WHERE kb_id = $1 AND iri = $2
RETURNING id",
)
.bind(kb_id)
.bind(iri)
.bind(label)
.bind(description)
.bind(temporal)
.fetch_optional(pool)
.await?;
let Some((id,)) = row else {
Expand Down Expand Up @@ -1683,13 +1697,16 @@ pub struct BulkRelation {
pub symmetric: bool,
pub asymmetric: bool,
pub irreflexive: bool,
/// `state` / `event` / `eternal`(0031)。OWL 自己没有这个概念,标准词表里的对象
/// 属性全是 `state`;只有本体用 `utopia:temporal` 说了的才不是——导出端写的那一条
pub temporal: String,
}

/// 一次插完一批关系或属性,返回 key → id。语义同 [`create_entity_types_bulk`]。
///
/// `kind` 决定走关系通道还是属性通道;`datatype` 只对属性有意义,关系传 `None`。
/// `temporal` 固定 `state`——OWL 里没有对应概念,猜 event 或 eternal 都更差
/// (与单条版的判断一致)。
/// `temporal` 照每一行写:没有 `utopia:temporal` 注解的本体一律 `state`
///(与单条版的默认一致),写了的按它说的。
pub async fn create_relation_types_bulk(
pool: &PgPool,
kb_id: Uuid,
Expand All @@ -1700,6 +1717,7 @@ pub async fn create_relation_types_bulk(
}
for r in rows {
validate_key(&r.key)?;
validate_temporal(&r.temporal)?;
}
let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect();
// 导入来的词表自己就可能不一致(unece.org 里 `brandName` 与
Expand All @@ -1716,16 +1734,18 @@ pub async fn create_relation_types_bulk(
let syms: Vec<bool> = rows.iter().map(|r| r.symmetric).collect();
let asyms: Vec<bool> = rows.iter().map(|r| r.asymmetric).collect();
let irrefs: Vec<bool> = rows.iter().map(|r| r.irreflexive).collect();
let temporals: Vec<&str> = rows.iter().map(|r| r.temporal.as_str()).collect();
let out: Vec<(Uuid, String)> = sqlx::query_as(
"INSERT INTO relation_types
(id, kb_id, key, label, temporal, functional, inverse_functional,
description, kind, datatype, iri,
is_transitive, is_symmetric, is_asymmetric, is_irreflexive)
SELECT gen_random_uuid(), $1, k, l, 'state', fu, iv, d, kind, dt, i,
SELECT gen_random_uuid(), $1, k, l, tm, fu, iv, d, kind, dt, i,
tr, sy, asym, irr
FROM UNNEST($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[],
$8::bool[], $9::bool[], $10::bool[], $11::bool[], $12::bool[], $13::bool[])
AS t(k, l, d, i, kind, dt, fu, iv, tr, sy, asym, irr)
$8::bool[], $9::bool[], $10::bool[], $11::bool[], $12::bool[], $13::bool[],
$14::text[])
AS t(k, l, d, i, kind, dt, fu, iv, tr, sy, asym, irr, tm)
ON CONFLICT (kb_id, key) DO NOTHING
RETURNING id, key",
)
Expand All @@ -1742,6 +1762,7 @@ pub async fn create_relation_types_bulk(
.bind(&syms)
.bind(&asyms)
.bind(&irrefs)
.bind(&temporals)
.fetch_all(pool)
.await?;
Ok(out.into_iter().map(|(id, k)| (k, id)).collect())
Expand Down
Loading
Loading