Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions core/engine/src/nodes/input/dates.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use serde_json::{Map, Value};
use zen_expression::DateValue;
use zen_types::variable::Variable;

pub(crate) struct DeclaredDates;

impl DeclaredDates {
const DATE_KEYS: [&str; 10] = [
"type",
"format",
"description",
"title",
"examples",
"default",
"$comment",
"readOnly",
"writeOnly",
"deprecated",
];

pub(crate) fn declared(schema: &Value) -> bool {
schema.as_object().is_some_and(Self::declared_map)
}

pub(crate) fn declared_map(schema: &Map<String, Value>) -> bool {
let format = schema.get("format").and_then(Value::as_str);
if matches!(format, Some("date" | "date-time")) && Self::only_keys(schema, &Self::DATE_KEYS)
{
return Self::string_type(schema.get("type"));
}

let Some(variants) = schema.get("anyOf").and_then(Value::as_array) else {
return false;
};
let is_null = |v: &Value| {
v.as_object().is_some_and(|o| {
o.len() == 1 && o.get("type").and_then(Value::as_str) == Some("null")
})
};
Self::only_keys(schema, &["anyOf", "description"])
&& variants.len() == 2
&& variants.iter().any(is_null)
&& variants.iter().any(|v| !is_null(v) && Self::declared(v))
}

pub(crate) fn prepare(value: &Variable, schema: Option<&Value>) -> Option<Variable> {
if schema.is_some_and(Self::declared) {
return match value {
Variable::String(text) => DateValue::from_text(text),
_ => None,
};
}
let object = schema.and_then(Value::as_object);
match value {
Variable::Dynamic(_) => DateValue::source_text(value),
Variable::Object(_) => {
let properties = object
.and_then(|o| Self::structure(o, "properties"))
.and_then(Value::as_object);
Self::rewrite_fields(value, |key, child| {
Self::prepare(child, properties.and_then(|p| p.get(key)))
})
}
Variable::Array(_) => {
let items = object.and_then(|o| Self::structure(o, "items"));
Self::rewrite_items(value, |item| Self::prepare(item, items))
}
_ => None,
}
}

fn structure<'s>(schema: &'s Map<String, Value>, key: &str) -> Option<&'s Value> {
schema.get(key).or_else(|| {
["anyOf", "oneOf", "allOf"]
.iter()
.filter_map(|keyword| schema.get(*keyword)?.as_array())
.flatten()
.find_map(|variant| variant.get(key))
})
}

pub(crate) fn rewrite_fields(
value: &Variable,
rewrite: impl Fn(&str, &Variable) -> Option<Variable>,
) -> Option<Variable> {
let object = value.as_object()?;
let changed: Vec<(String, Variable)> = object
.borrow()
.iter()
.filter_map(|(key, child)| {
let key: &str = key.as_ref();
rewrite(key, child).map(|next| (key.to_string(), next))
})
.collect();
if changed.is_empty() {
return None;
}
let mut next = object.borrow().clone();
for (key, rewritten) in changed {
next.insert_str(&key, rewritten);
}
Some(Variable::from_object(next))
}

pub(crate) fn rewrite_items(
value: &Variable,
rewrite: impl Fn(&Variable) -> Option<Variable>,
) -> Option<Variable> {
let array = value.as_array()?;
let array = array.borrow();
let rewritten: Vec<Option<Variable>> = array.iter().map(&rewrite).collect();
if rewritten.iter().all(Option::is_none) {
return None;
}
Some(Variable::from_array(
array
.iter()
.zip(rewritten)
.map(|(item, next)| next.unwrap_or_else(|| item.clone()))
.collect(),
))
}

fn string_type(value: Option<&Value>) -> bool {
match value {
Some(Value::String(t)) => t == "string",
Some(Value::Array(types)) if types.len() == 2 => {
let has = |name: &str| types.iter().any(|t| t.as_str() == Some(name));
has("string") && has("null")
}
_ => false,
}
}

fn only_keys(schema: &Map<String, Value>, allowed: &[&str]) -> bool {
schema.keys().all(|key| allowed.contains(&key.as_str()))
}
}
7 changes: 5 additions & 2 deletions core/engine/src/nodes/input/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub(crate) mod dates;

use crate::nodes::definition::NodeHandler;
use crate::nodes::result::NodeResult;
use crate::nodes::NodeContext;
Expand All @@ -17,8 +19,9 @@ impl NodeHandler for InputNodeHandler {
async fn handle(&self, ctx: NodeContext<Self::NodeData, Self::TraceData>) -> NodeResult {
if let Some(json_schema) = &ctx.node.schema {
ctx.validate(json_schema, &ctx.input)?;
};
}

ctx.success(ctx.input.clone())
let output = dates::DeclaredDates::prepare(&ctx.input, ctx.node.schema.as_deref());
ctx.success(output.unwrap_or_else(|| ctx.input.clone()))
}
}
2 changes: 2 additions & 0 deletions core/engine/src/nodes/validator_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ impl ValidatorCache {
let validator = Arc::new(
jsonschema::options_for::<VariableJson>()
.with_draft(jsonschema::Draft::Draft7)
.with_format("date", zen_expression::DateValue::is_text)
.with_format("date-time", zen_expression::DateValue::is_text)
.build(schema)?,
);
w_shared.insert(key, validator.clone());
Expand Down
16 changes: 12 additions & 4 deletions core/engine/src/policy/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,13 @@ impl EvalArtifact {
) -> Result<EvaluationResult, EvaluationError> {
let start = Instant::now();

self.validate_request(req)?;
let input = self
.input_schema
.convert_dates(&req.input)
.unwrap_or_else(|| req.input.clone());
self.validate_request(req, &input)?;

let store = req.input.depth_clone(1);
let store = input.depth_clone(1);
let ref_targets: HashSet<Arc<str>> = self
.reference_fields
.iter()
Expand Down Expand Up @@ -154,13 +158,17 @@ impl EvalArtifact {
})
}

fn validate_request(&self, req: &EvaluateRequest) -> Result<(), EvaluationError> {
fn validate_request(
&self,
req: &EvaluateRequest,
input: &Variable,
) -> Result<(), EvaluationError> {
for goal in &req.goals {
if !self.eval_graph.contains(goal) {
return Err(EvaluationError::GoalNotFound(goal.clone()));
}
}
let validation_errors = self.input_schema.validate(&req.input);
let validation_errors = self.input_schema.validate(input);
if !validation_errors.is_empty() {
return Err(EvaluationError::InputValidationFailed {
errors: validation_errors,
Expand Down
5 changes: 3 additions & 2 deletions core/engine/src/policy/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ impl DataModelIr {
visited: &mut HashSet<Arc<str>>,
) -> VariableType {
let inner = match &prop.kind {
PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String,
PropertyTypeIr::String => VariableType::String,
PropertyTypeIr::Date => VariableType::Date,
PropertyTypeIr::Enum(values) => VariableType::Enum(None, enum_values_to_rc(values)),
PropertyTypeIr::Number => VariableType::Number,
PropertyTypeIr::Boolean => VariableType::Bool,
Expand Down Expand Up @@ -550,7 +551,7 @@ impl std::fmt::Display for PropertyTypeIr {
}
PropertyTypeIr::Number => f.write_str("number"),
PropertyTypeIr::Boolean => f.write_str("bool"),
PropertyTypeIr::Date => f.write_str("date (string)"),
PropertyTypeIr::Date => f.write_str("date"),
PropertyTypeIr::Reference { target } => {
write!(f, "reference id (string → {target})")
}
Expand Down
3 changes: 2 additions & 1 deletion core/engine/src/policy/queries/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,8 @@ impl Property {
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
) -> VariableType {
let inner = match &self.kind {
PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String,
PropertyTypeIr::String => VariableType::String,
PropertyTypeIr::Date => VariableType::Date,
PropertyTypeIr::Enum(values) => {
VariableType::Enum(None, crate::policy::ir::enum_values_to_rc(values))
}
Expand Down
5 changes: 2 additions & 3 deletions core/engine/src/policy/queries/skeleton.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ impl SkeletonBuilder {

fn default_for(ty: &VariableType) -> Value {
match ty {
VariableType::String | VariableType::Date | VariableType::Interval => {
Value::String(String::new())
}
VariableType::String | VariableType::Interval => Value::String(String::new()),
VariableType::Date => Value::String("2000-01-01".to_string()),
VariableType::Number => Value::Number(0u64.into()),
VariableType::Bool => Value::Bool(false),
VariableType::Null | VariableType::Any => Value::Null,
Expand Down
55 changes: 54 additions & 1 deletion core/engine/src/policy/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::sync::Arc;
use ahash::{HashMap, HashMapExt, HashSet};
use zen_expression::variable::Variable;

use crate::nodes::input::dates::DeclaredDates;
use crate::policy::ir::{DataModelIr, DictionaryIr, Property, PropertyTypeIr};
use crate::policy::refs::RefPoolIndex;
use crate::policy::MAX_RECURSION_DEPTH;
Expand Down Expand Up @@ -114,6 +115,53 @@ impl InputSchema {
}
}

impl InputSchema {
pub(crate) fn convert_dates(&self, input: &Variable) -> Option<Variable> {
DeclaredDates::rewrite_fields(input, |key, value| {
if self.ref_targets.contains(key) {
DeclaredDates::rewrite_items(value, |item| self.convert_entity(item, key, 0))
} else if self.roots.contains(key) {
self.convert_entity(value, key, 0)
} else {
self.convert_property(value, self.globals.get(key)?, 0)
}
})
}

fn convert_entity(&self, value: &Variable, entity: &str, depth: usize) -> Option<Variable> {
if depth >= MAX_RECURSION_DEPTH {
return None;
}
let model = self.entities.get(entity)?;
DeclaredDates::rewrite_fields(value, |key, child| {
let property = model.properties.iter().find(|p| *p.name == *key)?;
self.convert_property(child, property, depth + 1)
})
}

fn convert_property(
&self,
value: &Variable,
property: &Property,
depth: usize,
) -> Option<Variable> {
let convert_one = |item: &Variable| match &property.kind {
PropertyTypeIr::Date => match item {
Variable::String(text) => zen_expression::DateValue::from_text(text),
_ => None,
},
PropertyTypeIr::Relationship { target } if self.entities.contains_key(target) => {
self.convert_entity(item, target, depth)
}
_ => None,
};
match property.array {
true => DeclaredDates::rewrite_items(value, convert_one),
false => convert_one(value),
}
}
}

struct InputValidator<'a> {
entities: &'a HashMap<Arc<str>, Arc<DataModelIr>>,
dictionaries: &'a HashMap<Arc<str>, Arc<DictionaryIr>>,
Expand Down Expand Up @@ -218,7 +266,12 @@ impl InputValidator<'_> {
}
PropertyTypeIr::Number => matches!(value, Variable::Number(_)),
PropertyTypeIr::Boolean => matches!(value, Variable::Bool(_)),
PropertyTypeIr::Date => matches!(value, Variable::String(_)),
PropertyTypeIr::Date => match value {
Variable::String(text) => {
text.is_empty() || zen_expression::DateValue::is_text(text)
}
other => zen_expression::DateValue::is(other),
},
PropertyTypeIr::Reference { target } => {
self.validate_reference(value, target, path);
return;
Expand Down
8 changes: 5 additions & 3 deletions core/engine/src/workspace/graph/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use ahash::{HashMap, HashMapExt};
use serde_json::{Map, Value};
use zen_expression::variable::VariableType;

use crate::nodes::input::dates::DeclaredDates;

pub(crate) type SchemaDictionaries = HashMap<Arc<str>, VariableType>;

pub(crate) struct SchemaType;
Expand Down Expand Up @@ -131,9 +133,9 @@ impl SchemaType {
.map(|items| Self::variable_type_with(items, dictionaries))
.unwrap_or(VariableType::Any),
)),
"string" => match object.get("format").and_then(Value::as_str) {
Some("date" | "date-time") => VariableType::Date,
_ => VariableType::String,
"string" => match DeclaredDates::declared_map(object) {
true => VariableType::Date,
false => VariableType::String,
},
"number" | "integer" => VariableType::Number,
"boolean" => VariableType::Bool,
Expand Down
24 changes: 24 additions & 0 deletions core/expression/src/dates.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use crate::variable::Variable;
use crate::vm::date::VmDate;
use std::rc::Rc;

pub struct DateValue;

impl DateValue {
pub fn from_text(text: &str) -> Option<Variable> {
VmDate::from_text(text).map(|date| Variable::Dynamic(Rc::new(date)))
}

pub fn is_text(text: &str) -> bool {
VmDate::parses(text)
}

pub fn source_text(value: &Variable) -> Option<Variable> {
let text = value.dynamic::<VmDate>()?.source()?;
Some(Variable::String(text.into()))
}

pub fn is(value: &Variable) -> bool {
matches!(value, Variable::Dynamic(dynamic) if dynamic.type_name() == "date")
}
}
Loading
Loading