use compact_str::CompactString;
use crate::error::SchemaError;
use crate::graph::GraphNodeType;
use crate::graph::schema::coerce::coerce_to_declared;
use crate::graph::schema::nested::{self, NestedCtx};
use crate::graph::schema::node::CreateDefault;
use crate::graph::schema::{DefaultFunction, GraphSchema, PropertySchema, SchemaMode};
use crate::value::PropertyMap;
use crate::value::json_path::JsonPathSegment;
use crate::value::temporal::{Date, DateTime};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateContext {
now: DateTime,
}
impl CreateContext {
pub fn at(now: DateTime) -> Self {
CreateContext { now }
}
pub fn for_storage<S: crate::graph::GraphStorage + ?Sized>(storage: &S) -> Self {
let micros = storage
.unit_started_at()
.unwrap_or_else(crate::graph::undo::now_micros);
CreateContext::at(datetime_of_micros(micros))
}
pub fn now(&self) -> DateTime {
self.now
}
pub fn today(&self) -> Date {
self.now.with_offset(0).unwrap_or(self.now).local().date()
}
}
pub(crate) fn datetime_of_micros(micros: i64) -> DateTime {
DateTime::from_micros(micros, 0).unwrap_or(DateTime::EPOCH)
}
pub(crate) fn fill_defaults(
schema: &GraphSchema,
kind: GraphNodeType,
labels: &[CompactString],
properties: &mut PropertyMap,
context: &CreateContext,
) {
if schema.mode == SchemaMode::None {
return;
}
for (position, label) in labels.iter().enumerate() {
let Some(root) = schema.label_root(kind, label.as_str()) else {
continue;
};
for (key, node) in &root.properties {
let Some(default) = node.create_default() else {
continue;
};
if properties.contains_key(key) {
continue;
}
let decided_before = labels[..position].iter().any(|earlier| {
schema
.label_root(kind, earlier.as_str())
.is_some_and(|root| root.properties.contains_key(key))
});
if !decided_before {
properties.insert(key.as_str(), default.value(context));
}
}
}
}
pub(crate) fn check_defaults(schema: &GraphSchema) -> Result<(), SchemaError> {
let additional_default = schema.mode.default_additional_properties();
let labels = schema
.vertices
.iter()
.map(|(label, v)| ("vertex", "vertices", label, &v.schema))
.chain(
schema
.edges
.iter()
.map(|(label, e)| ("edge", "edges", label, &e.schema)),
);
for (element_type, section, label, root) in labels {
let root_at = format!("/{section}/{}/schema", escape(label));
if let Some(at) = misplaced_function(root, &root_at, true) {
return Err(misplaced(at));
}
if let Some(at) = misplaced_embedder(root, &root_at, true) {
return Err(misplaced_embedder_error(at));
}
for (key, node) in &root.properties {
let at = format!("{root_at}/properties/{}", escape(key));
for (name, child) in children(node) {
if let Some(found) = misplaced_function(child, &format!("{at}/{name}"), false) {
return Err(misplaced(found));
}
if let Some(found) = misplaced_embedder(child, &format!("{at}/{name}"), false) {
return Err(misplaced_embedder_error(found));
}
}
let Some(default) = node.create_default() else {
continue;
};
let (keyword, hint) = match default {
CreateDefault::Value(_) => ("default", DEFAULT_HINT.to_string()),
CreateDefault::Function(function) => ("defaultFunction", function_hint(function)),
};
let fail = |reason: String| SchemaError::InvalidSchema {
path: format!("{at}/{keyword}"),
reason: format!("the {keyword} does not fit the property: {reason}"),
hint: hint.clone(),
};
let value = coerce_to_declared(
default.value(&CreateContext::at(datetime_of_micros(
crate::graph::undo::now_micros(),
))),
node,
)
.map_err(|failure| fail(failure.reason.to_string()))?;
let ctx = NestedCtx {
element_type,
label,
additional_default,
full: true,
};
let mut path = vec![JsonPathSegment::Key(key.as_str().into())];
nested::validate_nested(&ctx, &mut path, node, &value)
.map_err(|err| fail(err.to_string()))?;
}
}
Ok(())
}
const DEFAULT_HINT: &str = "A default is stored when an element is created without the \
property, so it must be a valid value of the property's schema.";
fn function_hint(function: DefaultFunction) -> String {
let declaration = match function.temporal_format() {
Some(format) => format!(
"{{\"type\": \"string\", \"format\": \"{}\"}}",
format.as_str()
),
None => "{\"type\": \"string\", \"format\": \"uuid\"}".to_string(),
};
let produces = match function {
DefaultFunction::Now => "a datetime (the unit's start)",
DefaultFunction::Today => "a date (the UTC date of the unit's start)",
DefaultFunction::Uuidv4 | DefaultFunction::Uuidv7 => "a uuid",
};
format!(
"defaultFunction {} produces {produces} for every created element, so the property \
must admit it: declare {declaration}.",
function.as_str()
)
}
fn children(node: &PropertySchema) -> Vec<(String, &PropertySchema)> {
let mut out = Vec::new();
if let Some(items) = &node.items {
out.push(("items".to_string(), items.as_ref()));
}
for (name, property) in &node.properties {
out.push((format!("properties/{}", escape(name)), property));
}
if let Some(members) = &node.any_of {
for (index, member) in members.iter().enumerate() {
out.push((format!("anyOf/{index}"), member));
}
}
out
}
fn misplaced_function(node: &PropertySchema, at: &str, root: bool) -> Option<String> {
if node
.annotations()
.is_some_and(|a| a.default_function.is_some())
{
return Some(format!("{at}/defaultFunction"));
}
children(node)
.into_iter()
.filter(|(name, _)| !(root && name.starts_with("properties/")))
.find_map(|(name, child)| misplaced_function(child, &format!("{at}/{name}"), false))
}
fn misplaced_embedder(node: &PropertySchema, at: &str, root: bool) -> Option<String> {
if node.vector.as_ref().is_some_and(|v| v.embedder.is_some()) {
return Some(format!("{at}/embedder"));
}
children(node)
.into_iter()
.filter(|(name, _)| !(root && name.starts_with("properties/")))
.find_map(|(name, child)| misplaced_embedder(child, &format!("{at}/{name}"), false))
}
fn misplaced_embedder_error(at: String) -> SchemaError {
SchemaError::InvalidSchema {
path: at,
reason: "embedder applies only to a label's top-level vector properties".to_string(),
hint: "Declare the vector directly under the label schema's \"properties\": \
{\"emb\": {\"type\": \"array\", \"format\": \"vector\", \"dimension\": 256, \
\"embedder\": \"hash-ngram/1\"}}; embed_property and the text queries of \
nearest() and similarity() read it there."
.to_string(),
}
}
pub(crate) fn default_function_in(node: &PropertySchema, at: &str) -> Option<String> {
misplaced_function(node, at, false)
}
fn misplaced(at: String) -> SchemaError {
SchemaError::InvalidSchema {
path: at,
reason: "defaultFunction applies only to a label's top-level properties".to_string(),
hint: "Put it on a property directly under the label schema's \"properties\"; values \
inside an object or array are not filled in."
.to_string(),
}
}
fn escape(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}