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 7beadc3..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}; @@ -97,6 +98,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 +153,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 { @@ -360,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, @@ -566,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/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()); +} 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()); +}