mirror of
https://github.com/gorules/zen.git
synced 2026-10-03 16:02:27 +00:00
feat: policy dictionary (#479)
* feat: policy dictionary * remove enum references * improve tests * reset index.js and package.json
This commit is contained in:
@@ -15,8 +15,8 @@ prepareLocalPublish:
|
||||
yarn createNpmDirs
|
||||
|
||||
publish:
|
||||
cd npm/wasm32-wasi && npm publish --registry $(REGISTRY) --ignore-scripts
|
||||
cd npm/darwin-arm64 && npm publish --registry $(REGISTRY) --ignore-scripts
|
||||
npm publish --registry $(REGISTRY) --ignore-scripts
|
||||
cd npm/wasm32-wasi && npm publish --registry $(REGISTRY) --ignore-scripts --tag latest
|
||||
cd npm/darwin-arm64 && npm publish --registry $(REGISTRY) --ignore-scripts --tag latest
|
||||
npm publish --registry $(REGISTRY) --ignore-scripts --tag latest
|
||||
|
||||
publishLocal: prepareLocalPublish wasm darwinArm publish
|
||||
|
||||
Vendored
+4
@@ -193,6 +193,8 @@ export interface NlResult {
|
||||
diagnostics: NlDiagnostic[];
|
||||
/** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */
|
||||
subjectType?: PolicyVariableType;
|
||||
/** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */
|
||||
subjectOptions?: NlEnumOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,6 +214,8 @@ export interface PolicyNlExpression {
|
||||
diagnostics: NlDiagnostic[];
|
||||
/** Resolved `$` type for unary cells. */
|
||||
subjectType?: PolicyVariableType;
|
||||
/** Labeled options when the unary subject is an enum (dictionary labels applied). */
|
||||
subjectOptions?: NlEnumOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+17
-65
@@ -193,6 +193,8 @@ export interface NlResult {
|
||||
diagnostics: NlDiagnostic[];
|
||||
/** Resolved `$` type for unary requests (decision-table input cells); present even for empty text. */
|
||||
subjectType?: PolicyVariableType;
|
||||
/** Labeled options when the unary subject is an enum (dictionary labels applied); present even for empty text. */
|
||||
subjectOptions?: NlEnumOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,6 +214,8 @@ export interface PolicyNlExpression {
|
||||
diagnostics: NlDiagnostic[];
|
||||
/** Resolved `$` type for unary cells. */
|
||||
subjectType?: PolicyVariableType;
|
||||
/** Labeled options when the unary subject is an enum (dictionary labels applied). */
|
||||
subjectOptions?: NlEnumOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,30 +366,14 @@ export declare class PolicyWorkspace {
|
||||
constructor()
|
||||
setPolicy(path: string, document: any): void
|
||||
removePolicy(path: string): boolean
|
||||
/**
|
||||
* Upsert a single block in an existing policy (replace-by-id or append).
|
||||
* Errors when the policy does not exist — call `setPolicy` first to
|
||||
* create one.
|
||||
*/
|
||||
updateBlock(req: PolicyUpdateBlockRequest): void
|
||||
/**
|
||||
* Remove a block from an existing policy by id. Returns `true` when a
|
||||
* block was removed, `false` when the policy or block didn't exist.
|
||||
*/
|
||||
removeBlock(req: PolicyRemoveBlockRequest): boolean
|
||||
policyPaths(): Array<string>
|
||||
/**
|
||||
* `max_diagnostics` caps the returned list (default: 100). Pass `0` for
|
||||
* no cap.
|
||||
*/
|
||||
diagnostics(policyPath: string, maxDiagnostics?: number | undefined | null): Array<PolicyDiagnostic>
|
||||
/**
|
||||
* `max_diagnostics` caps the returned list (default: 100). Pass `0` for
|
||||
* no cap.
|
||||
*/
|
||||
allDiagnostics(maxDiagnostics?: number | undefined | null): Array<PolicyDiagnostic>
|
||||
entities(req: PolicyScopeRequest): Array<PolicyEntityInfo>
|
||||
globals(req: PolicyScopeRequest): Array<PolicyGlobalInfo>
|
||||
dictionaries(req: PolicyScopeRequest): Array<PolicyDictionaryInfo>
|
||||
inputs(req: PolicyScopeRequest): Array<PolicyInputProperty>
|
||||
outputs(req: PolicyScopeRequest): Array<PolicyOutputProperty>
|
||||
conditionalSchema(req: PolicyScopeRequest): PolicyConditionalSchema
|
||||
@@ -394,32 +382,9 @@ export declare class PolicyWorkspace {
|
||||
nlTokenize(cursor: PolicyExpressionCursor, text: string): NlResult | null
|
||||
completions(cursor: PolicyExpressionCursor): Array<PolicyCompletion>
|
||||
prepareRename(cursor: PolicyExpressionCursor): PolicyPrepareRenameResult | null
|
||||
/**
|
||||
* Returns block-level edits the host applies via id-keyed swap (same
|
||||
* path as `update_block`). Each edit's `kind` field discriminates the
|
||||
* variant; `replaceBlock` carries a `newBlock` payload that is the
|
||||
* rewritten wire-format `BlockDoc`.
|
||||
*/
|
||||
rename(req: PolicyRenameRequest): PolicyEngineEdit[]
|
||||
/**
|
||||
* Returns every site in the workspace where `target` is used. Same
|
||||
* visitor as `rename`; carries policy/block/expression/source/span/kind
|
||||
* for each site so hosts can render a "find references" panel or drive
|
||||
* navigation.
|
||||
*/
|
||||
references(target: any): PolicyReferenceSite[]
|
||||
/**
|
||||
* Default-valued JSON object that matches the workspace's input shape
|
||||
* for `req.policy_path` (and optionally `req.goals`). Hosts use it as
|
||||
* the initial value of a "Run simulation" panel so `evaluate` can be
|
||||
* called immediately without first authoring an input by hand.
|
||||
*/
|
||||
inputSkeleton(req: PolicyScopeRequest): unknown
|
||||
/**
|
||||
* Returns the transitive dependency tree rooted at `target`. Inverse
|
||||
* of `references()`. Per-write granularity — multi-output blocks
|
||||
* don't conflate sibling outputs' deps.
|
||||
*/
|
||||
dependencies(target: string): PolicyDependencyNode
|
||||
evaluate(req: PolicyEvaluateRequest): PolicyEvaluationResult
|
||||
enhanceTrace(req: PolicyEvaluateRequest): PolicyEvaluationResult
|
||||
@@ -446,7 +411,7 @@ export declare class ZenEngine {
|
||||
getDecision(key: string): Promise<ZenDecision>
|
||||
safeEvaluate(key: string, context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<{ success: true, data: ZenEngineResponse } | { success: false; error: any; }>
|
||||
safeGetDecision(key: string): Promise<{ success: true, data: ZenDecision } | { success: false; error: any; }>
|
||||
evaluateBatch(requests: Array<EvaluateBatchRequest>, opts?: ZenEvaluateOptions | undefined | null): Promise<Array<EvaluateBatchResult>>
|
||||
evaluateBatch(requests: Array<EvaluateBatchRequest>, opts?: ZenEvaluateOptions | undefined | null): Promise<Array<{ success: true; data: ZenEngineResponse } | { success: false; error: any }>>
|
||||
reload(): Promise<void>
|
||||
compileFailures(): Array<{ key: string; kind: string; diagnostics?: Array<{ code: string; message: string; severity: string }>; error?: string }>
|
||||
dispose(): void
|
||||
@@ -472,12 +437,6 @@ export interface EvaluateBatchRequest {
|
||||
context: any
|
||||
}
|
||||
|
||||
export interface EvaluateBatchResult {
|
||||
success: boolean
|
||||
data?: any
|
||||
error?: any
|
||||
}
|
||||
|
||||
export declare function evaluateExpression(expression: string, context?: any | undefined | null): Promise<any>
|
||||
|
||||
export declare function evaluateExpressionSync(expression: string, context?: any | undefined | null): any
|
||||
@@ -524,6 +483,17 @@ export interface PolicyDiagnostic {
|
||||
target?: PolicyCursorTarget
|
||||
}
|
||||
|
||||
export interface PolicyDictionaryEntryInfo {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface PolicyDictionaryInfo {
|
||||
name: string
|
||||
source: string
|
||||
entries: Array<PolicyDictionaryEntryInfo>
|
||||
}
|
||||
|
||||
export interface PolicyDiscriminantVariant {
|
||||
value?: string
|
||||
arm: string
|
||||
@@ -550,7 +520,6 @@ export interface PolicyEntityInfo {
|
||||
export interface PolicyEvaluateRequest {
|
||||
policyPath: string
|
||||
input: unknown
|
||||
/** Goals to evaluate. Omit or pass empty for full evaluation. */
|
||||
goals?: Array<string>
|
||||
trace?: boolean
|
||||
}
|
||||
@@ -559,18 +528,9 @@ export interface PolicyExpressionCursor {
|
||||
policyPath: string
|
||||
blockId: string
|
||||
pos: number
|
||||
/**
|
||||
* Tagged `{ kind, ...payload }` discriminating what kind of span the
|
||||
* cursor sits in. See `PolicyCursorTarget` in the TypeScript types.
|
||||
*/
|
||||
target: PolicyCursorTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept as a `#[napi(object)]` struct purely so NAPI-RS emits the TS type
|
||||
* used by `PolicyFieldOrigin.schema.fieldKind`. The runtime shape is
|
||||
* hand-built in [`field_kind_to_json`].
|
||||
*/
|
||||
export interface PolicyFieldKindInfo {
|
||||
kind: PolicyFieldKind
|
||||
target?: string
|
||||
@@ -640,19 +600,11 @@ export interface PolicySchemaGroup {
|
||||
|
||||
export interface PolicyScopeRequest {
|
||||
policyPath: string
|
||||
/**
|
||||
* Goals to constrain schema introspection to. Omit or pass empty
|
||||
* for everything reachable from the policy.
|
||||
*/
|
||||
goals?: Array<string>
|
||||
}
|
||||
|
||||
export interface PolicyUpdateBlockRequest {
|
||||
policyPath: string
|
||||
/**
|
||||
* A single wire block (same shape as one entry of `PolicyDocument.blocks`).
|
||||
* Upserted by `block.id`: replaces in place if present, appends otherwise.
|
||||
*/
|
||||
block: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -581,6 +581,8 @@ module.exports.evaluateExpression = nativeBinding.evaluateExpression
|
||||
module.exports.evaluateExpressionSync = nativeBinding.evaluateExpressionSync
|
||||
module.exports.evaluateUnaryExpression = nativeBinding.evaluateUnaryExpression
|
||||
module.exports.evaluateUnaryExpressionSync = nativeBinding.evaluateUnaryExpressionSync
|
||||
module.exports.nlEncodeString = nativeBinding.nlEncodeString
|
||||
module.exports.nlTokenizeBatch = nativeBinding.nlTokenizeBatch
|
||||
module.exports.overrideConfig = nativeBinding.overrideConfig
|
||||
module.exports.renderTemplate = nativeBinding.renderTemplate
|
||||
module.exports.renderTemplateSync = nativeBinding.renderTemplateSync
|
||||
|
||||
@@ -103,6 +103,19 @@ pub struct PolicyGlobalInfo {
|
||||
pub origin: Value,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PolicyDictionaryInfo {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub entries: Vec<PolicyDictionaryEntryInfo>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PolicyDictionaryEntryInfo {
|
||||
pub value: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PolicyInputProperty {
|
||||
pub path: String,
|
||||
@@ -540,6 +553,26 @@ impl PolicyWorkspace {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn dictionaries(&self, req: PolicyScopeRequest) -> Vec<PolicyDictionaryInfo> {
|
||||
self.inner
|
||||
.dictionaries(&req.into())
|
||||
.into_iter()
|
||||
.map(|d| PolicyDictionaryInfo {
|
||||
name: d.name.to_string(),
|
||||
source: d.source.to_string(),
|
||||
entries: d
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| PolicyDictionaryEntryInfo {
|
||||
value: e.value.to_string(),
|
||||
label: e.label.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn inputs(&self, req: PolicyScopeRequest) -> Vec<PolicyInputProperty> {
|
||||
self.inner
|
||||
|
||||
@@ -12,7 +12,9 @@ use crate::policy::blocks::{
|
||||
SharedIntelliSense,
|
||||
};
|
||||
use crate::policy::evaluator::EvalArtifact;
|
||||
use crate::policy::ir::{DataModelIr, ParsedPolicy, Policy, Property, PropertyPath, Scope};
|
||||
use crate::policy::ir::{
|
||||
DataModelIr, DictionaryIr, ParsedPolicy, Policy, Property, PropertyPath, Scope,
|
||||
};
|
||||
use crate::policy::queries::dependency::{
|
||||
DataModelPaths, DependencyGraph, EnrichedState, EvalGraph, RuleShallowAnalysis, ShallowAnalyses,
|
||||
};
|
||||
@@ -141,6 +143,14 @@ pub struct Unit {
|
||||
opcode_cache: OnceCell<Arc<OpcodeCache>>,
|
||||
pub data_models: Vec<DataModelEntry>,
|
||||
pub entities: HashMap<Arc<str>, Arc<DataModelIr>>,
|
||||
pub dictionaries: HashMap<Arc<str>, Arc<DictionaryIr>>,
|
||||
pub dictionary_blocks: Vec<DictionaryUnitEntry>,
|
||||
}
|
||||
|
||||
pub struct DictionaryUnitEntry {
|
||||
pub policy_path: Arc<str>,
|
||||
pub block_id: Arc<str>,
|
||||
pub ir: Arc<DictionaryIr>,
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
@@ -624,6 +634,26 @@ impl Snapshot {
|
||||
});
|
||||
|
||||
let entities = Self::compute_unit_entities(&subset);
|
||||
let dictionaries = Self::compute_dictionary_map(&subset);
|
||||
|
||||
let mut dictionary_blocks: Vec<DictionaryUnitEntry> = subset
|
||||
.iter()
|
||||
.flat_map(|(path, p)| {
|
||||
p.policy
|
||||
.dictionaries
|
||||
.iter()
|
||||
.map(move |block| DictionaryUnitEntry {
|
||||
policy_path: path.clone(),
|
||||
block_id: block.id.clone(),
|
||||
ir: block.ir.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
dictionary_blocks.sort_by(|a, b| {
|
||||
a.ir.name
|
||||
.cmp(&b.ir.name)
|
||||
.then_with(|| a.policy_path.cmp(&b.policy_path))
|
||||
});
|
||||
|
||||
Unit {
|
||||
members: member_set,
|
||||
@@ -639,6 +669,8 @@ impl Snapshot {
|
||||
opcode_cache: OnceCell::new(),
|
||||
data_models,
|
||||
entities,
|
||||
dictionaries,
|
||||
dictionary_blocks,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,29 +44,63 @@ impl Db {
|
||||
return Vec::new();
|
||||
};
|
||||
let scope = self.enriched(policy).scope.shallow_clone();
|
||||
let labels = self.nl_label_resolver(policy);
|
||||
let intellisense = self.intellisense();
|
||||
let mut is = intellisense.borrow_mut();
|
||||
is.set_nl_labels(labels);
|
||||
let mut out = Vec::new();
|
||||
for rule in parsed.policy.rules() {
|
||||
out.extend(rule.nl(&policy_arc, &scope, &mut is));
|
||||
}
|
||||
is.set_nl_labels(None);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn nl_tokenize(&self, cursor: &Cursor, text: &str) -> Option<NlResult> {
|
||||
let (kind, scope) = self.nl_scope(cursor)?;
|
||||
let unary = matches!(kind, ExpressionKind::Unary);
|
||||
let labels = self.nl_label_resolver(&cursor.policy_path);
|
||||
let intellisense = self.intellisense();
|
||||
let mut result =
|
||||
intellisense
|
||||
.borrow_mut()
|
||||
.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope);
|
||||
let mut is = intellisense.borrow_mut();
|
||||
is.set_nl_labels(labels);
|
||||
let mut result = is.nl_tokenize_scoped(&cursor.block_id, text, unary, &scope);
|
||||
if unary {
|
||||
result.subject_type = Some(scope.get("$"));
|
||||
let subject = scope.get("$");
|
||||
result.subject_options = is.nl_subject_options(&subject);
|
||||
result.subject_type = Some(subject);
|
||||
}
|
||||
is.set_nl_labels(None);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
fn nl_label_resolver(
|
||||
&self,
|
||||
policy: &str,
|
||||
) -> Option<zen_expression::intellisense::NlLabelResolver> {
|
||||
let unit = self.unit(policy);
|
||||
if unit.dictionary_blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut labels: HashMap<Arc<str>, HashMap<Arc<str>, Arc<str>>> = HashMap::new();
|
||||
for (name, dict) in &unit.dictionaries {
|
||||
let entries: HashMap<Arc<str>, Arc<str>> = dict
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.label.is_empty())
|
||||
.map(|e| (e.value.clone(), e.label.clone()))
|
||||
.collect();
|
||||
if !entries.is_empty() {
|
||||
labels.insert(name.clone(), entries);
|
||||
}
|
||||
}
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::rc::Rc::new(move |name: &str, value: &str| {
|
||||
labels.get(name)?.get(value).map(|l| l.to_string())
|
||||
}))
|
||||
}
|
||||
|
||||
fn nl_scope(&self, cursor: &Cursor) -> Option<(ExpressionKind, VariableType)> {
|
||||
let block = self.block_ir(&BlockRef {
|
||||
policy_path: cursor.policy_path.clone(),
|
||||
|
||||
@@ -6,7 +6,9 @@ use ahash::{HashMap, HashMapExt, HashSet, HashSetExt};
|
||||
use zen_expression::variable::VariableType;
|
||||
|
||||
use crate::policy::blocks::{AssertionIr, Block, DecisionTableIr, ExpressionIr, MatchIr};
|
||||
use crate::policy::raw::{BlockDoc, DataModelDoc, PolicyDocument, PropertyTypeDoc, ScopeDoc};
|
||||
use crate::policy::raw::{
|
||||
BlockDoc, DataModelDoc, DictionaryDoc, PolicyDocument, PropertyTypeDoc, ScopeDoc,
|
||||
};
|
||||
use crate::policy::types::{Diagnostic, DiagnosticCode, DiagnosticLocation, SchemaFieldKind};
|
||||
use crate::policy::ArcStrTrim;
|
||||
|
||||
@@ -16,6 +18,7 @@ pub type PropertyPath = Arc<str>;
|
||||
pub struct Policy {
|
||||
pub rules: Vec<Block>,
|
||||
pub data_models: Vec<DataModelBlock>,
|
||||
pub dictionaries: Vec<DictionaryBlock>,
|
||||
pub imports: Vec<Arc<str>>,
|
||||
}
|
||||
|
||||
@@ -25,6 +28,12 @@ pub struct DataModelBlock {
|
||||
pub ir: Arc<DataModelIr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DictionaryBlock {
|
||||
pub id: Arc<str>,
|
||||
pub ir: Arc<DictionaryIr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedPolicy {
|
||||
pub policy: Arc<Policy>,
|
||||
@@ -36,6 +45,7 @@ impl Policy {
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut rules = Vec::new();
|
||||
let mut data_models = Vec::new();
|
||||
let mut dictionaries = Vec::new();
|
||||
|
||||
for env in &doc.blocks {
|
||||
match env {
|
||||
@@ -59,6 +69,14 @@ impl Policy {
|
||||
});
|
||||
}
|
||||
}
|
||||
BlockDoc::Dictionary { id, data } => {
|
||||
if let Some(ir) = DictionaryIr::parse(id, data, path, &mut diagnostics) {
|
||||
dictionaries.push(DictionaryBlock {
|
||||
id: id.clone(),
|
||||
ir: Arc::new(ir),
|
||||
});
|
||||
}
|
||||
}
|
||||
BlockDoc::Ignored(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +92,7 @@ impl Policy {
|
||||
policy: Arc::new(Policy {
|
||||
rules,
|
||||
data_models,
|
||||
dictionaries,
|
||||
imports,
|
||||
}),
|
||||
diagnostics: Arc::new(diagnostics),
|
||||
@@ -96,6 +115,10 @@ impl Policy {
|
||||
self.data_models().filter(|(_, dm)| dm.scope.is_global())
|
||||
}
|
||||
|
||||
pub fn dictionaries(&self) -> impl Iterator<Item = (&Arc<str>, &Arc<DictionaryIr>)> {
|
||||
self.dictionaries.iter().map(|b| (&b.id, &b.ir))
|
||||
}
|
||||
|
||||
pub fn imports(&self) -> &[Arc<str>] {
|
||||
&self.imports
|
||||
}
|
||||
@@ -194,6 +217,7 @@ impl DataModelIr {
|
||||
pub(crate) fn wire_property_type(
|
||||
prop: &Property,
|
||||
entities: &HashMap<Arc<str>, Arc<DataModelIr>>,
|
||||
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
|
||||
visited: &mut HashSet<Arc<str>>,
|
||||
) -> VariableType {
|
||||
let inner = match &prop.kind {
|
||||
@@ -202,7 +226,10 @@ impl DataModelIr {
|
||||
PropertyTypeIr::Number => VariableType::Number,
|
||||
PropertyTypeIr::Boolean => VariableType::Bool,
|
||||
PropertyTypeIr::Reference { .. } => VariableType::String,
|
||||
PropertyTypeIr::Relationship { target } => Self::wire_object(target, entities, visited),
|
||||
PropertyTypeIr::Relationship { target } => match dictionaries.get(target.as_ref()) {
|
||||
Some(dict) if !entities.contains_key(target.as_ref()) => dict.enum_type(),
|
||||
_ => Self::wire_object(target, entities, dictionaries, visited),
|
||||
},
|
||||
};
|
||||
if prop.array {
|
||||
inner.array()
|
||||
@@ -214,6 +241,7 @@ impl DataModelIr {
|
||||
pub(crate) fn wire_object(
|
||||
name: &Arc<str>,
|
||||
entities: &HashMap<Arc<str>, Arc<DataModelIr>>,
|
||||
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
|
||||
visited: &mut HashSet<Arc<str>>,
|
||||
) -> VariableType {
|
||||
if !visited.insert(name.clone()) {
|
||||
@@ -224,7 +252,7 @@ impl DataModelIr {
|
||||
for prop in &dm.properties {
|
||||
fields.insert(
|
||||
Rc::from(prop.name.as_ref()),
|
||||
Self::wire_property_type(prop, entities, visited),
|
||||
Self::wire_property_type(prop, entities, dictionaries, visited),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +260,7 @@ impl DataModelIr {
|
||||
VariableType::Object(Rc::new(RefCell::new(fields)))
|
||||
}
|
||||
|
||||
fn validate_identifier(name: &str) -> Result<(), &'static str> {
|
||||
pub(crate) fn validate_identifier(name: &str) -> Result<(), &'static str> {
|
||||
if name.is_empty() {
|
||||
return Err("is empty");
|
||||
}
|
||||
@@ -393,6 +421,86 @@ impl DataModelIr {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DictionaryIr {
|
||||
pub name: Arc<str>,
|
||||
pub entries: Vec<DictionaryEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DictionaryEntry {
|
||||
pub value: Arc<str>,
|
||||
pub label: Arc<str>,
|
||||
}
|
||||
|
||||
impl DictionaryIr {
|
||||
pub fn parse(
|
||||
id: &Arc<str>,
|
||||
doc: &DictionaryDoc,
|
||||
policy_path: &Arc<str>,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> Option<Self> {
|
||||
let name = doc.name.trimmed();
|
||||
if name.is_empty() {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
DiagnosticCode::ParseError,
|
||||
DiagnosticLocation::block(policy_path.clone(), id.clone()),
|
||||
"dictionary is missing a name",
|
||||
));
|
||||
return None;
|
||||
}
|
||||
if let Err(reason) = DataModelIr::validate_identifier(&name) {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
DiagnosticCode::InvalidName,
|
||||
DiagnosticLocation::block(policy_path.clone(), id.clone()),
|
||||
format!("dictionary name '{name}' {reason}"),
|
||||
));
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut entries: Vec<DictionaryEntry> = Vec::with_capacity(doc.entries.len());
|
||||
for entry in &doc.entries {
|
||||
let value = entry.value.trimmed();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if entries.iter().any(|e| e.value == value) {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
DiagnosticCode::DuplicateEnumValue,
|
||||
DiagnosticLocation::expression(
|
||||
policy_path.clone(),
|
||||
id.clone(),
|
||||
entry.id.clone(),
|
||||
None,
|
||||
),
|
||||
format!("duplicate value '{value}' in dictionary '{name}'"),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
entries.push(DictionaryEntry {
|
||||
value,
|
||||
label: entry.label.trimmed(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(DictionaryIr { name, entries })
|
||||
}
|
||||
|
||||
pub fn values(&self) -> impl Iterator<Item = &Arc<str>> {
|
||||
self.entries.iter().map(|e| &e.value)
|
||||
}
|
||||
|
||||
pub(crate) fn enum_type(&self) -> VariableType {
|
||||
VariableType::Enum(
|
||||
Some(Rc::from(self.name.as_ref())),
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|e| Rc::from(e.value.as_ref()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PropertyTypeIr {
|
||||
pub(crate) fn to_schema_field_kind(&self, array: bool) -> SchemaFieldKind {
|
||||
match self {
|
||||
|
||||
@@ -19,12 +19,12 @@ mod workspace;
|
||||
pub use raw::{BlockDoc, PolicyDocument};
|
||||
pub use types::{
|
||||
BlockExecution, BlockRef, Completion, ConditionalSchema, Cursor, CursorTarget, DependencyNode,
|
||||
Diagnostic, DiagnosticCode, DiagnosticLocation, DiscriminantVariant, DiscriminatedUnion,
|
||||
EngineEdit, Entity, EntityField, EvaluateRequest, EvaluationError, EvaluationResult,
|
||||
ExpressionKind, FieldOrigin, GuardedProperty, InputProperty, InputValidationError,
|
||||
InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind, ReferenceKind,
|
||||
ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest, Severity, Span, Trace,
|
||||
WriteConflict, WriteTrace,
|
||||
Diagnostic, DiagnosticCode, DiagnosticLocation, Dictionary, DictionaryEntryInfo,
|
||||
DiscriminantVariant, DiscriminatedUnion, EngineEdit, Entity, EntityField, EvaluateRequest,
|
||||
EvaluationError, EvaluationResult, ExpressionKind, FieldOrigin, GuardedProperty, InputProperty,
|
||||
InputValidationError, InspectResult, NlExpression, OutputProperty, PrepareRename, PropertyKind,
|
||||
ReferenceKind, ReferenceSite, RenameTarget, SchemaFieldKind, SchemaGroup, ScopeRequest,
|
||||
Severity, Span, Trace, WriteConflict, WriteTrace,
|
||||
};
|
||||
pub use workspace::PolicyWorkspace;
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ impl Db {
|
||||
|
||||
out.extend(self.data_model_diagnostics(path));
|
||||
|
||||
out.extend(self.dictionary_diagnostics(path));
|
||||
|
||||
out.extend(self.unreachable_reads_diagnostics(path));
|
||||
|
||||
out.extend(self.nested_iteration_diagnostics(path));
|
||||
@@ -476,7 +478,10 @@ impl Db {
|
||||
if let PropertyTypeIr::Relationship { target: t }
|
||||
| PropertyTypeIr::Reference { target: t } = &prop.kind
|
||||
{
|
||||
if !known_entities.contains(t) && policy_path == target {
|
||||
let dictionary_target =
|
||||
matches!(prop.kind, PropertyTypeIr::Relationship { .. })
|
||||
&& unit.dictionaries.contains_key(t);
|
||||
if !known_entities.contains(t) && !dictionary_target && policy_path == target {
|
||||
let owner = if is_global {
|
||||
format!("global property '{}'", prop.name)
|
||||
} else {
|
||||
@@ -499,4 +504,67 @@ impl Db {
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn dictionary_diagnostics(&self, target: &Arc<str>) -> Vec<Diagnostic> {
|
||||
let mut out = Vec::new();
|
||||
let unit = self.unit(target);
|
||||
let known_entities: HashSet<Arc<str>> = unit
|
||||
.data_models
|
||||
.iter()
|
||||
.filter(|e| !e.ir.scope.is_global())
|
||||
.map(|e| e.ir.name.clone())
|
||||
.collect();
|
||||
let global_property_names: HashSet<Arc<str>> = unit
|
||||
.data_models
|
||||
.iter()
|
||||
.filter(|e| e.ir.scope.is_global())
|
||||
.flat_map(|e| e.ir.properties.iter().map(|p| p.name.clone()))
|
||||
.collect();
|
||||
|
||||
let mut first_by_name: HashMap<Arc<str>, (Arc<str>, Arc<str>)> = HashMap::default();
|
||||
for entry in &unit.dictionary_blocks {
|
||||
let name = &entry.ir.name;
|
||||
if let Some((prev_policy, prev_block)) = first_by_name.get(name) {
|
||||
if entry.policy_path == *target {
|
||||
out.push(Diagnostic::error(
|
||||
DiagnosticCode::DataModelCollision,
|
||||
DiagnosticLocation::block(
|
||||
entry.policy_path.clone(),
|
||||
entry.block_id.clone(),
|
||||
),
|
||||
format!(
|
||||
"dictionary '{name}' is already defined in '{prev_policy}' (block '{prev_block}')"
|
||||
),
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
first_by_name.insert(
|
||||
name.clone(),
|
||||
(entry.policy_path.clone(), entry.block_id.clone()),
|
||||
);
|
||||
|
||||
if entry.policy_path != *target {
|
||||
continue;
|
||||
}
|
||||
if known_entities.contains(name) {
|
||||
out.push(Diagnostic::error(
|
||||
DiagnosticCode::DataModelCollision,
|
||||
DiagnosticLocation::block(entry.policy_path.clone(), entry.block_id.clone()),
|
||||
format!("dictionary name '{name}' collides with an entity of the same name"),
|
||||
));
|
||||
}
|
||||
if global_property_names.contains(name) {
|
||||
out.push(Diagnostic::error(
|
||||
DiagnosticCode::DataModelCollision,
|
||||
DiagnosticLocation::block(entry.policy_path.clone(), entry.block_id.clone()),
|
||||
format!(
|
||||
"dictionary name '{name}' collides with a global property of the same name"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use crate::policy::ir::DataModelIr;
|
||||
use crate::policy::queries::dependency::{DependencyGraph, PathPrefix};
|
||||
use crate::policy::queries::scope::PropertyScope;
|
||||
use crate::policy::types::{
|
||||
Entity, EntityField, FieldOrigin, Global, InputProperty, OutputProperty, PropertyKind,
|
||||
ScopeRequest,
|
||||
Dictionary, DictionaryEntryInfo, Entity, EntityField, FieldOrigin, Global, InputProperty,
|
||||
OutputProperty, PropertyKind, ScopeRequest,
|
||||
};
|
||||
|
||||
impl Db {
|
||||
@@ -59,8 +59,12 @@ impl Db {
|
||||
}
|
||||
}
|
||||
let mut visited: HashSet<Arc<str>> = HashSet::default();
|
||||
let resolved_type =
|
||||
DataModelIr::wire_property_type(&vp.property, entities_map, &mut visited);
|
||||
let resolved_type = DataModelIr::wire_property_type(
|
||||
&vp.property,
|
||||
entities_map,
|
||||
&unit.dictionaries,
|
||||
&mut visited,
|
||||
);
|
||||
out.push(Global {
|
||||
name: vp.property.name.clone(),
|
||||
resolved_type,
|
||||
@@ -99,6 +103,31 @@ impl Db {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn dictionaries(&self, req: &ScopeRequest) -> Vec<Dictionary> {
|
||||
let unit = self.unit(&req.policy_path);
|
||||
let mut seen: HashSet<Arc<str>> = HashSet::default();
|
||||
let mut out: Vec<Dictionary> = Vec::new();
|
||||
for entry in &unit.dictionary_blocks {
|
||||
if !seen.insert(entry.ir.name.clone()) {
|
||||
continue;
|
||||
}
|
||||
out.push(Dictionary {
|
||||
name: entry.ir.name.clone(),
|
||||
source: entry.policy_path.clone(),
|
||||
entries: entry
|
||||
.ir
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| DictionaryEntryInfo {
|
||||
value: e.value.clone(),
|
||||
label: e.label.clone(),
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn inputs(&self, req: &ScopeRequest) -> Vec<InputProperty> {
|
||||
let unit = self.unit(&req.policy_path);
|
||||
let visible = &unit.members;
|
||||
@@ -119,6 +148,7 @@ impl Db {
|
||||
resolved_type: DataModelIr::wire_property_type(
|
||||
&vp.property,
|
||||
entities,
|
||||
&unit.dictionaries,
|
||||
&mut visited,
|
||||
),
|
||||
}
|
||||
@@ -130,7 +160,8 @@ impl Db {
|
||||
continue;
|
||||
}
|
||||
let mut visited: HashSet<Arc<str>> = HashSet::default();
|
||||
let entity_type = DataModelIr::wire_object(target, entities, &mut visited);
|
||||
let entity_type =
|
||||
DataModelIr::wire_object(target, entities, &unit.dictionaries, &mut visited);
|
||||
if !matches!(entity_type, VariableType::Any) {
|
||||
result.push(InputProperty {
|
||||
path: target.clone(),
|
||||
@@ -184,8 +215,12 @@ impl Db {
|
||||
return None;
|
||||
};
|
||||
let mut visited: HashSet<Arc<str>> = HashSet::default();
|
||||
let resolved_type =
|
||||
DataModelIr::wire_property_type(&vp.property, entities, &mut visited);
|
||||
let resolved_type = DataModelIr::wire_property_type(
|
||||
&vp.property,
|
||||
entities,
|
||||
&unit.dictionaries,
|
||||
&mut visited,
|
||||
);
|
||||
let origin = FieldOrigin::Schema {
|
||||
source: vp.policy_path,
|
||||
kind: vp.property.kind.to_schema_field_kind(vp.property.array),
|
||||
|
||||
@@ -8,7 +8,7 @@ use zen_expression::variable::{Variable, VariableType};
|
||||
|
||||
use crate::policy::blocks::InstanceSource;
|
||||
use crate::policy::db::{Db, Snapshot};
|
||||
use crate::policy::ir::{DataModelIr, ParsedPolicy, Property, PropertyTypeIr};
|
||||
use crate::policy::ir::{DataModelIr, DictionaryIr, ParsedPolicy, Property, PropertyTypeIr};
|
||||
use crate::policy::queries::dependency::DependencyGraph;
|
||||
use crate::policy::types::InstanceTarget;
|
||||
|
||||
@@ -288,6 +288,20 @@ impl Snapshot {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn compute_dictionary_map(
|
||||
all_parsed: &HashMap<Arc<str>, Arc<ParsedPolicy>>,
|
||||
) -> HashMap<Arc<str>, Arc<DictionaryIr>> {
|
||||
let mut sorted: Vec<(&Arc<str>, &Arc<ParsedPolicy>)> = all_parsed.iter().collect();
|
||||
sorted.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let mut out: HashMap<Arc<str>, Arc<DictionaryIr>> = HashMap::new();
|
||||
for (_, parsed) in sorted {
|
||||
for (_, dict) in parsed.policy.dictionaries() {
|
||||
out.entry(dict.name.clone()).or_insert_with(|| dict.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn compute_entity_graph(
|
||||
all_parsed: &HashMap<Arc<str>, Arc<ParsedPolicy>>,
|
||||
entity_sources: &Arc<EntitySources>,
|
||||
@@ -320,6 +334,7 @@ impl Snapshot {
|
||||
entity_sources: &EntitySources,
|
||||
) -> VariableType {
|
||||
let mut entity_map: HashMap<Arc<str>, VariableType> = HashMap::new();
|
||||
let dictionaries = Self::compute_dictionary_map(all_parsed);
|
||||
|
||||
let mut models: Vec<(Arc<str>, &DataModelIr)> =
|
||||
Self::iter_data_models(all_parsed).collect();
|
||||
@@ -336,7 +351,7 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
for (_, dm) in models.iter().filter(|(_, dm)| !dm.scope.is_global()) {
|
||||
dm.wire_relationships(&entity_map);
|
||||
dm.wire_relationships(&entity_map, &dictionaries);
|
||||
}
|
||||
|
||||
for (entity_name, source) in entity_sources.iter() {
|
||||
@@ -370,7 +385,7 @@ impl Snapshot {
|
||||
for (_, dm) in models.iter().filter(|(_, dm)| dm.scope.is_global()) {
|
||||
for prop in &dm.properties {
|
||||
let key = Rc::from(prop.name.as_ref());
|
||||
let value_type = prop.build_global_type(&entity_map);
|
||||
let value_type = prop.build_global_type(&entity_map, &dictionaries);
|
||||
scope_fields.entry(key).or_insert(value_type);
|
||||
}
|
||||
}
|
||||
@@ -594,7 +609,11 @@ impl DataModelIr {
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_relationships(&self, entity_map: &HashMap<Arc<str>, VariableType>) {
|
||||
fn wire_relationships(
|
||||
&self,
|
||||
entity_map: &HashMap<Arc<str>, VariableType>,
|
||||
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
|
||||
) {
|
||||
for prop in &self.properties {
|
||||
let target_name = match &prop.kind {
|
||||
PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => {
|
||||
@@ -603,14 +622,20 @@ impl DataModelIr {
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let Some(target_entity) = entity_map.get(target_name.as_ref()) else {
|
||||
continue;
|
||||
let target_type = match entity_map.get(target_name.as_ref()) {
|
||||
Some(target_entity) => target_entity.shallow_clone(),
|
||||
None => match dictionaries.get(target_name.as_ref()) {
|
||||
Some(dict) if matches!(prop.kind, PropertyTypeIr::Relationship { .. }) => {
|
||||
dict.enum_type()
|
||||
}
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
|
||||
let mut final_type = if prop.array {
|
||||
target_entity.shallow_clone().array()
|
||||
target_type.array()
|
||||
} else {
|
||||
target_entity.shallow_clone()
|
||||
target_type
|
||||
};
|
||||
if prop.optional {
|
||||
final_type = VariableType::Nullable(Rc::new(final_type));
|
||||
@@ -644,6 +669,7 @@ impl Property {
|
||||
pub(crate) fn build_global_type(
|
||||
&self,
|
||||
entity_map: &HashMap<Arc<str>, VariableType>,
|
||||
dictionaries: &HashMap<Arc<str>, Arc<DictionaryIr>>,
|
||||
) -> VariableType {
|
||||
let inner = match &self.kind {
|
||||
PropertyTypeIr::String | PropertyTypeIr::Date => VariableType::String,
|
||||
@@ -655,7 +681,12 @@ impl Property {
|
||||
PropertyTypeIr::Relationship { target } | PropertyTypeIr::Reference { target } => {
|
||||
match entity_map.get(target.as_ref()) {
|
||||
Some(t) => t.shallow_clone(),
|
||||
None => VariableType::Any,
|
||||
None => match dictionaries.get(target.as_ref()) {
|
||||
Some(dict) if matches!(self.kind, PropertyTypeIr::Relationship { .. }) => {
|
||||
dict.enum_type()
|
||||
}
|
||||
_ => VariableType::Any,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,10 @@ pub enum BlockDoc {
|
||||
id: Arc<str>,
|
||||
data: DataModelDoc,
|
||||
},
|
||||
Dictionary {
|
||||
id: Arc<str>,
|
||||
data: DictionaryDoc,
|
||||
},
|
||||
Ignored(serde_json::Value),
|
||||
}
|
||||
|
||||
@@ -44,7 +48,8 @@ impl BlockDoc {
|
||||
| Self::DecisionTable { id, .. }
|
||||
| Self::Expression { id, .. }
|
||||
| Self::Match { id, .. }
|
||||
| Self::DataModel { id, .. } => Some(id),
|
||||
| Self::DataModel { id, .. }
|
||||
| Self::Dictionary { id, .. } => Some(id),
|
||||
Self::Ignored(value) => value.get("id").and_then(serde_json::Value::as_str),
|
||||
}
|
||||
}
|
||||
@@ -78,6 +83,10 @@ impl BlockDoc {
|
||||
id,
|
||||
data: serde_json::from_value(data)?,
|
||||
}),
|
||||
BlockTag::Dictionary => Ok(Self::Dictionary {
|
||||
id,
|
||||
data: serde_json::from_value(data)?,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +133,9 @@ impl Serialize for BlockDoc {
|
||||
Self::DataModel { id, data } => {
|
||||
TaggedBlockRef::new(BlockTag::DataModel, id, data).serialize(serializer)
|
||||
}
|
||||
Self::Dictionary { id, data } => {
|
||||
TaggedBlockRef::new(BlockTag::Dictionary, id, data).serialize(serializer)
|
||||
}
|
||||
Self::Ignored(value) => value.serialize(serializer),
|
||||
}
|
||||
}
|
||||
@@ -136,6 +148,7 @@ enum BlockTag {
|
||||
Expression,
|
||||
Match,
|
||||
DataModel,
|
||||
Dictionary,
|
||||
}
|
||||
|
||||
impl BlockTag {
|
||||
@@ -146,6 +159,7 @@ impl BlockTag {
|
||||
"expression" => Some(Self::Expression),
|
||||
"match" => Some(Self::Match),
|
||||
"dataModel" => Some(Self::DataModel),
|
||||
"dictionary" => Some(Self::Dictionary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -157,6 +171,7 @@ impl BlockTag {
|
||||
Self::Expression => "expression",
|
||||
Self::Match => "match",
|
||||
Self::DataModel => "dataModel",
|
||||
Self::Dictionary => "dictionary",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +242,24 @@ pub struct PropertyDoc {
|
||||
pub optional: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DictionaryDoc {
|
||||
pub name: Arc<str>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<DictionaryEntryDoc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DictionaryEntryDoc {
|
||||
#[serde(default)]
|
||||
pub id: Arc<str>,
|
||||
pub value: Arc<str>,
|
||||
#[serde(default)]
|
||||
pub label: Arc<str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PropertyTypeDoc {
|
||||
|
||||
@@ -18,8 +18,8 @@ pub use nl::NlExpression;
|
||||
pub use request::{EvaluateRequest, ScopeRequest};
|
||||
pub use result::{
|
||||
BlockExecution, BlockRef, BlockTrace, Completion, ConditionTrace, ConditionalSchema,
|
||||
DecisionTableExtras, DependencyNode, DiscriminantVariant, DiscriminatedUnion, Entity,
|
||||
EntityField, EvaluationResult, FieldOrigin, Global, GuardedProperty, InputProperty,
|
||||
InstanceTarget, OutputProperty, PropertyKind, SchemaFieldKind, SchemaGroup, Trace,
|
||||
WriteConflict, WriteTrace,
|
||||
DecisionTableExtras, DependencyNode, Dictionary, DictionaryEntryInfo, DiscriminantVariant,
|
||||
DiscriminatedUnion, Entity, EntityField, EvaluationResult, FieldOrigin, Global,
|
||||
GuardedProperty, InputProperty, InstanceTarget, OutputProperty, PropertyKind, SchemaFieldKind,
|
||||
SchemaGroup, Trace, WriteConflict, WriteTrace,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,9 @@ impl NlExpression {
|
||||
let unary = matches!(kind, ExpressionKind::Unary);
|
||||
let mut result = is.nl_tokenize_scoped(block_id, source, unary, scope);
|
||||
if unary {
|
||||
result.subject_type = Some(scope.get("$"));
|
||||
let subject = scope.get("$");
|
||||
result.subject_options = is.nl_subject_options(&subject);
|
||||
result.subject_type = Some(subject);
|
||||
}
|
||||
Self {
|
||||
policy_path: policy_path.clone(),
|
||||
|
||||
@@ -226,6 +226,21 @@ pub struct Global {
|
||||
pub origin: FieldOrigin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dictionary {
|
||||
pub name: Arc<str>,
|
||||
pub source: Arc<str>,
|
||||
pub entries: Vec<DictionaryEntryInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DictionaryEntryInfo {
|
||||
pub value: Arc<str>,
|
||||
pub label: Arc<str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EntityField {
|
||||
|
||||
@@ -4,7 +4,7 @@ use ahash::{HashMap, HashMapExt, HashSet};
|
||||
use zen_expression::variable::Variable;
|
||||
|
||||
use crate::policy::db::Db;
|
||||
use crate::policy::ir::{DataModelIr, Property, PropertyTypeIr};
|
||||
use crate::policy::ir::{DataModelIr, DictionaryIr, Property, PropertyTypeIr};
|
||||
use crate::policy::refs::RefPoolIndex;
|
||||
use crate::policy::types::InputValidationError;
|
||||
use crate::policy::MAX_RECURSION_DEPTH;
|
||||
@@ -21,6 +21,7 @@ impl Db {
|
||||
globals,
|
||||
roots,
|
||||
ref_targets,
|
||||
dictionaries: self.unit(policy_path).dictionaries.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +71,7 @@ pub(crate) struct InputSchema {
|
||||
globals: HashMap<Arc<str>, Property>,
|
||||
roots: HashSet<Arc<str>>,
|
||||
ref_targets: HashSet<Arc<str>>,
|
||||
dictionaries: HashMap<Arc<str>, Arc<DictionaryIr>>,
|
||||
}
|
||||
|
||||
impl InputSchema {
|
||||
@@ -77,6 +79,7 @@ impl InputSchema {
|
||||
let ref_pools = RefPoolIndex::from_input(input, self.ref_targets.iter().cloned());
|
||||
let mut validator = InputValidator {
|
||||
entities: &self.entities,
|
||||
dictionaries: &self.dictionaries,
|
||||
ref_pools: &ref_pools,
|
||||
errors: Vec::new(),
|
||||
depth: 0,
|
||||
@@ -113,6 +116,7 @@ impl InputSchema {
|
||||
|
||||
struct InputValidator<'a> {
|
||||
entities: &'a HashMap<Arc<str>, Arc<DataModelIr>>,
|
||||
dictionaries: &'a HashMap<Arc<str>, Arc<DictionaryIr>>,
|
||||
ref_pools: &'a RefPoolIndex,
|
||||
errors: Vec<InputValidationError>,
|
||||
depth: usize,
|
||||
@@ -220,6 +224,13 @@ impl InputValidator<'_> {
|
||||
return;
|
||||
}
|
||||
PropertyTypeIr::Relationship { target } => {
|
||||
if !self.entities.contains_key(target) {
|
||||
if let Some(dict) = self.dictionaries.get(target) {
|
||||
let values: Vec<Arc<str>> = dict.values().cloned().collect();
|
||||
self.validate_enum(value, &values, path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.validate_entity(value, target, path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ use crate::policy::raw::PolicyDocument;
|
||||
use zen_expression::nl::NlResult;
|
||||
|
||||
use crate::policy::types::{
|
||||
Completion, ConditionalSchema, Cursor, DependencyNode, Diagnostic, EngineEdit, Entity,
|
||||
EvaluateRequest, EvaluationError, EvaluationResult, Global, InputProperty, InspectResult,
|
||||
NlExpression, OutputProperty, PrepareRename, ReferenceSite, RenameTarget, ScopeRequest,
|
||||
WriteConflict,
|
||||
Completion, ConditionalSchema, Cursor, DependencyNode, Diagnostic, Dictionary, EngineEdit,
|
||||
Entity, EvaluateRequest, EvaluationError, EvaluationResult, Global, InputProperty,
|
||||
InspectResult, NlExpression, OutputProperty, PrepareRename, ReferenceSite, RenameTarget,
|
||||
ScopeRequest, WriteConflict,
|
||||
};
|
||||
|
||||
pub struct PolicyWorkspace {
|
||||
@@ -64,6 +64,10 @@ impl PolicyWorkspace {
|
||||
self.db.globals(req)
|
||||
}
|
||||
|
||||
pub fn dictionaries(&self, req: &ScopeRequest) -> Vec<Dictionary> {
|
||||
self.db.dictionaries(req)
|
||||
}
|
||||
|
||||
pub fn inputs(&self, req: &ScopeRequest) -> Vec<InputProperty> {
|
||||
self.db.inputs(req)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,541 @@
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use zen_engine::policy::{
|
||||
Cursor, CursorTarget, EvaluateRequest, EvaluationError, PolicyWorkspace, ScopeRequest,
|
||||
};
|
||||
use zen_expression::nl::{EditHint, NlTokenKind};
|
||||
use zen_expression::variable::Variable;
|
||||
|
||||
fn dictionary_block(id: &str, name: &str, entries: &[(&str, &str)]) -> serde_json::Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"type": "dictionary",
|
||||
"props": {
|
||||
"data": {
|
||||
"name": name,
|
||||
"entries": entries.iter().enumerate().map(|(i, (value, label))| json!({
|
||||
"id": format!("e{i}"),
|
||||
"value": value,
|
||||
"label": label,
|
||||
})).collect::<Vec<_>>(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tier_dictionary() -> serde_json::Value {
|
||||
dictionary_block(
|
||||
"dict1",
|
||||
"customerTier",
|
||||
&[("VIP", "Very important"), ("STD", "Standard")],
|
||||
)
|
||||
}
|
||||
|
||||
fn expression_block(id: &str, key: &str, value: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"type": "expression",
|
||||
"props": { "data": { "key": key, "value": value } }
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_with(blocks: Vec<serde_json::Value>) -> PolicyWorkspace {
|
||||
let mut ws = PolicyWorkspace::new();
|
||||
ws.set_policy(
|
||||
"main",
|
||||
serde_json::from_value(json!({ "blocks": blocks })).unwrap(),
|
||||
);
|
||||
ws
|
||||
}
|
||||
|
||||
fn evaluate(
|
||||
ws: &PolicyWorkspace,
|
||||
input: serde_json::Value,
|
||||
) -> Result<serde_json::Value, EvaluationError> {
|
||||
let result = ws.evaluate(&EvaluateRequest {
|
||||
policy_path: Arc::from("main"),
|
||||
input: Variable::from(input),
|
||||
goals: Vec::new(),
|
||||
trace: false,
|
||||
})?;
|
||||
Ok(result.output.to_value())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_is_not_referenceable_in_expressions() {
|
||||
for member in ["customerTier.VIP", "customerTier.GOLD"] {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
expression_block("e1", "tier", member),
|
||||
]);
|
||||
|
||||
let diagnostics = ws.diagnostics("main");
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|d| format!("{d:?}").contains("customerTier")),
|
||||
"expected '{member}' to be an unknown property, got: {diagnostics:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_typed_field_compares_as_plain_string() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"),
|
||||
]);
|
||||
|
||||
let diagnostics = ws.diagnostics("main");
|
||||
assert!(diagnostics.is_empty(), "{diagnostics:?}");
|
||||
|
||||
let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap();
|
||||
assert_eq!(output["customer"]["isVip"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_values_are_diagnosed() {
|
||||
let ws = workspace_with(vec![dictionary_block(
|
||||
"dict1",
|
||||
"customerTier",
|
||||
&[("VIP", "One"), ("VIP", "Two")],
|
||||
)]);
|
||||
|
||||
let diagnostics = ws.diagnostics("main");
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|d| format!("{d:?}").contains("duplicate value 'VIP'")),
|
||||
"got: {diagnostics:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_name_collisions_are_diagnosed() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
dictionary_block("dict2", "customerTier", &[("A", "")]),
|
||||
]);
|
||||
let diagnostics = ws.diagnostics("main");
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|d| format!("{d:?}").contains("already defined")),
|
||||
"got: {diagnostics:?}"
|
||||
);
|
||||
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customerTier",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
]);
|
||||
let diagnostics = ws.diagnostics("main");
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|d| format!("{d:?}").contains("collides with an entity")),
|
||||
"got: {diagnostics:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_model_property_can_reference_dictionary() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
expression_block("e1", "customer.isVip", "customer.tier == 'VIP'"),
|
||||
]);
|
||||
|
||||
let diagnostics = ws.diagnostics("main");
|
||||
assert!(
|
||||
!diagnostics
|
||||
.iter()
|
||||
.any(|d| format!("{d:?}").contains("unknown entity")),
|
||||
"dictionary target must not be an unknown entity: {diagnostics:?}"
|
||||
);
|
||||
|
||||
let output = evaluate(&ws, json!({ "customer": { "tier": "VIP" } })).unwrap();
|
||||
assert_eq!(output["customer"]["isVip"], json!(true));
|
||||
|
||||
let invalid = evaluate(&ws, json!({ "customer": { "tier": "GOLD" } }));
|
||||
assert!(
|
||||
matches!(invalid, Err(EvaluationError::InputValidationFailed { .. })),
|
||||
"value outside the dictionary must fail input validation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_membership_is_validated_for_arrays_and_globals() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tiers", "type": "relationship", "target": "customerTier", "array": true, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
json!({
|
||||
"id": "dm2",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "global",
|
||||
"scope": "global",
|
||||
"properties": [
|
||||
{ "id": "p2", "name": "defaultTier", "type": "relationship", "target": "customerTier", "array": false, "optional": true }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
expression_block("e1", "customer.first", "customer.tiers[0]"),
|
||||
]);
|
||||
|
||||
let ok = evaluate(
|
||||
&ws,
|
||||
json!({ "customer": { "tiers": ["VIP", "STD"] }, "defaultTier": "STD" }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok["customer"]["first"], json!("VIP"));
|
||||
|
||||
let bad_element = evaluate(&ws, json!({ "customer": { "tiers": ["VIP", "GOLD"] } }));
|
||||
assert!(matches!(
|
||||
bad_element,
|
||||
Err(EvaluationError::InputValidationFailed { .. })
|
||||
));
|
||||
|
||||
let bad_global = evaluate(
|
||||
&ws,
|
||||
json!({ "customer": { "tiers": [] }, "defaultTier": "GOLD" }),
|
||||
);
|
||||
assert!(matches!(
|
||||
bad_global,
|
||||
Err(EvaluationError::InputValidationFailed { .. })
|
||||
));
|
||||
|
||||
let not_a_string = evaluate(&ws, json!({ "customer": { "tiers": [42] } }));
|
||||
assert!(matches!(
|
||||
not_a_string,
|
||||
Err(EvaluationError::InputValidationFailed { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_key_matching_dictionary_name_is_plain_data() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
expression_block("e1", "echo", "input"),
|
||||
]);
|
||||
|
||||
let output = evaluate(&ws, json!({ "input": 1, "customerTier": "boom" })).unwrap();
|
||||
assert_eq!(output["echo"], json!(1));
|
||||
assert_eq!(output["customerTier"], json!("boom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionaries_query_exposes_labels() {
|
||||
let ws = workspace_with(vec![tier_dictionary()]);
|
||||
let dictionaries = ws.dictionaries(&ScopeRequest {
|
||||
policy_path: Arc::from("main"),
|
||||
goals: Vec::new(),
|
||||
});
|
||||
|
||||
assert_eq!(dictionaries.len(), 1);
|
||||
let dict = &dictionaries[0];
|
||||
assert_eq!(dict.name.as_ref(), "customerTier");
|
||||
assert_eq!(dict.entries.len(), 2);
|
||||
assert_eq!(dict.entries[0].value.as_ref(), "VIP");
|
||||
assert_eq!(dict.entries[0].label.as_ref(), "Very important");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_tokenize_uses_dictionary_labels() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
json!({
|
||||
"id": "dt1",
|
||||
"type": "decisionTable",
|
||||
"props": { "data": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ],
|
||||
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
|
||||
"rules": [ { "_id": "row1", "in1": "'VIP'", "out1": "'vip'" } ]
|
||||
}}
|
||||
}),
|
||||
]);
|
||||
|
||||
let cursor = Cursor {
|
||||
policy_path: "main".into(),
|
||||
block_id: "dt1".into(),
|
||||
pos: 0,
|
||||
target: CursorTarget::DecisionTableCell {
|
||||
row: "row1".into(),
|
||||
col: "in1".into(),
|
||||
},
|
||||
};
|
||||
let result = ws.nl_tokenize(&cursor, "'VIP'").expect("cursor resolves");
|
||||
|
||||
let str_tok = result
|
||||
.tokens
|
||||
.iter()
|
||||
.find(|t| matches!(t.token, NlTokenKind::Str { .. }))
|
||||
.expect("string token present");
|
||||
let Some(EditHint::Select { options }) = str_tok.hint else {
|
||||
panic!("expected select hint, got {:?}", str_tok.hint);
|
||||
};
|
||||
let options = &result.enums[options as usize];
|
||||
let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
|
||||
assert_eq!(labels, vec!["Very important", "Standard"]);
|
||||
let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect();
|
||||
assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_tokenize_empty_cell_exposes_labeled_subject_options() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
json!({
|
||||
"id": "dt1",
|
||||
"type": "decisionTable",
|
||||
"props": { "data": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "in1", "name": "Tier", "field": "customer.tier" } ],
|
||||
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
|
||||
"rules": [ { "_id": "row1", "in1": "", "out1": "'vip'" } ]
|
||||
}}
|
||||
}),
|
||||
]);
|
||||
|
||||
let cursor = Cursor {
|
||||
policy_path: "main".into(),
|
||||
block_id: "dt1".into(),
|
||||
pos: 0,
|
||||
target: CursorTarget::DecisionTableCell {
|
||||
row: "row1".into(),
|
||||
col: "in1".into(),
|
||||
},
|
||||
};
|
||||
let result = ws.nl_tokenize(&cursor, "").expect("cursor resolves");
|
||||
|
||||
let options = result.subject_options.expect("subject options present");
|
||||
let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
|
||||
assert_eq!(labels, vec!["Very important", "Standard"]);
|
||||
let sources: Vec<&str> = options.iter().filter_map(|o| o.source.as_deref()).collect();
|
||||
assert_eq!(sources, vec!["\"VIP\"", "\"STD\""]);
|
||||
|
||||
let batch = ws.nl("main");
|
||||
let cell = batch
|
||||
.iter()
|
||||
.find(|e| {
|
||||
e.block_id.as_ref() == "dt1"
|
||||
&& matches!(&e.target, CursorTarget::DecisionTableCell { col, .. } if col.as_ref() == "in1")
|
||||
})
|
||||
.expect("batch projection for the table cell");
|
||||
let batch_options = cell
|
||||
.result
|
||||
.subject_options
|
||||
.as_ref()
|
||||
.expect("batch projection carries subject options");
|
||||
let batch_labels: Vec<&str> = batch_options.iter().map(|o| o.label.as_str()).collect();
|
||||
assert_eq!(batch_labels, vec!["Very important", "Standard"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_tokenize_closure_membership_gets_enum_multiselect() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
json!({
|
||||
"id": "dt1",
|
||||
"type": "decisionTable",
|
||||
"props": { "data": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ],
|
||||
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
|
||||
"rules": [ { "_id": "row1", "in1": "all(['VIP'], # in $)", "out1": "'vip'" } ]
|
||||
}}
|
||||
}),
|
||||
]);
|
||||
|
||||
let cursor = Cursor {
|
||||
policy_path: "main".into(),
|
||||
block_id: "dt1".into(),
|
||||
pos: 0,
|
||||
target: CursorTarget::DecisionTableCell {
|
||||
row: "row1".into(),
|
||||
col: "in1".into(),
|
||||
},
|
||||
};
|
||||
let result = ws
|
||||
.nl_tokenize(&cursor, "all(['VIP'], # in $)")
|
||||
.expect("cursor resolves");
|
||||
|
||||
let list = result
|
||||
.tokens
|
||||
.iter()
|
||||
.find(|t| matches!(&t.token, NlTokenKind::EnumList { .. }))
|
||||
.expect("array literal projects as enum list");
|
||||
let NlTokenKind::EnumList { selected } = &list.token else {
|
||||
unreachable!();
|
||||
};
|
||||
assert_eq!(selected.as_slice(), [Box::from("VIP")]);
|
||||
let Some(EditHint::MultiSelect { options }) = list.hint else {
|
||||
panic!("expected multi-select hint, got {:?}", list.hint);
|
||||
};
|
||||
let options = &result.enums[options as usize];
|
||||
let labels: Vec<&str> = options.iter().map(|o| o.label.as_str()).collect();
|
||||
assert_eq!(labels, vec!["Very important", "Standard"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nl_tokenize_reversed_membership_and_contains_get_enum_hints() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tags", "type": "relationship", "target": "customerTier", "array": true, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
json!({
|
||||
"id": "dt1",
|
||||
"type": "decisionTable",
|
||||
"props": { "data": {
|
||||
"hitPolicy": "first",
|
||||
"inputs": [ { "id": "in1", "name": "Tags", "field": "customer.tags" } ],
|
||||
"outputs": [ { "id": "out1", "name": "Tag", "field": "customer.tag" } ],
|
||||
"rules": [ { "_id": "row1", "in1": "'VIP' in $", "out1": "'vip'" } ]
|
||||
}}
|
||||
}),
|
||||
]);
|
||||
|
||||
let cursor = Cursor {
|
||||
policy_path: "main".into(),
|
||||
block_id: "dt1".into(),
|
||||
pos: 0,
|
||||
target: CursorTarget::DecisionTableCell {
|
||||
row: "row1".into(),
|
||||
col: "in1".into(),
|
||||
},
|
||||
};
|
||||
|
||||
let assert_labeled_select = |result: zen_expression::nl::NlResult| {
|
||||
let str_tok = result
|
||||
.tokens
|
||||
.iter()
|
||||
.find(|t| matches!(t.token, NlTokenKind::Str { .. }))
|
||||
.expect("string token present");
|
||||
let Some(EditHint::Select { options }) = str_tok.hint else {
|
||||
panic!("expected select hint, got {:?}", str_tok.hint);
|
||||
};
|
||||
let labels: Vec<&str> = result.enums[options as usize]
|
||||
.iter()
|
||||
.map(|o| o.label.as_str())
|
||||
.collect();
|
||||
assert_eq!(labels, vec!["Very important", "Standard"]);
|
||||
};
|
||||
|
||||
assert_labeled_select(
|
||||
ws.nl_tokenize(&cursor, "'VIP' in $")
|
||||
.expect("cursor resolves"),
|
||||
);
|
||||
assert_labeled_select(
|
||||
ws.nl_tokenize(&cursor, "contains($, 'VIP')")
|
||||
.expect("cursor resolves"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_block_round_trips_through_wire_format() {
|
||||
let block_json = tier_dictionary();
|
||||
let block: zen_engine::policy::BlockDoc = serde_json::from_value(block_json.clone()).unwrap();
|
||||
let serialized = serde_json::to_value(&block).unwrap();
|
||||
assert_eq!(serialized, block_json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_member_literal_comparison_is_diagnosed() {
|
||||
let ws = workspace_with(vec![
|
||||
tier_dictionary(),
|
||||
json!({
|
||||
"id": "dm1",
|
||||
"type": "dataModel",
|
||||
"props": { "data": {
|
||||
"name": "customer",
|
||||
"properties": [
|
||||
{ "id": "p1", "name": "tier", "type": "relationship", "target": "customerTier", "array": false, "optional": false }
|
||||
]
|
||||
}}
|
||||
}),
|
||||
expression_block("e1", "customer.flag", "customer.tier == 'GOLD'"),
|
||||
]);
|
||||
|
||||
let diagnostics = ws.diagnostics("main");
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|d| format!("{d:?}").contains("GOLD")),
|
||||
"expected non-member literal to be diagnosed, got: {diagnostics:?}"
|
||||
);
|
||||
}
|
||||
@@ -53,10 +53,13 @@ pub struct ExpressionAnalysis {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
pub type NlLabelResolver = Rc<dyn Fn(&str, &str) -> Option<String>>;
|
||||
|
||||
pub struct IntelliSense {
|
||||
arena: Bump,
|
||||
lexer: Lexer,
|
||||
strict: bool,
|
||||
nl_labels: Option<NlLabelResolver>,
|
||||
}
|
||||
|
||||
impl IntelliSense {
|
||||
@@ -65,6 +68,7 @@ impl IntelliSense {
|
||||
arena: Bump::new(),
|
||||
lexer: Lexer::new(),
|
||||
strict: false,
|
||||
nl_labels: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +77,10 @@ impl IntelliSense {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_nl_labels(&mut self, labels: Option<NlLabelResolver>) {
|
||||
self.nl_labels = labels;
|
||||
}
|
||||
|
||||
pub fn completions(
|
||||
&mut self,
|
||||
source: &str,
|
||||
@@ -203,11 +211,17 @@ impl IntelliSense {
|
||||
let mut result =
|
||||
self.nl_tokenize_scoped(&request.id, &request.expression, request.unary, &scope);
|
||||
if request.unary {
|
||||
result.subject_type = Some(scope.get("$"));
|
||||
let subject = scope.get("$");
|
||||
result.subject_options = self.nl_subject_options(&subject);
|
||||
result.subject_type = Some(subject);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn nl_subject_options(&self, subject: &VariableType) -> Option<Vec<crate::nl::EnumOption>> {
|
||||
crate::nl::subject_enum_options(subject, self.nl_labels.as_ref())
|
||||
}
|
||||
|
||||
pub fn nl_tokenize_scoped(
|
||||
&mut self,
|
||||
id: &str,
|
||||
@@ -221,6 +235,7 @@ impl IntelliSense {
|
||||
enums: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
subject_type: None,
|
||||
subject_options: None,
|
||||
};
|
||||
|
||||
self.arena.reset();
|
||||
@@ -270,7 +285,8 @@ impl IntelliSense {
|
||||
let type_data = TypesProvider::generate(ast, scope, self.strict);
|
||||
collect_type_diagnostics(ast, &type_data, &metadata, &mut result.diagnostics);
|
||||
|
||||
let (tokens, enums) = Projector::new(source, &type_data, &metadata, unary).run(ast);
|
||||
let (tokens, enums) =
|
||||
Projector::new(source, &type_data, &metadata, unary, self.nl_labels.clone()).run(ast);
|
||||
result.tokens = tokens;
|
||||
result.enums = enums;
|
||||
result
|
||||
|
||||
@@ -4,8 +4,10 @@ pub mod token;
|
||||
pub use token::{EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym};
|
||||
|
||||
use serde::Serialize;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::intellisense::diagnostic::Diagnostic;
|
||||
use crate::intellisense::NlLabelResolver;
|
||||
use crate::variable::VariableType;
|
||||
|
||||
pub fn encode_string(value: &str) -> Option<String> {
|
||||
@@ -18,6 +20,37 @@ pub fn encode_string(value: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enum_options(
|
||||
name: Option<&str>,
|
||||
values: &[Rc<str>],
|
||||
labels: Option<&NlLabelResolver>,
|
||||
) -> Vec<EnumOption> {
|
||||
values
|
||||
.iter()
|
||||
.map(|v| EnumOption {
|
||||
label: labels
|
||||
.zip(name)
|
||||
.and_then(|(resolve, n)| resolve(n, v))
|
||||
.filter(|l| !l.is_empty())
|
||||
.unwrap_or_else(|| v.to_string()),
|
||||
source: encode_string(v),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn subject_enum_options(
|
||||
subject: &VariableType,
|
||||
labels: Option<&NlLabelResolver>,
|
||||
) -> Option<Vec<EnumOption>> {
|
||||
match subject {
|
||||
VariableType::Enum(name, values) => Some(enum_options(name.as_deref(), values, labels)),
|
||||
VariableType::Nullable(inner) | VariableType::Array(inner) => {
|
||||
subject_enum_options(inner, labels)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NlRequest {
|
||||
pub id: String,
|
||||
@@ -35,4 +68,6 @@ pub struct NlResult {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
#[serde(skip)]
|
||||
pub subject_type: Option<VariableType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject_options: Option<Vec<EnumOption>>,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ use std::rc::Rc;
|
||||
|
||||
use crate::functions::FunctionKind;
|
||||
use crate::intellisense::type_provider::TypesProvider;
|
||||
use crate::intellisense::AstMetadata;
|
||||
use crate::lexer::{Bracket, Operator};
|
||||
use crate::nl::encode_string;
|
||||
use crate::intellisense::{AstMetadata, NlLabelResolver};
|
||||
use crate::lexer::{Bracket, ComparisonOperator, Operator};
|
||||
use crate::nl::token::{
|
||||
EditHint, EnumOption, NlToken, NlTokenKind, OpChoice, OpSym, TypeTag, WordSym,
|
||||
};
|
||||
@@ -21,6 +20,7 @@ pub(crate) struct Projector<'a> {
|
||||
types: &'a TypesProvider,
|
||||
metadata: &'a AstMetadata,
|
||||
unary: bool,
|
||||
labels: Option<NlLabelResolver>,
|
||||
aliases: Vec<AliasScope>,
|
||||
pending_elide: bool,
|
||||
pending_implied: bool,
|
||||
@@ -34,12 +34,14 @@ impl<'a> Projector<'a> {
|
||||
types: &'a TypesProvider,
|
||||
metadata: &'a AstMetadata,
|
||||
unary: bool,
|
||||
labels: Option<NlLabelResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
types,
|
||||
metadata,
|
||||
unary,
|
||||
labels,
|
||||
aliases: Vec::new(),
|
||||
pending_elide: false,
|
||||
pending_implied: false,
|
||||
@@ -67,8 +69,8 @@ impl<'a> Projector<'a> {
|
||||
),
|
||||
Node::String(value) => {
|
||||
let hint = match Self::enum_values(expected.as_ref()) {
|
||||
Some(values) => Some(EditHint::Select {
|
||||
options: self.intern_enum(&values),
|
||||
Some((name, values)) => Some(EditHint::Select {
|
||||
options: self.intern_enum(name.as_deref(), &values),
|
||||
}),
|
||||
None if Self::expects_date(expected.as_ref()) => Some(EditHint::DatePicker),
|
||||
None => None,
|
||||
@@ -141,7 +143,14 @@ impl<'a> Projector<'a> {
|
||||
operator,
|
||||
right,
|
||||
} => {
|
||||
let (exp_left, exp_right) = self.operand_expectations(*operator, left, right);
|
||||
let (exp_left, exp_right) = if matches!(
|
||||
operator,
|
||||
Operator::Logical(crate::lexer::LogicalOperator::NullishCoalescing)
|
||||
) {
|
||||
(expected.clone(), expected)
|
||||
} else {
|
||||
self.operand_expectations(*operator, left, right)
|
||||
};
|
||||
let context_subject = matches!(operator, Operator::Comparison(_))
|
||||
&& matches!(left, Node::Identifier(name) if *name == "$");
|
||||
if !context_subject && !self.is_elided_subject(left) {
|
||||
@@ -195,14 +204,14 @@ impl<'a> Projector<'a> {
|
||||
NlTokenKind::Word { sym: WordSym::Then },
|
||||
(self.span_of(condition).1, self.span_of(on_true).0),
|
||||
);
|
||||
self.project(on_true, None);
|
||||
self.project(on_true, expected.clone());
|
||||
self.push(
|
||||
NlTokenKind::Word {
|
||||
sym: WordSym::Otherwise,
|
||||
},
|
||||
(self.span_of(on_true).1, self.span_of(on_false).0),
|
||||
);
|
||||
self.project(on_false, None);
|
||||
self.project(on_false, expected);
|
||||
}
|
||||
|
||||
Node::Interval {
|
||||
@@ -242,7 +251,7 @@ impl<'a> Projector<'a> {
|
||||
Node::Array(items) => {
|
||||
let enum_domain = Self::enum_values(expected.as_ref())
|
||||
.filter(|_| items.iter().all(|item| matches!(item, Node::String(_))));
|
||||
if let Some(values) = enum_domain {
|
||||
if let Some((name, values)) = enum_domain {
|
||||
let selected = items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
@@ -251,7 +260,7 @@ impl<'a> Projector<'a> {
|
||||
})
|
||||
.collect();
|
||||
let hint = EditHint::MultiSelect {
|
||||
options: self.intern_enum(&values),
|
||||
options: self.intern_enum(name.as_deref(), &values),
|
||||
};
|
||||
self.push_hint(NlTokenKind::EnumList { selected }, span, Some(hint));
|
||||
return;
|
||||
@@ -355,7 +364,11 @@ impl<'a> Projector<'a> {
|
||||
},
|
||||
(self.span_of(left).1, self.span_of(right).0),
|
||||
);
|
||||
self.project(right, None);
|
||||
let needle_expected = match self.type_of(left) {
|
||||
haystack @ VariableType::Array(_) => Some(haystack),
|
||||
_ => None,
|
||||
};
|
||||
self.project(right, needle_expected);
|
||||
return;
|
||||
}
|
||||
if !(self.unary && sym.as_ref() == "bool" && self.out.is_empty()) {
|
||||
@@ -393,8 +406,9 @@ impl<'a> Projector<'a> {
|
||||
);
|
||||
self.push(NlTokenKind::Word { sym: WordSym::In }, (span.0, span.0));
|
||||
}
|
||||
let membership = self.closure_membership_expectation(arguments, alias);
|
||||
if let Some(collection) = arguments.first() {
|
||||
self.project(collection, None);
|
||||
self.project(collection, membership);
|
||||
}
|
||||
let leftmost = match arguments.get(1) {
|
||||
Some(Node::Closure { body, .. }) => Some(Self::leftmost_leaf(body)),
|
||||
@@ -438,6 +452,36 @@ impl<'a> Projector<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn closure_membership_expectation(
|
||||
&self,
|
||||
arguments: &[&Node],
|
||||
alias: Option<&str>,
|
||||
) -> Option<VariableType> {
|
||||
let Some(Node::Closure { body, .. }) = arguments.get(1) else {
|
||||
return None;
|
||||
};
|
||||
let mut node: &Node = body;
|
||||
while let Node::Parenthesized(inner) = node {
|
||||
node = inner;
|
||||
}
|
||||
let Node::Binary {
|
||||
left,
|
||||
operator: Operator::Comparison(cmp),
|
||||
right,
|
||||
} = node
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if !matches!(cmp, ComparisonOperator::In | ComparisonOperator::NotIn) {
|
||||
return None;
|
||||
}
|
||||
if !Self::is_binding_leaf(left, alias) {
|
||||
return None;
|
||||
}
|
||||
let rhs = self.type_of(right);
|
||||
Self::enum_values(Some(&rhs)).map(|(name, values)| VariableType::Enum(name, values))
|
||||
}
|
||||
|
||||
fn leftmost_leaf<'n>(body: &'n Node<'n>) -> &'n Node<'n> {
|
||||
let mut node = body;
|
||||
loop {
|
||||
@@ -561,14 +605,10 @@ impl<'a> Projector<'a> {
|
||||
left: &Node,
|
||||
right: &Node,
|
||||
) -> (Option<VariableType>, Option<VariableType>) {
|
||||
use crate::lexer::ComparisonOperator::{In, NotIn};
|
||||
let Operator::Comparison(comparison) = operator else {
|
||||
let Operator::Comparison(_) = operator else {
|
||||
return (None, None);
|
||||
};
|
||||
match comparison {
|
||||
In | NotIn => (None, Some(self.type_of(left))),
|
||||
_ => (Some(self.type_of(right)), Some(self.type_of(left))),
|
||||
}
|
||||
(Some(self.type_of(right)), Some(self.type_of(left)))
|
||||
}
|
||||
|
||||
fn code(&mut self, span: (u32, u32)) {
|
||||
@@ -616,8 +656,8 @@ impl<'a> Projector<'a> {
|
||||
VariableType::Object(_) => TypeTag::Object,
|
||||
VariableType::Null => TypeTag::Null,
|
||||
VariableType::Any => TypeTag::Unknown,
|
||||
VariableType::Enum(_, values) => TypeTag::Enum {
|
||||
index: self.intern_enum(values),
|
||||
VariableType::Enum(name, values) => TypeTag::Enum {
|
||||
index: self.intern_enum(name.as_deref(), values),
|
||||
},
|
||||
VariableType::Array(inner) => TypeTag::Array {
|
||||
items: Box::new(self.tag_of(inner)),
|
||||
@@ -626,28 +666,19 @@ impl<'a> Projector<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn intern_enum(&mut self, values: &[Rc<str>]) -> u32 {
|
||||
let existing = self.enums.iter().position(|e| {
|
||||
e.len() == values.len() && e.iter().zip(values).all(|(a, b)| a.label == b.as_ref())
|
||||
});
|
||||
fn intern_enum(&mut self, name: Option<&str>, values: &[Rc<str>]) -> u32 {
|
||||
let options = crate::nl::enum_options(name, values, self.labels.as_ref());
|
||||
let existing = self.enums.iter().position(|e| *e == options);
|
||||
if let Some(index) = existing {
|
||||
return index as u32;
|
||||
}
|
||||
self.enums.push(
|
||||
values
|
||||
.iter()
|
||||
.map(|v| EnumOption {
|
||||
label: v.to_string(),
|
||||
source: encode_string(v),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
self.enums.push(options);
|
||||
(self.enums.len() - 1) as u32
|
||||
}
|
||||
|
||||
fn enum_values(ty: Option<&VariableType>) -> Option<Vec<Rc<str>>> {
|
||||
fn enum_values(ty: Option<&VariableType>) -> Option<(Option<Rc<str>>, Vec<Rc<str>>)> {
|
||||
match ty? {
|
||||
VariableType::Enum(_, values) => Some(values.clone()),
|
||||
VariableType::Enum(name, values) => Some((name.clone(), values.clone())),
|
||||
VariableType::Nullable(inner) | VariableType::Array(inner) => {
|
||||
Self::enum_values(Some(inner))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user