Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support for DMN model imports #215

Closed
wants to merge 35 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d81dc46
chore(deps-dev): bump log4j-core from 2.17.1 to 2.20.0
dependabot[bot] Feb 22, 2023
2b48fc0
chore(deps-dev): bump log4j-slf4j-impl from 2.19.0 to 2.20.0
dependabot[bot] Feb 22, 2023
5e8df0e
chore(deps-dev): bump log4j-api from 2.17.1 to 2.20.0
dependabot[bot] Feb 22, 2023
37263b3
chore(deps): bump scala-maven-plugin from 4.8.0 to 4.8.1
dependabot[bot] Mar 7, 2023
375bf32
chore(deps): bump maven-surefire-plugin from 2.22.2 to 3.0.0
dependabot[bot] Mar 15, 2023
2125117
chore(deps): bump slf4j-api from 2.0.6 to 2.0.7
dependabot[bot] Mar 20, 2023
3e7cc43
chore(deps): bump feel-engine from 1.15.3 to 1.16.0
dependabot[bot] Mar 22, 2023
f86643c
test: verify failure for non-existing decision
saig0 Mar 22, 2023
91533a3
fix: handle audit log with no entries
saig0 Mar 22, 2023
db11fce
test: remove the audit log properties from the test
saig0 Mar 22, 2023
472b1cc
test: verify failure for non-existing decision by name
saig0 Mar 22, 2023
c163785
test: verify cyclic dependencies in BKMs
saig0 Mar 22, 2023
5a209d5
fix: detect cyclic dependencies between BKMs
saig0 Mar 22, 2023
fa99e31
refactor: cyclic dependency check
saig0 Mar 23, 2023
3881147
refactor: cyclic dependency check
saig0 Mar 23, 2023
78e6295
refactor: cyclic dependency check
saig0 Mar 23, 2023
c1f9893
refactor: extract cyclic dependency check
saig0 Mar 23, 2023
ed57bc7
deps: replace log4j-slf4j-impl by log4j-slf4j2-impl
saig0 Mar 23, 2023
3607bba
Create CODEOWNERS
saig0 Mar 23, 2023
f325e51
chore(deps): bump license-maven-plugin from 4.1 to 4.2
dependabot[bot] Mar 24, 2023
c8949d9
refactor: move variable to its usage
saig0 Mar 24, 2023
9375b2d
build: change version to 1.7.5-SNAPSHOT
saig0 Mar 24, 2023
d7b25da
build: change version to 1.8.0-SNAPSHOT
saig0 Mar 24, 2023
7e1f6c9
build: ignore AuditLog API change
saig0 Mar 24, 2023
526b96b
release(v1.8.0)
actions-user Mar 24, 2023
9553a49
release(v1.8.0): prepare for next development iteration
actions-user Mar 24, 2023
c0e920b
chore(deps): bump camunda-dmn-model from 7.18.0 to 7.19.0
dependabot[bot] Apr 12, 2023
52bedf3
test: verify nested non-cyclic dependencies in decisions
remcowesterhoud Apr 19, 2023
ab9e039
fix: incorrect cyclic dependency detection
remcowesterhoud Apr 19, 2023
b42c9a7
release(v1.8.1)
actions-user Apr 21, 2023
b9ea01a
release(v1.8.1): prepare for next development iteration
actions-user Apr 21, 2023
6bc26be
Issue 71 dmn import (#2)
tracyhires Apr 25, 2023
c4956df
Issue 71 dmn import (#2)
tracyhires Apr 25, 2023
832519f
Merge branch 'camunda-main'
tracyhires Apr 26, 2023
e33641c
more special characters to search for when escaping names in expressions
tracyhires Apr 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api-check-ignore.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,12 @@
<differenceType>7002</differenceType>
<method>*</method>
</difference>
<difference>
<className>org/camunda/dmn/DmnEngine$Configuration</className>
<method>*Configuration*</method>
<!-- num params changed -->
<differenceType>7004</differenceType>
<from>3</from>
<to>4</to>
</difference>
</differences>
18 changes: 15 additions & 3 deletions src/main/scala/org/camunda/dmn/DmnEngine.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,20 @@
*/
package org.camunda.dmn

import org.camunda.bpm.model.dmn.DmnModelInstance

import java.io.InputStream
import java.util.ServiceLoader
import org.camunda.dmn.Audit._
import org.camunda.dmn.evaluation._
import org.camunda.dmn.model.xml.instance.DmnModelInstanceProvider
import org.camunda.dmn.parser._
import org.camunda.feel.{FeelEngine, FeelEngineClock}
import org.camunda.feel.context.{CustomFunctionProvider, FunctionProvider}
import org.camunda.feel.syntaxtree.{Val, ValError, ValNull}
import org.camunda.feel.valuemapper.{CustomValueMapper, ValueMapper}

import scala.collection.JavaConverters._
import scala.jdk.CollectionConverters._
import scala.collection.mutable.{ListBuffer => mutableList}
import scala.reflect.{ClassTag, classTag}

Expand Down Expand Up @@ -79,13 +82,15 @@ object DmnEngine {

case class Configuration(escapeNamesWithSpaces: Boolean = false,
escapeNamesWithDashes: Boolean = false,
lazyEvaluation: Boolean = false)
lazyEvaluation: Boolean = false,
modelInstanceProvider: Option[DmnModelInstanceProvider] = None)

class Builder {

private var escapeNamesWithSpaces_ = false
private var escapeNamesWithDashes_ = false
private var lazyEvaluation_ = false
private var modelInstanceProvider_ : Option[DmnModelInstanceProvider] = None
private var auditLogListeners_ = List[AuditLogListener]().toBuffer
private var clock: FeelEngineClock = FeelEngineClock.SystemClock

Expand All @@ -104,6 +109,11 @@ object DmnEngine {
this
}

def modelInstanceProvider(provider: DmnModelInstanceProvider): Builder = {
modelInstanceProvider_ = Some(provider)
this
}

def addAuditListener(listener: AuditLogListener): Builder = {
auditLogListeners_ += listener
this
Expand All @@ -119,7 +129,8 @@ object DmnEngine {
configuration = DmnEngine.Configuration(
escapeNamesWithSpaces = escapeNamesWithSpaces_,
escapeNamesWithDashes = escapeNamesWithDashes_,
lazyEvaluation = lazyEvaluation_),
lazyEvaluation = lazyEvaluation_,
modelInstanceProvider = modelInstanceProvider_),
auditLogListeners = auditLogListeners_.toList,
clock = clock
)
Expand All @@ -137,6 +148,7 @@ class DmnEngine(configuration: DmnEngine.Configuration =

private val valueMapper = loadValueMapper()
private val functionProvider = loadFunctionProvider()
private val loadedModels: Map[String, DmnModelInstance] = Map.empty
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 This variable is never used. We can delete it.


logger.info(
s"DMN-Engine created. [" +
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright © 2022 Camunda Services GmbH ([email protected])
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.camunda.dmn.model.xml.instance

import jdk.dynalink.Namespace
import org.camunda.bpm.model.dmn.DmnModelInstance
import org.camunda.bpm.model.xml.ModelBuilder
import org.camunda.bpm.model.xml.impl.ModelImpl
import org.camunda.bpm.model.xml.instance.DomDocument

import java.io.InputStream

/**
* Provides DmnModelInstances via an InputStream or through some other locator
*/
trait DmnModelInstanceProvider {

/**
* Attempts to find and load a dmn model for the given namespace, optionally
* using the locator if necessary
*
* @param namespace - the namespace of the dmn model searched for
* @param locator - a hint for the provider about where the model might be found
* @return - a DmnModelInstance if one was located for the given namespace, or None if the model could not be found
*/
def loadModel[T](namespace: String, locator: Option[T]): Option[DmnModelInstance]

/**
* Instantiates a DmnModelInstance from the given Input Stream
*
* @param is - the input stream from which a dmn model can be read
* @return
*/
def readModelFromStream(is: InputStream): DmnModelInstance
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright © 2022 Camunda Services GmbH ([email protected])
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.camunda.dmn.model.xml.instance

import org.camunda.bpm.model.dmn.impl.DmnModelInstanceImpl
import org.camunda.bpm.model.dmn.impl.DmnModelConstants
import org.camunda.bpm.model.xml.impl.ModelImpl
import org.camunda.bpm.model.xml.{ModelBuilder, ModelException}
import org.camunda.bpm.model.xml.instance.DomDocument
import org.camunda.bpm.model.xml.instance.ModelElementInstance
import org.camunda.bpm.model.dmn.instance.{DrgElement, NamedElement, Variable}
import org.camunda.bpm.model.xml.`type`.ModelElementType
import org.slf4j.LoggerFactory

import java.util
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try}

object ImportAwareDmnModelInstanceImpl {

val dmnNameSpaces: List[String] = List(
DmnModelConstants.DMN12_NS,
DmnModelConstants.DMN12_NS,
DmnModelConstants.DMN13_NS,
DmnModelConstants.DMN11_ALTERNATIVE_NS,
DmnModelConstants.DMN13_ALTERNATIVE_NS
).map(_.toLowerCase())

/**
* Allows an element's name to be qualified by the (import) name of the model that defines the element
*
* @param modelElement
*/
implicit class ModelQualifiedElementName(modelElement: NamedElement) {
def qualifiedName: String = {
Comment on lines +47 to +48
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ We should avoid implicit classes. They can be hard to understand and need to be migrated for Scala 3.

((modelElement.getModelInstance, modelElement) match {
case (m: ImportAwareDmnModelInstanceImpl, e: DrgElement) => m.importedModelName
case (m: ImportAwareDmnModelInstanceImpl, v: Variable) => m.importedModelName
case (_, _) => None
}) match {
case Some(qualifier) => s"$qualifier.${modelElement.getName}"
case None => modelElement.getName
}
}
}
}

/**
* A dmn model instance that is able to resolve elements existing in the DRG that were imported from other dmn models.
*/

class ImportAwareDmnModelInstanceImpl (model: ModelImpl,
modelBuilder: ModelBuilder,
document: DomDocument,
private val dmnModelInstanceProvider: DmnModelInstanceProvider)
extends DmnModelInstanceImpl(model, modelBuilder, document) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 This is an interesting idea. By extending the DmnModelInstanceImpl, we can hide the importing logic in this class transparently from the caller of the model API.

However, I would prefer composition over inheritance. The DmnModelInstanceImpl is a core class of the model API.

import ImportAwareDmnModelInstanceImpl._

private var importedModelName: Option[String] = None

def withImportedModelName(modelName: Option[String]): ImportAwareDmnModelInstanceImpl = {
val copy = clone()
copy.importedModelName = modelName;
copy
}

/**
* The collection of imported models to be loaded once when required
*/
private val importedModels = loadImports

private def loadImports = getDefinitions.getImports.asScala
.filter(id => dmnNameSpaces.contains(id.getImportType.toLowerCase()))
.flatMap(id =>
Try(dmnModelInstanceProvider.loadModel(id.getNamespace, Option(id.getLocationUri))
.map {
case iam: ImportAwareDmnModelInstanceImpl =>
iam.withImportedModelName(Option(id.getAttributeValue("name")))
case other => other
}) match {
case Success(m) => m
case Failure(exception) =>
val errorMsg = s"Unable to load imported model at location ${id.getLocationUri} " +
s"for name ${id.getAttributeValue("name")}"
LoggerFactory.getLogger(getClass).error(errorMsg, exception)
throw new ModelException(errorMsg, exception)
}).toSeq

/**
* Gets a model element from the model or an imported model whose id matches the given id
*
* @param id
* @tparam T
* @return
*/
override def getModelElementById[T <: ModelElementInstance](id: String): T = {
var element = super.getModelElementById[T](id)
if (element == null) {
importedModels.map(_.getModelElementById[T](id)).find(e => e != null) match {
case Some(value) => element = value
case None => //nothing to do
}
}
element
}

/**
* Gets all elements of the model and its imported models
*
* @param referencingClass - the type of element to be retrieved
* @tparam T
* @return
*/
override def getModelElementsByType[T <: ModelElementInstance](referencingClass: Class[T]): util.Collection[T] = {
(super.getModelElementsByType[T](referencingClass).asScala ++
importedModels.flatMap(_.getModelElementsByType[T](referencingClass).asScala)).asJavaCollection
}

override def getModelElementsByType(`type`: ModelElementType): util.Collection[ModelElementInstance] = {
(super.getModelElementsByType(`type`).asScala ++
importedModels.flatMap(_.getModelElementsByType(`type`).asScala)).asJavaCollection
}
override def clone(): ImportAwareDmnModelInstanceImpl = {
val superClone = super.clone()
new ImportAwareDmnModelInstanceImpl(model, modelBuilder, superClone.getDocument, dmnModelInstanceProvider)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright © 2022 Camunda Services GmbH ([email protected])
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.camunda.dmn.model.xml.instance

import org.camunda.bpm.model.dmn.impl.{DmnModelInstanceImpl, DmnParser}
import org.camunda.bpm.model.dmn.{Dmn, DmnModelInstance}
import org.camunda.bpm.model.xml.impl.ModelImpl
import org.camunda.bpm.model.xml.instance.DomDocument

import java.io.InputStream

/**
* A DmnModelInstanceProvider that maintains a list of all models that it has already been used to load,
* which can then be retrieved by namespace
*/
class StatefulDmnModelInstanceProvider extends DmnParser with DmnModelInstanceProvider {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 We should favor composition over inheritance. For example, by injecting the provider in the parser.


val loadedModels: scala.collection.mutable.Map[String, DmnModelInstance] = scala.collection.mutable.Map.empty

/**
* Retrieves the dmn model
* @param namespace - the namespace of the dmn model searched for
* @param locator - a hint for the provider about where the model might be found. In this case, returns none if locator is not an InputStream
* @return - a DmnModelInstance if one was located for the given namespace, or None if the model could not be found
*/
override def loadModel[T](namespace: String, locator: Option[T] = None): Option[DmnModelInstance] = {
if (!loadedModels.contains(namespace)) {
None
} else {
loadedModels.get(namespace)
}
}

override def readModelFromStream(is: InputStream): DmnModelInstance = {
Option(parseModelFromStream(is))
.map(modelInstance => {
loadedModels.put(
modelInstance.getDefinitions.getNamespace,
modelInstance)
modelInstance
}).orNull
}

override def createModelInstance(document: DomDocument): DmnModelInstanceImpl = {
new ImportAwareDmnModelInstanceImpl(
Dmn.INSTANCE.getDmnModel.asInstanceOf[ModelImpl],
Dmn.INSTANCE.getDmnModelBuilder,
document,
this)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright © 2022 Camunda Services GmbH ([email protected])
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.camunda.dmn.model.xml.instance

import org.camunda.bpm.model.dmn.{Dmn, DmnModelInstance}
import org.camunda.bpm.model.xml.impl.ModelImpl

import java.io.InputStream
import java.net.URI
import scala.reflect.ClassTag.Nothing

object URILocatorDmnModelInstanceProvider {
/**
* Attempts to open an input stream on a resource at the given uri. If the uri is absolute, then an attempt is made
* to open the stream at that location. If the URI is relative, then the resource is assumed to be a classpath
* resource, and an attempt will be made to locate it on the classpath.
*
* @param uri - A java.net.URI suitable for locating a resource
*/
implicit class URItoStream(val uri: URI) {
def inputStream: InputStream = if (uri.isAbsolute) {
uri.toURL.openStream()
} else {
ClassLoader.getSystemResourceAsStream(s"${uri.getPath}")
}
}
}

class URILocatorDmnModelInstanceProvider extends StatefulDmnModelInstanceProvider {
import org.camunda.dmn.model.xml.instance.URILocatorDmnModelInstanceProvider._

/**
* Loads a model from a URI given by the locator, or returns a model already loaded
*
* @param namespace - the namespace of the dmn model searched for
* @param locator - An absolute URI or a path to a location on the classpath where the model can be found
* @tparam T
* @return - a DmnModelInstance if one was located for the given namespace, or None if the model could not be found
*/
override def loadModel[T](namespace: String, locator: Option[T]): Option[DmnModelInstance] = {
super.loadModel(namespace, locator)
.orElse(locator.map {
case s: String => readModelFromStream(URI.create(s).inputStream)
case uri: URI => readModelFromStream(uri.inputStream)
case _ => null
})
}
Comment on lines +53 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ It feels insecure to load an arbitrary URL from the DMN XML.

}
Loading