Packages

A native Elixir loader, validator, and evaluator for Decision Model and Notation 1.4 models.

Current section

Files

Jump to
boxic_dmn lib boxic dmn xml loader.ex
Raw

lib/boxic/dmn/xml/loader.ex

defmodule Boxic.DMN.XML.Loader do
@moduledoc false
alias Boxic.DMN.Model
alias Boxic.DMN.Model.Binding
alias Boxic.DMN.Model.BusinessKnowledgeModel
alias Boxic.DMN.Model.ContextEntry
alias Boxic.DMN.Model.ContextExpression
alias Boxic.DMN.Model.ConditionalExpression
alias Boxic.DMN.Model.Decision
alias Boxic.DMN.Model.DecisionRule
alias Boxic.DMN.Model.DecisionService
alias Boxic.DMN.Model.DecisionTable
alias Boxic.DMN.Model.Definitions
alias Boxic.DMN.Model.FunctionDefinition
alias Boxic.DMN.Model.FilterExpression
alias Boxic.DMN.Model.InformationRequirement
alias Boxic.DMN.Model.InputData
alias Boxic.DMN.Model.InputClause
alias Boxic.DMN.Model.Invocation
alias Boxic.DMN.Model.IteratorExpression
alias Boxic.DMN.Model.ItemComponent
alias Boxic.DMN.Model.ItemDefinition
alias Boxic.DMN.Model.LiteralExpression
alias Boxic.DMN.Model.ListExpression
alias Boxic.DMN.Model.OutputClause
alias Boxic.DMN.Model.Relation
alias Boxic.DMN.Model.RelationColumn
alias Boxic.DMN.Model.Variable
alias Boxic.DMN.Imports
@doc """
Loads a DMN document from either an XML string or a file path.
New code should prefer `load_xml/1` or `load_file/1`, which avoid input
ambiguity.
"""
@spec load(String.t()) :: {:ok, Model.t()} | {:error, load_error()}
def load(path_or_xml) when is_binary(path_or_xml) do
if xml_input?(path_or_xml), do: load_xml(path_or_xml), else: load_file(path_or_xml)
end
@typedoc "An error returned while reading or decoding a DMN document."
@type load_error ::
{:file_error, Path.t(), File.posix()}
| :invalid_xml
| :trailing_xml_content
| :invalid_definitions_document
@doc "Loads a DMN document and its sibling imports from `path`."
@spec load_file(Path.t()) :: {:ok, Model.t()} | {:error, load_error()}
def load_file(path) when is_binary(path) do
with {:ok, xml} <- read_file(path),
{:ok, model} <- load_xml(xml) do
{imported_models, import_issues} =
path
|> Path.dirname()
|> Path.join("*.dmn")
|> Path.wildcard()
|> Enum.reject(&(&1 == path))
|> Enum.reduce({[], []}, fn imported_path, {models, issues} ->
case File.read(imported_path) do
{:ok, imported_xml} ->
case load_xml(imported_xml) do
{:ok, imported} -> {[imported | models], issues}
{:error, reason} -> {models, [{:import_error, imported_path, reason} | issues]}
end
{:error, reason} ->
{models, [{:import_file_error, imported_path, reason} | issues]}
end
end)
merged = Imports.merge(model, imported_models)
{:ok, %{merged | issues: merged.issues ++ Enum.reverse(import_issues)}}
end
end
@doc "Loads a DMN document from an XML string."
@spec load_xml(String.t()) :: {:ok, Model.t()} | {:error, load_error()}
def load_xml(xml) when is_binary(xml) do
with {:ok, document} <- parse_xml(xml),
:ok <- definitions_document?(document) do
{:ok, build_model(document)}
end
end
defp read_file(path) do
case File.read(path) do
{:ok, contents} -> {:ok, contents}
{:error, reason} -> {:error, {:file_error, path, reason}}
end
end
defp xml_input?(input), do: input |> String.trim_leading() |> String.starts_with?("<")
defp parse_xml(xml) do
try do
{document, rest} =
:xmerl_scan.string(:binary.bin_to_list(xml), namespace_conformant: true)
if rest |> to_string() |> String.trim() == "",
do: {:ok, document},
else: {:error, :trailing_xml_content}
rescue
_ -> {:error, :invalid_xml}
catch
:exit, _ -> {:error, :invalid_xml}
end
end
defp definitions_document?(document) do
case nodes(document, "/*[local-name()='definitions']") do
[_] -> :ok
_ -> {:error, :invalid_definitions_document}
end
end
defp build_model(document) do
definitions = %Definitions{
id: attr(document, "id"),
name: attr(document, "name"),
namespace: attr(document, "namespace"),
expression_language: attr(document, "expressionLanguage"),
type_language: attr(document, "typeLanguage")
}
{input_data, input_issues} =
document |> nodes("./*[local-name()='inputData']") |> index_nodes(&parse_input_data/1)
{decisions, decision_issues} =
document |> nodes("./*[local-name()='decision']") |> index_nodes(&parse_decision/1)
{bkms, bkm_issues} =
document
|> nodes("./*[local-name()='businessKnowledgeModel']")
|> index_nodes(&parse_bkm/1)
{decision_services, service_issues} =
document
|> nodes("./*[local-name()='decisionService']")
|> index_nodes(&parse_decision_service/1)
item_definitions =
document
|> nodes("./*[local-name()='itemDefinition']")
|> Map.new(fn node ->
item = parse_item_definition(node)
{item.name, item}
end)
%Model{
definitions: definitions,
imports:
document
|> nodes("./*[local-name()='import']")
|> Map.new(&{attr(&1, "namespace"), attr(&1, "name")}),
input_data: input_data,
decisions: decisions,
bkms: bkms,
item_definitions: item_definitions,
decision_services: decision_services,
issues: input_issues ++ decision_issues ++ bkm_issues ++ service_issues
}
end
defp parse_item_definition(node) do
function_item = node |> nodes("./*[local-name()='functionItem']") |> List.first()
%ItemDefinition{
id: attr(node, "id"),
name: attr(node, "name"),
type_ref:
child_text_value(node, "typeRef") ||
(function_item && "function:" <> (attr(function_item, "outputTypeRef") || "Any")),
allowed_values: child_text(node, "allowedValues"),
is_collection: attr(node, "isCollection") == "true",
components:
Enum.map(nodes(node, "./*[local-name()='itemComponent']"), &parse_item_component/1)
}
end
defp parse_item_component(node) do
%ItemComponent{
id: attr(node, "id"),
name: attr(node, "name"),
type_ref: child_text_value(node, "typeRef"),
allowed_values: child_text(node, "allowedValues"),
is_collection: attr(node, "isCollection") == "true",
components:
Enum.map(nodes(node, "./*[local-name()='itemComponent']"), &parse_item_component/1)
}
end
defp parse_decision_service(node) do
%DecisionService{
id: attr(node, "id"),
name: attr(node, "name"),
variable: parse_variable(node),
output_decisions: service_references(node, "outputDecision"),
input_decisions: service_references(node, "inputDecision"),
input_data: service_references(node, "inputData")
}
end
defp service_references(node, child) do
node
|> nodes("./*[local-name()='#{child}']")
|> Enum.map(&reference/1)
end
defp parse_bkm(node) do
logic = node |> nodes("./*[local-name()='encapsulatedLogic']") |> List.first()
expression =
logic &&
logic
|> nodes("./*[not(local-name()='formalParameter') and not(local-name()='description')]")
|> List.first()
%BusinessKnowledgeModel{
id: attr(node, "id"),
name: attr(node, "name"),
variable: parse_variable(node),
requirements: parse_requirements(node),
parameters:
if(logic,
do: Enum.map(nodes(logic, "./*[local-name()='formalParameter']"), &parse_parameter/1),
else: []
),
expression: expression && parse_expression_node(expression)
}
end
defp parse_parameter(node),
do: %Variable{id: attr(node, "id"), name: attr(node, "name"), type_ref: attr(node, "typeRef")}
defp parse_input_data(node) do
%InputData{id: attr(node, "id"), name: attr(node, "name"), variable: parse_variable(node)}
end
defp parse_decision(node) do
%Decision{
id: attr(node, "id"),
name: attr(node, "name"),
variable: parse_variable(node),
requirements: parse_requirements(node),
expression: parse_expression(node)
}
end
defp parse_variable(node) do
case nodes(node, "./*[local-name()='variable']") do
[variable | _] ->
%Variable{
id: attr(variable, "id"),
name: attr(variable, "name"),
type_ref: attr(variable, "typeRef")
}
[] ->
nil
end
end
defp parse_requirements(node) do
information =
node
|> nodes("./*[local-name()='informationRequirement']")
|> Enum.flat_map(fn requirement ->
input =
requirement
|> nodes("./*[local-name()='requiredInput']")
|> Enum.map(&%InformationRequirement{kind: :input_data, href: reference(&1)})
decisions =
requirement
|> nodes("./*[local-name()='requiredDecision']")
|> Enum.map(&%InformationRequirement{kind: :decision, href: reference(&1)})
case input ++ decisions do
[] -> [%InformationRequirement{}]
references -> references
end
end)
knowledge =
node
|> nodes("./*[local-name()='knowledgeRequirement']/*[local-name()='requiredKnowledge']")
|> Enum.map(&%InformationRequirement{kind: :knowledge, href: reference(&1)})
information ++ knowledge
end
defp parse_expression(node) do
children =
nodes(
node,
"./*[not(local-name()='description') and not(local-name()='question') and not(local-name()='allowedAnswers') and not(local-name()='variable') and not(local-name()='informationRequirement') and not(local-name()='knowledgeRequirement') and not(local-name()='authorityRequirement') and not(local-name()='extensionElements')]"
)
case children do
[expression | _] ->
parse_expression_node(expression)
[] ->
nil
end
end
defp parse_expression_node(expression) do
case local_name(expression) do
"literalExpression" ->
%LiteralExpression{
id: attr(expression, "id"),
text: text_child(expression),
type_ref: attr(expression, "typeRef"),
expression_language: attr(expression, "expressionLanguage") || "feel"
}
"decisionTable" ->
parse_decision_table(expression)
"context" ->
%ContextExpression{
id: attr(expression, "id"),
entries:
Enum.map(
nodes(expression, "./*[local-name()='contextEntry']"),
&parse_context_entry/1
)
}
"invocation" ->
parse_invocation(expression)
"functionDefinition" ->
body =
expression
|> nodes("./*[not(local-name()='formalParameter') and not(local-name()='description')]")
|> List.first()
%FunctionDefinition{
id: attr(expression, "id"),
parameters:
Enum.map(nodes(expression, "./*[local-name()='formalParameter']"), &parse_parameter/1),
body: body && parse_expression_node(body)
}
"relation" ->
%Relation{
id: attr(expression, "id"),
columns:
Enum.map(nodes(expression, "./*[local-name()='column']"), fn column ->
%RelationColumn{
id: attr(column, "id"),
name: attr(column, "name"),
type_ref: attr(column, "typeRef")
}
end),
rows:
Enum.map(nodes(expression, "./*[local-name()='row']"), fn row ->
Enum.map(
nodes(row, "./*[local-name()='literalExpression']"),
&parse_expression_node/1
)
end)
}
"list" ->
%ListExpression{
id: attr(expression, "id"),
items:
Enum.map(
nodes(expression, "./*[local-name()='literalExpression']"),
&parse_expression_node/1
)
}
"conditional" ->
%ConditionalExpression{
id: attr(expression, "id"),
condition: parse_boxed_child(expression, "if"),
then_branch: parse_boxed_child(expression, "then"),
else_branch: parse_boxed_child(expression, "else")
}
"filter" ->
%FilterExpression{
id: attr(expression, "id"),
source: parse_boxed_child(expression, "in"),
match: parse_boxed_child(expression, "match")
}
kind when kind in ["for", "some", "every"] ->
body_name = if kind == "for", do: "return", else: "satisfies"
%IteratorExpression{
id: attr(expression, "id"),
kind: String.to_atom(kind),
variable: attr(expression, "iteratorVariable"),
source: parse_boxed_child(expression, "in"),
body: parse_boxed_child(expression, body_name)
}
kind ->
{:unsupported, kind}
end
end
defp parse_boxed_child(expression, name) do
expression
|> nodes("./*[local-name()='#{name}']/*[1]")
|> List.first()
|> then(&(&1 && parse_expression_node(&1)))
end
defp parse_invocation(node) do
function = node |> nodes("./*[local-name()='literalExpression']") |> List.first()
%Invocation{
id: attr(node, "id"),
type_ref: attr(node, "typeRef"),
function: function && parse_expression_node(function),
bindings: Enum.map(nodes(node, "./*[local-name()='binding']"), &parse_binding/1)
}
end
defp parse_binding(node) do
parameter = node |> nodes("./*[local-name()='parameter']") |> List.first()
expression = node |> nodes("./*[local-name()='literalExpression']") |> List.first()
%Binding{
parameter: parameter && attr(parameter, "name"),
expression: expression && parse_expression_node(expression)
}
end
defp parse_context_entry(node) do
expression =
node
|> nodes("./*[not(local-name()='variable') and not(local-name()='description')]")
|> List.first()
%ContextEntry{
id: attr(node, "id"),
variable: parse_variable(node),
expression: expression && parse_expression_node(expression)
}
end
defp parse_decision_table(node) do
%DecisionTable{
id: attr(node, "id"),
hit_policy: node |> attr("hitPolicy") |> normalize_hit_policy(),
aggregation: attr(node, "aggregation"),
output_label: attr(node, "outputLabel"),
inputs: Enum.map(nodes(node, "./*[local-name()='input']"), &parse_input_clause/1),
outputs: Enum.map(nodes(node, "./*[local-name()='output']"), &parse_output_clause/1),
rules: Enum.map(nodes(node, "./*[local-name()='rule']"), &parse_decision_rule/1)
}
end
defp normalize_hit_policy(nil), do: "UNIQUE"
defp normalize_hit_policy(value), do: String.replace(value, " ", "_")
defp parse_input_clause(node) do
expression = node |> nodes("./*[local-name()='inputExpression']") |> List.first()
%InputClause{
id: attr(node, "id"),
label: attr(node, "label"),
expression: expression && text_child(expression),
type_ref: expression && attr(expression, "typeRef"),
allowed_values: child_text(node, "inputValues")
}
end
defp parse_output_clause(node) do
%OutputClause{
id: attr(node, "id"),
name: attr(node, "name"),
label: attr(node, "label"),
type_ref: attr(node, "typeRef"),
allowed_values: child_text(node, "outputValues"),
default_output: child_text(node, "defaultOutputEntry")
}
end
defp parse_decision_rule(node) do
%DecisionRule{
id: attr(node, "id"),
input_entries: Enum.map(nodes(node, "./*[local-name()='inputEntry']"), &text_child/1),
output_entries: Enum.map(nodes(node, "./*[local-name()='outputEntry']"), &text_child/1)
}
end
defp index_nodes(nodes, parser) do
Enum.reduce(nodes, {%{}, []}, fn node, {items, issues} ->
item = parser.(node)
cond do
is_nil(item.id) and is_binary(Map.get(item, :name)) ->
item = Map.put(item, :id, item.name)
{Map.put(items, item.name, item),
[{:missing_id, struct_name(item), item.name} | issues]}
is_nil(item.id) ->
{items, [{:missing_id, struct_name(item), item.name} | issues]}
Map.has_key?(items, item.id) ->
{items, [{:duplicate_id, item.id} | issues]}
true ->
{Map.put(items, item.id, item), issues}
end
end)
end
defp reference(node) do
case attr(node, "href") do
"#" <> id -> id
href -> href
end
end
defp text_child(node), do: xpath_string(node, "./*[local-name()='text']")
defp child_text(node, child),
do: xpath_string(node, "./*[local-name()='#{child}']/*[local-name()='text']")
defp child_text_value(node, child),
do: xpath_string(node, "./*[local-name()='#{child}']/text()")
defp attr(node, name), do: xpath_string(node, "./@*[local-name()='#{name}']")
defp nodes(node, path), do: :xmerl_xpath.string(String.to_charlist(path), node)
defp xpath_string(node, path) do
query = ~c"string(" ++ String.to_charlist(path) ++ ~c")"
case :xmerl_xpath.string(query, node) do
{:xmlObj, :string, value} -> value |> to_string() |> String.trim() |> empty_to_nil()
value -> value |> to_string() |> String.trim() |> empty_to_nil()
end
end
defp local_name(node) do
node |> xpath_string("local-name(.)")
end
defp struct_name(%module{}),
do: module |> Module.split() |> List.last() |> Macro.underscore() |> String.to_atom()
defp empty_to_nil(""), do: nil
defp empty_to_nil(value), do: value
end