From 89237d8998a70b24cda914e266ce9b8d7af165d3 Mon Sep 17 00:00:00 2001 From: Frank Dekervel Date: Tue, 25 Aug 2026 10:25:28 +0200 Subject: [PATCH 1/2] feat(schemaview): add RangeInfo::is_numeric() Answers the question a consumer asks when deciding whether a column's values compare as numbers or as text: '9' >= '10' holds as text and fails as a number, so getting it wrong is silent in both directions. is_integer/is_floating_point cannot answer it. They were written to pick a JSON canonicalisation at boxing time, and between them they cover exactly integer, float, double and decimal -- so a slot declared xsd:int, xsd:long, xsd:short, xsd:byte, any xsd:unsigned*, or any of the bounded-integer datatypes reads as non-numeric. Those four names stay as they are: their callers want the narrower question. is_numeric uses the same IRI-primary, builtin-name-fallback resolution: the resolved datatype IRI also catches schema-defined subtypes, and the builtin LinkML type names keep detection working against a schema whose linkml:types is not loaded -- which is the case a consumer keeping its own list of datatype IRIs gets wrong. LinkML has no builtin name for the XSD numeric datatypes beyond those four, so a schema can only reach them by declaring its own type; the test fixture does that once per datatype and asserts detection comes off the IRI. Co-Authored-By: Claude Opus 5 (1M context) --- src/schemaview/src/slotview.rs | 50 ++++++++ .../tests/data/numeric_no_types.yaml | 17 +++ .../tests/data/rdf_type_schema.yaml | 120 ++++++++++++++++++ src/schemaview/tests/range_info_rdf_types.rs | 85 +++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 src/schemaview/tests/data/numeric_no_types.yaml diff --git a/src/schemaview/src/slotview.rs b/src/schemaview/src/slotview.rs index 7beadc3..618efa9 100644 --- a/src/schemaview/src/slotview.rs +++ b/src/schemaview/src/slotview.rs @@ -97,6 +97,31 @@ impl RangeInfo { /// builtin type other than by its reserved name. const FLOAT_TYPE_NAMES: &'static [&'static str] = &["float", "double", "decimal"]; const INTEGER_TYPE_NAMES: &'static [&'static str] = &["integer"]; + /// Every builtin LinkML type name that denotes a number. The XSD numeric + /// datatypes beyond these four have no builtin LinkML name at all, so a + /// schema can only reach them by declaring its own type — which is why + /// [`is_numeric`](Self::is_numeric) has to recognise them by IRI. + const NUMERIC_TYPE_NAMES: &'static [&'static str] = &["integer", "float", "double", "decimal"]; + + /// XSD IRIs of every numeric datatype. + const XSD_NUMERIC: &'static [&'static str] = &[ + Self::XSD_INTEGER, + Self::XSD_FLOAT, + Self::XSD_DOUBLE, + Self::XSD_DECIMAL, + "http://www.w3.org/2001/XMLSchema#int", + "http://www.w3.org/2001/XMLSchema#long", + "http://www.w3.org/2001/XMLSchema#short", + "http://www.w3.org/2001/XMLSchema#byte", + "http://www.w3.org/2001/XMLSchema#unsignedInt", + "http://www.w3.org/2001/XMLSchema#unsignedLong", + "http://www.w3.org/2001/XMLSchema#unsignedShort", + "http://www.w3.org/2001/XMLSchema#unsignedByte", + "http://www.w3.org/2001/XMLSchema#nonNegativeInteger", + "http://www.w3.org/2001/XMLSchema#positiveInteger", + "http://www.w3.org/2001/XMLSchema#negativeInteger", + "http://www.w3.org/2001/XMLSchema#nonPositiveInteger", + ]; /// `true` when this range is a real-valued number type (`float`, `double` /// or `decimal`), for which an integer JSON literal should be canonicalised @@ -127,6 +152,31 @@ impl RangeInfo { self.range_name_matches(Self::INTEGER_TYPE_NAMES) } + /// `true` when this range is any numeric type — every XSD numeric datatype, + /// not only the four that need JSON canonicalisation. + /// + /// This is the question a consumer asks when it has to decide whether values + /// compare as numbers or as text: `'9' >= '10'` holds as text and fails as a + /// number, so getting it wrong is silent in both directions. It is + /// deliberately broader than [`is_integer`](Self::is_integer) and + /// [`is_floating_point`](Self::is_floating_point), which answer the narrower + /// question of *which* canonicalisation to apply at boxing time. + /// + /// Same IRI-primary, name-fallback resolution as those two: the resolved + /// datatype IRI also catches user-defined subtypes through the `typeof` + /// chain, and the builtin type names keep detection working when the + /// `linkml:types` schema that defines those IRIs has not been loaded. + pub fn is_numeric(&self) -> bool { + if self + .rdf_datatype_iri + .as_deref() + .is_some_and(|iri| Self::XSD_NUMERIC.contains(&iri)) + { + return true; + } + self.range_name_matches(Self::NUMERIC_TYPE_NAMES) + } + /// Fallback range check by builtin type name. Only the range itself, never /// a class or enum, can be a number type. fn range_name_matches(&self, names: &[&str]) -> bool { diff --git a/src/schemaview/tests/data/numeric_no_types.yaml b/src/schemaview/tests/data/numeric_no_types.yaml new file mode 100644 index 0000000..eb18b44 --- /dev/null +++ b/src/schemaview/tests/data/numeric_no_types.yaml @@ -0,0 +1,17 @@ +# Deliberately declares no `types:` block and imports nothing: numeric +# detection has to fall back to the builtin LinkML type names, because there is +# no datatype IRI to resolve. +id: https://example.com/notypes +name: notypes +prefixes: + notypes: https://example.com/notypes/ +default_prefix: notypes +classes: + Thing: + attributes: + count: + range: integer + score: + range: double + name: + range: string diff --git a/src/schemaview/tests/data/rdf_type_schema.yaml b/src/schemaview/tests/data/rdf_type_schema.yaml index 3259ffd..9820297 100644 --- a/src/schemaview/tests/data/rdf_type_schema.yaml +++ b/src/schemaview/tests/data/rdf_type_schema.yaml @@ -3,6 +3,7 @@ name: rdftype prefixes: rdftype: https://example.com/rdftype/ geosparql: http://www.opengis.net/ont/geosparql# + xsd: http://www.w3.org/2001/XMLSchema# linkml: https://w3id.org/linkml/ default_prefix: rdftype imports: @@ -11,7 +12,72 @@ types: wktLiteral: uri: geosparql:wktLiteral typeof: string + # LinkML declares no builtin name for these XSD numeric datatypes, so a schema + # has to define its own type for each. Numeric detection therefore has to work + # off the resolved datatype IRI, not off the range's name. + xsdInt: + uri: xsd:int + base: int + xsdLong: + uri: xsd:long + base: int + xsdShort: + uri: xsd:short + base: int + xsdByte: + uri: xsd:byte + base: int + xsdUnsignedInt: + uri: xsd:unsignedInt + base: int + xsdUnsignedLong: + uri: xsd:unsignedLong + base: int + xsdUnsignedShort: + uri: xsd:unsignedShort + base: int + xsdUnsignedByte: + uri: xsd:unsignedByte + base: int + xsdNonNegativeInteger: + uri: xsd:nonNegativeInteger + base: int + xsdPositiveInteger: + uri: xsd:positiveInteger + base: int + xsdNegativeInteger: + uri: xsd:negativeInteger + base: int + xsdNonPositiveInteger: + uri: xsd:nonPositiveInteger + base: int + # A schema-defined subtype of a builtin, carrying its own datatype IRI. + # Inheriting a datatype through `typeof` alone is covered by + # tests/typeof_inheritance.rs. + trackLength: + typeof: integer + uri: xsd:int + base: int +enums: + Status: + permissible_values: + active: + meaning: rdftype:Active + retired: + meaning: rdftype:Retired + Flag: + permissible_values: + up: {} + down: {} classes: + Target: + attributes: + target_id: + identifier: true + Nested: + attributes: + label: + range: string Thing: attributes: name: @@ -28,3 +94,57 @@ classes: range: uri see_also: range: uriorcurie + truthy: + range: boolean + when: + range: date + status: + range: Status + flag: + range: Flag + target: + range: Target + nested: + range: Nested + description: + range: string + in_language: en + counted_in_english: + range: integer + in_language: en + Numbers: + attributes: + as_int: + range: xsdInt + as_long: + range: xsdLong + as_short: + range: xsdShort + as_byte: + range: xsdByte + as_unsigned_int: + range: xsdUnsignedInt + as_unsigned_long: + range: xsdUnsignedLong + as_unsigned_short: + range: xsdUnsignedShort + as_unsigned_byte: + range: xsdUnsignedByte + as_non_negative_integer: + range: xsdNonNegativeInteger + as_positive_integer: + range: xsdPositiveInteger + as_negative_integer: + range: xsdNegativeInteger + as_non_positive_integer: + range: xsdNonPositiveInteger + as_integer: + range: integer + as_float: + range: float + as_double: + range: double + as_decimal: + range: decimal + as_track_length: + range: trackLength diff --git a/src/schemaview/tests/range_info_rdf_types.rs b/src/schemaview/tests/range_info_rdf_types.rs index 1911497..2cf45f5 100644 --- a/src/schemaview/tests/range_info_rdf_types.rs +++ b/src/schemaview/tests/range_info_rdf_types.rs @@ -129,3 +129,88 @@ fn uriorcurie_range_is_iri() { ); assert!(ri.is_range_iri, "uriorcurie range should be flagged as IRI"); } + +/// Every XSD numeric datatype counts as numeric, not only the four that need +/// JSON canonicalisation (`is_integer` / `is_floating_point`). +#[test] +fn every_xsd_numeric_datatype_is_numeric() { + let (_schema, sv, conv) = load_schema_with_types("rdf_type_schema.yaml"); + for slot in [ + "as_int", + "as_long", + "as_short", + "as_byte", + "as_unsigned_int", + "as_unsigned_long", + "as_unsigned_short", + "as_unsigned_byte", + "as_non_negative_integer", + "as_positive_integer", + "as_negative_integer", + "as_non_positive_integer", + "as_integer", + "as_float", + "as_double", + "as_decimal", + ] { + let ri = range_info_for_slot(&sv, &conv, "Numbers", slot); + assert!( + ri.is_numeric(), + "'{}' (datatype {:?}) should be numeric", + slot, + ri.rdf_datatype_iri + ); + } +} + +/// A schema-defined subtype carrying its own datatype IRI is numeric through +/// the IRI path — nothing about the name "trackLength" says it is a number. +#[test] +fn schema_defined_numeric_subtype_is_numeric_through_its_datatype() { + let (_schema, sv, conv) = load_schema_with_types("rdf_type_schema.yaml"); + let ri = range_info_for_slot(&sv, &conv, "Numbers", "as_track_length"); + assert_eq!( + ri.rdf_datatype_iri.as_deref(), + Some("http://www.w3.org/2001/XMLSchema#int") + ); + assert!(ri.is_numeric()); + // `is_integer` answers a narrower question — which JSON canonicalisation to + // apply — and xsd:int is not xsd:integer, so it stays false. + assert!(!ri.is_integer()); + assert!(!ri.is_floating_point()); +} + +#[test] +fn non_numeric_ranges_are_not_numeric() { + let (_schema, sv, conv) = load_schema_with_types("rdf_type_schema.yaml"); + for slot in [ + "name", // string + "truthy", // boolean + "when", // date + "see_also", // uriorcurie + "location", // a custom string subtype + "status", // an enum range + "target", // a class range + ] { + let ri = range_info_for_slot(&sv, &conv, "Thing", slot); + assert!(!ri.is_numeric(), "'{}' should not be numeric", slot); + } +} + +/// The case a local list of datatype IRIs gets wrong: with no `types:` block +/// there is no datatype IRI to resolve, so detection has to fall back to the +/// builtin LinkML type names. +#[test] +fn builtin_range_is_numeric_without_a_types_block() { + let schema = from_yaml(Path::new(&data_path("numeric_no_types.yaml"))).unwrap(); + let mut sv = SchemaView::new(); + sv.add_schema(schema.clone()).unwrap(); + let conv = converter_from_schemas([&schema]); + + for slot in ["count", "score"] { + let ri = range_info_for_slot(&sv, &conv, "Thing", slot); + assert_eq!(ri.rdf_datatype_iri, None, "no types block, no datatype IRI"); + assert!(ri.is_numeric(), "'{}' should be numeric by name", slot); + } + assert!(!range_info_for_slot(&sv, &conv, "Thing", "name").is_numeric()); +} From 5fd10634d89ecf91677bb2fa0b9b99416491c929 Mon Sep 17 00:00:00 2001 From: Frank Dekervel Date: Tue, 25 Aug 2026 10:34:01 +0200 Subject: [PATCH 2/2] refactor(schemaview): expose the value->term decision as TermDescriptor The turtle writer spelled out the same precedence chain at three of its call sites (single value, list item, mapping value): reference-or-IRI-range, then enum meaning, then literal. Each copy was ~20 lines of triple building, and the chain's ordering invariant was nowhere stated. Lift the decision to SlotView::term_descriptor(), which resolves it from the slot alone -- an enum meaning map, an IRI disposition, a datatype, a language tag -- and returns None when the values are not a term anything can reproduce (an inlined structure serializes as a blank node whose label nothing can reproduce). turtle::term_for() applies a descriptor to one value, so the three call sites collapse to one line each and turtle.rs loses 41 lines net. Writing the chain once also makes an invariant visible that was previously accidental: the writer tested is_range_iri before the enum meaning while the order is in fact immaterial, because determine_rdf_type_info yields (None, false) for an enum range and so is_range_iri is never true for one. The split across crates is deliberate. The descriptor is schema knowledge and lives in schemaview; term_for stays in the runtime because schemaview depends on neither oxrdf nor serde_json and should not start. Consumers that must render values without serializing them -- pushing a query down to SQL over stored JSON, where the rendering is decided at plan time and has to match this writer term for term -- need the descriptor, not the Term. term_descriptor takes the converter rather than deriving one, so the enum meaning CURIEs expand exactly as they do during serialization; deriving one internally would diverge for a schema with conflicting prefixes. Tests: one case per precedence rule on the descriptor (schemaview), the exact Term per rule through term_for (runtime, on the same fixtures the golden turtle tests use, including the mixed enum whose `unknown` value has no meaning and a CURIE-valued IRI range), and the existing turtle serialisation tests passing unchanged -- which is what makes this a refactor rather than a rewrite. Co-Authored-By: Claude Opus 5 (1M context) --- src/runtime/src/turtle.rs | 219 ++++++++------------ src/runtime/tests/term_descriptor_parity.rs | 175 ++++++++++++++++ src/schemaview/src/slotview.rs | 148 +++++++++++++ src/schemaview/tests/term_descriptor.rs | 148 +++++++++++++ 4 files changed, 560 insertions(+), 130 deletions(-) create mode 100644 src/runtime/tests/term_descriptor_parity.rs create mode 100644 src/schemaview/tests/term_descriptor.rs diff --git a/src/runtime/src/turtle.rs b/src/runtime/src/turtle.rs index c267f34..9f7df19 100644 --- a/src/runtime/src/turtle.rs +++ b/src/runtime/src/turtle.rs @@ -3,7 +3,7 @@ use linkml_schemaview::Converter; use linkml_schemaview::identifier::Identifier; use linkml_schemaview::schemaview::{ClassView, SchemaView}; -use linkml_schemaview::slotview::{SlotInlineMode, SlotView}; +use linkml_schemaview::slotview::{SlotView, TermDescriptor, TermKind}; use serde_json::Value as JsonValue; use std::io::{Result as IoResult, Write}; @@ -105,33 +105,80 @@ fn literal_and_type(value: &JsonValue, slot: &SlotView) -> (String, Option bool { - slot.get_range_info() - .first() - .is_some_and(|ri| ri.is_range_iri) -} - -/// Build an RDF literal `Term` for a scalar value, respecting: -/// 1. `in_language` on the slot definition → language-tagged literal -/// 2. Custom RDF datatype IRI → typed literal -/// 3. Otherwise → plain simple literal -fn scalar_literal_term(value: &JsonValue, slot: &SlotView) -> Term { - let (lit, dt_opt) = literal_and_type(value, slot); - // Language tag takes priority over datatype (they are mutually exclusive in RDF) - if dt_opt.is_none() { - if let Some(lang) = &slot.definition().in_language { - if let Ok(tagged) = Literal::new_language_tagged_literal(lit.clone(), lang) { - return Term::Literal(tagged); +/// Apply a [`TermDescriptor`] to one value. +/// +/// The value-dependent half of [`SlotView::term_descriptor`]: the descriptor +/// resolves the precedence from the slot once, this turns a single value into the +/// `Term` that precedence calls for. Exposed so a consumer that has to render +/// values without serializing them — pushing a query down to SQL over stored +/// JSON, say — produces the same terms this writer does. +pub fn term_for(descriptor: &TermDescriptor, value: &JsonValue, conv: &Converter) -> Term { + let lit = literal_value(value); + match descriptor.kind { + TermKind::Iri => { + // The stored value may be a CURIE; expand it, and fall back to the + // value itself when it is not one. + let iri = Identifier::new(&lit) + .to_uri(conv) + .map(|u| u.0) + .unwrap_or(lit); + Term::NamedNode(NamedNode::new_unchecked(iri)) + } + TermKind::EnumIri => { + // Only a string can name a permissible value. A value with no + // meaning has no IRI to become, so it renders as a literal. + if matches!(value, JsonValue::String(_)) { + if let Ok(idx) = descriptor + .enum_map + .binary_search_by(|(text, _)| text.as_str().cmp(lit.as_str())) + { + let iri = descriptor.enum_map[idx].1.clone(); + return Term::NamedNode(NamedNode::new_unchecked(iri)); + } } + literal_term(lit, descriptor) } + TermKind::Literal => literal_term(lit, descriptor), } - if let Some(dt) = dt_opt { - Term::Literal(Literal::new_typed_literal( +} + +/// A typed, language-tagged or plain literal, per the descriptor. `datatype` and +/// `lang` are already mutually exclusive by construction. +fn literal_term(lit: String, descriptor: &TermDescriptor) -> Term { + if let Some(dt) = &descriptor.datatype { + return Term::Literal(Literal::new_typed_literal( lit, - NamedNode::new_unchecked(dt), - )) - } else { - Term::Literal(Literal::new_simple_literal(lit)) + NamedNode::new_unchecked(dt.clone()), + )); + } + if let Some(lang) = &descriptor.lang { + if let Ok(tagged) = Literal::new_language_tagged_literal(lit.clone(), lang) { + return Term::Literal(tagged); + } + } + Term::Literal(Literal::new_simple_literal(lit)) +} + +/// The object term for one scalar value of `slot`. +/// +/// A slot whose values are not a reproducible term has no descriptor — an +/// inlined structure is a blank node — but a scalar can still turn up there +/// (an `Anything` range, for instance), and it serializes as a literal. +fn scalar_object_term(value: &JsonValue, slot: &SlotView, conv: &Converter) -> Term { + match slot.term_descriptor(conv) { + Some(descriptor) => term_for(&descriptor, value, conv), + None => literal_term( + literal_value(value), + &TermDescriptor { + kind: TermKind::Literal, + datatype: slot + .get_range_info() + .first() + .and_then(|ri| ri.rdf_datatype_iri.clone()), + lang: slot.definition().in_language.clone(), + enum_map: Vec::new(), + }, + ), } } @@ -158,25 +205,6 @@ fn try_lang_tag_collapse( .map(Term::Literal) } -/// If the slot's range is an enum and the scalar value matches a permissible -/// value that has a `meaning` URI, resolve and return that URI. Otherwise -/// return `None` so the caller falls through to literal serialization. -fn enum_meaning_iri(value: &JsonValue, slot: &SlotView, conv: &Converter) -> Option { - let text = match value { - JsonValue::String(s) => s.as_str(), - _ => return None, - }; - let enum_view = slot.get_range_enum()?; - let pv_map = enum_view.definition().permissible_values.as_ref()?; - let pv = pv_map.get(text)?; - let meaning = pv.meaning.as_ref()?; - let iri = Identifier::new(meaning) - .to_uri(conv) - .map(|u| u.0) - .unwrap_or_else(|_| meaning.clone()); - Some(iri) -} - fn identifier_node( map: &std::collections::HashMap, class: &ClassView, @@ -291,35 +319,12 @@ fn serialize_map( let predicate = NamedNode::new_unchecked(pred_iri.clone()); match v { LinkMLInstance::Scalar { value, slot, .. } => { - let inline_mode = slot.determine_slot_inline_mode(); - if inline_mode == SlotInlineMode::Reference || is_range_iri(slot) { - let lit = literal_value(value); - let iri = Identifier::new(&lit) - .to_uri(conv) - .map(|u| u.0) - .unwrap_or(lit); - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object: Term::NamedNode(NamedNode::new_unchecked(iri)), - }; - formatter.serialize_triple(triple.as_ref())?; - } else if let Some(iri) = enum_meaning_iri(value, slot, conv) { - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object: Term::NamedNode(NamedNode::new_unchecked(iri)), - }; - formatter.serialize_triple(triple.as_ref())?; - } else { - let object = scalar_literal_term(value, slot); - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object, - }; - formatter.serialize_triple(triple.as_ref())?; - } + let triple = Triple { + subject: subject.as_subject(), + predicate: predicate.clone(), + object: scalar_object_term(value, slot, conv), + }; + formatter.serialize_triple(triple.as_ref())?; } LinkMLInstance::Null { .. } => { // Null is treated as absent; emit nothing @@ -359,35 +364,12 @@ fn serialize_map( for (idx, item) in values.iter().enumerate() { match item { LinkMLInstance::Scalar { value, .. } => { - let inline_mode = slot.determine_slot_inline_mode(); - if inline_mode == SlotInlineMode::Reference || is_range_iri(slot) { - let lit = literal_value(value); - let iri = Identifier::new(&lit) - .to_uri(conv) - .map(|u| u.0) - .unwrap_or(lit); - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object: Term::NamedNode(NamedNode::new_unchecked(iri)), - }; - formatter.serialize_triple(triple.as_ref())?; - } else if let Some(iri) = enum_meaning_iri(value, slot, conv) { - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object: Term::NamedNode(NamedNode::new_unchecked(iri)), - }; - formatter.serialize_triple(triple.as_ref())?; - } else { - let object = scalar_literal_term(value, slot); - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object, - }; - formatter.serialize_triple(triple.as_ref())?; - } + let triple = Triple { + subject: subject.as_subject(), + predicate: predicate.clone(), + object: scalar_object_term(value, slot, conv), + }; + formatter.serialize_triple(triple.as_ref())?; } LinkMLInstance::Null { .. } => { // Skip null items @@ -439,35 +421,12 @@ fn serialize_map( for (idx, item) in values.values().enumerate() { match item { LinkMLInstance::Scalar { value: v, slot, .. } => { - let inline_mode = slot.determine_slot_inline_mode(); - if inline_mode == SlotInlineMode::Reference || is_range_iri(slot) { - let lit = literal_value(v); - let iri = Identifier::new(&lit) - .to_uri(conv) - .map(|u| u.0) - .unwrap_or(lit); - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object: Term::NamedNode(NamedNode::new_unchecked(iri)), - }; - formatter.serialize_triple(triple.as_ref())?; - } else if let Some(iri) = enum_meaning_iri(v, slot, conv) { - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object: Term::NamedNode(NamedNode::new_unchecked(iri)), - }; - formatter.serialize_triple(triple.as_ref())?; - } else { - let object = scalar_literal_term(v, slot); - let triple = Triple { - subject: subject.as_subject(), - predicate: predicate.clone(), - object, - }; - formatter.serialize_triple(triple.as_ref())?; - } + let triple = Triple { + subject: subject.as_subject(), + predicate: predicate.clone(), + object: scalar_object_term(v, slot, conv), + }; + formatter.serialize_triple(triple.as_ref())?; } LinkMLInstance::Null { .. } => { // nothing diff --git a/src/runtime/tests/term_descriptor_parity.rs b/src/runtime/tests/term_descriptor_parity.rs new file mode 100644 index 0000000..f83a2a9 --- /dev/null +++ b/src/runtime/tests/term_descriptor_parity.rs @@ -0,0 +1,175 @@ +#![cfg(feature = "ttl")] +//! `term_for` is the value-dependent half of `SlotView::term_descriptor`, and it +//! is what the turtle writer now uses at all three of its scalar call sites. +//! +//! These assert the exact `Term` produced per precedence rule, on the same +//! fixtures the golden turtle tests use — so a change here shows up as both a +//! wrong term and wrong turtle output. + +use linkml_runtime::turtle::term_for; +use linkml_schemaview::identifier::{converter_from_schema, converter_from_schemas, Identifier}; +use linkml_schemaview::io::from_yaml; +use linkml_schemaview::schemaview::SchemaView; +use linkml_schemaview::slotview::SlotView; +use oxrdf::{Literal, NamedNode, Term}; +use serde_json::json; +use std::path::{Path, PathBuf}; + +fn data_path(name: &str) -> PathBuf { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("tests"); + p.push("data"); + p.push(name); + p +} + +fn load_alone(schema_file: &str) -> (SchemaView, linkml_schemaview::Converter) { + let schema = from_yaml(Path::new(&data_path(schema_file))).unwrap(); + let mut sv = SchemaView::new(); + sv.add_schema(schema.clone()).unwrap(); + let conv = converter_from_schema(&schema); + (sv, conv) +} + +fn load_with_types(schema_file: &str) -> (SchemaView, linkml_schemaview::Converter) { + let schema = from_yaml(Path::new(&data_path(schema_file))).unwrap(); + let types_schema = from_yaml(Path::new(&data_path("types.yaml"))).unwrap(); + let mut sv = SchemaView::new(); + sv.add_schema(schema.clone()).unwrap(); + sv.add_schema_with_import_ref( + types_schema.clone(), + Some((schema.id.clone(), "linkml:types".to_string())), + ) + .unwrap(); + let conv = converter_from_schemas([&schema, &types_schema]); + (sv, conv) +} + +fn slot( + sv: &SchemaView, + conv: &linkml_schemaview::Converter, + class_name: &str, + slot_name: &str, +) -> SlotView { + sv.get_class(&Identifier::new(class_name), conv) + .unwrap() + .unwrap() + .slot(&Identifier::Name(slot_name.to_string())) + .unwrap_or_else(|| panic!("slot '{}' not found on '{}'", slot_name, class_name)) +} + +/// Resolve the descriptor once, apply it to one value — the two-step the SQL +/// pushdown route needs and the turtle writer performs per value. +fn term( + sv: &SchemaView, + conv: &linkml_schemaview::Converter, + class_name: &str, + slot_name: &str, + value: serde_json::Value, +) -> Term { + let s = slot(sv, conv, class_name, slot_name); + let d = s + .term_descriptor(conv) + .unwrap_or_else(|| panic!("no descriptor for '{}'", slot_name)); + term_for(&d, &value, conv) +} + +#[test] +fn plain_string_is_a_simple_literal() { + let (sv, conv) = load_with_types("custom_type_schema.yaml"); + assert_eq!( + term(&sv, &conv, "Place", "name", json!("Brussels")), + Term::Literal(Literal::new_simple_literal("Brussels")) + ); +} + +#[test] +fn custom_type_is_a_typed_literal() { + let (sv, conv) = load_with_types("custom_type_schema.yaml"); + assert_eq!( + term( + &sv, + &conv, + "Place", + "location", + json!("POINT(4.3517 50.8503)") + ), + Term::Literal(Literal::new_typed_literal( + "POINT(4.3517 50.8503)", + NamedNode::new_unchecked("http://www.opengis.net/ont/geosparql#wktLiteral") + )) + ); +} + +#[test] +fn iri_range_is_a_named_node() { + let (sv, conv) = load_with_types("custom_type_schema.yaml"); + assert_eq!( + term( + &sv, + &conv, + "Place", + "homepage", + json!("https://www.brussels.be") + ), + Term::NamedNode(NamedNode::new_unchecked("https://www.brussels.be")) + ); +} + +/// An IRI-ish value may be a CURIE, which is expanded with the converter — the +/// same converter the values are serialized with. +#[test] +fn iri_range_expands_a_curie() { + let (sv, conv) = load_with_types("lang_tag_schema.yaml"); + assert_eq!( + term(&sv, &conv, "Station", "id", json!("langtest:brussels")), + Term::NamedNode(NamedNode::new_unchecked( + "https://example.com/langtest/brussels" + )) + ); +} + +/// A number renders through its JSON representation, not through Rust's +/// `Display` for `serde_json::Value`. +#[test] +fn numbers_and_booleans_render_as_their_json_text() { + let (sv, conv) = load_with_types("typed_literals_schema.yaml"); + assert_eq!( + term(&sv, &conv, "Thing", "count", json!(42)), + Term::Literal(Literal::new_typed_literal( + "42", + NamedNode::new_unchecked("http://www.w3.org/2001/XMLSchema#integer") + )) + ); +} + +#[test] +fn language_tag_is_applied_when_there_is_no_datatype() { + let (sv, conv) = load_with_types("lang_tag_schema.yaml"); + assert_eq!( + term(&sv, &conv, "Station", "opName", json!("Brussels North")), + Term::Literal(Literal::new_language_tagged_literal("Brussels North", "en").unwrap()) + ); +} + +#[test] +fn enum_value_with_a_meaning_is_that_named_node() { + let (sv, conv) = load_alone("enum_meaning_schema.yaml"); + assert_eq!( + term(&sv, &conv, "Item", "status", json!("active")), + Term::NamedNode(NamedNode::new_unchecked( + "https://example.com/status/Active" + )) + ); +} + +/// The mixed enum: `unknown` carries no `meaning`, so it falls back to a literal +/// rather than inventing an IRI. +#[test] +fn enum_value_without_a_meaning_falls_back_to_a_literal() { + let (sv, conv) = load_alone("enum_meaning_schema.yaml"); + assert_eq!( + term(&sv, &conv, "Item", "status", json!("unknown")), + Term::Literal(Literal::new_simple_literal("unknown")) + ); +} diff --git a/src/schemaview/src/slotview.rs b/src/schemaview/src/slotview.rs index 618efa9..6d00285 100644 --- a/src/schemaview/src/slotview.rs +++ b/src/schemaview/src/slotview.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, OnceLock}; use crate::classview::ClassView; use crate::identifier::Identifier; use crate::schemaview::{EnumView, SchemaView}; +use crate::Converter; use linkml_meta::poly::SlotExpression; use linkml_meta::{SlotDefinition, SlotExpressionOrSubtype}; @@ -410,6 +411,57 @@ impl RangeInfo { } } +/// What kind of RDF term a slot's values become. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TermKind { + /// A named node — the range is IRI-ish, or the slot holds a reference to an + /// object identified by its own URI. + Iri, + /// A literal, possibly typed or language-tagged. + Literal, + /// An enum whose permissible values carry `meaning` IRIs. A value present in + /// [`TermDescriptor::enum_map`] becomes that IRI; a value without a meaning + /// falls back to a literal, which is what the turtle writer does. + EnumIri, +} + +/// How a slot's stored values render as RDF terms. +/// +/// Decided from the slot alone, so it can be resolved once and applied per +/// value — which is what a consumer that has to render values *before* it sees +/// any of them needs (e.g. pushing a query down to SQL over stored JSON, where +/// the rendering has to be decided at plan time and must match, term for term, +/// what the turtle writer would have produced for the same data). +/// +/// Obtain one from [`SlotView::term_descriptor`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TermDescriptor { + pub kind: TermKind, + /// Datatype IRI for a typed literal. `None` means a plain literal. + pub datatype: Option, + /// Language tag. Mutually exclusive with `datatype`, per RDF. + pub lang: Option, + /// Stored value → expanded meaning IRI, sorted by stored value. Non-empty + /// only for [`TermKind::EnumIri`]. + /// + /// Sorted because it crosses into generated code downstream, where an + /// unstable order makes queries and their tests flap. + pub enum_map: Vec<(String, String)>, +} + +impl TermDescriptor { + /// A named node with nothing else to decide: an IRI-ish range, or a + /// reference to an object identified by its own URI. + fn iri() -> Self { + Self { + kind: TermKind::Iri, + datatype: None, + lang: None, + enum_map: Vec::new(), + } + } +} + pub struct SlotViewData { pub definitions: Vec, cached_definition: OnceLock, @@ -616,4 +668,100 @@ impl SlotView { .first() .map_or(SlotInlineMode::Primitive, |ri| ri.slot_inline_mode) } + + /// Returns how this slot's values render as RDF terms, or `None` when they + /// are not a term anything can reproduce. + /// + /// The whole decision depends on the slot, never on the value, so it can be + /// resolved once and then applied per value. The precedence, in order: + /// + /// 1. an enum value carrying a `meaning` → that IRI; + /// 2. an IRI-ish range (`uri`/`uriorcurie` in the `typeof` chain) → a named + /// node; + /// 3. `in_language` on the slot, only when there is no datatype (RDF allows + /// one or the other) → a language-tagged literal; + /// 4. a custom RDF datatype → a typed literal; + /// 5. otherwise → a plain literal. + /// + /// `conv` expands the enum `meaning` CURIEs, so pass the same converter the + /// values will be serialized with. + /// + /// Rules 1 and 2 cannot both apply, so their order is immaterial: + /// `determine_rdf_type_info` yields `(None, false)` for an enum range, so + /// `is_range_iri` is never true for one. Stating the chain in one place is + /// what makes that invariant visible. + pub fn term_descriptor(&self, conv: &Converter) -> Option { + // A class range needs care, and the two cases differ. A *reference* + // stores the target's URI, so the stored value is exactly the named node + // that gets emitted. An *inlined* structure serializes as a blank node + // whose label nothing can reproduce — not a second serialization run, + // not a consumer reading the stored value back — so there is no term to + // describe. Such a slot is still traversable; it is just never a value. + if self.get_range_class().is_some() { + return match self.determine_slot_inline_mode() { + SlotInlineMode::Reference => Some(TermDescriptor::iri()), + _ => None, + }; + } + + let info = self.get_range_info().first(); + let datatype = info.and_then(|ri| ri.rdf_datatype_iri.clone()); + // Rules 3 and 4 collide — RDF allows a datatype or a language tag, not + // both — and the datatype wins. + let lang = if datatype.is_none() { + self.definition().in_language.clone() + } else { + None + }; + + // Rule 1. The map is finite (the permissible values) so materializing it + // is safe, unlike walking the schema graph. `lang` is carried through + // because a value with no meaning falls back to a literal. + let enum_map = self.enum_meanings(conv); + if !enum_map.is_empty() { + return Some(TermDescriptor { + kind: TermKind::EnumIri, + datatype, + lang, + enum_map, + }); + } + + // Rule 2. + if info.is_some_and(|ri| ri.is_range_iri) { + return Some(TermDescriptor::iri()); + } + + // Rules 3, 4 and 5. + Some(TermDescriptor { + kind: TermKind::Literal, + datatype, + lang, + enum_map: Vec::new(), + }) + } + + /// Permissible value → expanded meaning IRI, sorted. Empty when the range is + /// not an enum, or when no permissible value carries a `meaning`. + fn enum_meanings(&self, conv: &Converter) -> Vec<(String, String)> { + let Some(enum_view) = self.get_range_enum() else { + return Vec::new(); + }; + let Some(values) = enum_view.definition().permissible_values.as_ref() else { + return Vec::new(); + }; + let mut out: Vec<(String, String)> = values + .iter() + .filter_map(|(text, pv)| { + let meaning = pv.meaning.as_ref()?; + let iri = Identifier::new(meaning) + .to_uri(conv) + .map(|u| u.0) + .unwrap_or_else(|_| meaning.clone()); + Some((text.clone(), iri)) + }) + .collect(); + out.sort(); + out + } } diff --git a/src/schemaview/tests/term_descriptor.rs b/src/schemaview/tests/term_descriptor.rs new file mode 100644 index 0000000..6ce8957 --- /dev/null +++ b/src/schemaview/tests/term_descriptor.rs @@ -0,0 +1,148 @@ +//! How a slot's values become RDF terms, decided from the slot alone. +//! +//! One case per rule of the precedence chain, so the ordering is pinned rather +//! than implied. The turtle writer applies the same descriptor per value; its +//! own tests (`turtle_enum_meaning`, `turtle_lang_tags`, `turtle_typed_literals`, +//! `turtle_custom_types`) are the end-to-end half. + +use linkml_schemaview::identifier::{converter_from_schemas, Identifier}; +use linkml_schemaview::io::from_yaml; +use linkml_schemaview::schemaview::SchemaView; +use linkml_schemaview::slotview::{TermDescriptor, TermKind}; +use std::path::{Path, PathBuf}; + +fn data_path(name: &str) -> PathBuf { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("tests"); + p.push("data"); + p.push(name); + p +} + +fn load() -> (SchemaView, linkml_schemaview::Converter) { + let schema = from_yaml(Path::new(&data_path("rdf_type_schema.yaml"))).unwrap(); + let types_schema = from_yaml(Path::new(&data_path("types.yaml"))).unwrap(); + let mut sv = SchemaView::new(); + sv.add_schema(schema.clone()).unwrap(); + sv.add_schema_with_import_ref( + types_schema.clone(), + Some((schema.id.clone(), "linkml:types".to_string())), + ) + .unwrap(); + let conv = converter_from_schemas([&schema, &types_schema]); + (sv, conv) +} + +/// The descriptor for `Thing.`, or `None` when the slot's values are not a +/// reproducible term. +fn descriptor_for(slot_name: &str) -> Option { + let (sv, conv) = load(); + let class = sv + .get_class(&Identifier::new("Thing"), &conv) + .unwrap() + .unwrap(); + class + .slot(&Identifier::Name(slot_name.to_string())) + .unwrap_or_else(|| panic!("slot '{}' not found", slot_name)) + .term_descriptor(&conv) +} + +fn describe(slot_name: &str) -> TermDescriptor { + descriptor_for(slot_name).unwrap_or_else(|| panic!("no descriptor for '{}'", slot_name)) +} + +#[test] +fn plain_string_is_an_untyped_literal() { + let d = describe("name"); + assert_eq!(d.kind, TermKind::Literal); + assert_eq!(d.datatype, None); + assert_eq!(d.lang, None); + assert!(d.enum_map.is_empty()); +} + +#[test] +fn declared_type_carries_its_datatype() { + let d = describe("count"); + assert_eq!(d.kind, TermKind::Literal); + assert_eq!( + d.datatype.as_deref(), + Some("http://www.w3.org/2001/XMLSchema#integer") + ); +} + +/// Rule 1: an enum whose permissible values carry `meaning` IRIs. The map is +/// expanded and sorted — it crosses into generated SQL downstream, where an +/// unstable order makes queries and tests flap. +#[test] +fn enum_with_meanings_maps_values_to_sorted_iris() { + let d = describe("status"); + assert_eq!(d.kind, TermKind::EnumIri); + assert_eq!( + d.enum_map, + vec![ + ( + "active".to_string(), + "https://example.com/rdftype/Active".to_string() + ), + ( + "retired".to_string(), + "https://example.com/rdftype/Retired".to_string() + ), + ] + ); +} + +/// Nothing to map to, so the turtle writer emits the value as a literal and the +/// descriptor has to agree. +#[test] +fn enum_without_meanings_stays_a_literal() { + let d = describe("flag"); + assert_eq!(d.kind, TermKind::Literal); + assert!(d.enum_map.is_empty()); +} + +/// Rule 2. +#[test] +fn iri_range_is_a_named_node() { + let d = describe("see_also"); + assert_eq!(d.kind, TermKind::Iri); + assert_eq!(d.datatype, None); + assert_eq!(d.lang, None); +} + +/// Rule 3. +#[test] +fn language_tag_is_carried_when_there_is_no_datatype() { + let d = describe("description"); + assert_eq!(d.kind, TermKind::Literal); + assert_eq!(d.lang.as_deref(), Some("en")); + assert_eq!(d.datatype, None); +} + +/// Rules 3 and 4 collide: RDF allows a datatype or a language tag, not both, and +/// the turtle writer lets the datatype win. A slot declaring both must resolve +/// the same way here or the two would disagree. +#[test] +fn datatype_wins_over_a_language_tag() { + let d = describe("counted_in_english"); + assert_eq!( + d.datatype.as_deref(), + Some("http://www.w3.org/2001/XMLSchema#integer") + ); + assert_eq!(d.lang, None); +} + +/// A reference stores the target's URI, which is exactly the term the writer +/// emits — so it is a value a consumer can reproduce. +#[test] +fn a_reference_is_the_target_iri() { + let d = describe("target"); + assert_eq!(d.kind, TermKind::Iri); +} + +/// An inlined structure serialises as a blank node whose label nothing can +/// reproduce, so there is no descriptor to give. +#[test] +fn an_inlined_structure_is_not_a_term() { + assert!(descriptor_for("nested").is_none()); +}