diff --git a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/TemplateEvaluationResult.scala b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/TemplateEvaluationResult.scala new file mode 100644 index 00000000000..0353b003bea --- /dev/null +++ b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/TemplateEvaluationResult.scala @@ -0,0 +1,15 @@ +package pl.touk.nussknacker.engine.api + +case class TemplateEvaluationResult(renderedParts: List[TemplateRenderedPart]) { + def renderedTemplate: String = renderedParts.map(_.value).mkString("") +} + +sealed trait TemplateRenderedPart { + def value: String +} + +object TemplateRenderedPart { + case class RenderedLiteral(value: String) extends TemplateRenderedPart + + case class RenderedSubExpression(value: String) extends TemplateRenderedPart +} diff --git a/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala b/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala index 9b4f525235e..457ff3a92ae 100644 --- a/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala +++ b/components/sql/src/main/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricher.scala @@ -41,10 +41,7 @@ object DatabaseQueryEnricher { final val queryParamName: ParameterName = ParameterName("Query") - final val queryParamDeclaration = - ParameterDeclaration - .mandatory[String](queryParamName) - .withCreator(modify = _.copy(editor = Some(SqlParameterEditor))) + final val queryParam = Parameter[String](queryParamName).copy(editor = Some(SqlParameterEditor)) final val resultStrategyParamName: ParameterName = ParameterName("Result strategy") @@ -132,7 +129,7 @@ class DatabaseQueryEnricher(val dbPoolConfig: DBPoolConfig, val dbMetaDataProvid ): ContextTransformationDefinition = { case TransformationStep(Nil, _) => NextParameters(parameters = resultStrategyParamDeclaration.createParameter() :: - queryParamDeclaration.createParameter() :: + queryParam :: cacheTTLParamDeclaration.createParameter() :: Nil ) } @@ -142,14 +139,15 @@ class DatabaseQueryEnricher(val dbPoolConfig: DBPoolConfig, val dbMetaDataProvid ): ContextTransformationDefinition = { case TransformationStep( (`resultStrategyParamName`, DefinedEagerParameter(strategyName: String, _)) :: - (`queryParamName`, DefinedEagerParameter(query: String, _)) :: + (`queryParamName`, DefinedEagerParameter(query: TemplateEvaluationResult, _)) :: (`cacheTTLParamName`, _) :: Nil, None ) => - if (query.isEmpty) { + val renderedQuery = query.renderedTemplate + if (renderedQuery.isEmpty) { FinalResults(context, errors = CustomNodeError("Query is missing", Some(queryParamName)) :: Nil, state = None) } else { - parseQuery(context, dependencies, strategyName, query) + parseQuery(context, dependencies, strategyName, renderedQuery) } } diff --git a/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala b/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala index 1b7c63d4766..62a2873475a 100644 --- a/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala +++ b/components/sql/src/test/scala/pl/touk/nussknacker/sql/service/DatabaseQueryEnricherValidationTest.scala @@ -1,10 +1,11 @@ package pl.touk.nussknacker.sql.service +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.RenderedLiteral import pl.touk.nussknacker.engine.api.context.ProcessCompilationError.CustomNodeError import pl.touk.nussknacker.engine.api.context.transformation.{DefinedEagerParameter, OutputVariableNameValue} import pl.touk.nussknacker.engine.api.context.{OutputVar, ValidationContext} import pl.touk.nussknacker.engine.api.typed.typing.{Typed, Unknown} -import pl.touk.nussknacker.engine.api.NodeId +import pl.touk.nussknacker.engine.api.{NodeId, TemplateEvaluationResult} import pl.touk.nussknacker.sql.db.query.{ResultSetStrategy, SingleResultStrategy} import pl.touk.nussknacker.sql.db.schema.MetaDataProviderFactory import pl.touk.nussknacker.sql.utils.BaseHsqlQueryEnricherTest @@ -32,8 +33,10 @@ class DatabaseQueryEnricherValidationTest extends BaseHsqlQueryEnricherTest { service.TransformationStep( List( DatabaseQueryEnricher.resultStrategyParamName -> eagerValueParameter(SingleResultStrategy.name), - DatabaseQueryEnricher.queryParamName -> eagerValueParameter("select from"), - DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), + DatabaseQueryEnricher.queryParamName -> eagerValueParameter( + TemplateEvaluationResult(List(RenderedLiteral("select from"))) + ), + DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), ), None ) @@ -62,8 +65,10 @@ class DatabaseQueryEnricherValidationTest extends BaseHsqlQueryEnricherTest { service.TransformationStep( List( DatabaseQueryEnricher.resultStrategyParamName -> eagerValueParameter(ResultSetStrategy.name), - DatabaseQueryEnricher.queryParamName -> eagerValueParameter("select * from persons"), - DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), + DatabaseQueryEnricher.queryParamName -> eagerValueParameter( + TemplateEvaluationResult(List(RenderedLiteral("select * from persons"))) + ), + DatabaseQueryEnricher.cacheTTLParamName -> eagerValueParameter(Duration.ofMinutes(1)), ), None ) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala index d9cd34c4017..56c0eb77e07 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/definition/DefinitionsService.scala @@ -9,6 +9,8 @@ import pl.touk.nussknacker.engine.definition.component.methodbased.MethodBasedCo import pl.touk.nussknacker.engine.definition.component.{ComponentStaticDefinition, FragmentSpecificData} import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap import pl.touk.nussknacker.engine.ModelData +import pl.touk.nussknacker.engine.api.TemplateEvaluationResult +import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypingResult} import pl.touk.nussknacker.restmodel.definition._ import pl.touk.nussknacker.ui.definition.DefinitionsService.{ ComponentUiConfigMode, @@ -162,7 +164,7 @@ object DefinitionsService { def createUIParameter(parameter: Parameter): UIParameter = { UIParameter( name = parameter.name.value, - typ = parameter.typ, + typ = toUIType(parameter.typ), editor = parameter.finalEditor, defaultValue = parameter.finalDefaultValue, additionalVariables = parameter.additionalVariables.mapValuesNow(_.typingResult), @@ -174,6 +176,10 @@ object DefinitionsService { ) } + private def toUIType(typingResult: TypingResult): TypingResult = { + if (typingResult == Typed[TemplateEvaluationResult]) Typed[String] else typingResult + } + def createUIScenarioPropertyConfig(config: ScenarioPropertyConfig): UiScenarioPropertyConfig = { val editor = UiScenarioPropertyEditorDeterminer.determine(config) UiScenarioPropertyConfig(config.defaultValue, editor, config.label, config.hintText) diff --git a/docs/Changelog.md b/docs/Changelog.md index 9e45ab6c331..dd7f1d53d38 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -13,6 +13,8 @@ * [#7145](https://github.com/TouK/nussknacker/pull/7145) Lift TypingResult information for dictionaries * [#7116](https://github.com/TouK/nussknacker/pull/7116) Improve missing Flink Kafka Source / Sink TypeInformation * [#7123](https://github.com/TouK/nussknacker/pull/7123) Fix deployments for scenarios with dict editors after model reload +* [#7162](https://github.com/TouK/nussknacker/pull/7162) Component API enhancement: ability to access information about + expression parts used in SpEL template ## 1.18 diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index 308a2d12f8d..8b270d52433 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -52,6 +52,9 @@ To see the biggest differences please consult the [changelog](Changelog.md). * [#6988](https://github.com/TouK/nussknacker/pull/6988) Removed unused API classes: `MultiMap`, `TimestampedEvictableStateFunction`. `MultiMap` was incorrectly handled by Flink's default Kryo serializer, so if you want to copy it to your code you should write and register a proper serializer. +* [#7162](https://github.com/TouK/nussknacker/pull/7162) When component declares that requires parameter with either `SpelTemplateParameterEditor` + or `SqlParameterEditor` editor, in the runtime, for the expression evaluation result, will be used the new `TemplateEvaluationResult` + class instead of `String` class. To access the previous `String` use `TemplateEvaluationResult.renderedTemplate` method. ### REST API changes diff --git a/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/SpelTemplateLazyParameterTest.scala b/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/SpelTemplateLazyParameterTest.scala new file mode 100644 index 00000000000..c2f3d218673 --- /dev/null +++ b/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/SpelTemplateLazyParameterTest.scala @@ -0,0 +1,118 @@ +package pl.touk.nussknacker.engine.flink + +import com.typesafe.config.ConfigFactory +import org.apache.flink.api.common.functions.FlatMapFunction +import org.apache.flink.api.connector.source.Boundedness +import org.apache.flink.streaming.api.datastream.DataStream +import org.apache.flink.util.Collector +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.{RenderedLiteral, RenderedSubExpression} +import pl.touk.nussknacker.engine.api._ +import pl.touk.nussknacker.engine.api.component.{BoundedStreamComponent, ComponentDefinition} +import pl.touk.nussknacker.engine.api.context.ValidationContext +import pl.touk.nussknacker.engine.api.context.transformation.{DefinedLazyParameter, NodeDependencyValue, SingleInputDynamicComponent} +import pl.touk.nussknacker.engine.api.definition.{NodeDependency, OutputVariableNameDependency, Parameter, SpelTemplateParameterEditor} +import pl.touk.nussknacker.engine.api.parameter.ParameterName +import pl.touk.nussknacker.engine.api.typed.typing.Typed +import pl.touk.nussknacker.engine.build.ScenarioBuilder +import pl.touk.nussknacker.engine.flink.api.process.{AbstractOneParamLazyParameterFunction, FlinkCustomNodeContext, FlinkCustomStreamTransformation} +import pl.touk.nussknacker.engine.flink.test.FlinkSpec +import pl.touk.nussknacker.engine.flink.util.test.FlinkTestScenarioRunner._ +import pl.touk.nussknacker.engine.graph.expression.Expression +import pl.touk.nussknacker.engine.process.FlinkJobConfig.ExecutionMode +import pl.touk.nussknacker.engine.spel.SpelExtension._ +import pl.touk.nussknacker.engine.util.test.TestScenarioRunner +import pl.touk.nussknacker.test.ValidatedValuesDetailedMessage + +class SpelTemplateLazyParameterTest extends AnyFunSuite with FlinkSpec with Matchers with ValidatedValuesDetailedMessage { + + private lazy val runner = TestScenarioRunner + .flinkBased(ConfigFactory.empty(), flinkMiniCluster) + .withExecutionMode(ExecutionMode.Batch) + .withExtraComponents( + List(ComponentDefinition("spelTemplatePartsCustomTransformer", SpelTemplatePartsCustomTransformer)) + ) + .build() + + test("flink custom transformer using spel template rendered parts") { + val scenario = ScenarioBuilder + .streaming("test") + .source("source", TestScenarioRunner.testDataSource) + .customNode( + "custom", + "output", + "spelTemplatePartsCustomTransformer", + "template" -> Expression.spelTemplate(s"Hello#{#input}") + ) + .emptySink("sink", TestScenarioRunner.testResultSink, "value" -> "#output".spel) + + val result = runner.runWithData(scenario, List(1, 2, 3), Boundedness.BOUNDED) + result.validValue.errors shouldBe empty + result.validValue.successes shouldBe List( + "[Hello]-literal[1]-subexpression", + "[Hello]-literal[2]-subexpression", + "[Hello]-literal[3]-subexpression" + ) + } + +} + +object SpelTemplatePartsCustomTransformer + extends CustomStreamTransformer + with SingleInputDynamicComponent[FlinkCustomStreamTransformation] + with BoundedStreamComponent { + + private val spelTemplateParameterName = ParameterName("template") + + private val spelTemplateParameter = Parameter + .optional[String](spelTemplateParameterName) + .copy( + isLazyParameter = true, + editor = Some(SpelTemplateParameterEditor) + ) + + override type State = Unit + + override def contextTransformation(context: ValidationContext, dependencies: List[NodeDependencyValue])( + implicit nodeId: NodeId + ): SpelTemplatePartsCustomTransformer.ContextTransformationDefinition = { + case TransformationStep(Nil, _) => NextParameters(List(spelTemplateParameter)) + case TransformationStep((`spelTemplateParameterName`, DefinedLazyParameter(_)) :: Nil, _) => + val outName = OutputVariableNameDependency.extract(dependencies) + FinalResults(context.withVariableUnsafe(outName, Typed[String]), List.empty) + } + + override def nodeDependencies: List[NodeDependency] = List(OutputVariableNameDependency) + + override def implementation( + params: Params, + dependencies: List[NodeDependencyValue], + finalState: Option[Unit] + ): FlinkCustomStreamTransformation = { + val templateLazyParam: LazyParameter[TemplateEvaluationResult] = + params.extractUnsafe[LazyParameter[TemplateEvaluationResult]](spelTemplateParameterName) + FlinkCustomStreamTransformation { + (dataStream: DataStream[Context], flinkCustomNodeContext: FlinkCustomNodeContext) => + dataStream.flatMap( + new AbstractOneParamLazyParameterFunction[TemplateEvaluationResult]( + templateLazyParam, + flinkCustomNodeContext.lazyParameterHelper + ) with FlatMapFunction[Context, ValueWithContext[String]] { + override def flatMap(value: Context, out: Collector[ValueWithContext[String]]): Unit = { + collectHandlingErrors(value, out) { + val templateResult = evaluateParameter(value) + val result = templateResult.renderedParts.map { + case RenderedLiteral(value) => s"[$value]-literal" + case RenderedSubExpression(value) => s"[$value]-subexpression" + }.mkString + ValueWithContext(result, value) + } + } + }, + flinkCustomNodeContext.valueWithContextInfo.forClass[String] + ).asInstanceOf[DataStream[ValueWithContext[AnyRef]]] + } + } + +} diff --git a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala index 1ae07feef83..1794b005f20 100644 --- a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala +++ b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/LoggingService.scala @@ -18,7 +18,9 @@ object LoggingService extends EagerService { def prepare( @ParamName("logger") @Nullable loggerName: String, @ParamName("level") @DefaultValue("T(org.slf4j.event.Level).DEBUG") level: Level, - @ParamName("message") @SimpleEditor(`type` = SimpleEditorType.SPEL_TEMPLATE_EDITOR) message: LazyParameter[String] + @ParamName("message") @SimpleEditor(`type` = SimpleEditorType.SPEL_TEMPLATE_EDITOR) message: LazyParameter[ + TemplateEvaluationResult + ] )(implicit metaData: MetaData, nodeId: NodeId): ServiceInvoker = new ServiceInvoker { @@ -31,7 +33,7 @@ object LoggingService extends EagerService { collector: ServiceInvocationCollector, componentUseCase: ComponentUseCase ): Future[Any] = { - val msg = message.evaluate(context) + val msg = message.evaluate(context).renderedTemplate level match { case Level.TRACE => logger.trace(msg) case Level.DEBUG => logger.debug(msg) diff --git a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala index f353c1427e0..fdccb9b7a4a 100644 --- a/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala +++ b/engine/flink/management/dev-model/src/main/scala/pl/touk/nussknacker/engine/management/sample/source/SqlSource.scala @@ -4,14 +4,14 @@ import pl.touk.nussknacker.engine.api.component.UnboundedStreamComponent import pl.touk.nussknacker.engine.api.editor.{SimpleEditor, SimpleEditorType} import pl.touk.nussknacker.engine.api.process.SourceFactory import pl.touk.nussknacker.engine.api.typed.typing.Unknown -import pl.touk.nussknacker.engine.api.{MethodToInvoke, ParamName} +import pl.touk.nussknacker.engine.api.{MethodToInvoke, ParamName, TemplateEvaluationResult} import pl.touk.nussknacker.engine.flink.util.source.CollectionSource //It's only for test FE sql editor object SqlSource extends SourceFactory with UnboundedStreamComponent { @MethodToInvoke - def source(@ParamName("sql") @SimpleEditor(`type` = SimpleEditorType.SQL_EDITOR) sql: String) = + def source(@ParamName("sql") @SimpleEditor(`type` = SimpleEditorType.SQL_EDITOR) sql: TemplateEvaluationResult) = new CollectionSource[Any](List.empty, None, Unknown) } diff --git a/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala b/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala index 4f14299516e..4a56c525bbd 100644 --- a/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala +++ b/engine/flink/schemed-kafka-components-utils/src/main/scala/pl/touk/nussknacker/engine/schemedkafka/FlinkUniversalSchemaBasedSerdeProvider.scala @@ -2,7 +2,12 @@ package pl.touk.nussknacker.engine.schemedkafka import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.serialization.KafkaSchemaRegistryBasedValueSerializationSchemaFactory import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.universal.UniversalSchemaBasedSerdeProvider.createSchemaIdFromMessageExtractor -import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.universal.{UniversalKafkaDeserializerFactory, UniversalSchemaValidator, UniversalSerializerFactory, UniversalToJsonFormatterFactory} +import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.universal.{ + UniversalKafkaDeserializerFactory, + UniversalSchemaValidator, + UniversalSerializerFactory, + UniversalToJsonFormatterFactory +} import pl.touk.nussknacker.engine.schemedkafka.schemaregistry.{SchemaBasedSerdeProvider, SchemaRegistryClientFactory} import pl.touk.nussknacker.engine.schemedkafka.source.flink.FlinkKafkaSchemaRegistryBasedKeyValueDeserializationSchemaFactory diff --git a/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala b/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala index bb03fb10cfe..7e9da4b0968 100644 --- a/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala +++ b/extensions-api/src/main/scala/pl/touk/nussknacker/engine/api/process/ClassExtractionSettings.scala @@ -5,7 +5,7 @@ import io.circe.{Decoder, Encoder} import pl.touk.nussknacker.engine.api.definition.ParameterEditor import pl.touk.nussknacker.engine.api.typed.supertype.ReturningSingleClassPromotionStrategy import pl.touk.nussknacker.engine.api.typed.typing.Typed -import pl.touk.nussknacker.engine.api.{Hidden, HideToString} +import pl.touk.nussknacker.engine.api.{Hidden, HideToString, TemplateEvaluationResult} import java.lang.reflect.{AccessibleObject, Member, Method} import java.text.NumberFormat @@ -109,8 +109,9 @@ object ClassExtractionSettings { // we want only boxed types ClassPredicate { case cl => cl.isPrimitive }, ExactClassPredicate[ReturningSingleClassPromotionStrategy], - // We use this type only programmable + // We use these types only programmable ClassNamePredicate("pl.touk.nussknacker.engine.spel.SpelExpressionRepr"), + ExactClassPredicate[TemplateEvaluationResult], ) lazy val ExcludedCollectionFunctionalClasses: List[ClassPredicate] = List( diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala index f366f2b2d12..1d9657ecc44 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/component/methodbased/MethodDefinition.scala @@ -65,7 +65,7 @@ class OrderedDependencies(dependencies: List[NodeDependency]) extends Serializab ): List[Any] = { dependencies.map { case param: Parameter => - values.getOrElse(param.name, throw new IllegalArgumentException(s"Missing parameter: ${param.name}")) + values.getOrElse(param.name, throw new IllegalArgumentException(s"Missing parameter: ${param.name.value}")) case OutputVariableNameDependency => outputVariableNameOpt.getOrElse(throw MissingOutputVariableException) case TypedNodeDependency(clazz) => diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala index a521545c97f..07b67ff495f 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpression.scala @@ -13,13 +13,14 @@ import org.springframework.expression.spel.{ SpelParserConfiguration, standard } -import pl.touk.nussknacker.engine.api.Context +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.{RenderedLiteral, RenderedSubExpression} import pl.touk.nussknacker.engine.api.context.ValidationContext import pl.touk.nussknacker.engine.api.dict.DictRegistry import pl.touk.nussknacker.engine.api.exception.NonTransientException import pl.touk.nussknacker.engine.api.generics.ExpressionParseError import pl.touk.nussknacker.engine.api.typed.typing import pl.touk.nussknacker.engine.api.typed.typing.{SingleTypingResult, TypingResult} +import pl.touk.nussknacker.engine.api.{Context, TemplateEvaluationResult, TemplateRenderedPart} import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionSet import pl.touk.nussknacker.engine.definition.globalvariables.ExpressionConfigDefinition import pl.touk.nussknacker.engine.dict.{KeysDictTyper, LabelsDictTyper} @@ -107,7 +108,28 @@ class SpelExpression( return SpelExpressionRepr(parsed.parsed, ctx, globals, original).asInstanceOf[T] } val evaluationContext = evaluationContextPreparer.prepareEvaluationContext(ctx, globals) - parsed.getValue[T](evaluationContext, expectedClass) + flavour match { + case SpelExpressionParser.Standard => + parsed.getValue[T](evaluationContext, expectedClass) + case SpelExpressionParser.Template => + val parts = renderTemplateExpressionParts(evaluationContext) + TemplateEvaluationResult(parts).asInstanceOf[T] + } + } + + private def renderTemplateExpressionParts(evaluationContext: EvaluationContext): List[TemplateRenderedPart] = { + def renderExpression(expression: Expression): List[TemplateRenderedPart] = expression match { + case literal: LiteralExpression => List(RenderedLiteral(literal.getExpressionString)) + case spelExpr: org.springframework.expression.spel.standard.SpelExpression => + // TODO: Should we use the same trick with re-parsing after ClassCastException as we use in ParsedSpelExpression? + List(RenderedSubExpression(spelExpr.getValue[String](evaluationContext, classOf[String]))) + case composite: CompositeStringExpression => composite.getExpressions.toList.flatMap(renderExpression) + case other => + throw new IllegalArgumentException( + s"Unsupported expression type: ${other.getClass.getName} for a template expression" + ) + } + renderExpression(parsed.parsed) } private def logOnException[A](ctx: Context)(block: => A): A = { diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala index ac49dff185c..1e708e1a636 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionValidator.scala @@ -3,6 +3,7 @@ package pl.touk.nussknacker.engine.spel import cats.data.Validated.{Invalid, Valid} import cats.data.{NonEmptyList, Validated} import org.springframework.expression.Expression +import pl.touk.nussknacker.engine.api.TemplateEvaluationResult import pl.touk.nussknacker.engine.api.context.ValidationContext import pl.touk.nussknacker.engine.api.generics.ExpressionParseError import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypingResult} @@ -18,9 +19,13 @@ class SpelExpressionValidator(typer: Typer) { val typedExpression = typer.typeExpression(expr, ctx) typedExpression.andThen { collected => collected.finalResult.typingResult match { - case a: TypingResult if a.canBeSubclassOf(expectedType) || expectedType == Typed[SpelExpressionRepr] => + case _ if expectedType == Typed[SpelExpressionRepr] => Valid(collected) - case a: TypingResult => + case a if a == Typed[String] && expectedType == Typed[TemplateEvaluationResult] => + Valid(collected) + case a if a.canBeSubclassOf(expectedType) => + Valid(collected) + case a => Invalid(NonEmptyList.of(ExpressionTypeError(expectedType, a))) } } diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala index e5135ece369..b41507da7d5 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/InterpreterSpec.scala @@ -6,6 +6,8 @@ import cats.effect.IO import cats.effect.unsafe.IORuntime import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks.forAll +import org.scalatest.prop.Tables.Table import org.springframework.expression.spel.standard.SpelExpression import pl.touk.nussknacker.engine.InterpreterSpec._ import pl.touk.nussknacker.engine.api._ @@ -33,8 +35,8 @@ import pl.touk.nussknacker.engine.canonicalgraph.canonicalnode.FlatNode import pl.touk.nussknacker.engine.canonicalgraph.{CanonicalProcess, canonicalnode} import pl.touk.nussknacker.engine.compile._ import pl.touk.nussknacker.engine.compiledgraph.part.{CustomNodePart, ProcessPart, SinkPart} +import pl.touk.nussknacker.engine.definition.component.Components import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode -import pl.touk.nussknacker.engine.definition.component.{ComponentDefinitionWithImplementation, Components} import pl.touk.nussknacker.engine.definition.model.{ModelDefinition, ModelDefinitionWithClasses} import pl.touk.nussknacker.engine.dict.SimpleDictRegistry import pl.touk.nussknacker.engine.graph.evaluatedparam.{Parameter => NodeParameter} @@ -47,6 +49,7 @@ import pl.touk.nussknacker.engine.graph.variable.Field import pl.touk.nussknacker.engine.modelconfig.ComponentsUiConfig import pl.touk.nussknacker.engine.resultcollector.ProductionServiceInvocationCollector import pl.touk.nussknacker.engine.spel.SpelExpressionRepr +import pl.touk.nussknacker.engine.testcomponents.SpelTemplatePartsService import pl.touk.nussknacker.engine.testing.ModelDefinitionBuilder import pl.touk.nussknacker.engine.util.service.{ EagerServiceWithStaticParametersAndReturnType, @@ -72,6 +75,7 @@ class InterpreterSpec extends AnyFunSuite with Matchers { ComponentDefinition("spelNodeService", SpelNodeService), ComponentDefinition("withExplicitMethod", WithExplicitDefinitionService), ComponentDefinition("spelTemplateService", ServiceUsingSpelTemplate), + ComponentDefinition("spelTemplatePartsService", SpelTemplatePartsService), ComponentDefinition("optionTypesService", OptionTypesService), ComponentDefinition("optionalTypesService", OptionalTypesService), ComponentDefinition("nullableTypesService", NullableTypesService), @@ -1020,6 +1024,52 @@ class InterpreterSpec extends AnyFunSuite with Matchers { interpretProcess(process, Transaction()) shouldBe "someKey" } + test("service using spel template rendered parts") { + val testCases = Seq( + ( + "subexpression and literal value", + s"Hello#{#input.msisdn}", + Transaction(msisdn = "foo"), + "[Hello]-literal[foo]-subexpression" + ), + ( + "single literal value", + "Hello", + Transaction(msisdn = "foo"), + "[Hello]-literal" + ), + ( + "single function call expression", + "#{#input.msisdn.toString()}", + Transaction(msisdn = "foo"), + "[foo]-subexpression" + ), + ( + "empty value", + "", + Transaction(msisdn = "foo"), + "[]-literal" + ), + ) + for ((description, templateExpression, inputTransaction, expectedOutput) <- testCases) { + withClue(s"Test case: $description") { + val process = ScenarioBuilder + .streaming("test") + .source("start", "transaction-source") + .enricher( + "ex", + "out", + "spelTemplatePartsService", + "template" -> Expression.spelTemplate(templateExpression) + ) + .buildSimpleVariable("result-end", resultVariable, "#out".spel) + .emptySink("end-end", "dummySink") + + interpretProcess(process, inputTransaction) should equal(expectedOutput) + } + } + } + } class ThrowingService extends Service { @@ -1134,8 +1184,10 @@ object InterpreterSpec { object ServiceUsingSpelTemplate extends EagerServiceWithStaticParametersAndReturnType { + private val spelTemplateParameterName = ParameterName("template") + private val spelTemplateParameter = Parameter - .optional[String](ParameterName("template")) + .optional[String](spelTemplateParameterName) .copy(isLazyParameter = true, editor = Some(SpelTemplateParameterEditor)) override def parameters: List[Parameter] = List(spelTemplateParameter) @@ -1149,7 +1201,7 @@ object InterpreterSpec { metaData: MetaData, componentUseCase: ComponentUseCase ): Future[AnyRef] = { - Future.successful(params.head._2.toString) + Future.successful(params(spelTemplateParameterName).asInstanceOf[TemplateEvaluationResult].renderedTemplate) } } diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala index 6e7331fda8f..77cbdddcb66 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala @@ -26,7 +26,7 @@ import pl.touk.nussknacker.engine.api.process.ExpressionConfig._ import pl.touk.nussknacker.engine.api.typed.TypedMap import pl.touk.nussknacker.engine.api.typed.typing.Typed.typedListWithElementValues import pl.touk.nussknacker.engine.api.typed.typing.{Typed, _} -import pl.touk.nussknacker.engine.api.{Context, Hidden, NodeId, SpelExpressionExcludeList} +import pl.touk.nussknacker.engine.api.{Context, Hidden, NodeId, SpelExpressionExcludeList, TemplateEvaluationResult} import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinitionSet, ClassDefinitionTestUtils, JavaClassWithVarargs} import pl.touk.nussknacker.engine.dict.SimpleDictRegistry import pl.touk.nussknacker.engine.expression.parse.{CompiledExpression, TypedExpression} @@ -81,10 +81,10 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD private implicit class EvaluateSyncTyped(expression: TypedExpression) { - def evaluateSync[T](ctx: Context = ctx): T = { + def evaluateSync[T](ctx: Context = ctx, skipReturnTypeCheck: Boolean = false): T = { val evaluationResult = expression.expression.evaluate[T](ctx, Map.empty) expression.typingInfo.typingResult match { - case result: SingleTypingResult if evaluationResult != null => + case result: SingleTypingResult if evaluationResult != null && !skipReturnTypeCheck => result.runtimeObjType.klass isAssignableFrom evaluationResult.getClass shouldBe true case _ => } @@ -1088,16 +1088,21 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD test("evaluates expression with template context") { parse[String]("alamakota #{444}", ctx, flavour = SpelExpressionParser.Template).validExpression - .evaluateSync[String]() shouldBe "alamakota 444" + .evaluateSync[TemplateEvaluationResult](skipReturnTypeCheck = true) + .renderedTemplate shouldBe "alamakota 444" parse[String]( "alamakota #{444 + #obj.value} #{#mapValue.foo}", ctx, flavour = SpelExpressionParser.Template - ).validExpression.evaluateSync[String]() shouldBe "alamakota 446 bar" + ).validExpression + .evaluateSync[TemplateEvaluationResult](skipReturnTypeCheck = true) + .renderedTemplate shouldBe "alamakota 446 bar" } test("evaluates empty template as empty string") { - parse[String]("", ctx, flavour = SpelExpressionParser.Template).validExpression.evaluateSync[String]() shouldBe "" + parse[String]("", ctx, flavour = SpelExpressionParser.Template).validExpression + .evaluateSync[TemplateEvaluationResult](skipReturnTypeCheck = true) + .renderedTemplate shouldBe "" } test("variables with TypeMap type") { diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/testcomponents/SpelTemplatePartsService.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/testcomponents/SpelTemplatePartsService.scala new file mode 100644 index 00000000000..675889bd025 --- /dev/null +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/testcomponents/SpelTemplatePartsService.scala @@ -0,0 +1,75 @@ +package pl.touk.nussknacker.engine.testcomponents + +import pl.touk.nussknacker.engine.api.TemplateRenderedPart.{RenderedLiteral, RenderedSubExpression} +import pl.touk.nussknacker.engine.api._ +import pl.touk.nussknacker.engine.api.context.transformation.{ + DefinedLazyParameter, + NodeDependencyValue, + SingleInputDynamicComponent +} +import pl.touk.nussknacker.engine.api.context.{OutputVar, ValidationContext} +import pl.touk.nussknacker.engine.api.definition.{ + NodeDependency, + OutputVariableNameDependency, + Parameter, + SpelTemplateParameterEditor +} +import pl.touk.nussknacker.engine.api.parameter.ParameterName +import pl.touk.nussknacker.engine.api.process.ComponentUseCase +import pl.touk.nussknacker.engine.api.test.InvocationCollectors +import pl.touk.nussknacker.engine.api.typed.typing + +import scala.concurrent.{ExecutionContext, Future} + +object SpelTemplatePartsService extends EagerService with SingleInputDynamicComponent[ServiceInvoker] { + + private val spelTemplateParameterName = ParameterName("template") + + private val spelTemplateParameter = Parameter + .optional[String](spelTemplateParameterName) + .copy( + isLazyParameter = true, + editor = Some(SpelTemplateParameterEditor) + ) + + override type State = Any + + override def contextTransformation(context: ValidationContext, dependencies: List[NodeDependencyValue])( + implicit nodeId: NodeId + ): SpelTemplatePartsService.ContextTransformationDefinition = { + case TransformationStep(Nil, _) => NextParameters(List(spelTemplateParameter)) + case TransformationStep((`spelTemplateParameterName`, DefinedLazyParameter(_)) :: Nil, _) => + FinalResults.forValidation(context, List.empty)(validation = + ctx => + ctx.withVariable( + OutputVariableNameDependency.extract(dependencies), + typing.Typed[String], + Some(ParameterName(OutputVar.VariableFieldName)) + ) + ) + } + + override def implementation( + params: Params, + dependencies: List[NodeDependencyValue], + finalState: Option[Any] + ): ServiceInvoker = new ServiceInvoker { + + override def invoke(context: Context)( + implicit ec: ExecutionContext, + collector: InvocationCollectors.ServiceInvocationCollector, + componentUseCase: ComponentUseCase + ): Future[Any] = { + val templateResult = + params.extractOrEvaluateLazyParamUnsafe[TemplateEvaluationResult](spelTemplateParameterName, context) + val result = templateResult.renderedParts.map { + case RenderedLiteral(value) => s"[$value]-literal" + case RenderedSubExpression(value) => s"[$value]-subexpression" + }.mkString + Future.successful(result) + } + + } + + override def nodeDependencies: List[NodeDependency] = List(OutputVariableNameDependency) +}