Skip to content

Commit b5a930f

Browse files
authored
Merge pull request #908 from camunda/771-distinct-values
fix: Detect duplicated context values in `distinct values()` + `union()` + `duplicate values()`
2 parents 97db122 + 4f78808 commit b5a930f

File tree

7 files changed

+393
-200
lines changed

7 files changed

+393
-200
lines changed

src/main/scala/org/camunda/feel/FeelEngine.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ class FeelEngine(
119119
val clock: FeelEngineClock = FeelEngine.defaultClock
120120
) {
121121

122-
private val interpreter = new FeelInterpreter()
122+
private val interpreter = new FeelInterpreter(valueMapper)
123123

124124
private val validator = new ExpressionValidator(
125125
externalFunctionsEnabled = configuration.externalFunctionsEnabled

src/main/scala/org/camunda/feel/impl/builtin/ListBuiltinFunctions.scala

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package org.camunda.feel.impl.builtin
1818

1919
import org.camunda.feel.impl.builtin.BuiltinFunction.builtinFunction
2020
import org.camunda.feel.Number
21+
import org.camunda.feel.impl.interpreter.ValComparator
2122
import org.camunda.feel.syntaxtree.{
2223
Val,
2324
ValBoolean,
@@ -28,10 +29,13 @@ import org.camunda.feel.syntaxtree.{
2829
ValNumber,
2930
ValString
3031
}
32+
import org.camunda.feel.valuemapper.ValueMapper
3133

3234
import scala.annotation.tailrec
3335

34-
object ListBuiltinFunctions {
36+
class ListBuiltinFunctions(private val valueMapper: ValueMapper) {
37+
38+
private val valueComparator = new ValComparator(valueMapper)
3539

3640
def functions = Map(
3741
"list contains" -> List(listContainsFunction),
@@ -375,32 +379,40 @@ object ListBuiltinFunctions {
375379
private def unionFunction = builtinFunction(
376380
params = List("lists"),
377381
invoke = { case List(ValList(lists)) =>
378-
ValList(
379-
lists
380-
.flatMap(_ match {
381-
case ValList(list) => list
382-
case v => List(v)
383-
})
384-
.toList
385-
.distinct
386-
)
382+
val listOfLists = lists.flatMap {
383+
case ValList(list) => list
384+
case v => List(v)
385+
}
386+
ValList(distinct(listOfLists))
387387
},
388388
hasVarArgs = true
389389
)
390390

391391
private def distinctValuesFunction =
392392
builtinFunction(
393393
params = List("list"),
394-
invoke = { case List(ValList(list)) =>
395-
ValList(list.distinct)
394+
invoke = { case List(ValList(list)) => ValList(distinct(list)) }
395+
)
396+
397+
private def distinct(list: List[Val]): List[Val] = {
398+
list.foldLeft(List[Val]())((result, item) =>
399+
if (result.exists(y => valueComparator.equals(item, y))) {
400+
// duplicate value
401+
result
402+
} else {
403+
result :+ item
396404
}
397405
)
406+
}
398407

399408
private def duplicateValuesFunction =
400409
builtinFunction(
401410
params = List("list"),
402411
invoke = { case List(ValList(list)) =>
403-
ValList(list.distinct.filter(x => list.count(_ == x) > 1))
412+
val duplicatedValues =
413+
distinct(list).filter(x => list.count(valueComparator.equals(_, x)) > 1)
414+
415+
ValList(duplicatedValues)
404416
}
405417
)
406418

src/main/scala/org/camunda/feel/impl/interpreter/BuiltinFunctions.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class BuiltinFunctions(clock: FeelEngineClock, valueMapper: ValueMapper) extends
4242
new ConversionBuiltinFunctions(valueMapper).functions ++
4343
BooleanBuiltinFunctions.functions ++
4444
StringBuiltinFunctions.functions ++
45-
ListBuiltinFunctions.functions ++
45+
new ListBuiltinFunctions(valueMapper).functions ++
4646
NumericBuiltinFunctions.functions ++
4747
new ContextBuiltinFunctions(valueMapper).functions ++
4848
RangeBuiltinFunction.functions ++

src/main/scala/org/camunda/feel/impl/interpreter/FeelInterpreter.scala

Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ import scala.reflect.ClassTag
3939
/** @author
4040
* Philipp Ossler
4141
*/
42-
class FeelInterpreter {
42+
class FeelInterpreter(private val valueMapper: ValueMapper) {
43+
44+
private val valueComparator = new ValComparator(valueMapper)
4345

4446
def eval(expression: Exp)(implicit context: EvalContext): Val = {
4547
// Check if the current thread was interrupted, otherwise long-running evaluations can not be interrupted and fully block the thread
@@ -80,7 +82,7 @@ class FeelInterpreter {
8082

8183
// simple unary tests
8284
case InputEqualTo(x) =>
83-
withVal(getImplicitInputValue, i => checkEquality(i, eval(x), _ == _, ValBoolean))
85+
withVal(getImplicitInputValue, i => checkEquality(i, eval(x)))
8486
case InputLessThan(x) =>
8587
withVal(getImplicitInputValue, i => dualOp(i, eval(x), _ < _, ValBoolean))
8688
case InputLessOrEqual(x) =>
@@ -118,7 +120,7 @@ class FeelInterpreter {
118120
withValOrNull(withNumber(eval(x), x => ValNumber(-x)))
119121

120122
// dual comparators
121-
case Equal(x, y) => checkEquality(eval(x), eval(y), _ == _, ValBoolean)
123+
case Equal(x, y) => checkEquality(eval(x), eval(y))
122124
case LessThan(x, y) => dualOp(eval(x), eval(y), _ < _, ValBoolean)
123125
case LessOrEqual(x, y) => dualOp(eval(x), eval(y), _ <= _, ValBoolean)
124126
case GreaterThan(x, y) => dualOp(eval(x), eval(y), _ > _, ValBoolean)
@@ -513,65 +515,15 @@ class FeelInterpreter {
513515
}
514516
}
515517

516-
private def checkEquality(x: Val, y: Val, c: (Any, Any) => Boolean, f: Boolean => Val)(implicit
517-
context: EvalContext
518-
): Val =
518+
private def checkEquality(x: Val, y: Val)(implicit context: EvalContext): Val =
519519
withValues(
520520
x,
521521
y,
522-
{
523-
case (ValNull, _) => f(c(ValNull, y.toOption.getOrElse(ValNull)))
524-
case (_, ValNull) => f(c(x.toOption.getOrElse(ValNull), ValNull))
525-
case (ValNumber(x), ValNumber(y)) => f(c(x, y))
526-
case (ValBoolean(x), ValBoolean(y)) => f(c(x, y))
527-
case (ValString(x), ValString(y)) => f(c(x, y))
528-
case (ValDate(x), ValDate(y)) => f(c(x, y))
529-
case (ValLocalTime(x), ValLocalTime(y)) => f(c(x, y))
530-
case (ValTime(x), ValTime(y)) => f(c(x, y))
531-
case (ValLocalDateTime(x), ValLocalDateTime(y)) => f(c(x, y))
532-
case (ValDateTime(x), ValDateTime(y)) => f(c(x, y))
533-
case (ValYearMonthDuration(x), ValYearMonthDuration(y)) => f(c(x, y))
534-
case (ValDayTimeDuration(x), ValDayTimeDuration(y)) => f(c(x, y))
535-
case (ValList(x), ValList(y)) =>
536-
if (x.size != y.size) {
537-
f(false)
538-
539-
} else {
540-
val isEqual = x.zip(y).foldRight(true) { case ((x, y), listIsEqual) =>
541-
listIsEqual && {
542-
checkEquality(x, y, c, f) match {
543-
case ValBoolean(itemIsEqual) => itemIsEqual
544-
case _ => false
545-
}
546-
}
547-
}
548-
f(isEqual)
549-
}
550-
case (ValContext(x), ValContext(y)) =>
551-
val xVars = x.variableProvider.getVariables
552-
val yVars = y.variableProvider.getVariables
553-
554-
if (xVars.keys != yVars.keys) {
555-
f(false)
556-
557-
} else {
558-
val isEqual = xVars.keys.foldRight(true) { case (key, contextIsEqual) =>
559-
contextIsEqual && {
560-
val xVal = context.valueMapper.toVal(xVars(key))
561-
val yVal = context.valueMapper.toVal(yVars(key))
562-
563-
checkEquality(xVal, yVal, c, f) match {
564-
case ValBoolean(entryIsEqual) => entryIsEqual
565-
case _ => false
566-
}
567-
}
568-
}
569-
f(isEqual)
570-
}
571-
case _ =>
522+
(x, y) =>
523+
valueComparator.compare(x, y).toOption.getOrElse {
572524
error(EvaluationFailureType.NOT_COMPARABLE, s"Can't compare '$x' with '$y'")
573525
ValNull
574-
}
526+
}
575527
)
576528

577529
private def dualOp(x: Val, y: Val, c: (Val, Val) => Boolean, f: Boolean => Val)(implicit
@@ -715,7 +667,7 @@ class FeelInterpreter {
715667
// the expression contains the input value
716668
ValBoolean(true)
717669
case x =>
718-
checkEquality(inputValue, x, _ == _, ValBoolean) match {
670+
checkEquality(inputValue, x) match {
719671
case ValBoolean(true) =>
720672
// the expression is the input value
721673
ValBoolean(true)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH
3+
* under one or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information regarding copyright
5+
* ownership. Camunda licenses this file to you under the Apache License,
6+
* Version 2.0; you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.camunda.feel.impl.interpreter
18+
19+
import org.camunda.feel.context.Context
20+
import org.camunda.feel.syntaxtree._
21+
import org.camunda.feel.valuemapper.ValueMapper
22+
23+
class ValComparator(private val valueMapper: ValueMapper) {
24+
25+
def equals(x: Val, y: Val): Boolean = compare(x, y) match {
26+
case ValBoolean(isEqual) => isEqual
27+
case _ => false
28+
}
29+
30+
def compare(x: Val, y: Val): Val = (x, y) match {
31+
// both values are null
32+
case (ValNull, _) => ValBoolean(ValNull == y.toOption.getOrElse(ValNull))
33+
case (_, ValNull) => ValBoolean(x.toOption.getOrElse(ValNull) == ValNull)
34+
// compare values of the same type
35+
case (ValNumber(x), ValNumber(y)) => ValBoolean(x == y)
36+
case (ValBoolean(x), ValBoolean(y)) => ValBoolean(x == y)
37+
case (ValString(x), ValString(y)) => ValBoolean(x == y)
38+
case (ValDate(x), ValDate(y)) => ValBoolean(x == y)
39+
case (ValLocalTime(x), ValLocalTime(y)) => ValBoolean(x == y)
40+
case (ValTime(x), ValTime(y)) => ValBoolean(x == y)
41+
case (ValLocalDateTime(x), ValLocalDateTime(y)) => ValBoolean(x == y)
42+
case (ValDateTime(x), ValDateTime(y)) => ValBoolean(x == y)
43+
case (ValYearMonthDuration(x), ValYearMonthDuration(y)) => ValBoolean(x == y)
44+
case (ValDayTimeDuration(x), ValDayTimeDuration(y)) => ValBoolean(x == y)
45+
case (ValList(x), ValList(y)) => compare(x, y)
46+
case (ValContext(x), ValContext(y)) => compare(x, y)
47+
// values have a different type
48+
case _ => ValError(s"Can't compare '$x' with '$y'")
49+
}
50+
51+
private def compare(x: List[Val], y: List[Val]): ValBoolean = {
52+
ValBoolean(
53+
x.size == y.size && x.zip(y).forall { case (itemX, itemY) => equals(itemX, itemY) }
54+
)
55+
}
56+
57+
private def compare(x: Context, y: Context): ValBoolean = {
58+
val xVars = x.variableProvider.getVariables
59+
val yVars = y.variableProvider.getVariables
60+
61+
ValBoolean(xVars.keys == yVars.keys && xVars.keys.forall { key =>
62+
val xVal = valueMapper.toVal(xVars(key))
63+
val yVal = valueMapper.toVal(yVars(key))
64+
65+
equals(xVal, yVal)
66+
})
67+
}
68+
69+
}

src/test/scala/org/camunda/feel/impl/FeelIntegrationTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import org.camunda.feel.{
3737
trait FeelIntegrationTest {
3838

3939
val interpreter: FeelInterpreter =
40-
new FeelInterpreter
40+
new FeelInterpreter(ValueMapper.defaultValueMapper)
4141

4242
private val clock: TimeTravelClock = new TimeTravelClock
4343

0 commit comments

Comments
 (0)