mod records;
mod schema;
mod validation;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde_json::{Map, Value};
use crate::graph::languages::cypher;
#[cfg(feature = "okf")]
pub(crate) use records::inherit_group_descriptions;
#[cfg(feature = "okf")]
pub use records::render_markdown;
pub use records::{
catalogue_from_graph, delete, export_path, export_value, get, import_path, import_value, list,
set, validate, RecipeRecord, RecipeWarning, SetOutcome, RECIPE_LABEL,
};
#[cfg(feature = "okf")]
pub use records::{parse_markdown, set_from_markdown};
pub use schema::ParameterSchema;
pub use validation::{
query_conversion_error, VariableIssue, VariableIssueKind, VariablesValidationError,
};
const RECIPE_KEYS: &[&str] = &["description", "queries"];
const QUERY_KEYS: &[&str] = &["description", "parameters", "cypher", "tool"];
pub const RECIPE_RESULT_ROW_LIMIT: usize = 200;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecipeCatalogError {
message: String,
}
impl RecipeCatalogError {
pub fn message(&self) -> &str {
&self.message
}
fn context(self, prefix: impl fmt::Display) -> Self {
Self {
message: format!("{prefix}: {}", self.message),
}
}
}
impl fmt::Display for RecipeCatalogError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for RecipeCatalogError {}
impl From<RecipeCatalogError> for crate::error::KgError {
fn from(error: RecipeCatalogError) -> Self {
crate::error::KgError::Argument(error.message)
}
}
pub type CatalogResult<T> = Result<T, RecipeCatalogError>;
fn invalid(message: impl Into<String>) -> RecipeCatalogError {
RecipeCatalogError {
message: message.into(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogSummary {
pub recipe_count: usize,
pub query_count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct RecipeCatalog {
recipes: BTreeMap<String, RecipeDefinition>,
}
impl RecipeCatalog {
pub fn from_manifest_value(raw: Option<&Value>) -> CatalogResult<Self> {
let Some(raw) = raw else {
return Ok(Self::default());
};
let recipes = raw
.as_object()
.ok_or_else(|| invalid("must be a mapping of recipe names"))?;
if recipes.is_empty() {
return Ok(Self::default());
}
let mut parsed = BTreeMap::new();
for (name, raw_recipe) in recipes {
validate_identifier(name, "recipe")?;
let recipe = RecipeDefinition::parse(name, raw_recipe)
.map_err(|error| error.context(format!("recipe {name:?}")))?;
parsed.insert(name.clone(), recipe);
}
let catalog = Self { recipes: parsed };
catalog.tool_names()?;
Ok(catalog)
}
pub fn is_empty(&self) -> bool {
self.recipes.is_empty()
}
pub fn summary(&self) -> CatalogSummary {
CatalogSummary {
recipe_count: self.recipes.len(),
query_count: self
.recipes
.values()
.map(|recipe| recipe.queries.len())
.sum(),
}
}
pub fn discovery_summary(&self) -> Option<CatalogSummary> {
(!self.is_empty()).then(|| self.summary())
}
pub fn recipes(&self) -> impl ExactSizeIterator<Item = &RecipeDefinition> {
self.recipes.values()
}
pub fn get(&self, name: &str) -> Option<&RecipeDefinition> {
self.recipes.get(name)
}
pub fn tool_names(&self) -> CatalogResult<BTreeMap<String, String>> {
let mut names: BTreeMap<String, String> = BTreeMap::new();
for recipe in self.recipes.values() {
for query in recipe.queries.values() {
let Some(tool) = &query.tool else { continue };
let owner = format!("{}.{}", recipe.name, query.name);
if let Some(existing) = names.insert(tool.clone(), owner.clone()) {
return Err(invalid(format!(
"tool name {tool:?} is claimed by two queries: {existing} and {owner}"
)));
}
}
}
Ok(names)
}
pub fn insert_query(
&mut self,
recipe: &str,
recipe_description: &str,
query: RecipeQueryDefinition,
) {
let group = self
.recipes
.entry(recipe.to_string())
.or_insert_with(|| RecipeDefinition {
name: recipe.to_string(),
description: recipe_description.to_string(),
queries: BTreeMap::new(),
});
group.queries.insert(query.name.clone(), query);
}
}
pub fn merge(graph: RecipeCatalog, manifest: RecipeCatalog) -> RecipeCatalog {
let mut merged = graph;
for (name, group) in manifest.recipes {
match merged.recipes.get_mut(&name) {
Some(existing) => {
existing.description = group.description;
existing.queries.extend(group.queries);
}
None => {
merged.recipes.insert(name, group);
}
}
}
merged
}
#[derive(Debug, Clone)]
pub struct RecipeDefinition {
pub name: String,
pub description: String,
queries: BTreeMap<String, RecipeQueryDefinition>,
}
impl RecipeDefinition {
fn parse(name: &str, raw: &Value) -> CatalogResult<Self> {
let map = object_with_only(raw, RECIPE_KEYS, "recipe")?;
let description = required_nonempty_string(map, "description")?;
let raw_queries = map
.get("queries")
.and_then(Value::as_object)
.ok_or_else(|| invalid("queries must be a non-empty mapping"))?;
if raw_queries.is_empty() {
return Err(invalid("queries must be a non-empty mapping"));
}
let mut queries = BTreeMap::new();
for (query_name, raw_query) in raw_queries {
validate_identifier(query_name, "query")?;
let query = RecipeQueryDefinition::parse(query_name, raw_query)
.map_err(|error| error.context(format!("query {query_name:?}")))?;
queries.insert(query_name.clone(), query);
}
Ok(Self {
name: name.to_string(),
description,
queries,
})
}
pub fn queries(&self) -> impl ExactSizeIterator<Item = &RecipeQueryDefinition> {
self.queries.values()
}
pub fn get(&self, name: &str) -> Option<&RecipeQueryDefinition> {
self.queries.get(name)
}
}
#[derive(Debug, Clone)]
pub struct RecipeQueryDefinition {
pub name: String,
pub description: String,
pub parameters: ParameterSchema,
pub cypher: String,
pub tool: Option<String>,
}
impl RecipeQueryDefinition {
fn parse(name: &str, raw: &Value) -> CatalogResult<Self> {
let map = object_with_only(raw, QUERY_KEYS, "query")?;
let description = required_nonempty_string(map, "description")?;
let cypher_source = required_nonempty_string(map, "cypher")?;
let raw_parameters = map
.get("parameters")
.ok_or_else(|| invalid("parameters is required"))?;
let tool = match map.get("tool") {
None | Some(Value::Null) => None,
Some(value) => Some(
value
.as_str()
.ok_or_else(|| invalid("tool must be a string"))?
.to_string(),
),
};
Self::compile(name, description, cypher_source, raw_parameters, tool)
}
pub fn compile(
name: &str,
description: String,
cypher_source: String,
raw_parameters: &Value,
tool: Option<String>,
) -> CatalogResult<Self> {
if let Some(tool) = &tool {
validate_tool_name(tool)?;
}
let features = cypher::query_features(&cypher_source)
.map_err(|error| invalid(format!("cypher is not a valid KGLite query: {error}")))?;
validate_read_only_query(&features)?;
let parameter_names = cypher::parameter_names(&cypher_source).map_err(|error| {
invalid(format!("could not collect Cypher parameter names: {error}"))
})?;
let parameters = ParameterSchema::compile_root(raw_parameters, ¶meter_names)
.map_err(|error| error.context("parameters schema is invalid"))?;
Ok(Self {
name: name.to_string(),
description,
parameters,
cypher: cypher_source,
tool,
})
}
pub fn validate_variables(
&self,
variables: &Map<String, Value>,
) -> Result<(), VariablesValidationError> {
self.parameters.validate_variables(variables)
}
}
fn validate_read_only_query(features: &cypher::QueryFeatures) -> CatalogResult<()> {
if features.explain {
return Err(invalid("EXPLAIN is not allowed in recipe queries"));
}
if features.profile {
return Err(invalid("PROFILE is not allowed in recipe queries"));
}
if features.format_csv {
return Err(invalid("FORMAT CSV is not allowed in recipe queries"));
}
if features.has_load_csv {
return Err(invalid("LOAD CSV is not allowed in recipe queries"));
}
if features
.literal_limits
.contains(&(RECIPE_RESULT_ROW_LIMIT as i64))
{
return Err(invalid(format!(
"literal LIMIT {RECIPE_RESULT_ROW_LIMIT} is reserved for the recipe result payload cap; stored queries must not hide overflow from the server"
)));
}
if features.is_mutation {
return Err(invalid(
"cypher must be read-only; mutation clauses are not allowed",
));
}
Ok(())
}
fn object_with_only<'a>(
raw: &'a Value,
allowed: &[&str],
label: &str,
) -> CatalogResult<&'a Map<String, Value>> {
let map = raw
.as_object()
.ok_or_else(|| invalid(format!("{label} must be a mapping")))?;
let allowed: BTreeSet<_> = allowed.iter().copied().collect();
let unknown: Vec<_> = map
.keys()
.filter(|key| !allowed.contains(key.as_str()))
.cloned()
.collect();
if !unknown.is_empty() {
return Err(invalid(format!("unsupported {label} keys: {unknown:?}")));
}
Ok(map)
}
fn required_nonempty_string(map: &Map<String, Value>, key: &str) -> CatalogResult<String> {
let value = map
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| invalid(format!("{key} must be a non-empty string")))?;
Ok(value.to_string())
}
pub fn validate_identifier(identifier: &str, label: &str) -> CatalogResult<()> {
let mut chars = identifier.chars();
let valid_start = chars
.next()
.is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_');
if !valid_start || !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
return Err(invalid(format!(
"{label} identifier {identifier:?} must match ^[A-Za-z_][A-Za-z0-9_]*$"
)));
}
Ok(())
}
pub fn validate_tool_name(name: &str) -> CatalogResult<()> {
let mut chars = name.chars();
let valid_start = chars
.next()
.is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_');
let valid_rest = chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-');
if !valid_start || !valid_rest || name.len() > 64 {
return Err(invalid(format!(
"tool name {name:?} must match ^[A-Za-z_][A-Za-z0-9_-]{{0,63}}$"
)));
}
Ok(())
}
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;