Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions java-tests/extensions/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import io.spine.dependency.build.Ksp
import io.spine.dependency.lib.AutoService
import io.spine.dependency.lib.AutoServiceKsp
import io.spine.dependency.local.McJava
import io.spine.dependency.local.ProtoData

buildscript {
forceCodegenPlugins()
Expand All @@ -42,6 +43,7 @@ dependencies {
ksp(AutoServiceKsp.processor)

implementation(project(":java"))
implementation(ProtoData.java)
}

configurations.all {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2025, TeamDev. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.validation.test

import io.spine.protodata.ast.TypeName
import io.spine.protodata.java.CodeBlock
import io.spine.protodata.java.Expression
import io.spine.protodata.java.ReadVar
import io.spine.protodata.java.field
import io.spine.server.query.Querying
import io.spine.server.query.select
import io.spine.validate.ConstraintViolation
import io.spine.validation.java.expression.orElse
import io.spine.validation.java.expression.stringify
import io.spine.validation.java.generate.OptionGenerator
import io.spine.validation.java.generate.SingleOptionCode
import io.spine.validation.java.generate.ValidationCodeInjector.MessageScope.message
import io.spine.validation.java.generate.ValidationCodeInjector.ValidateScope.parentName
import io.spine.validation.java.generate.ValidationCodeInjector.ValidateScope.violations
import io.spine.validation.java.violation.constraintViolation
import io.spine.validation.java.violation.templateString
import io.spine.validation.test.money.CurrencyMessage

/**
* The generator for the `(currency)` option.
*/
internal class CurrencyGenerator(private val querying: Querying) : OptionGenerator {

/**
* All `(currency)`-marked messages in the current compilation process.
*/
private val allCurrencyMessages by lazy {
querying.select<CurrencyMessage>()
.all()
}

override fun codeFor(type: TypeName): List<SingleOptionCode> {
val requireMessage = allCurrencyMessages.find { it.type == type }
if (requireMessage == null) {
return emptyList()
}
val code = GenerateCurrency(requireMessage).code()
return listOf(code)
}
}

/**
* Generates code for a single application of the `(currency)` option
* represented by the [view].
*/
private class GenerateCurrency(private val view: CurrencyMessage) {

private val minorField = view.minorUnitField
private val minorThreshold = view.currency.minorUnits

/**
* Returns the generated code.
*/
fun code(): SingleOptionCode {
val getter = message.field(minorField)
.getter<Int>()
val constraint = CodeBlock(
"""
if ($getter > $minorThreshold) {
var typeName = ${parentName.orElse(view.type)};
var violation = ${violation(ReadVar("typeName"), getter)};
$violations.add(violation);
}
""".trimIndent()
)
return SingleOptionCode(constraint)
}

private fun violation(
typeName: Expression<TypeName>,
minorValue: Expression<Int>
): Expression<ConstraintViolation> {
val typeNameStr = typeName.stringify()
val placeholders = supportedPlaceholders(minorValue)
val errorMessage = templateString(view.errorMessage, placeholders, CURRENCY)
return constraintViolation(errorMessage, typeNameStr, fieldPath = null, fieldValue = null)
}

private fun supportedPlaceholders(
minorValue: Expression<Int>
): Map<String, Expression<String>> = mapOf("minor.value" to minorValue.stringify(),)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2024, TeamDev. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.validation.test

import io.spine.core.External
import io.spine.core.Where
import io.spine.protodata.Compilation
import io.spine.protodata.ast.Field
import io.spine.protodata.ast.File
import io.spine.protodata.ast.MessageType
import io.spine.protodata.ast.PrimitiveType.TYPE_INT32
import io.spine.protodata.ast.PrimitiveType.TYPE_INT64
import io.spine.protodata.ast.event.MessageOptionDiscovered
import io.spine.protodata.ast.unpack
import io.spine.protodata.check
import io.spine.protodata.plugin.Policy
import io.spine.server.event.Just
import io.spine.server.event.React
import io.spine.server.event.just
import io.spine.validation.OPTION_NAME
import io.spine.validation.test.money.Currency
import io.spine.validation.test.money.CurrencyMessageDiscovered
import io.spine.validation.test.money.currencyMessageDiscovered

/**
* The name of the option.
*/
internal const val CURRENCY = "currency"

/**
* Controls whether a message should be validated with the `(currency)` option.
*
* Whenever a message marked with the `(currency)` option is discovered, emits
* [CurrencyMessageDiscovered] event if the message has exactly two integer fields.
*
* Otherwise, a compilation error is reported.
*/
public class CurrencyPolicy : Policy<MessageOptionDiscovered>() {

@React
override fun whenever(
@External @Where(field = OPTION_NAME, equals = CURRENCY)
event: MessageOptionDiscovered
): Just<CurrencyMessageDiscovered> {
val file = event.file
val messageType = event.subject
val fields = messageType.fieldList
checkFieldType(fields.size == 2, file, messageType)

val firstField = messageType.fieldList[0]
val secondField = messageType.fieldList[1]
checkFieldType(firstField.isInteger && secondField.isInteger, file, messageType)

val option = event.option.unpack<Currency>()
val message = errorMessage(firstField, secondField, option.minorUnits)
return currencyMessageDiscovered {
type = messageType.name
currency = option
majorUnitField = firstField
minorUnitField = secondField
errorMessage = message
}.just()
}
}

private val Field.isInteger: Boolean
get() = type.primitive in listOf(TYPE_INT32, TYPE_INT64)

private fun errorMessage(minor: Field, major: Field, minorUnits: Int) =
"Expected `${minor.name.value}` field to have less than `$minorUnits`" +
" per one unit in `${major.name.value}` field. The passed value: `\${minor.value}`."

private fun checkFieldType(condition: Boolean, file: File, message: MessageType) =
Compilation.check(condition, file, message.span) {
"The `($CURRENCY)` option cannot be applied to `${message.qualifiedName}`. It is" +
" applicable only to messages that have exactly two integer fields."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2025, TeamDev. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.validation.test

import com.intellij.psi.PsiJavaFile
import io.spine.protodata.java.file.hasJavaRoot
import io.spine.protodata.java.javaClassName
import io.spine.protodata.java.render.JavaRenderer
import io.spine.protodata.java.render.findClass
import io.spine.protodata.java.render.findMessageTypes
import io.spine.protodata.render.SourceFile
import io.spine.protodata.render.SourceFileSet
import io.spine.tools.code.Java
import io.spine.validation.java.generate.MessageValidationCode
import io.spine.validation.java.generate.ValidationCodeInjector

/**
* Renders Java code for the `(currency)` option.
*/
public class CurrencyRenderer : JavaRenderer() {

private val codeInjector = ValidationCodeInjector()
private val querying = this@CurrencyRenderer
private val currencyGenerator = CurrencyGenerator(querying)

override fun render(sources: SourceFileSet) {
// We receive `grpc` and `kotlin` output sources roots here as well.
// As for now, we modify only `java` sources.
if (!sources.hasJavaRoot) {
return
}

findMessageTypes()
.mapNotNull { message ->
val optionCode = currencyGenerator.codeFor(message.name)
.firstOrNull()
optionCode?.let {
message to it
}
}
.forEach { (message, optionCode) ->
val messageCode = MessageValidationCode(
message = message.name.javaClassName(typeSystem),
constraints = listOf(optionCode.constraint),
fields = emptyList(),
methods = emptyList(),
)
val file = sources.javaFileOf(message)
file.render(messageCode)
}
}

private fun SourceFile<Java>.render(code: MessageValidationCode) {
val psiFile = psi() as PsiJavaFile
val messageClass = psiFile.findClass(code.message)
codeInjector.inject(code, messageClass)
overwrite(psiFile.text)
}
}
Loading
Loading