diff --git a/cli/Cargo.toml b/cli/Cargo.toml index b72aaaad..17b5dd9d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -19,8 +19,9 @@ treeldr = { path = "../core" } treeldr-syntax = { path = "../syntax" } iref = "2.1.2" grdf = { version = "0.7.3", features = ["loc"] } +serde_json = "1.0.79" log = "0.4" -locspan = "*" +locspan = "0.3" codespan-reporting = "0.11" stderrlog = "0.5" clap = { version = "3.0", features = ["derive"] } diff --git a/cli/src/main.rs b/cli/src/main.rs index 13e08d3c..4e5cee94 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -46,10 +46,29 @@ fn main() { let mut vocab = treeldr::Vocabulary::new(); let mut quads = Vec::new(); for filename in args.filenames { - match files.load(&filename, None) { - Ok(file_id) => { - import_treeldr(&mut vocab, &mut quads, &files, file_id); - } + match files.load(&filename, None, None) { + Ok(file_id) => match files.get(file_id).unwrap().mime_type() { + Some(source::MimeType::TreeLdr) => { + import_treeldr(&mut vocab, &mut quads, &files, file_id); + } + #[cfg(feature = "json-schema")] + Some(source::MimeType::JsonSchema) => { + import_json_schema(&mut vocab, &mut quads, &files, file_id); + } + #[allow(unreachable_patterns)] + Some(mime_type) => { + log::error!( + "unsupported mime type `{}` for file `{}`", + mime_type, + filename.display() + ); + std::process::exit(1); + } + None => { + log::error!("unknown format for file `{}`", filename.display()); + std::process::exit(1); + } + }, Err(e) => { log::error!("unable to read file `{}`: {}", filename.display(), e); std::process::exit(1); @@ -133,3 +152,49 @@ fn import_treeldr( } } } + +#[cfg(feature = "json-schema")] +/// Import a JSON Schema file. +fn import_json_schema( + vocab: &mut treeldr::Vocabulary, + quads: &mut Vec>, + files: &source::Files, + source_id: source::FileId, +) { + let file = files.get(source_id).unwrap(); + + match serde_json::from_str::(file.buffer()) { + Ok(json) => match treeldr_json_schema::Schema::try_from(json) { + Ok(schema) => { + if let Err(e) = + treeldr_json_schema::import_schema(&schema, &source_id, None, vocab, quads) + { + let diagnostic = codespan_reporting::diagnostic::Diagnostic::error() + .with_message(format!("JSON Schema import failed: {}", e)); + let writer = StandardStream::stderr(ColorChoice::Always); + let config = codespan_reporting::term::Config::default(); + term::emit(&mut writer.lock(), &config, files, &diagnostic) + .expect("diagnostic failed"); + std::process::exit(1); + } + } + Err(e) => { + let diagnostic = codespan_reporting::diagnostic::Diagnostic::error() + .with_message(format!("JSON Schema error: {}", e)); + let writer = StandardStream::stderr(ColorChoice::Always); + let config = codespan_reporting::term::Config::default(); + term::emit(&mut writer.lock(), &config, files, &diagnostic) + .expect("diagnostic failed"); + std::process::exit(1); + } + }, + Err(e) => { + let diagnostic = codespan_reporting::diagnostic::Diagnostic::error() + .with_message(format!("JSON parse error: {}", e)); + let writer = StandardStream::stderr(ColorChoice::Always); + let config = codespan_reporting::term::Config::default(); + term::emit(&mut writer.lock(), &config, files, &diagnostic).expect("diagnostic failed"); + std::process::exit(1); + } + } +} diff --git a/cli/src/source.rs b/cli/src/source.rs index 1ae4c306..1f0b16eb 100644 --- a/cli/src/source.rs +++ b/cli/src/source.rs @@ -1,5 +1,6 @@ use iref::{Iri, IriBuf}; use std::collections::HashMap; +use std::fmt; use std::ops::{Deref, Range}; use std::path::{Path, PathBuf}; @@ -10,6 +11,7 @@ pub struct File { source: PathBuf, base_iri: Option, buffer: Buffer, + mime_type: Option, } impl File { @@ -24,6 +26,45 @@ impl File { pub fn buffer(&self) -> &Buffer { &self.buffer } + + pub fn mime_type(&self) -> Option { + self.mime_type + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum MimeType { + /// application/treeldr + TreeLdr, + + /// application/schema+json + JsonSchema, +} + +impl MimeType { + fn name(&self) -> &'static str { + match self { + Self::TreeLdr => "application/treeldr", + Self::JsonSchema => "application/schema+json", + } + } + + fn infer(source: &Path, _content: &str) -> Option { + source + .extension() + .and_then(std::ffi::OsStr::to_str) + .and_then(|ext| match ext { + "tldr" => Some(MimeType::TreeLdr), + "json" => Some(MimeType::JsonSchema), + _ => None, + }) + } +} + +impl fmt::Display for MimeType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.name().fmt(f) + } } #[derive(Default)] @@ -53,6 +94,7 @@ impl Files { &mut self, source: &impl AsRef, base_iri: Option, + mime_type: Option, ) -> std::io::Result { let source = source.as_ref(); match self.sources.get(source).cloned() { @@ -60,10 +102,12 @@ impl Files { None => { let content = std::fs::read_to_string(source)?; let id = FileId(self.files.len()); + let mime_type = mime_type.or_else(|| MimeType::infer(source, &content)); self.files.push(File { source: source.into(), base_iri, buffer: Buffer::new(content), + mime_type, }); self.sources.insert(source.into(), id); Ok(id) diff --git a/core/src/build.rs b/core/src/build.rs index 9b19bc2b..0ea79765 100644 --- a/core/src/build.rs +++ b/core/src/build.rs @@ -178,17 +178,10 @@ impl Context { } } Term::Schema(vocab::Schema::MultipleValues) => { - let (prop, field) = - self.require_property_or_layout_field_mut(id, Some(id_loc))?; + let prop = self.require_property_mut(id, Some(id_loc))?; let Loc(multiple, _) = expect_boolean(object)?; - if let Some(prop) = prop { - prop.set_functional(!multiple, Some(loc.clone()))? - } - - if let Some(field) = field { - field.set_functional(!multiple, Some(loc))? - } + prop.set_functional(!multiple, Some(loc.clone()))? } Term::Owl(vocab::Owl::UnionOf) => { let ty = self.require_type_mut(id, Some(id_loc))?; @@ -258,6 +251,44 @@ impl Context { let layout = self.require_layout_mut(id, Some(id_loc))?; layout.set_enum(fields_id, Some(loc))? } + Term::TreeLdr(vocab::TreeLdr::Set) => { + let Loc(item_layout_id, _) = expect_id(object)?; + let layout = self.require_layout_mut(id, Some(id_loc))?; + layout.set_set(item_layout_id, Some(loc))? + } + Term::TreeLdr(vocab::TreeLdr::List) => { + let Loc(item_layout_id, _) = expect_id(object)?; + let layout = self.require_layout_mut(id, Some(id_loc))?; + layout.set_list(item_layout_id, Some(loc))? + } + Term::TreeLdr(vocab::TreeLdr::Native) => { + let Loc(native_layout_id, layout_id_loc) = expect_id(object)?; + + let native_layout = match native_layout_id { + Id::Iri(Term::Xsd(vocab::Xsd::AnyUri)) => layout::Native::Uri, + Id::Iri(Term::Xsd(vocab::Xsd::Boolean)) => layout::Native::Boolean, + Id::Iri(Term::Xsd(vocab::Xsd::Date)) => layout::Native::Date, + Id::Iri(Term::Xsd(vocab::Xsd::DateTime)) => layout::Native::DateTime, + Id::Iri(Term::Xsd(vocab::Xsd::Double)) => layout::Native::Double, + Id::Iri(Term::Xsd(vocab::Xsd::Float)) => layout::Native::Float, + Id::Iri(Term::Xsd(vocab::Xsd::Int)) => layout::Native::Integer, + Id::Iri(Term::Xsd(vocab::Xsd::Integer)) => layout::Native::Integer, + Id::Iri(Term::Xsd(vocab::Xsd::PositiveInteger)) => { + layout::Native::PositiveInteger + } + Id::Iri(Term::Xsd(vocab::Xsd::String)) => layout::Native::String, + Id::Iri(Term::Xsd(vocab::Xsd::Time)) => layout::Native::Time, + _ => { + return Err(Error::new( + error::LayoutNativeInvalid(native_layout_id).into(), + Some(layout_id_loc), + )) + } + }; + + let layout = self.require_layout_mut(id, Some(id_loc))?; + layout.set_native(native_layout, Some(loc))? + } _ => (), } } diff --git a/core/src/build/context.rs b/core/src/build/context.rs index 5ae24c47..707eb23f 100644 --- a/core/src/build/context.rs +++ b/core/src/build/context.rs @@ -117,7 +117,7 @@ impl Context { /// Resolve all the reference layouts. /// - /// Checks that the type of a reference layout (`&T`) is equal to the type of the target layout (`T`). + /// Checks that the type of a reference layout (`&T`) is equal to the type of the target layout (`T`), if any. /// If no type is defined for the reference layout, it is set to the correct type. pub fn resolve_references(&mut self) -> Result<(), Error> where @@ -165,12 +165,15 @@ impl Context { for (id, target_layout_id) in by_depth { let (target_layout_id, cause) = target_layout_id.into_parts(); let target_layout = self.require_layout(target_layout_id, cause.clone())?; - let (target_ty_id, ty_cause) = target_layout.require_ty(cause)?.clone().into_parts(); - self.get_mut(id) - .unwrap() - .as_layout_mut() - .unwrap() - .set_type(target_ty_id, ty_cause.into_preferred())? + + if let Some(target_ty) = target_layout.ty().cloned() { + let (target_ty_id, ty_cause) = target_ty.into_parts(); + self.get_mut(id) + .unwrap() + .as_layout_mut() + .unwrap() + .set_type(target_ty_id, ty_cause.into_preferred())? + } } Ok(()) diff --git a/core/src/build/layout.rs b/core/src/build/layout.rs index 6a2ebfc2..55acc7d1 100644 --- a/core/src/build/layout.rs +++ b/core/src/build/layout.rs @@ -61,6 +61,8 @@ pub enum Description { Reference(Id), Literal(RegExp), Enum(Id), + Set(Id), + List(Id), } impl Description { @@ -71,6 +73,8 @@ impl Description { Self::Native(n) => Type::Native(*n), Self::Literal(_) => Type::Literal, Self::Enum(_) => Type::Enum, + Self::Set(_) => Type::Set, + Self::List(_) => Type::List, } } } @@ -91,11 +95,6 @@ impl Definition { self.ty.with_causes() } - pub fn require_ty(&self, cause: Option>) -> Result<&WithCauses, Error> { - self.ty - .value_or_else(|| Caused::new(error::LayoutMissingType(self.id).into(), cause)) - } - pub fn add_use(&mut self, user_layout: Id, field: Id) { self.uses.insert(UsedBy { user_layout, field }); } @@ -249,6 +248,20 @@ impl Definition { { self.set_description(Description::Enum(items), cause) } + + pub fn set_set(&mut self, item_layout: Id, cause: Option>) -> Result<(), Error> + where + F: Clone + Ord, + { + self.set_description(Description::Set(item_layout), cause) + } + + pub fn set_list(&mut self, item_layout: Id, cause: Option>) -> Result<(), Error> + where + F: Clone + Ord, + { + self.set_description(Description::List(item_layout), cause) + } } /// Field/layout usage. @@ -306,15 +319,11 @@ impl WithCauses, F> { ) -> Result, Error> { let (def, causes) = self.into_parts(); - let ty_id = def.ty.ok_or_else(|| { - Caused::new( - error::LayoutMissingType(id).into(), - causes.preferred().cloned(), - ) + let ty = def.ty.try_map_with_causes(|ty_id| { + Ok(*nodes + .require_type(*ty_id, ty_id.causes().preferred().cloned())? + .inner()) })?; - let ty = nodes - .require_type(*ty_id, ty_id.causes().preferred().cloned())? - .clone_with_causes(ty_id.into_causes()); let def_desc = def.desc.ok_or_else(|| { Caused::new( @@ -415,6 +424,18 @@ impl WithCauses, F> { let lit = crate::layout::Literal::new(regexp, name, def.id.is_blank()); Ok(crate::layout::Description::Literal(lit)) } + Description::Set(layout_id) => { + let layout_ref = *nodes + .require_layout(layout_id, desc_causes.preferred().cloned())? + .inner(); + Ok(crate::layout::Description::Set(layout_ref, def.name)) + } + Description::List(layout_id) => { + let layout_ref = *nodes + .require_layout(layout_id, desc_causes.preferred().cloned())? + .inner(); + Ok(crate::layout::Description::List(layout_ref, def.name)) + } }) .map_err(Caused::flatten)?; diff --git a/core/src/build/layout/field.rs b/core/src/build/layout/field.rs index 9a54242f..08d69ca8 100644 --- a/core/src/build/layout/field.rs +++ b/core/src/build/layout/field.rs @@ -9,7 +9,7 @@ pub struct Definition { name: MaybeSet, layout: MaybeSet, required: MaybeSet, - functional: MaybeSet, + // functional: MaybeSet, } impl Definition { @@ -20,7 +20,7 @@ impl Definition { name: MaybeSet::default(), layout: MaybeSet::default(), required: MaybeSet::default(), - functional: MaybeSet::default(), + // functional: MaybeSet::default(), } } @@ -126,29 +126,29 @@ impl Definition { }) } - pub fn is_functional(&self) -> bool { - self.functional.value().cloned().unwrap_or(true) - } - - pub fn set_functional( - &mut self, - value: bool, - cause: Option>, - ) -> Result<(), Error> - where - F: Ord + Clone, - { - self.functional - .try_set(value, cause, |expected, because, found| { - error::LayoutFieldMismatchFunctional { - id: self.id, - expected: *expected, - found, - because: because.cloned(), - } - .into() - }) - } + // pub fn is_functional(&self) -> bool { + // self.functional.value().cloned().unwrap_or(true) + // } + + // pub fn set_functional( + // &mut self, + // value: bool, + // cause: Option>, + // ) -> Result<(), Error> + // where + // F: Ord + Clone, + // { + // self.functional + // .try_set(value, cause, |expected, because, found| { + // error::LayoutFieldMismatchFunctional { + // id: self.id, + // expected: *expected, + // found, + // because: because.cloned(), + // } + // .into() + // }) + // } } impl WithCauses, F> { @@ -173,15 +173,21 @@ impl WithCauses, F> { vocab: &Vocabulary, nodes: &super::super::context::AllocatedNodes, ) -> Result, Error> { - let prop_id = self.prop.value_or_else(|| { - Caused::new( - error::LayoutFieldMissingProperty(self.id).into(), - self.causes().preferred().cloned(), - ) + let prop = self.prop.clone().try_map_with_causes(|prop_id| { + Ok(*nodes + .require_property(*prop_id.inner(), prop_id.causes().preferred().cloned())? + .inner()) })?; - let prop = nodes - .require_property(*prop_id.inner(), prop_id.causes().preferred().cloned())? - .clone_with_causes(prop_id.causes().clone()); + + // let prop_id = self.prop.value_or_else(|| { + // Caused::new( + // error::LayoutFieldMissingProperty(self.id).into(), + // self.causes().preferred().cloned(), + // ) + // })?; + // let prop = nodes + // .require_property(*prop_id.inner(), prop_id.causes().preferred().cloned())? + // .clone_with_causes(prop_id.causes().clone()); let name = self.require_name(vocab)?; @@ -191,10 +197,10 @@ impl WithCauses, F> { .clone_with_causes(layout_id.causes().clone()); let required = self.required.clone().unwrap_or(false); - let functional = self.functional.clone().unwrap_or(true); + // let functional = self.functional.clone().unwrap_or(true); Ok(crate::layout::Field::new( - prop, name, label, layout, required, functional, doc, + prop, name, label, layout, required, doc, )) } } diff --git a/core/src/error.rs b/core/src/error.rs index d2828a0a..6a4522d1 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -98,7 +98,6 @@ errors! { layout_mismatch_type::LayoutMismatchType, layout_missing_name::LayoutMissingName, layout_missing_description::LayoutMissingDescription, - layout_missing_type::LayoutMissingType, layout_literal_field::LayoutLiteralField, layout_field_mismatch_functional::LayoutFieldMismatchFunctional, layout_field_mismatch_layout::LayoutFieldMismatchLayout, @@ -106,8 +105,8 @@ errors! { layout_field_mismatch_property::LayoutFieldMismatchProperty, layout_field_mismatch_required::LayoutFieldMismatchRequired, layout_field_missing_layout::LayoutFieldMissingLayout, - layout_field_missing_property::LayoutFieldMissingProperty, layout_field_missing_name::LayoutFieldMissingName, + layout_native_invalid::LayoutNativeInvalid, layout_variant_missing_name::LayoutVariantMissingName, list_mismatch_item::ListMismatchItem, list_mismatch_rest::ListMismatchRest, diff --git a/core/src/error/layout_field_missing_property.rs b/core/src/error/layout_field_missing_property.rs deleted file mode 100644 index e2938403..00000000 --- a/core/src/error/layout_field_missing_property.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::{Id, Vocabulary, vocab::Display}; - -#[derive(Debug)] -pub struct LayoutFieldMissingProperty(pub Id); - -impl super::AnyError for LayoutFieldMissingProperty { - fn message(&self, vocab: &Vocabulary) -> String { - format!("no property defined for field `{}`", self.0.display(vocab)) - } -} \ No newline at end of file diff --git a/core/src/error/layout_missing_type.rs b/core/src/error/layout_missing_type.rs deleted file mode 100644 index 87015e9c..00000000 --- a/core/src/error/layout_missing_type.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::{Id, Vocabulary, vocab::Display}; - -#[derive(Debug)] -pub struct LayoutMissingType(pub Id); - -impl super::AnyError for LayoutMissingType { - fn message(&self, vocab: &Vocabulary) -> String { - format!("no type defined for layout `{}`", self.0.display(vocab)) - } -} \ No newline at end of file diff --git a/core/src/error/layout_native_invalid.rs b/core/src/error/layout_native_invalid.rs new file mode 100644 index 00000000..9c52e668 --- /dev/null +++ b/core/src/error/layout_native_invalid.rs @@ -0,0 +1,10 @@ +use crate::{Id, Vocabulary, vocab::Display}; + +#[derive(Debug)] +pub struct LayoutNativeInvalid(pub Id); + +impl super::AnyError for LayoutNativeInvalid { + fn message(&self, vocab: &Vocabulary) -> String { + format!("invalid native layout `{}`", self.0.display(vocab)) + } +} \ No newline at end of file diff --git a/core/src/layout.rs b/core/src/layout.rs index aa79e19b..72efb636 100644 --- a/core/src/layout.rs +++ b/core/src/layout.rs @@ -25,12 +25,18 @@ pub enum Type { Enum, Reference, Literal, + Set, + List, } /// Layout definition. pub struct Definition { id: Id, - ty: WithCauses>, F>, + + /// Type for witch this layout is defined. + /// + /// If unset, this layout is an "orphan" layout. + ty: MaybeSet>, F>, desc: WithCauses, F>, causes: Causes, } @@ -51,6 +57,12 @@ pub enum Description { /// Literal layout. Literal(Literal), + + /// Set layout. + Set(Ref>, MaybeSet), + + /// List/array layout. + List(Ref>, MaybeSet), } impl Description { @@ -61,6 +73,8 @@ impl Description { Self::Enum(_) => Type::Enum, Self::Native(n, _) => Type::Native(*n), Self::Literal(_) => Type::Literal, + Self::Set(_, _) => Type::Set, + Self::List(_, _) => Type::List, } } } @@ -68,7 +82,7 @@ impl Description { impl Definition { pub fn new( id: Id, - ty: WithCauses>, F>, + ty: MaybeSet>, F>, desc: WithCauses, F>, causes: impl Into>, ) -> Self { @@ -81,8 +95,8 @@ impl Definition { } /// Type for which the layout is defined. - pub fn ty(&self) -> Ref> { - *self.ty + pub fn ty(&self) -> Option>> { + self.ty.value().cloned() } /// Returns the identifier of the defined layout. @@ -97,6 +111,8 @@ impl Definition { Description::Reference(_, n) => n.value(), Description::Native(_, n) => n.value(), Description::Literal(l) => Some(l.name()), + Description::Set(_, n) => n.value(), + Description::List(_, n) => n.value(), } } @@ -115,8 +131,10 @@ impl Definition { pub fn preferred_label<'a>(&'a self, model: &'a crate::Model) -> Option<&'a str> { let label = self.label(model); if label.is_none() { - let ty_id = model.types().get(*self.ty).unwrap().id(); - model.get(ty_id).unwrap().label() + self.ty().and_then(|ty| { + let ty_id = model.types().get(ty).unwrap().id(); + model.get(ty_id).unwrap().label() + }) } else { label } @@ -129,8 +147,13 @@ impl Definition { pub fn preferred_documentation<'m>(&self, model: &'m crate::Model) -> &'m Documentation { let doc = self.documentation(model); if doc.is_empty() { - let ty_id = model.types().get(*self.ty).unwrap().id(); - model.get(ty_id).unwrap().documentation() + match self.ty() { + Some(ty) => { + let ty_id = model.types().get(ty).unwrap().id(); + model.get(ty_id).unwrap().documentation() + } + None => doc, + } } else { doc } @@ -143,6 +166,8 @@ impl Definition { Description::Literal(_) => ComposingLayouts::None, Description::Reference(_, _) => ComposingLayouts::None, Description::Native(_, _) => ComposingLayouts::None, + Description::Set(i, _) => ComposingLayouts::One(Some(*i)), + Description::List(i, _) => ComposingLayouts::One(Some(*i)), } } } @@ -150,6 +175,7 @@ impl Definition { pub enum ComposingLayouts<'a, F> { Struct(std::slice::Iter<'a, Field>), Enum(enumeration::ComposingLayouts<'a, F>), + One(Option>>), None, } @@ -160,6 +186,7 @@ impl<'a, F> Iterator for ComposingLayouts<'a, F> { match self { Self::Struct(fields) => Some(fields.next()?.layout()), Self::Enum(layouts) => layouts.next(), + Self::One(r) => r.take(), Self::None => None, } } diff --git a/core/src/layout/structure.rs b/core/src/layout/structure.rs index 253da038..5da67995 100644 --- a/core/src/layout/structure.rs +++ b/core/src/layout/structure.rs @@ -1,4 +1,4 @@ -use crate::{layout, prop, vocab::Name, Documentation, WithCauses}; +use crate::{layout, prop, vocab::Name, Documentation, MaybeSet, WithCauses}; use shelves::Ref; /// Structure layout. @@ -31,23 +31,23 @@ impl Struct { /// Layout field. pub struct Field { - prop: WithCauses>, F>, + prop: MaybeSet>, F>, name: WithCauses, label: Option, layout: WithCauses>, F>, required: WithCauses, - functional: WithCauses, + // functional: WithCauses, doc: Documentation, } impl Field { pub fn new( - prop: WithCauses>, F>, + prop: MaybeSet>, F>, name: WithCauses, label: Option, layout: WithCauses>, F>, required: WithCauses, - functional: WithCauses, + // functional: WithCauses, doc: Documentation, ) -> Self { Self { @@ -56,13 +56,13 @@ impl Field { label, layout, required, - functional, + // functional, doc, } } - pub fn property(&self) -> Ref> { - *self.prop.inner() + pub fn property(&self) -> Option>> { + self.prop.value().cloned() } pub fn name(&self) -> &Name { @@ -75,8 +75,10 @@ impl Field { pub fn preferred_label<'a>(&'a self, model: &'a crate::Model) -> Option<&'a str> { if self.label.is_none() { - let prop_id = model.properties().get(*self.prop).unwrap().id(); - model.get(prop_id).unwrap().label() + self.property().and_then(|prop| { + let prop_id = model.properties().get(prop).unwrap().id(); + model.get(prop_id).unwrap().label() + }) } else { self.label.as_deref() } @@ -90,9 +92,9 @@ impl Field { *self.required.inner() } - pub fn is_functional(&self) -> bool { - *self.functional.inner() - } + // pub fn is_functional(&self) -> bool { + // *self.functional.inner() + // } pub fn documentation(&self) -> &Documentation { &self.doc @@ -100,8 +102,13 @@ impl Field { pub fn preferred_documentation<'a>(&'a self, model: &'a crate::Model) -> &'a Documentation { if self.doc.is_empty() { - let prop_id = model.properties().get(*self.prop).unwrap().id(); - model.get(prop_id).unwrap().documentation() + match self.property() { + Some(prop) => { + let prop_id = model.properties().get(prop).unwrap().id(); + model.get(prop_id).unwrap().documentation() + } + None => &self.doc, + } } else { &self.doc } diff --git a/examples/literals.tldr b/examples/literals.tldr index 9f67d37b..4ba41f68 100644 --- a/examples/literals.tldr +++ b/examples/literals.tldr @@ -5,4 +5,4 @@ type Type { ident: /[A-Za-z][A-Za-z0-9]*/ } -type Literal = "hi" \ No newline at end of file +type Literal = "hi" diff --git a/json-ld-context/src/lib.rs b/json-ld-context/src/lib.rs index 0692237e..5bee3632 100644 --- a/json-ld-context/src/lib.rs +++ b/json-ld-context/src/lib.rs @@ -41,14 +41,16 @@ fn generate_layout_term_definition( use treeldr::layout::Description; match layout.description() { Description::Struct(s) => { - let ty_ref = layout.ty(); - let ty = model.types().get(ty_ref).unwrap(); - let mut def = serde_json::Map::new(); - def.insert( - "@id".into(), - ty.id().display(model.vocabulary()).to_string().into(), - ); + + if let Some(ty_ref) = layout.ty() { + let ty = model.types().get(ty_ref).unwrap(); + def.insert( + "@id".into(), + ty.id().display(model.vocabulary()).to_string().into(), + ); + } + def.insert( "@context".into(), generate_struct_context(model, s.fields())?.into(), @@ -58,57 +60,103 @@ fn generate_layout_term_definition( } Description::Enum(_) => (), Description::Literal(lit) => { - let ty_ref = layout.ty(); - let ty = model.types().get(ty_ref).unwrap(); - - if !lit.should_inline() { - let mut def = serde_json::Map::new(); - def.insert( - "@id".into(), - ty.id().display(model.vocabulary()).to_string().into(), - ); - ld_context.insert(lit.name().to_pascal_case(), def.into()); + if let Some(ty_ref) = layout.ty() { + let ty = model.types().get(ty_ref).unwrap(); + + if !lit.should_inline() { + let mut def = serde_json::Map::new(); + def.insert( + "@id".into(), + ty.id().display(model.vocabulary()).to_string().into(), + ); + ld_context.insert(lit.name().to_pascal_case(), def.into()); + } } } Description::Reference(_, _) => (), Description::Native(_, _) => (), + Description::Set(_, _) => (), + Description::List(_, _) => (), } Ok(()) } -fn generate_layout_type( +pub struct Context { + id: serde_json::Value, + ty: Option, + container: Option, +} + +impl Context { + fn new(id: serde_json::Value) -> Self { + Self { + id, + ty: None, + container: None, + } + } + + fn into_json(self) -> serde_json::Value { + if self.ty.is_none() && self.container.is_none() { + self.id + } else { + let mut map = serde_json::Map::new(); + map.insert("@id".into(), self.id); + + if let Some(ty) = self.ty { + map.insert("@type".into(), ty); + } + + if let Some(container) = self.container { + map.insert("@container".into(), container); + } + + map.into() + } + } +} + +fn generate_layout_context( + context: &mut Context, model: &treeldr::Model, layout_ref: Ref>, -) -> Option { +) { let layout = model.layouts().get(layout_ref).unwrap(); use treeldr::layout::Description; + + let non_blank_id = layout.ty().and_then(|ty_ref| { + let ty = model.types().get(ty_ref).unwrap(); + if ty.id().is_blank() { + None + } else { + Some(ty.id()) + } + }); + match layout.description() { Description::Struct(_) => { - let ty_ref = layout.ty(); - let ty = model.types().get(ty_ref).unwrap(); - Some(ty.id().display(model.vocabulary()).to_string().into()) + if let Some(ty_ref) = layout.ty() { + let ty = model.types().get(ty_ref).unwrap(); + context.ty = Some(ty.id().display(model.vocabulary()).to_string().into()) + } } Description::Enum(_) => { - let ty_ref = layout.ty(); - let ty = model.types().get(ty_ref).unwrap(); - if ty.id().is_blank() { - None - } else { - Some(ty.id().display(model.vocabulary()).to_string().into()) - } + context.ty = non_blank_id.map(|id| id.display(model.vocabulary()).to_string().into()) } Description::Literal(_) => { - let ty_ref = layout.ty(); - let ty = model.types().get(ty_ref).unwrap(); - if ty.id().is_blank() { - None - } else { - Some(ty.id().display(model.vocabulary()).to_string().into()) - } + context.ty = non_blank_id.map(|id| id.display(model.vocabulary()).to_string().into()) + } + Description::Reference(_, _) => context.ty = Some("@id".into()), + Description::Native(n, _) => context.ty = Some(generate_native_type(*n)), + Description::Set(_, _) => { + context.ty = non_blank_id.map(|id| id.display(model.vocabulary()).to_string().into()); + context.container = Some("@set".into()); + } + Description::List(_, _) => { + context.ty = non_blank_id.map(|id| id.display(model.vocabulary()).to_string().into()); + context.container = Some("@list".into()); } - Description::Reference(_, _) => Some("@id".into()), - Description::Native(n, _) => Some(generate_native_type(*n)), } } @@ -119,28 +167,15 @@ fn generate_struct_context( let mut json = serde_json::Map::new(); for field in fields { - let property_ref = field.property(); - let property = model.properties().get(property_ref).unwrap(); - - let field_layout_ref = field.layout(); - let field_type = generate_layout_type(model, field_layout_ref); - let field_def: serde_json::Value = if field_type.is_none() { - property.id().display(model.vocabulary()).to_string().into() - } else { - let mut field_def = serde_json::Map::new(); - field_def.insert( - "@id".into(), - property.id().display(model.vocabulary()).to_string().into(), - ); - - if let Some(field_type) = field_type { - field_def.insert("@type".into(), field_type); - } - - field_def.into() - }; - - json.insert(field.name().to_camel_case(), field_def); + if let Some(property_ref) = field.property() { + let property = model.properties().get(property_ref).unwrap(); + + let field_layout_ref = field.layout(); + let mut field_context = + Context::new(property.id().display(model.vocabulary()).to_string().into()); + generate_layout_context(&mut field_context, model, field_layout_ref); + json.insert(field.name().to_camel_case(), field_context.into_json()); + } } Ok(json) diff --git a/json-schema/.rustfmt.toml b/json-schema/.rustfmt.toml index 18d655e2..3e37fe3f 100644 --- a/json-schema/.rustfmt.toml +++ b/json-schema/.rustfmt.toml @@ -1 +1,2 @@ -hard_tabs = true \ No newline at end of file +hard_tabs = true +wrap_comments = true \ No newline at end of file diff --git a/json-schema/Cargo.toml b/json-schema/Cargo.toml index d6bd5a08..0943a3dd 100644 --- a/json-schema/Cargo.toml +++ b/json-schema/Cargo.toml @@ -11,4 +11,14 @@ log = "0.4" iref = "2.0" serde_json = "1.0" clap = { version = "3.0", features = ["derive"] } -derivative = "2.2.0" \ No newline at end of file +derivative = "2.2.0" + +# For the import function. +locspan = "0.3" +rdf-types = { version = "0.4.0", features = ["loc"] } + +[dev-dependencies] +treeldr-vocab = { path = "../vocab" } +static-iref = "2.0" +nquads-syntax = "0.2.0" +grdf = "0.7.0" \ No newline at end of file diff --git a/json-schema/src/import.rs b/json-schema/src/import.rs new file mode 100644 index 00000000..faaab97c --- /dev/null +++ b/json-schema/src/import.rs @@ -0,0 +1,588 @@ +//! JSON Schema import functions. +//! +//! Semantics follows . +use crate::schema::{self, RegularSchema, Schema}; +use iref::{Iri, IriBuf}; +use locspan::{Loc, Location, Span}; +use rdf_types::Quad; +use serde_json::Value; +use std::fmt; +use treeldr::{vocab, Id, Vocabulary}; +use vocab::{LocQuad, Object, Term}; + +/// Import error. +#[derive(Debug)] +pub enum Error { + UnsupportedType, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::UnsupportedType => write!(f, "unsupported schema `type` value."), + } + } +} + +/// Create a dummy location. +fn loc(file: &F) -> Location { + Location::new(file.clone(), Span::default()) +} + +pub fn import_schema( + schema: &Schema, + file: &F, + base_iri: Option, + vocabulary: &mut Vocabulary, + quads: &mut Vec>, +) -> Result, F>, Error> { + match schema { + Schema::True => todo!(), + Schema::False => todo!(), + Schema::Ref(r) => { + let iri = r.target.resolved(base_iri.unwrap()); + let id = vocab::Term::from_iri(iri, vocabulary); + Ok(Loc(Object::Iri(id), loc(file))) + } + Schema::DynamicRef(_) => todo!(), + Schema::Regular(schema) => import_regular_schema(schema, file, base_iri, vocabulary, quads), + } +} + +#[derive(Clone, Copy)] +enum LayoutKind { + Unknown, + Boolean, + Integer, + Number, + String, + ArrayOrSet, + Array, + Set, + Struct, +} + +impl LayoutKind { + pub fn is_struct(&self) -> bool { + matches!(self, Self::Struct) + } + + pub fn refine(&mut self, other: Self) -> Result<(), Error> { + *self = match (*self, other) { + (Self::Unknown, k) => k, + (Self::Boolean, Self::Boolean) => Self::Boolean, + (Self::Integer, Self::Integer) => Self::Integer, + (Self::Number, Self::Integer) => Self::Number, + (Self::Number, Self::Number) => Self::Number, + (Self::ArrayOrSet, Self::Array) => Self::Array, + (Self::ArrayOrSet, Self::Set) => Self::Set, + (Self::ArrayOrSet, Self::ArrayOrSet) => Self::ArrayOrSet, + (Self::Array, Self::Array) => Self::Array, + (Self::Set, Self::Set) => Self::Set, + (Self::Struct, Self::Struct) => Self::Struct, + _ => return Err(Error::UnsupportedType), + }; + + Ok(()) + } +} + +pub fn import_regular_schema( + schema: &RegularSchema, + file: &F, + base_iri: Option, + vocabulary: &mut Vocabulary, + quads: &mut Vec>, +) -> Result, F>, Error> { + let (id, mut name, base_iri) = match &schema.id { + Some(iri) => { + let id = Id::Iri(vocab::Term::from_iri(iri.clone(), vocabulary)); + let name = iri.path().file_name().and_then(|name| { + match std::path::Path::new(name).file_stem() { + Some(stem) => vocab::Name::new(stem.to_string_lossy()).ok(), + None => vocab::Name::new(name).ok(), + } + }); + + (id, name, Some(iri.clone())) + } + None => { + let id = Id::Blank(vocabulary.new_blank_label()); + let base_iri = base_iri.map(IriBuf::from); + (id, None, base_iri) + } + }; + + if name.is_none() { + if let Some(title) = &schema.meta_data.title { + if let Ok(n) = vocab::Name::new(title) { + name = Some(n) + } + } + } + + // Declare the layout. + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::Rdf(vocab::Rdf::Type), loc(file)), + Loc( + Object::Iri(Term::TreeLdr(vocab::TreeLdr::Layout)), + loc(file), + ), + None, + ), + loc(file), + )); + + if let Some(name) = name { + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::TreeLdr(vocab::TreeLdr::Name), loc(file)), + Loc( + Object::Literal(vocab::Literal::String(Loc( + name.to_string().into(), + loc(file), + ))), + loc(file), + ), + None, + ), + loc(file), + )); + } + + if let Some(title) = &schema.meta_data.title { + // The title of a schema is translated in an rdfs:label. + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::Rdfs(vocab::Rdfs::Label), loc(file)), + Loc( + Object::Literal(vocab::Literal::String(Loc(title.clone().into(), loc(file)))), + loc(file), + ), + None, + ), + loc(file), + )); + } + + if let Some(description) = &schema.meta_data.description { + // The title of a schema is translated in an rdfs:comment. + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::Rdfs(vocab::Rdfs::Comment), loc(file)), + Loc( + Object::Literal(vocab::Literal::String(Loc( + description.clone().into(), + loc(file), + ))), + loc(file), + ), + None, + ), + loc(file), + )); + } + + let mut kind = LayoutKind::Unknown; + if let Some(types) = &schema.validation.any.ty { + for ty in types { + let k = match ty { + schema::Type::Null => todo!(), + schema::Type::Boolean => LayoutKind::Boolean, + schema::Type::Integer => LayoutKind::Integer, + schema::Type::Number => LayoutKind::Number, + schema::Type::String => LayoutKind::String, + schema::Type::Array => LayoutKind::ArrayOrSet, + schema::Type::Object => LayoutKind::Struct, + }; + + kind.refine(k)? + } + } + + match &schema.desc { + schema::Description::Definition { + string, + array, + object, + } => { + if !string.is_empty() { + todo!() + } + + if !array.is_empty() || !schema.validation.array.is_empty() { + kind.refine(LayoutKind::ArrayOrSet)?; + import_array_schema( + id, + schema, + array, + &mut kind, + file, + base_iri.as_ref().map(IriBuf::as_iri), + vocabulary, + quads, + )?; + } + + if kind.is_struct() || !object.is_empty() || !schema.validation.object.is_empty() { + kind.refine(LayoutKind::Struct)?; + import_object_schema( + id, + schema, + object, + file, + base_iri.as_ref().map(IriBuf::as_iri), + vocabulary, + quads, + )?; + } + } + // schema::Description::OneOf(schemas) => { + // todo!() + // } + _ => todo!(), + } + + if let Some(cnst) = &schema.validation.any.cnst { + // The presence of this key means that the schema represents a TreeLDR + // literal/singleton layout. + let singleton = value_into_object(file, vocabulary, quads, cnst)?; + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::TreeLdr(vocab::TreeLdr::Singleton), loc(file)), + singleton, + None, + ), + loc(file), + )); + } + + if let Some(pattern) = &schema.validation.string.pattern { + // The presence of this key means that the schema represents a TreeLDR literal + // regular expression layout. + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::TreeLdr(vocab::TreeLdr::Matches), loc(file)), + Loc( + Object::Literal(vocab::Literal::String(Loc( + pattern.clone().into(), + loc(file), + ))), + loc(file), + ), + None, + ), + loc(file), + )); + } + + let native_layout = if let Some(format) = schema.validation.format { + Some(format_layout(file, format)?) + } else { + match kind { + LayoutKind::Boolean => { + Some(Loc(Object::Iri(Term::Xsd(vocab::Xsd::Boolean)), loc(file))) + } + LayoutKind::Integer => { + Some(Loc(Object::Iri(Term::Xsd(vocab::Xsd::Integer)), loc(file))) + } + LayoutKind::Number => Some(Loc(Object::Iri(Term::Xsd(vocab::Xsd::Double)), loc(file))), + LayoutKind::String => Some(Loc(Object::Iri(Term::Xsd(vocab::Xsd::String)), loc(file))), + _ => None, + } + }; + + if let Some(layout) = native_layout { + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::TreeLdr(vocab::TreeLdr::Native), loc(file)), + layout, + None, + ), + loc(file), + )); + } + + let result = match id { + Id::Iri(id) => Object::Iri(id), + Id::Blank(id) => Object::Blank(id), + }; + + Ok(Loc(result, loc(file))) +} + +#[allow(clippy::too_many_arguments)] +fn import_array_schema( + id: Id, + schema: &RegularSchema, + array: &schema::ArraySchema, + kind: &mut LayoutKind, + file: &F, + base_iri: Option, + vocabulary: &mut Vocabulary, + quads: &mut Vec>, +) -> Result<(), Error> { + let layout_kind = if matches!(schema.validation.array.unique_items, Some(true)) { + kind.refine(LayoutKind::Set)?; + Loc(Term::TreeLdr(vocab::TreeLdr::Set), loc(file)) + } else { + kind.refine(LayoutKind::Array)?; + Loc(Term::TreeLdr(vocab::TreeLdr::List), loc(file)) + }; + + let item_type = match &array.items { + Some(items) => import_schema(items, file, base_iri, vocabulary, quads)?, + None => todo!(), + }; + + quads.push(Loc( + Quad(Loc(id, loc(file)), layout_kind, item_type, None), + loc(file), + )); + + Ok(()) +} + +fn import_object_schema( + id: Id, + schema: &RegularSchema, + object: &schema::ObjectSchema, + file: &F, + base_iri: Option, + vocabulary: &mut Vocabulary, + quads: &mut Vec>, +) -> Result<(), Error> { + let mut fields: Vec, F>> = Vec::new(); + + if let Some(properties) = &object.properties { + fields.reserve(properties.len()); + + // First, we build each field. + for (prop, prop_schema) in properties { + let prop_label = vocabulary.new_blank_label(); + // rdf:type treeldr:Field + quads.push(Loc( + Quad( + Loc(Id::Blank(prop_label), loc(file)), + Loc(Term::Rdf(vocab::Rdf::Type), loc(file)), + Loc(Object::Iri(Term::TreeLdr(vocab::TreeLdr::Field)), loc(file)), + None, + ), + loc(file), + )); + // treeldr:name + quads.push(Loc( + Quad( + Loc(Id::Blank(prop_label), loc(file)), + Loc(Term::TreeLdr(vocab::TreeLdr::Name), loc(file)), + Loc( + Object::Literal(vocab::Literal::String(Loc( + prop.to_string().into(), + loc(file), + ))), + loc(file), + ), + None, + ), + loc(file), + )); + + let prop_schema = import_schema(prop_schema, file, base_iri, vocabulary, quads)?; + quads.push(Loc( + Quad( + Loc(Id::Blank(prop_label), loc(file)), + Loc(Term::TreeLdr(vocab::TreeLdr::Format), loc(file)), + prop_schema, + None, + ), + loc(file), + )); + + let field = Loc(Object::Blank(prop_label), loc(file)); + fields.push(field); + + // property_fields.insert(prop, Loc(Id::Blank(prop_label), loc(file))); + if let Some(required) = &schema.validation.object.required { + if required.contains(prop) { + quads.push(Loc( + Quad( + Loc(Id::Blank(prop_label), loc(file)), + Loc(Term::Schema(vocab::Schema::ValueRequired), loc(file)), + Loc(Object::Iri(Term::Schema(vocab::Schema::True)), loc(file)), + None, + ), + loc(file), + )); + } + } + } + } + + let fields = fields.into_iter().try_into_rdf_list::( + &mut (), + vocabulary, + quads, + loc(file), + |field, _, _, _| Ok(field), + )?; + + // Then we declare the structure content. + quads.push(Loc( + Quad( + Loc(id, loc(file)), + Loc(Term::TreeLdr(vocab::TreeLdr::Fields), loc(file)), + fields, + None, + ), + loc(file), + )); + + Ok(()) +} + +fn value_into_object( + file: &F, + vocab: &mut Vocabulary, + quads: &mut Vec>, + value: &Value, +) -> Result, F>, Error> { + match value { + Value::Null => todo!(), + Value::Bool(true) => Ok(Loc( + Object::Iri(vocab::Term::Schema(vocab::Schema::True)), + loc(file), + )), + Value::Bool(false) => Ok(Loc( + Object::Iri(vocab::Term::Schema(vocab::Schema::False)), + loc(file), + )), + Value::Number(n) => Ok(Loc( + Object::Literal(vocab::Literal::TypedString( + Loc(n.to_string().into(), loc(file)), + Loc(vocab::Term::Xsd(vocab::Xsd::Integer), loc(file)), + )), + loc(file), + )), + Value::String(s) => Ok(Loc( + Object::Literal(vocab::Literal::String(Loc(s.to_string().into(), loc(file)))), + loc(file), + )), + Value::Array(items) => items.iter().try_into_rdf_list( + &mut (), + vocab, + quads, + loc(file), + |item, _, vocab, quads| value_into_object(file, vocab, quads, item), + ), + Value::Object(_) => todo!(), + } +} + +fn format_layout(file: &F, format: schema::Format) -> Result, F>, Error> { + let layout = match format { + schema::Format::DateTime => Term::Xsd(vocab::Xsd::DateTime), + schema::Format::Date => Term::Xsd(vocab::Xsd::Date), + schema::Format::Time => Term::Xsd(vocab::Xsd::Time), + schema::Format::Duration => todo!(), + schema::Format::Email => todo!(), + schema::Format::IdnEmail => todo!(), + schema::Format::Hostname => todo!(), + schema::Format::IdnHostname => todo!(), + schema::Format::Ipv4 => todo!(), + schema::Format::Ipv6 => todo!(), + schema::Format::Uri => todo!(), + schema::Format::UriReference => todo!(), + schema::Format::Iri => Term::Xsd(vocab::Xsd::AnyUri), + schema::Format::IriReference => todo!(), + schema::Format::Uuid => todo!(), + schema::Format::UriTemplate => todo!(), + schema::Format::JsonPointer => todo!(), + schema::Format::RelativeJsonPointer => todo!(), + schema::Format::Regex => todo!(), + }; + + Ok(Loc(Object::Iri(layout), loc(file))) +} + +pub trait TryIntoRdfList { + fn try_into_rdf_list( + self, + ctx: &mut C, + vocab: &mut Vocabulary, + quads: &mut Vec>, + loc: Location, + f: K, + ) -> Result, F>, E> + where + K: FnMut(T, &mut C, &mut Vocabulary, &mut Vec>) -> Result, F>, E>; +} + +impl TryIntoRdfList for I { + fn try_into_rdf_list( + self, + ctx: &mut C, + vocab: &mut Vocabulary, + quads: &mut Vec>, + loc: Location, + mut f: K, + ) -> Result, F>, E> + where + K: FnMut( + I::Item, + &mut C, + &mut Vocabulary, + &mut Vec>, + ) -> Result, F>, E>, + { + use vocab::Rdf; + let mut head = Loc(Object::Iri(Term::Rdf(Rdf::Nil)), loc); + for item in self.rev() { + let item = f(item, ctx, vocab, quads)?; + let item_label = vocab.new_blank_label(); + let item_loc = item.location().clone(); + let list_loc = head.location().clone().with(item_loc.span()); + + quads.push(Loc( + Quad( + Loc(Id::Blank(item_label), list_loc.clone()), + Loc(Term::Rdf(Rdf::Type), list_loc.clone()), + Loc(Object::Iri(Term::Rdf(Rdf::List)), list_loc.clone()), + None, + ), + item_loc.clone(), + )); + + quads.push(Loc( + Quad( + Loc(Id::Blank(item_label), item_loc.clone()), + Loc(Term::Rdf(Rdf::First), item_loc.clone()), + item, + None, + ), + item_loc.clone(), + )); + + quads.push(Loc( + Quad( + Loc(Id::Blank(item_label), head.location().clone()), + Loc(Term::Rdf(Rdf::Rest), head.location().clone()), + head, + None, + ), + item_loc.clone(), + )); + + head = Loc(Object::Blank(item_label), list_loc); + } + + Ok(head) + } +} diff --git a/json-schema/src/lib.rs b/json-schema/src/lib.rs index 30342f08..9e0382aa 100644 --- a/json-schema/src/lib.rs +++ b/json-schema/src/lib.rs @@ -2,9 +2,13 @@ use treeldr::{layout, vocab::Display, Ref}; mod command; pub mod embedding; +pub mod import; +pub mod schema; pub use command::Command; pub use embedding::Embedding; +pub use import::import_schema; +pub use schema::Schema; pub enum Error { NoLayoutName(Ref>), @@ -130,10 +134,7 @@ fn generate_layout( Ok(()) } Description::Struct(s) => generate_struct(json, model, embedding, type_property, s), - Description::Enum(enm) => { - generate_enum_type(json, model, enm)?; - Ok(()) - } + Description::Enum(enm) => generate_enum_type(json, model, embedding, type_property, enm), Description::Literal(lit) => { generate_literal_type(json, lit); Ok(()) @@ -142,6 +143,12 @@ fn generate_layout( generate_native_type(json, *n); Ok(()) } + Description::Set(item_layout, _) => { + generate_set_type(json, model, embedding, type_property, *item_layout) + } + Description::List(item_layout, _) => { + generate_list_type(json, model, embedding, type_property, *item_layout) + } } } @@ -170,47 +177,22 @@ fn generate_struct( let mut layout_schema = serde_json::Map::new(); - match embedding.get(field_layout_ref) { - Embedding::Reference => { - generate_layout_ref(&mut layout_schema, model, field_layout_ref)?; - } - Embedding::Indirect => { - generate_layout_defs_ref(&mut layout_schema, model, field_layout_ref)?; - } - Embedding::Direct => { - generate_layout( - &mut layout_schema, - model, - embedding, - type_property, - field_layout_ref, - )?; - } - } - - let mut field_schema = if field.is_functional() { - layout_schema - } else { - let mut field_schema = serde_json::Map::new(); - - field_schema.insert("type".into(), "array".into()); - field_schema.insert("items".into(), layout_schema.into()); - - if field.is_required() { - field_schema.insert("minItems".into(), 1.into()); - } - - field_schema - }; + embed_layout( + &mut layout_schema, + model, + embedding, + type_property, + field_layout_ref, + )?; if let Some(description) = field.preferred_label(model) { - field_schema.insert( + layout_schema.insert( "description".into(), remove_newlines(description.trim()).into(), ); } - properties.insert(field.name().to_camel_case(), field_schema.into()); + properties.insert(field.name().to_camel_case(), layout_schema.into()); if field.is_required() { required_properties.push(serde_json::Value::from(field.name().to_camel_case())); @@ -227,6 +209,22 @@ fn generate_struct( Ok(()) } +fn embed_layout( + json: &mut serde_json::Map, + model: &treeldr::Model, + embedding: &embedding::Configuration, + type_property: Option<&str>, + layout_ref: Ref>, +) -> Result<(), Error> { + match embedding.get(layout_ref) { + Embedding::Reference => { + generate_layout_ref(json, model, embedding, type_property, layout_ref) + } + Embedding::Indirect => generate_layout_defs_ref(json, model, layout_ref), + Embedding::Direct => generate_layout(json, model, embedding, type_property, layout_ref), + } +} + fn generate_layout_defs_ref( json: &mut serde_json::Map, model: &treeldr::Model, @@ -251,6 +249,8 @@ fn generate_layout_defs_ref( fn generate_layout_ref( json: &mut serde_json::Map, model: &treeldr::Model, + embedding: &embedding::Configuration, + type_property: Option<&str>, layout_ref: Ref>, ) -> Result<(), Error> { let layout = model.layouts().get(layout_ref).unwrap(); @@ -270,7 +270,7 @@ fn generate_layout_ref( Ok(()) } Description::Enum(enm) => { - generate_enum_type(json, model, enm)?; + generate_enum_type(json, model, embedding, type_property, enm)?; Ok(()) } Description::Literal(lit) => { @@ -281,19 +281,74 @@ fn generate_layout_ref( generate_native_type(json, *n); Ok(()) } + Description::Set(item_layout, _) => { + generate_set_type(json, model, embedding, type_property, *item_layout) + } + Description::List(item_layout, _) => { + generate_list_type(json, model, embedding, type_property, *item_layout) + } } } +fn generate_set_type( + def: &mut serde_json::Map, + model: &treeldr::Model, + embedding: &embedding::Configuration, + type_property: Option<&str>, + item_layout_ref: Ref>, +) -> Result<(), Error> { + let mut item_schema = serde_json::Map::new(); + generate_layout_ref( + &mut item_schema, + model, + embedding, + type_property, + item_layout_ref, + )?; + def.insert("type".into(), "array".into()); + def.insert("items".into(), item_schema.into()); + def.insert("uniqueItems".into(), true.into()); + Ok(()) +} + +fn generate_list_type( + def: &mut serde_json::Map, + model: &treeldr::Model, + embedding: &embedding::Configuration, + type_property: Option<&str>, + item_layout_ref: Ref>, +) -> Result<(), Error> { + let mut item_schema = serde_json::Map::new(); + generate_layout_ref( + &mut item_schema, + model, + embedding, + type_property, + item_layout_ref, + )?; + def.insert("type".into(), "array".into()); + def.insert("items".into(), item_schema.into()); + Ok(()) +} + fn generate_enum_type( def: &mut serde_json::Map, model: &treeldr::Model, + embedding: &embedding::Configuration, + type_property: Option<&str>, enm: &layout::Enum, ) -> Result<(), Error> { let mut variants = Vec::with_capacity(enm.variants().len()); for variant in enm.variants() { let layout_ref = variant.layout().unwrap(); let mut variant_json = serde_json::Map::new(); - generate_layout_ref(&mut variant_json, model, layout_ref)?; + embed_layout( + &mut variant_json, + model, + embedding, + type_property, + layout_ref, + )?; variants.push(serde_json::Value::Object(variant_json)) } diff --git a/json-schema/src/schema.rs b/json-schema/src/schema.rs new file mode 100644 index 00000000..d74f57cd --- /dev/null +++ b/json-schema/src/schema.rs @@ -0,0 +1,301 @@ +use iref::{IriBuf, IriRefBuf}; +use std::collections::BTreeMap; + +mod validation; +pub use validation::*; + +pub mod from_serde_json; + +#[allow(clippy::large_enum_variant)] +pub enum Schema { + True, + False, + Ref(RefSchema), + DynamicRef(DynamicRefSchema), + Regular(RegularSchema), +} + +impl Schema { + pub fn as_ref(&self) -> Option<&RefSchema> { + match self { + Self::Ref(r) => Some(r), + _ => None, + } + } + + pub fn as_dynamic_ref(&self) -> Option<&DynamicRefSchema> { + match self { + Self::DynamicRef(r) => Some(r), + _ => None, + } + } + + pub fn as_regular(&self) -> Option<&RegularSchema> { + match self { + Self::Regular(r) => Some(r), + _ => None, + } + } +} + +impl From for Schema { + fn from(s: RefSchema) -> Self { + Self::Ref(s) + } +} + +impl From for Schema { + fn from(s: DynamicRefSchema) -> Self { + Self::DynamicRef(s) + } +} + +impl From for Schema { + fn from(s: RegularSchema) -> Self { + Self::Regular(s) + } +} + +/// Regular schema definition. +pub struct RegularSchema { + /// Meta schema properties. + pub meta_schema: MetaSchema, + + /// Schema identifier. + pub id: Option, + + /// Meta data. + pub meta_data: MetaData, + + /// Schema description. + pub desc: Description, + + /// Schema validation. + pub validation: Validation, + + pub anchor: Option, + + pub dynamic_anchor: Option, + + /// The "$defs" keyword reserves a location for schema authors to inline + /// re-usable JSON Schemas into a more general schema. The keyword does not + /// directly affect the validation result. + pub defs: Option>, +} + +/// A Vocabulary for Basic Meta-Data Annotations. +pub struct MetaData { + pub title: Option, + pub description: Option, + pub default: Option, + pub deprecated: Option, + pub read_only: Option, + pub write_only: Option, + pub examples: Option>, +} + +/// Meta-Schemas and Vocabularies. +pub struct MetaSchema { + /// The "$schema" keyword is both used as a JSON Schema dialect identifier + /// and as the identifier of a resource which is itself a JSON Schema, which + /// describes the set of valid schemas written for this particular dialect. + pub schema: Option, + + /// The "$vocabulary" keyword is used in meta-schemas to identify the + /// vocabularies available for use in schemas described by that meta-schema. + /// It is also used to indicate whether each vocabulary is required or + /// optional, in the sense that an implementation MUST understand the + /// required vocabularies in order to successfully process the schema. + /// Together, this information forms a dialect. Any vocabulary that is + /// understood by the implementation MUST be processed in a manner + /// consistent with the semantic definitions contained within the + /// vocabulary. + pub vocabulary: Option>, +} + +/// Schema defined with the `$ref` keyword. +pub struct RefSchema { + pub meta_data: MetaData, + pub target: IriRefBuf, +} + +/// Schema defined with the `$dynamicRef` keyword. +pub struct DynamicRefSchema { + pub meta_data: MetaData, + pub target: IriRefBuf, +} + +/// Schema description. +#[allow(clippy::large_enum_variant)] +pub enum Description { + Definition { + string: StringEncodedData, + array: ArraySchema, + object: ObjectSchema, + }, + AllOf(Vec), + AnyOf(Vec), + OneOf(Vec), + Not(Box), + If { + condition: Box, + then: Option>, + els: Option>, + }, +} + +pub struct ArraySchema { + /// Validation succeeds if each element of the instance validates against + /// the schema at the same position, if any. This keyword does not constrain + /// the length of the array. If the array is longer than this keyword's + /// value, this keyword validates only the prefix of matching length. + /// + /// Omitting this keyword has the same assertion behavior as an empty array. + pub prefix_items: Option>, + + /// This keyword applies its subschema to all instance elements at indexes + /// greater than the length of the "prefixItems" array in the same schema + /// object, as reported by the annotation result of that "prefixItems" + /// keyword. If no such annotation result exists, "items" applies its + /// subschema to all instance array elements. [CREF11] + /// + /// If the "items" subschema is applied to any positions within the instance + /// array, it produces an annotation result of boolean true, indicating that + /// all remaining array elements have been evaluated against this keyword's + /// subschema. + /// + /// Omitting this keyword has the same assertion behavior as an empty + /// schema. + pub items: Option>, + + /// An array instance is valid against "contains" if at least one of its + /// elements is valid against the given schema. The subschema MUST be + /// applied to every array element even after the first match has been + /// found, in order to collect annotations for use by other keywords. This + /// is to ensure that all possible annotations are collected. + pub contains: Option>, + + /// The behavior of this keyword depends on the annotation results of + /// adjacent keywords that apply to the instance location being validated. + /// Specifically, the annotations from "prefixItems", "items", and + /// "contains", which can come from those keywords when they are adjacent to + /// the "unevaluatedItems" keyword. Those three annotations, as well as + /// "unevaluatedItems", can also result from any and all adjacent in-place + /// applicator keywords. This includes but is not limited to the in-place + /// applicators defined in this document. + pub unevaluated_items: Option>, +} + +impl ArraySchema { + pub fn is_empty(&self) -> bool { + self.prefix_items.is_none() + && self.items.is_none() + && self.contains.is_none() + && self.unevaluated_items.is_none() + } +} + +/// Keywords for Applying Subschemas to Objects. +pub struct ObjectSchema { + /// Validation succeeds if, for each name that appears in both the instance + /// and as a name within this keyword's value, the child instance for that + /// name successfully validates against the corresponding schema. + /// The annotation result of this keyword is the set of instance property + /// names matched by this keyword. + /// Omitting this keyword has the same assertion behavior as an empty + /// object. + pub properties: Option>, + + /// The value of "patternProperties" MUST be an object. + /// Each property name of this object SHOULD be a valid regular expression, + /// according to the ECMA-262 regular expression dialect. + /// Each property value of this object MUST be a valid JSON Schema. + pub pattern_properties: Option>, + + /// The behavior of this keyword depends on the presence and annotation + /// results of "properties" and "patternProperties" within the same schema + /// object. Validation with "additionalProperties" applies only to the child + /// values of instance names that do not appear in the annotation results of + /// either "properties" or "patternProperties". + pub additional_properties: Option>, + + /// This keyword specifies subschemas that are evaluated if the instance is + /// an object and contains a certain property. + /// + /// This keyword's value MUST be an object. Each value in the object MUST be + /// a valid JSON Schema. + /// + /// If the object key is a property in the instance, the entire instance + /// must validate against the subschema. Its use is dependent on the + /// presence of the property. + /// + /// Omitting this keyword has the same behavior as an empty object. + pub dependent_schemas: Option>, + + /// The behavior of this keyword depends on the annotation results of + /// adjacent keywords that apply to the instance location being validated. + /// Specifically, the annotations from "properties", "patternProperties", + /// and "additionalProperties", which can come from those keywords when they + /// are adjacent to the "unevaluatedProperties" keyword. Those three + /// annotations, as well as "unevaluatedProperties", can also result from + /// any and all adjacent in-place applicator keywords. This includes but is + /// not limited to the in-place applicators defined in this document. + /// + /// Validation with "unevaluatedProperties" applies only to the child values + /// of instance names that do not appear in the "properties", + /// "patternProperties", "additionalProperties", or "unevaluatedProperties" + /// annotation results that apply to the instance location being validated. + /// + /// For all such properties, validation succeeds if the child instance + /// validates against the "unevaluatedProperties" schema. + /// + /// This means that "properties", "patternProperties", + /// "additionalProperties", and all in-place applicators MUST be evaluated + /// before this keyword can be evaluated. Authors of extension keywords MUST + /// NOT define an in-place applicator that would need to be evaluated after + /// this keyword. + pub unevaluated_properties: Option>, +} + +impl ObjectSchema { + pub fn is_empty(&self) -> bool { + self.properties.is_none() + && self.pattern_properties.is_none() + && self.additional_properties.is_none() + && self.dependent_schemas.is_none() + && self.unevaluated_properties.is_none() + } +} + +/// A Vocabulary for the Contents of String-Encoded Data +pub struct StringEncodedData { + /// Defines that the string SHOULD be interpreted as binary data and decoded + /// using the encoding named by this property. + pub content_encoding: Option, + + /// If the instance is a string, this property indicates the media type of + /// the contents of the string. If "contentEncoding" is present, this + /// property describes the decoded string. + /// + /// The value of this property MUST be a string, which MUST be a media type, + /// as defined by RFC 2046. + pub content_media_type: Option, + + /// If the instance is a string, and if "contentMediaType" is present, this + /// property contains a schema which describes the structure of the string. + /// + /// This keyword MAY be used with any media type that can be mapped into + /// JSON Schema's data model. + /// + /// The value of this property MUST be a valid JSON schema. It SHOULD be + /// ignored if "contentMediaType" is not present. + pub content_schema: Option>, +} + +impl StringEncodedData { + pub fn is_empty(&self) -> bool { + self.content_encoding.is_none() + && self.content_media_type.is_none() + && self.content_schema.is_none() + } +} diff --git a/json-schema/src/schema/from_serde_json.rs b/json-schema/src/schema/from_serde_json.rs new file mode 100644 index 00000000..ed2fec22 --- /dev/null +++ b/json-schema/src/schema/from_serde_json.rs @@ -0,0 +1,551 @@ +use super::*; +use iref::{IriBuf, IriRefBuf}; +use serde_json::Value; +use std::fmt; + +#[derive(Debug)] +pub enum Error { + InvalidSchema, + InvalidUri, + InvalidUriRef, + InvalidType, + NotABoolean, + NotANumber, + NotAPositiveInteger, + NotAString, + NotAnArray, + NotAnObject, + UnknownFormat, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::InvalidSchema => write!(f, "invalid `$schema` value"), + Self::InvalidUri => write!(f, "invalid URI"), + Self::InvalidUriRef => write!(f, "invalid URI reference"), + Self::InvalidType => write!(f, "invalid `type` value"), + Self::NotABoolean => write!(f, "expected a boolean"), + Self::NotANumber => write!(f, "expected a number"), + Self::NotAPositiveInteger => write!(f, "expected a positive integer"), + Self::NotAString => write!(f, "expected a string"), + Self::NotAnArray => write!(f, "expected an array"), + Self::NotAnObject => write!(f, "expected an object"), + Self::UnknownFormat => write!(f, "unknown `format` value"), + } + } +} + +trait ValueTryInto: Sized { + fn try_into_bool(self) -> Result; + fn try_into_number(self) -> Result; + fn try_into_u64(self) -> Result { + self.try_into_number()? + .as_u64() + .ok_or(Error::NotAPositiveInteger) + } + fn try_into_string(self) -> Result; + fn try_into_array(self) -> Result, Error>; + + fn try_into_schema_array(self) -> Result, Error> { + let mut schemas = Vec::new(); + for v in self.try_into_array()? { + schemas.push(v.try_into_schema()?) + } + Ok(schemas) + } + + fn try_into_string_array(self) -> Result, Error> { + let mut schemas = Vec::new(); + for v in self.try_into_array()? { + schemas.push(v.try_into_string()?) + } + Ok(schemas) + } + + fn try_into_schema(self) -> Result; + + fn try_into_boxed_schema(self) -> Result, Error> { + Ok(Box::new(self.try_into_schema()?)) + } + + fn try_into_object(self) -> Result, Error>; + + fn try_into_uri(self) -> Result { + IriBuf::from_string(self.try_into_string()?).map_err(|_| Error::InvalidUri) + } + + fn try_into_uri_ref(self) -> Result { + IriRefBuf::from_string(self.try_into_string()?).map_err(|_| Error::InvalidUriRef) + } +} + +impl ValueTryInto for Value { + fn try_into_bool(self) -> Result { + match self { + Self::Bool(b) => Ok(b), + _ => Err(Error::NotABoolean), + } + } + + fn try_into_number(self) -> Result { + match self { + Self::Number(n) => Ok(n), + _ => Err(Error::NotANumber), + } + } + + fn try_into_string(self) -> Result { + match self { + Self::String(s) => Ok(s), + _ => Err(Error::NotAString), + } + } + + fn try_into_array(self) -> Result, Error> { + match self { + Self::Array(a) => Ok(a), + _ => Err(Error::NotAnArray), + } + } + + fn try_into_object(self) -> Result, Error> { + match self { + Self::Object(o) => Ok(o), + _ => Err(Error::NotAnObject), + } + } + + fn try_into_schema(self) -> Result { + Schema::try_from(self) + } +} + +fn read_meta_data(value: &mut serde_json::Map) -> Result { + Ok(MetaData { + title: value + .remove("title") + .map(|t| t.try_into_string()) + .transpose()?, + description: value + .remove("description") + .map(|t| t.try_into_string()) + .transpose()?, + default: value.remove("default"), + deprecated: value + .remove("deprecated") + .map(|t| t.try_into_bool()) + .transpose()?, + read_only: value + .remove("readOnly") + .map(|t| t.try_into_bool()) + .transpose()?, + write_only: value + .remove("writeOnly") + .map(|t| t.try_into_bool()) + .transpose()?, + examples: value + .remove("examples") + .map(|t| t.try_into_array()) + .transpose()?, + }) +} + +fn read_meta_schema(value: &mut serde_json::Map) -> Result { + Ok(MetaSchema { + schema: value + .remove("$schema") + .map(|t| t.try_into_uri()) + .transpose()?, + vocabulary: value + .remove("$vocabulary") + .map(|t| { + let obj = t.try_into_object()?; + let mut vocab = BTreeMap::new(); + for (key, value) in obj { + let uri = IriBuf::from_string(key).map_err(|_| Error::InvalidUriRef)?; + vocab.insert(uri, value.try_into_bool()?); + } + Ok(vocab) + }) + .transpose()?, + }) +} + +fn read_description(value: &mut serde_json::Map) -> Result { + if let Some(all_of) = value.remove("allOf") { + Ok(Description::AllOf(all_of.try_into_schema_array()?)) + } else if let Some(any_of) = value.remove("anyOf") { + Ok(Description::AnyOf(any_of.try_into_schema_array()?)) + } else if let Some(one_of) = value.remove("oneOf") { + Ok(Description::OneOf(one_of.try_into_schema_array()?)) + } else if let Some(not) = value.remove("not") { + Ok(Description::Not(not.try_into_boxed_schema()?)) + } else if let Some(condition) = value.remove("if") { + Ok(Description::If { + condition: Box::new(Schema::try_from(condition)?), + then: value + .remove("then") + .map(|s| Ok(Box::new(s.try_into()?))) + .transpose()?, + els: value + .remove("els") + .map(|s| Ok(Box::new(s.try_into()?))) + .transpose()?, + }) + } else { + Ok(Description::Definition { + string: read_string_encoded_data_schema(value)?, + array: read_array_schema(value)?, + object: read_object_schema(value)?, + }) + } +} + +fn read_string_encoded_data_schema( + value: &mut serde_json::Map, +) -> Result { + Ok(StringEncodedData { + content_encoding: value + .remove("contentEncoding") + .map(ValueTryInto::try_into_string) + .transpose()?, + content_media_type: value + .remove("contentMediaType") + .map(ValueTryInto::try_into_string) + .transpose()?, + content_schema: value + .remove("contentSchema") + .map(|s| Ok(Box::new(s.try_into()?))) + .transpose()?, + }) +} + +fn read_array_schema(value: &mut serde_json::Map) -> Result { + Ok(ArraySchema { + prefix_items: value + .remove("prefixItems") + .map(ValueTryInto::try_into_schema_array) + .transpose()?, + items: value + .remove("items") + .map(ValueTryInto::try_into_boxed_schema) + .transpose()?, + contains: value + .remove("contains") + .map(ValueTryInto::try_into_boxed_schema) + .transpose()?, + unevaluated_items: value + .remove("unevaluatedItems") + .map(ValueTryInto::try_into_boxed_schema) + .transpose()?, + }) +} + +fn read_object_schema(value: &mut serde_json::Map) -> Result { + Ok(ObjectSchema { + properties: value + .remove("properties") + .map(|v| { + let obj = v.try_into_object()?; + let mut properties = BTreeMap::new(); + for (key, value) in obj { + properties.insert(key, value.try_into_schema()?); + } + Ok(properties) + }) + .transpose()?, + pattern_properties: value + .remove("patternProperties") + .map(|v| { + let obj = v.try_into_object()?; + let mut properties = BTreeMap::new(); + for (key, value) in obj { + properties.insert(key, value.try_into_schema()?); + } + Ok(properties) + }) + .transpose()?, + additional_properties: value + .remove("additionalProperties") + .map(ValueTryInto::try_into_boxed_schema) + .transpose()?, + dependent_schemas: value + .remove("dependentSchemas") + .map(|v| { + let obj = v.try_into_object()?; + let mut properties = BTreeMap::new(); + for (key, value) in obj { + properties.insert(key, value.try_into_schema()?); + } + Ok(properties) + }) + .transpose()?, + unevaluated_properties: value + .remove("unevaluatedProperties") + .map(ValueTryInto::try_into_boxed_schema) + .transpose()?, + }) +} + +fn read_validation(value: &mut serde_json::Map) -> Result { + Ok(Validation { + any: read_any_validation(value)?, + numeric: read_numeric_validation(value)?, + string: read_string_validation(value)?, + array: read_array_validation(value)?, + object: read_object_validation(value)?, + format: value.remove("format").map(Format::try_from).transpose()?, + }) +} + +fn read_any_validation(value: &mut serde_json::Map) -> Result { + Ok(AnyValidation { + ty: value + .remove("type") + .map(|t| { + Ok(match t { + Value::Array(items) => { + let mut types = Vec::with_capacity(items.len()); + for i in items { + types.push(i.try_into()?); + } + types + } + t => vec![t.try_into()?], + }) + }) + .transpose()?, + enm: value + .remove("enum") + .map(ValueTryInto::try_into_array) + .transpose()?, + cnst: value.remove("const"), + }) +} + +fn read_numeric_validation( + value: &mut serde_json::Map, +) -> Result { + Ok(NumericValidation { + multiple_of: value + .remove("multipleOf") + .map(ValueTryInto::try_into_number) + .transpose()?, + maximum: value + .remove("maximum") + .map(ValueTryInto::try_into_number) + .transpose()?, + exclusive_maximum: value + .remove("exclusiveMaximum") + .map(ValueTryInto::try_into_number) + .transpose()?, + minimum: value + .remove("minimum") + .map(ValueTryInto::try_into_number) + .transpose()?, + exclusive_minimum: value + .remove("exclusiveMinimum") + .map(ValueTryInto::try_into_number) + .transpose()?, + }) +} + +fn read_string_validation( + value: &mut serde_json::Map, +) -> Result { + Ok(StringValidation { + max_length: value + .remove("maxLength") + .map(ValueTryInto::try_into_u64) + .transpose()?, + min_length: value + .remove("minLength") + .map(ValueTryInto::try_into_u64) + .transpose()?, + pattern: value + .remove("pattern") + .map(ValueTryInto::try_into_string) + .transpose()?, + }) +} + +fn read_array_validation( + value: &mut serde_json::Map, +) -> Result { + Ok(ArrayValidation { + max_items: value + .remove("maxItems") + .map(ValueTryInto::try_into_u64) + .transpose()?, + min_items: value + .remove("minItems") + .map(ValueTryInto::try_into_u64) + .transpose()?, + unique_items: value + .remove("uniqueItems") + .map(ValueTryInto::try_into_bool) + .transpose()?, + max_contains: value + .remove("maxContains") + .map(ValueTryInto::try_into_u64) + .transpose()?, + min_contains: value + .remove("minContains") + .map(ValueTryInto::try_into_u64) + .transpose()?, + }) +} + +fn read_object_validation( + value: &mut serde_json::Map, +) -> Result { + Ok(ObjectValidation { + max_properties: value + .remove("maxProperties") + .map(ValueTryInto::try_into_u64) + .transpose()?, + min_properties: value + .remove("minProperties") + .map(ValueTryInto::try_into_u64) + .transpose()?, + required: value + .remove("required") + .map(ValueTryInto::try_into_string_array) + .transpose()?, + dependent_required: value + .remove("dependentRequired") + .map(|v| { + let obj = v.try_into_object()?; + let mut map = BTreeMap::new(); + for (key, value) in obj { + map.insert(key, value.try_into_string_array()?); + } + Ok(map) + }) + .transpose()?, + }) +} + +impl TryFrom for Type { + type Error = Error; + + fn try_from(v: Value) -> Result { + let s = v.try_into_string()?; + let t = match s.as_str() { + "null" => Self::Null, + "boolean" => Self::Boolean, + "number" => Self::Number, + "integer" => Self::Integer, + "string" => Self::String, + "array" => Self::Array, + "object" => Self::Object, + _ => return Err(Error::InvalidType), + }; + + Ok(t) + } +} + +impl TryFrom for Format { + type Error = Error; + + fn try_from(v: Value) -> Result { + let s = v.try_into_string()?; + let f = match s.as_str() { + "date-time" => Self::DateTime, + "date" => Self::Date, + "time" => Self::Time, + "duration" => Self::Duration, + "email" => Self::Email, + "idn-email" => Self::IdnEmail, + "hostname" => Self::Hostname, + "idn-hostname" => Self::IdnHostname, + "ipv4" => Self::Ipv4, + "ipv6" => Self::Ipv6, + "uri" => Self::Uri, + "uri-reference" => Self::UriReference, + "iri" => Self::Iri, + "iri-reference" => Self::IriReference, + "uuid" => Self::Uuid, + "uri-template" => Self::UriTemplate, + "json-pointer" => Self::JsonPointer, + "relative-json-pointer" => Self::RelativeJsonPointer, + "regex" => Self::Regex, + _ => return Err(Error::UnknownFormat), + }; + + Ok(f) + } +} + +impl TryFrom for Schema { + type Error = Error; + + fn try_from(v: Value) -> Result { + match v { + Value::Bool(true) => Ok(Self::True), + Value::Bool(false) => Ok(Self::False), + Value::Object(mut obj) => { + if let Some(value) = obj.remove("$ref") { + let value = value.as_str().ok_or(Error::NotAString)?; + let uri_ref = IriRefBuf::new(value).map_err(|_| Error::InvalidUriRef)?; + Ok(Self::Ref(RefSchema { + meta_data: read_meta_data(&mut obj)?, + target: uri_ref, + })) + } else if let Some(value) = obj.remove("$dynamicRef") { + let value = value.as_str().ok_or(Error::NotAString)?; + let uri_ref = IriRefBuf::new(value).map_err(|_| Error::InvalidUriRef)?; + Ok(Self::DynamicRef(DynamicRefSchema { + meta_data: read_meta_data(&mut obj)?, + target: uri_ref, + })) + } else { + let meta_schema = read_meta_schema(&mut obj)?; + let meta_data = read_meta_data(&mut obj)?; + let id = obj + .remove("$id") + .map(ValueTryInto::try_into_uri) + .transpose() + .map_err(|_| Error::InvalidUri)?; + let anchor = obj + .remove("$anchor") + .map(ValueTryInto::try_into_string) + .transpose()?; + let dynamic_anchor = obj + .remove("$dynamicAnchor") + .map(ValueTryInto::try_into_string) + .transpose()?; + let defs = obj + .remove("$defs") + .map(|t| { + let obj = t.try_into_object()?; + let mut defs = BTreeMap::new(); + for (key, value) in obj { + let schema: Schema = value.try_into()?; + defs.insert(key, schema); + } + Ok(defs) + }) + .transpose()?; + + let desc = read_description(&mut obj)?; + let validation = read_validation(&mut obj)?; + + Ok(Self::Regular(RegularSchema { + meta_schema, + id, + meta_data, + desc, + validation, + anchor, + dynamic_anchor, + defs, + })) + } + } + _ => Err(Error::InvalidSchema), + } + } +} diff --git a/json-schema/src/schema/validation.rs b/json-schema/src/schema/validation.rs new file mode 100644 index 00000000..9e336bd6 --- /dev/null +++ b/json-schema/src/schema/validation.rs @@ -0,0 +1,299 @@ +use std::collections::BTreeMap; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)] +pub enum Type { + Null, + Boolean, + Number, + Integer, + String, + Array, + Object, +} + +pub struct Validation { + pub any: AnyValidation, + pub numeric: NumericValidation, + pub string: StringValidation, + pub array: ArrayValidation, + pub object: ObjectValidation, + pub format: Option, +} + +/// Validation Keywords for Any Instance Type. +pub struct AnyValidation { + pub ty: Option>, + pub enm: Option>, + pub cnst: Option, +} + +/// Validation Keywords for Numeric Instances (number and integer). +pub struct NumericValidation { + /// The value of "multipleOf" MUST be a number, strictly greater than 0. + /// + /// A numeric instance is valid only if division by this keyword's value + /// results in an integer. + pub multiple_of: Option, + + /// The value of "maximum" MUST be a number, representing an inclusive upper + /// limit for a numeric instance. + /// + /// If the instance is a number, then this keyword validates only if the + /// instance is less than or exactly equal to "maximum". + pub maximum: Option, + + /// The value of "exclusiveMaximum" MUST be a number, representing an + /// exclusive upper limit for a numeric instance. + /// + /// If the instance is a number, then the instance is valid only if it has a + /// value strictly less than (not equal to) "exclusiveMaximum". + pub exclusive_maximum: Option, + + /// The value of "minimum" MUST be a number, representing an inclusive lower + /// limit for a numeric instance. + /// + /// If the instance is a number, then this keyword validates only if the + /// instance is greater than or exactly equal to "minimum". + pub minimum: Option, + + /// The value of "exclusiveMinimum" MUST be a number, representing an + /// exclusive lower limit for a numeric instance. + /// + /// If the instance is a number, then the instance is valid only if it has a + /// value strictly greater than (not equal to) "exclusiveMinimum". + pub exclusive_minimum: Option, +} + +impl NumericValidation { + pub fn is_empty(&self) -> bool { + self.multiple_of.is_some() + || self.maximum.is_some() + || self.exclusive_maximum.is_some() + || self.minimum.is_some() + || self.exclusive_minimum.is_some() + } +} + +/// Validation Keywords for Strings +pub struct StringValidation { + /// A string instance is valid against this keyword if its length is less + /// than, or equal to, the value of this keyword. + /// + /// The length of a string instance is defined as the number of its + /// characters as defined by RFC 8259. + pub max_length: Option, + + /// A string instance is valid against this keyword if its length is greater + /// than, or equal to, the value of this keyword. + /// + /// The length of a string instance is defined as the number of its + /// characters as defined by RFC 8259. + /// + /// Omitting this keyword has the same behavior as a value of 0. + pub min_length: Option, + + /// The value of this keyword MUST be a string. This string SHOULD be a + /// valid regular expression, according to the ECMA-262 regular expression + /// dialect. + /// + /// A string instance is considered valid if the regular expression matches + /// the instance successfully. Recall: regular expressions are not + /// implicitly anchored. + pub pattern: Option, +} + +impl StringValidation { + pub fn is_empty(&self) -> bool { + self.max_length.is_none() && self.min_length.is_none() && self.pattern.is_none() + } +} + +/// Validation Keywords for Arrays +pub struct ArrayValidation { + /// The value of this keyword MUST be a non-negative integer. + /// + /// An array instance is valid against "maxItems" if its size is less than, + /// or equal to, the value of this keyword. + pub max_items: Option, + + /// An array instance is valid against "minItems" if its size is greater + /// than, or equal to, the value of this keyword. + /// + /// Omitting this keyword has the same behavior as a value of 0. + pub min_items: Option, + + /// If this keyword has boolean value false, the instance validates + /// successfully. If it has boolean value true, the instance validates + /// successfully if all of its elements are unique. + /// + /// Omitting this keyword has the same behavior as a value of false. + pub unique_items: Option, + + /// If "contains" is not present within the same schema object, then this + /// keyword has no effect. + /// + /// An instance array is valid against "maxContains" in two ways, depending + /// on the form of the annotation result of an adjacent "contains" keyword. + /// The first way is if the annotation result is an array and the length of + /// that array is less than or equal to the "maxContains" value. The second + /// way is if the annotation result is a boolean "true" and the instance + /// array length is less than or equal to the "maxContains" value. + pub max_contains: Option, + + /// If "contains" is not present within the same schema object, then this + /// keyword has no effect. + /// + /// An instance array is valid against "minContains" in two ways, depending + /// on the form of the annotation result of an adjacent "contains" keyword. + /// The first way is if the annotation result is an array and the length of + /// that array is greater than or equal to the "minContains" value. The + /// second way is if the annotation result is a boolean "true" and the + /// instance array length is greater than or equal to the "minContains" + /// value. + /// + /// A value of 0 is allowed, but is only useful for setting a range of + /// occurrences from 0 to the value of "maxContains". A value of 0 with no + /// "maxContains" causes "contains" to always pass validation. + /// + /// Omitting this keyword has the same behavior as a value of 1. + pub min_contains: Option, +} + +impl ArrayValidation { + pub fn is_empty(&self) -> bool { + self.max_items.is_none() + && self.min_items.is_none() + && self.unique_items.is_none() + && self.max_contains.is_none() + && self.min_contains.is_none() + } +} + +/// Validation Keywords for Objects +pub struct ObjectValidation { + /// An object instance is valid against "maxProperties" if its number of + /// properties is less than, or equal to, the value of this keyword. + pub max_properties: Option, + + /// An object instance is valid against "minProperties" if its number of + /// properties is greater than, or equal to, the value of this keyword. + /// + /// Omitting this keyword has the same behavior as a value of 0. + pub min_properties: Option, + + /// Elements of this array, if any, MUST be strings, and MUST be unique. + /// + /// An object instance is valid against this keyword if every item in the + /// array is the name of a property in the instance. + /// + /// Omitting this keyword has the same behavior as an empty array. + pub required: Option>, + + /// Elements in each array, if any, MUST be strings, and MUST be unique. + /// + /// This keyword specifies properties that are required if a specific other + /// property is present. Their requirement is dependent on the presence of + /// the other property. + /// + /// Validation succeeds if, for each name that appears in both the instance + /// and as a name within this keyword's value, every item in the + /// corresponding array is also the name of a property in the instance. + /// + /// Omitting this keyword has the same behavior as an empty object. + pub dependent_required: Option>>, +} + +impl ObjectValidation { + pub fn is_empty(&self) -> bool { + self.max_properties.is_none() + && self.min_properties.is_none() + && self.required.is_none() + && self.dependent_required.is_none() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)] +pub enum Format { + /// A string instance is valid against this attribute if it is a valid + /// representation according to the "date-time" production. + DateTime, + + /// A string instance is valid against this attribute if it is a valid + /// representation according to the "full-date" production. + Date, + + /// A string instance is valid against this attribute if it is a valid + /// representation according to the "full-time" production. + Time, + + /// A string instance is valid against this attribute if it is a valid + /// representation according to the "duration" production. + Duration, + + /// As defined by the "Mailbox" ABNF rule in RFC 5321, section 4.1.2. + Email, + + /// As defined by the extended "Mailbox" ABNF rule in RFC 6531, section 3.3. + IdnEmail, + + /// As defined by RFC 1123, section 2.1, including host names produced using + /// the Punycode algorithm specified in RFC 5891, section 4.4. + Hostname, + + /// As defined by either RFC 1123 as for hostname, or an internationalized + /// hostname as defined by RFC 5890, section 2.3.2.3. + IdnHostname, + + /// An IPv4 address according to the "dotted-quad" ABNF syntax as defined in + /// RFC 2673, section 3.2. + Ipv4, + + /// An IPv6 address as defined in RFC 4291, section 2.2. + Ipv6, + + /// A string instance is valid against this attribute if it is a valid URI, + /// according to [RFC3986]. + Uri, + + /// A string instance is valid against this attribute if it is a valid URI + /// Reference (either a URI or a relative-reference), according to + /// [RFC3986]. + UriReference, + + /// A string instance is valid against this attribute if it is a valid IRI, + /// according to [RFC3987]. + Iri, + + /// A string instance is valid against this attribute if it is a valid IRI + /// Reference (either an IRI or a relative-reference), according to + /// [RFC3987]. + IriReference, + + /// A string instance is valid against this attribute if it is a valid + /// string representation of a UUID, according to [RFC4122]. + Uuid, + + /// A string instance is valid against this attribute if it is a valid URI + /// Template (of any level), according to [RFC6570]. + /// + /// Note that URI Templates may be used for IRIs; there is no separate IRI + /// Template specification. + UriTemplate, + + /// A string instance is valid against this attribute if it is a valid JSON + /// string representation of a JSON Pointer, according to RFC 6901, section + /// 5. + JsonPointer, + + /// A string instance is valid against this attribute if it is a valid + /// Relative JSON Pointer. + RelativeJsonPointer, + + /// A regular expression, which SHOULD be valid according to the ECMA-262 + /// regular expression dialect. + /// + /// Implementations that validate formats MUST accept at least the subset of + /// ECMA-262 defined in the Regular Expressions section of this + /// specification, and SHOULD accept all valid ECMA-262 expressions. + Regex, +} diff --git a/json-schema/tests/i01.json b/json-schema/tests/i01.json new file mode 100644 index 00000000..bc2864fc --- /dev/null +++ b/json-schema/tests/i01.json @@ -0,0 +1,4 @@ +{ + "$comment": "A simple string layout", + "type": "string" +} \ No newline at end of file diff --git a/json-schema/tests/i01.nq b/json-schema/tests/i01.nq new file mode 100644 index 00000000..9a8e9442 --- /dev/null +++ b/json-schema/tests/i01.nq @@ -0,0 +1,2 @@ +_:0 . +_:0 . diff --git a/json-schema/tests/i02.json b/json-schema/tests/i02.json new file mode 100644 index 00000000..2ec83ace --- /dev/null +++ b/json-schema/tests/i02.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/product.schema.json", + "title": "Product", + "description": "A product in the catalog", + "type": "object" +} \ No newline at end of file diff --git a/json-schema/tests/i02.nq b/json-schema/tests/i02.nq new file mode 100644 index 00000000..f38f992b --- /dev/null +++ b/json-schema/tests/i02.nq @@ -0,0 +1,5 @@ + . + "A product in the catalog" . + . + "Product" . + "product" . \ No newline at end of file diff --git a/json-schema/tests/i03.json b/json-schema/tests/i03.json new file mode 100644 index 00000000..c5310a11 --- /dev/null +++ b/json-schema/tests/i03.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/product.schema.json", + "title": "Product", + "description": "A product from Acme's catalog", + "type": "object", + "properties": { + "productId": { + "description": "The unique identifier for a product", + "type": "integer" + } + }, + "required": [ "productId" ] +} \ No newline at end of file diff --git a/json-schema/tests/i03.nq b/json-schema/tests/i03.nq new file mode 100644 index 00000000..fdced8c9 --- /dev/null +++ b/json-schema/tests/i03.nq @@ -0,0 +1,15 @@ + "A product from Acme's catalog" . + "Product" . + "product" . + _:2 . + . +_:1 . +_:1 "The unique identifier for a product" . +_:1 . +_:2 . +_:2 . +_:2 _:0 . +_:0 . +_:0 _:1 . +_:0 . +_:0 "productId" . \ No newline at end of file diff --git a/json-schema/tests/i04.json b/json-schema/tests/i04.json new file mode 100644 index 00000000..0ea69a08 --- /dev/null +++ b/json-schema/tests/i04.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/product.schema.json", + "title": "Product", + "description": "A product from Acme's catalog", + "type": "object", + "properties": { + "productId": { + "description": "The unique identifier for a product", + "type": "integer" + }, + "productName": { + "description": "Name of the product", + "type": "string" + } + }, + "required": [ "productId", "productName" ] +} \ No newline at end of file diff --git a/json-schema/tests/i04.nq b/json-schema/tests/i04.nq new file mode 100644 index 00000000..664d8d29 --- /dev/null +++ b/json-schema/tests/i04.nq @@ -0,0 +1,25 @@ + _:5 . + "A product from Acme's catalog" . + . + "Product" . + "product" . +_:3 "Name of the product" . +_:3 . +_:3 . +_:1 . +_:1 "The unique identifier for a product" . +_:1 . +_:0 . +_:0 "productId" . +_:0 _:1 . +_:0 . +_:2 . +_:2 _:3 . +_:2 . +_:2 "productName" . +_:4 _:2 . +_:4 . +_:4 . +_:5 . +_:5 _:0 . +_:5 _:4 . \ No newline at end of file diff --git a/json-schema/tests/i05.json b/json-schema/tests/i05.json new file mode 100644 index 00000000..18289acd --- /dev/null +++ b/json-schema/tests/i05.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/product.schema.json", + "title": "Product", + "description": "A product from Acme's catalog", + "type": "object", + "properties": { + "productId": { + "description": "The unique identifier for a product", + "type": "integer" + }, + "productName": { + "description": "Name of the product", + "type": "string" + }, + "price": { + "description": "The price of the product", + "type": "number", + "exclusiveMinimum": 0 + }, + "tags": { + "description": "Tags for the product", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ "productId", "productName", "price" ] +} \ No newline at end of file diff --git a/json-schema/tests/i05.nq b/json-schema/tests/i05.nq new file mode 100644 index 00000000..8f116e93 --- /dev/null +++ b/json-schema/tests/i05.nq @@ -0,0 +1,46 @@ +_:2 "productId" . +_:2 . +_:2 _:3 . +_:2 . +_:0 "price" . +_:0 . +_:0 _:1 . +_:0 . +_:9 . +_:9 _:6 . +_:9 . +_:10 _:4 . +_:10 _:9 . +_:10 . +_:11 _:10 . +_:11 _:2 . +_:11 . +_:12 . +_:12 _:0 . +_:12 _:11 . +_:5 . +_:5 "Name of the product" . +_:5 . + "A product from Acme's catalog" . + _:12 . + . + "Product" . + "product" . +_:1 "The price of the product" . +_:1 . +_:1 . +_:3 . +_:3 "The unique identifier for a product" . +_:3 . +_:4 . +_:4 _:5 . +_:4 "productName" . +_:4 . +_:7 _:8 . +_:7 . +_:7 "Tags for the product" . +_:6 . +_:6 "tags" . +_:6 _:7 . +_:8 . +_:8 . \ No newline at end of file diff --git a/json-schema/tests/i06.json b/json-schema/tests/i06.json new file mode 100644 index 00000000..ddac521b --- /dev/null +++ b/json-schema/tests/i06.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/foo.schema.json", + "title": "Foo", + "description": "A layout with a reference", + "type": "object", + "properties": { + "bar": { + "description": "A reference", + "$ref": "https://example.com/bar.schema.json" + } + } +} \ No newline at end of file diff --git a/json-schema/tests/i06.nq b/json-schema/tests/i06.nq new file mode 100644 index 00000000..66421159 --- /dev/null +++ b/json-schema/tests/i06.nq @@ -0,0 +1,11 @@ +_:1 . +_:1 . +_:1 _:0 . + "A layout with a reference" . + . + "Foo" . + "foo" . + _:1 . +_:0 . +_:0 "bar" . +_:0 . \ No newline at end of file diff --git a/json-schema/tests/import.rs b/json-schema/tests/import.rs new file mode 100644 index 00000000..d20d8087 --- /dev/null +++ b/json-schema/tests/import.rs @@ -0,0 +1,110 @@ +use locspan::Loc; +use std::collections::HashMap; +use std::path::Path; +use treeldr_vocab::{GraphLabel, Id, StrippedObject, Term, Vocabulary}; + +fn infallible(t: T) -> Result { + Ok(t) +} + +#[derive(Default)] +struct BlankIdGenerator(HashMap); + +impl BlankIdGenerator { + pub fn generate(&mut self, label: rdf_types::BlankIdBuf) -> treeldr_vocab::BlankLabel { + use std::collections::hash_map::Entry; + let len = self.0.len() as u32; + match self.0.entry(label) { + Entry::Occupied(entry) => entry.get().clone(), + Entry::Vacant(entry) => { + let label = treeldr_vocab::BlankLabel::new(len); + entry.insert(label); + label + } + } + } +} + +fn parse_nquads>( + vocabulary: &mut Vocabulary, + path: P, +) -> grdf::HashDataset { + use nquads_syntax::{lexing::Utf8Decoded, Document, Lexer, Parse}; + + let buffer = std::fs::read_to_string(path).expect("unable to read file"); + let mut lexer = Lexer::new( + (), + Utf8Decoded::new(buffer.chars().map(infallible)).peekable(), + ); + let Loc(quads, _) = Document::parse(&mut lexer).expect("parse error"); + + let mut generator = BlankIdGenerator::default(); + let mut generate = move |label| generator.generate(label); + + quads + .into_iter() + .map(move |quad| treeldr_vocab::stripped_loc_quad_from_rdf(quad, vocabulary, &mut generate)) + .collect() +} + +fn parse_json_schema>(path: P) -> treeldr_json_schema::Schema { + let buffer = std::fs::read_to_string(path).expect("unable to read file"); + let json: serde_json::Value = serde_json::from_str(&buffer).expect("invalid JSON"); + treeldr_json_schema::Schema::try_from(json).expect("invalid JSON Schema") +} + +fn import_json_schema>( + vocabulary: &mut Vocabulary, + path: P, +) -> grdf::HashDataset { + let input = parse_json_schema(path); + let mut quads = Vec::new(); + treeldr_json_schema::import_schema(&input, &(), None, vocabulary, &mut quads) + .expect("import failed"); + + quads.into_iter().map(treeldr_vocab::strip_quad).collect() +} + +fn test, O: AsRef>(input_path: I, expected_output_path: O) { + use treeldr_vocab::RdfDisplay; + let mut vocabulary = Vocabulary::new(); + + let output = import_json_schema(&mut vocabulary, input_path); + let expected_output = parse_nquads(&mut vocabulary, expected_output_path); + + for quad in output.quads() { + println!("{} .", quad.rdf_display(&vocabulary)) + } + + assert!(output.is_isomorphic_to(&expected_output)) +} + +#[test] +fn t001() { + test("tests/i01.json", "tests/i01.nq") +} + +#[test] +fn t002() { + test("tests/i02.json", "tests/i02.nq") +} + +#[test] +fn t003() { + test("tests/i03.json", "tests/i03.nq") +} + +#[test] +fn t004() { + test("tests/i04.json", "tests/i04.nq") +} + +#[test] +fn t005() { + test("tests/i05.json", "tests/i05.nq") +} + +#[test] +fn t006() { + test("tests/i06.json", "tests/i06.nq") +} diff --git a/syntax/Cargo.toml b/syntax/Cargo.toml index f095af44..5fbbecef 100644 --- a/syntax/Cargo.toml +++ b/syntax/Cargo.toml @@ -15,4 +15,4 @@ rdf-types = { version = "0.4.1", features = ["loc"] } [dev-dependencies] static-iref = "2.0" nquads-syntax = "0.2.0" -grdf = "0.7.3" \ No newline at end of file +grdf = "0.7.3" diff --git a/syntax/src/build.rs b/syntax/src/build.rs index 7add232c..481e1500 100644 --- a/syntax/src/build.rs +++ b/syntax/src/build.rs @@ -1011,32 +1011,42 @@ impl Build for Loc, F> { name_loc, )); - if let Some(Loc(layout, _)) = def.layout { + if let Some(Loc(layout, layout_loc)) = def.layout { let scope = ctx.scope.take(); - let object = layout.expr.build(ctx, quads)?; + + let mut object = layout.expr.build(ctx, quads)?; let object_loc = object.location().clone(); ctx.scope = scope; - quads.push(Loc( - Quad( - Loc(Id::Blank(label), prop_id_loc.clone()), - Loc(Term::TreeLdr(TreeLdr::Format), object_loc.clone()), - object, - None, - ), - object_loc, - )); for Loc(ann, ann_loc) in layout.annotations { match ann { - crate::Annotation::Multiple => quads.push(Loc( - Quad( - Loc(Id::Blank(label), prop_id_loc.clone()), - Loc(Term::Schema(Schema::MultipleValues), ann_loc.clone()), - Loc(Object::Iri(Term::Schema(Schema::True)), ann_loc.clone()), - None, - ), - ann_loc, - )), + crate::Annotation::Multiple => { + // Create a new orphan container layout to store the multiple values. + let container_label = ctx.vocabulary.new_blank_label(); + quads.push(Loc( + Quad( + Loc(Id::Blank(container_label), layout_loc.clone()), + Loc(Term::Rdf(Rdf::Type), layout_loc.clone()), + Loc( + Object::Iri(Term::TreeLdr(TreeLdr::Layout)), + layout_loc.clone(), + ), + None, + ), + layout_loc.clone(), + )); + quads.push(Loc( + Quad( + Loc(Id::Blank(container_label), layout_loc.clone()), + Loc(Term::TreeLdr(TreeLdr::Set), layout_loc.clone()), + object, + None, + ), + layout_loc.clone(), + )); + + object = Loc(Object::Blank(container_label), layout_loc.clone()); + } crate::Annotation::Required => quads.push(Loc( Quad( Loc(Id::Blank(label), prop_id_loc.clone()), @@ -1048,6 +1058,16 @@ impl Build for Loc, F> { )), } } + + quads.push(Loc( + Quad( + Loc(Id::Blank(label), prop_id_loc), + Loc(Term::TreeLdr(TreeLdr::Format), object_loc.clone()), + object, + None, + ), + object_loc, + )); } Ok(Loc(Object::Blank(label), loc)) diff --git a/syntax/tests/006-in.tldr b/syntax/tests/006-in.tldr new file mode 100644 index 00000000..4629e07f --- /dev/null +++ b/syntax/tests/006-in.tldr @@ -0,0 +1,6 @@ +use as xs + +/// Foo. +type Foo { + bar: multiple xs:anyURI +} \ No newline at end of file diff --git a/syntax/tests/006-out.nq b/syntax/tests/006-out.nq new file mode 100644 index 00000000..e06b1f79 --- /dev/null +++ b/syntax/tests/006-out.nq @@ -0,0 +1,20 @@ + . + "Foo." . + "foo" . + . + . + . + . + + . + . + _:11 . +_:11 . +_:11 _:12 . +_:11 . +_:12 . +_:12 . +_:12 "bar" . +_:12 _:1 . +_:1 . +_:1 . \ No newline at end of file diff --git a/syntax/tests/build.rs b/syntax/tests/build.rs index 10dff033..27cf7550 100644 --- a/syntax/tests/build.rs +++ b/syntax/tests/build.rs @@ -111,6 +111,11 @@ fn t005() { test("tests/005-in.tldr", "tests/005-out.nq") } +#[test] +fn t006() { + test("tests/006-in.tldr", "tests/006-out.nq") +} + #[test] fn t007() { test("tests/007-in.tldr", "tests/007-out.nq") diff --git a/vocab/Cargo.toml b/vocab/Cargo.toml index 8b61a092..3ffe8e07 100644 --- a/vocab/Cargo.toml +++ b/vocab/Cargo.toml @@ -10,4 +10,4 @@ iref = "2.1.1" iref-enum = "2.0" static-iref = "2.0" locspan = { version = "0.3.2", features = ["reporting"] } -rdf-types = { version = "0.4.1", features = ["loc"] } \ No newline at end of file +rdf-types = { version = "0.4.1", features = ["loc"] } diff --git a/vocab/src/display.rs b/vocab/src/display.rs index 3c5e370a..e6439c77 100644 --- a/vocab/src/display.rs +++ b/vocab/src/display.rs @@ -1,5 +1,6 @@ use super::Vocabulary; use fmt::Display as StdDisplay; +use locspan::Loc; use std::fmt; pub trait Display { @@ -27,12 +28,34 @@ impl Display for super::Id { } } +impl Display for super::Literal { + fn fmt(&self, namespace: &Vocabulary, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::String(s) => s.fmt(f), + Self::TypedString(Loc(s, _), Loc(ty, _)) => { + write!(f, "{}^^<{}>", s, ty.display(namespace)) + } + Self::LangString(Loc(s, _), Loc(tag, _)) => write!(f, "{}@{}", s, tag), + } + } +} + impl Display for super::Object { fn fmt(&self, namespace: &Vocabulary, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::Iri(id) => id.fmt(namespace, f), Self::Blank(id) => id.fmt(f), - Self::Literal(lit) => lit.fmt(f), + Self::Literal(lit) => lit.fmt(namespace, f), + } + } +} + +impl Display for super::StrippedLiteral { + fn fmt(&self, namespace: &Vocabulary, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::String(s) => s.fmt(f), + Self::TypedString(s, ty) => write!(f, "{}^^<{}>", s, ty.display(namespace)), + Self::LangString(s, tag) => write!(f, "{}@{}", s, tag), } } } @@ -42,7 +65,7 @@ impl Display for super::StrippedObject { match self { Self::Iri(id) => id.fmt(namespace, f), Self::Blank(id) => id.fmt(f), - Self::Literal(lit) => lit.fmt(f), + Self::Literal(lit) => lit.fmt(namespace, f), } } } @@ -95,7 +118,7 @@ impl RdfDisplay for super::Object { match self { Self::Iri(id) => id.rdf_fmt(namespace, f), Self::Blank(id) => id.fmt(f), - Self::Literal(lit) => lit.fmt(f), + Self::Literal(lit) => lit.fmt(namespace, f), } } } @@ -105,7 +128,7 @@ impl RdfDisplay for super::StrippedObject { match self { Self::Iri(id) => id.rdf_fmt(namespace, f), Self::Blank(id) => id.fmt(f), - Self::Literal(lit) => lit.fmt(f), + Self::Literal(lit) => lit.fmt(namespace, f), } } } diff --git a/vocab/src/lib.rs b/vocab/src/lib.rs index fb257693..b7facf8f 100644 --- a/vocab/src/lib.rs +++ b/vocab/src/lib.rs @@ -1,7 +1,7 @@ use iref::{Iri, IriBuf}; use iref_enum::IriEnum; use locspan::Loc; -use rdf_types::{loc::Literal, Quad}; +use rdf_types::{Quad, StringLiteral}; use std::{collections::HashMap, fmt}; mod display; @@ -13,9 +13,15 @@ pub use name::*; #[derive(IriEnum, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] #[iri_prefix("tldr" = "https://treeldr.org/")] pub enum TreeLdr { + /// TreeLDR Layout. #[iri("tldr:Layout")] Layout, + /// Defines the name of a layout, field or variant. + #[iri("tldr:name")] + Name, + + /// Associates a layout to the type its represents. #[iri("tldr:layoutFor")] LayoutFor, @@ -23,6 +29,7 @@ pub enum TreeLdr { #[iri("tldr:format")] Format, + /// Structure layout. #[iri("tldr:fields")] Fields, @@ -35,9 +42,6 @@ pub enum TreeLdr { #[iri("tldr:Field")] Field, - #[iri("tldr:name")] - Name, - #[iri("tldr:fieldFor")] FieldFor, @@ -48,7 +52,7 @@ pub enum TreeLdr { #[iri("tldr:derefTo")] DerefTo, - /// Layout equality constraint. + /// Singleton layout. /// /// The only possible instance of the subject layout is the given object. #[iri("tldr:singleton")] @@ -76,6 +80,18 @@ pub enum TreeLdr { /// property. #[iri("tldr:Variant")] Variant, + + /// Native layout. + #[iri("tldr:native")] + Native, + + /// List layout. + #[iri("tldr:list")] + List, + + /// Set layout. + #[iri("tldr:set")] + Set, } #[derive(IriEnum, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] @@ -101,6 +117,43 @@ pub enum Schema { ValueRequired, } +#[derive(IriEnum, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +#[iri_prefix("xsd" = "http://www.w3.org/2001/XMLSchema#")] +pub enum Xsd { + #[iri("xsd:boolean")] + Boolean, + + #[iri("xsd:int")] + Int, + + #[iri("xsd:integer")] + Integer, + + #[iri("xsd:positiveInteger")] + PositiveInteger, + + #[iri("xsd:float")] + Float, + + #[iri("xsd:double")] + Double, + + #[iri("xsd:string")] + String, + + #[iri("xsd:time")] + Time, + + #[iri("xsd:date")] + Date, + + #[iri("xsd:dateTime")] + DateTime, + + #[iri("xsd:anyURI")] + AnyUri, +} + #[derive(IriEnum, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] #[iri_prefix("rdfs" = "http://www.w3.org/2000/01/rdf-schema#")] pub enum Rdfs { @@ -150,6 +203,7 @@ pub struct UnknownTerm(usize); pub enum Term { Rdf(Rdf), Rdfs(Rdfs), + Xsd(Xsd), Schema(Schema), Owl(Owl), TreeLdr(TreeLdr), @@ -157,19 +211,47 @@ pub enum Term { } impl Term { + pub fn try_from_known_iri(iri: Iri) -> Option { + match Rdf::try_from(iri) { + Ok(id) => Some(Term::Rdf(id)), + Err(_) => match Rdfs::try_from(iri) { + Ok(id) => Some(Term::Rdfs(id)), + Err(_) => match Xsd::try_from(iri) { + Ok(id) => Some(Term::Xsd(id)), + Err(_) => match Schema::try_from(iri) { + Ok(id) => Some(Term::Schema(id)), + Err(_) => match Owl::try_from(iri) { + Ok(id) => Some(Term::Owl(id)), + Err(_) => match TreeLdr::try_from(iri) { + Ok(id) => Some(Term::TreeLdr(id)), + Err(_) => None, + }, + }, + }, + }, + }, + } + } + pub fn try_from_iri(iri: Iri, ns: &Vocabulary) -> Option { match Rdf::try_from(iri) { Ok(id) => Some(Term::Rdf(id)), Err(_) => match Rdfs::try_from(iri) { Ok(id) => Some(Term::Rdfs(id)), - Err(_) => match Schema::try_from(iri) { - Ok(id) => Some(Term::Schema(id)), - Err(_) => match TreeLdr::try_from(iri) { - Ok(id) => Some(Term::TreeLdr(id)), - Err(_) => { - let iri_buf: IriBuf = iri.into(); - ns.get(&iri_buf).map(Term::Unknown) - } + Err(_) => match Xsd::try_from(iri) { + Ok(id) => Some(Term::Xsd(id)), + Err(_) => match Schema::try_from(iri) { + Ok(id) => Some(Term::Schema(id)), + Err(_) => match Owl::try_from(iri) { + Ok(id) => Some(Term::Owl(id)), + Err(_) => match TreeLdr::try_from(iri) { + Ok(id) => Some(Term::TreeLdr(id)), + Err(_) => { + let iri_buf: IriBuf = iri.into(); + ns.get(&iri_buf).map(Term::Unknown) + } + }, + }, }, }, }, @@ -181,13 +263,16 @@ impl Term { Ok(id) => Term::Rdf(id), Err(_) => match Rdfs::try_from(iri.as_iri()) { Ok(id) => Term::Rdfs(id), - Err(_) => match Schema::try_from(iri.as_iri()) { - Ok(id) => Term::Schema(id), - Err(_) => match Owl::try_from(iri.as_iri()) { - Ok(id) => Term::Owl(id), - Err(_) => match TreeLdr::try_from(iri.as_iri()) { - Ok(id) => Term::TreeLdr(id), - Err(_) => Term::Unknown(ns.insert(iri)), + Err(_) => match Xsd::try_from(iri.as_iri()) { + Ok(id) => Term::Xsd(id), + Err(_) => match Schema::try_from(iri.as_iri()) { + Ok(id) => Term::Schema(id), + Err(_) => match Owl::try_from(iri.as_iri()) { + Ok(id) => Term::Owl(id), + Err(_) => match TreeLdr::try_from(iri.as_iri()) { + Ok(id) => Term::TreeLdr(id), + Err(_) => Term::Unknown(ns.insert(iri)), + }, }, }, }, @@ -199,6 +284,7 @@ impl Term { match self { Self::Rdf(id) => Some(id.into()), Self::Rdfs(id) => Some(id.into()), + Self::Xsd(id) => Some(id.into()), Self::Schema(id) => Some(id.into()), Self::Owl(id) => Some(id.into()), Self::TreeLdr(id) => Some(id.into()), @@ -210,7 +296,7 @@ impl Term { impl rdf_types::AsTerm for Term { type Iri = Self; type BlankId = BlankLabel; - type Literal = rdf_types::Literal; + type Literal = rdf_types::Literal; fn as_term(&self) -> rdf_types::Term<&Self::Iri, &Self::BlankId, &Self::Literal> { rdf_types::Term::Iri(self) @@ -220,7 +306,7 @@ impl rdf_types::AsTerm for Term { impl rdf_types::IntoTerm for Term { type Iri = Self; type BlankId = BlankLabel; - type Literal = rdf_types::Literal; + type Literal = rdf_types::Literal; fn into_term(self) -> rdf_types::Term { rdf_types::Term::Iri(self) @@ -250,11 +336,15 @@ pub type Id = rdf_types::Subject; pub type GraphLabel = rdf_types::GraphLabel; +pub type Literal = rdf_types::loc::Literal; + pub type Object = rdf_types::Object>; pub type LocQuad = rdf_types::loc::LocQuad, GraphLabel, F>; -pub type StrippedObject = rdf_types::Object; +pub type StrippedLiteral = rdf_types::Literal; + +pub type StrippedObject = rdf_types::Object; pub type StrippedQuad = rdf_types::Quad; @@ -287,7 +377,17 @@ pub fn object_from_rdf( match object { rdf_types::Object::Iri(iri) => Object::Iri(Term::from_iri(iri, ns)), rdf_types::Object::Blank(label) => Object::Blank(blank_label(label)), - rdf_types::Object::Literal(lit) => Object::Literal(lit), + rdf_types::Object::Literal(lit) => { + let lit = match lit { + rdf_types::loc::Literal::String(s) => Literal::String(s), + rdf_types::loc::Literal::TypedString(s, Loc(ty, ty_loc)) => { + Literal::TypedString(s, Loc(Term::from_iri(ty, ns), ty_loc)) + } + rdf_types::loc::Literal::LangString(s, l) => Literal::LangString(s, l), + }; + + Object::Literal(lit) + } } } @@ -299,7 +399,17 @@ pub fn stripped_object_from_rdf( match object { rdf_types::Object::Iri(iri) => StrippedObject::Iri(Term::from_iri(iri, ns)), rdf_types::Object::Blank(label) => StrippedObject::Blank(blank_label(label)), - rdf_types::Object::Literal(lit) => StrippedObject::Literal(lit), + rdf_types::Object::Literal(lit) => { + let lit = match lit { + rdf_types::Literal::String(s) => rdf_types::Literal::String(s), + rdf_types::Literal::TypedString(s, ty) => { + rdf_types::Literal::TypedString(s, Term::from_iri(ty, ns)) + } + rdf_types::Literal::LangString(s, l) => rdf_types::Literal::LangString(s, l), + }; + + StrippedObject::Literal(lit) + } } } @@ -375,6 +485,7 @@ impl Vocabulary { match self.reverse.entry(iri) { Entry::Occupied(entry) => *entry.get(), Entry::Vacant(entry) => { + debug_assert!(Term::try_from_known_iri(entry.key().as_iri()).is_none()); let name = UnknownTerm(self.map.len()); self.map.push(entry.key().clone()); entry.insert(name);