Current section
Files
Jump to
Current section
Files
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.Import
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.Compatibility
alias Boxic.DMN.Imports
@normalized_element_names ~w(
definitions import itemDefinition itemComponent typeRef allowedValues
inputData variable decision informationRequirement requiredInput
requiredDecision knowledgeRequirement requiredKnowledge
businessKnowledgeModel encapsulatedLogic formalParameter decisionService
outputDecision inputDecision inputData literalExpression text decisionTable
input inputExpression inputValues output outputValues defaultOutputEntry
rule inputEntry outputEntry context contextEntry invocation binding parameter
functionDefinition relation column row list conditional if then else filter
in match for some every return satisfies
)
@retained_attributes %{
"definitions" => ~w(id name namespace expressionLanguage typeLanguage),
"import" => ~w(name namespace locationURI importType),
"itemDefinition" => ~w(id name isCollection),
"itemComponent" => ~w(id name isCollection),
"inputData" => ~w(id name),
"variable" => ~w(id name typeRef),
"decision" => ~w(id name),
"informationRequirement" => [],
"requiredInput" => ~w(href),
"requiredDecision" => ~w(href),
"knowledgeRequirement" => [],
"requiredKnowledge" => ~w(href),
"businessKnowledgeModel" => ~w(id name),
"encapsulatedLogic" => [],
"formalParameter" => ~w(id name typeRef),
"decisionService" => ~w(id name),
"outputDecision" => ~w(href),
"inputDecision" => ~w(href),
"literalExpression" => ~w(id typeRef expressionLanguage),
"decisionTable" => ~w(id hitPolicy aggregation outputLabel),
"input" => ~w(id label),
"inputExpression" => ~w(typeRef),
"output" => ~w(id name label typeRef),
"rule" => ~w(id),
"context" => ~w(id),
"contextEntry" => ~w(id),
"invocation" => ~w(id typeRef),
"parameter" => ~w(name),
"functionDefinition" => ~w(id),
"relation" => ~w(id),
"column" => ~w(id name typeRef),
"list" => ~w(id),
"conditional" => ~w(id),
"filter" => ~w(id),
"for" => ~w(id iteratorVariable),
"some" => ~w(id iteratorVariable),
"every" => ~w(id iteratorVariable)
}
@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)
merged =
if imported_models == [] do
merged
else
add_fidelity_loss(
merged,
{:resolved_import_graph, "load_file/1 merged sibling DMN documents"}
)
end
{: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
source_profile =
document
|> namespace_uri()
|> Compatibility.source_profile()
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,
source_profile: source_profile,
imports:
document
|> nodes("./*[local-name()='import']")
|> Map.new(fn import ->
namespace = attr(import, "namespace")
{namespace,
%Import{
name: attr(import, "name"),
namespace: namespace,
location_uri: attr(import, "locationURI"),
import_type: attr(import, "importType")
}}
end),
input_data: input_data,
decisions: decisions,
bkms: bkms,
item_definitions: item_definitions,
decision_services: decision_services,
serialization_fidelity: serialization_fidelity(document),
issues: input_issues ++ decision_issues ++ bkm_issues ++ service_issues
}
end
defp serialization_fidelity(document) do
known_losses =
[
{"description", "description"},
{"documentation", "documentation"},
{"extensionElements", "extension elements"},
{"authorityRequirement", "authority requirements"},
{"annotationClause", "decision-table annotation clauses"},
{"annotationEntry", "decision-table annotation entries"},
{"DMNDI", "DMNDI layout"},
{"DMNDiagram", "DMNDI layout"},
{"encapsulatedDecision", "encapsulated decision references"}
]
|> Enum.flat_map(fn {local_name, label} ->
count = length(nodes(document, ".//*[local-name()='#{local_name}']"))
if count == 0, do: [], else: [{:discarded_xml_content, label, count}]
end)
unknown_losses =
document
|> nodes(".//*")
|> Enum.map(&local_name/1)
|> Enum.reject(&(&1 in @normalized_element_names))
|> Enum.frequencies()
|> Enum.sort()
|> Enum.map(fn {name, count} -> {:unsupported_xml_element, name, count} end)
attribute_losses =
document
|> nodes("descendant-or-self::*")
|> Enum.flat_map(&discarded_attributes/1)
|> Enum.frequencies()
|> Enum.sort()
|> Enum.map(fn {{element, attribute}, count} ->
{:discarded_xml_attribute, element, attribute, count}
end)
losses = Enum.uniq(known_losses ++ unknown_losses ++ attribute_losses)
case losses do
[] -> :complete
losses -> {:lossy, losses}
end
end
defp discarded_attributes(element) do
element_name = local_name(element)
retained = Map.get(@retained_attributes, element_name, [])
element
|> nodes("./@*")
|> Enum.flat_map(fn attribute ->
case attribute_identity(attribute) do
{nil, name} ->
if name in retained, do: [], else: [{element_name, name}]
{namespace, name} ->
[{element_name, "{#{namespace}}#{name}"}]
end
end)
end
defp attribute_identity(attribute) do
case elem(attribute, 2) do
{namespace, local_name} -> {to_string(namespace), to_string(local_name)}
local_name -> {nil, to_string(local_name)}
end
end
defp add_fidelity_loss(%Model{serialization_fidelity: :complete} = model, loss),
do: %{model | serialization_fidelity: {:lossy, [loss]}}
defp add_fidelity_loss(%Model{serialization_fidelity: {:lossy, losses}} = model, loss),
do: %{model | serialization_fidelity: {:lossy, losses ++ [loss]}}
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_raw_string(node, "./*[local-name()='text']")
defp child_text(node, child),
do: xpath_raw_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 xpath_raw_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() |> empty_to_nil()
value -> value |> to_string() |> empty_to_nil()
end
end
defp local_name(node) do
node |> xpath_string("local-name(.)")
end
defp namespace_uri(node) when is_tuple(node) do
case elem(node, 2) do
{namespace, _local_name} -> to_string(namespace)
_unqualified_name -> nil
end
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