diff --git a/build.gradle.kts b/build.gradle.kts
index c9b8fc2d0..4a5afa4b9 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -36,6 +36,7 @@ import io.spine.dependency.local.Base
import io.spine.dependency.local.CoreJvm
import io.spine.dependency.local.Logging
import io.spine.dependency.local.Reflect
+import io.spine.dependency.local.Time
import io.spine.dependency.local.ToolBase
import io.spine.dependency.local.Validation
import io.spine.gradle.publish.PublishingRepos
@@ -86,6 +87,7 @@ buildscript {
logging.lib,
logging.middleware,
validation.runtime,
+ io.spine.dependency.local.Compiler.api
)
}
}
@@ -101,7 +103,6 @@ plugins {
id("org.jetbrains.kotlinx.kover")
idea
jacoco
- `gradle-doctor`
`project-report`
}
@@ -148,6 +149,8 @@ allprojects {
Validation.runtime,
Validation.javaBundle,
CoreJvm.server,
+ Time.lib,
+ Time.javaExtensions,
)
}
}
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt
index febf8ae9b..304cbf4a9 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Compiler.kt
@@ -72,7 +72,7 @@ object Compiler : Dependency() {
* The version of the Compiler dependencies.
*/
override val version: String
- private const val fallbackVersion = "2.0.0-SNAPSHOT.037"
+ private const val fallbackVersion = "2.0.0-SNAPSHOT.039"
/**
* The distinct version of the Compiler used by other build tools.
@@ -81,7 +81,7 @@ object Compiler : Dependency() {
* transitive dependencies, this is the version used to build the project itself.
*/
val dogfoodingVersion: String
- private const val fallbackDfVersion = "2.0.0-SNAPSHOT.037"
+ private const val fallbackDfVersion = "2.0.0-SNAPSHOT.039"
/**
* The artifact for the Compiler Gradle plugin.
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt
index a6a93f0ba..49df6fbe1 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/CoreJvm.kt
@@ -39,7 +39,7 @@ typealias CoreJava = CoreJvm
@Suppress("ConstPropertyName", "unused")
object CoreJvm {
const val group = Spine.group
- const val version = "2.0.0-SNAPSHOT.370"
+ const val version = "2.0.0-SNAPSHOT.371"
const val coreArtifact = "spine-core"
const val clientArtifact = "spine-client"
diff --git a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
index 7cf8dd686..1ef4f097a 100644
--- a/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
+++ b/buildSrc/src/main/kotlin/io/spine/dependency/local/Validation.kt
@@ -36,7 +36,7 @@ object Validation {
/**
* The version of the Validation library artifacts.
*/
- const val version = "2.0.0-SNAPSHOT.391"
+ const val version = "2.0.0-SNAPSHOT.402"
/**
* The last version of Validation compatible with ProtoData.
diff --git a/config b/config
index dcd2cee3a..17e0dbb81 160000
--- a/config
+++ b/config
@@ -1 +1 @@
-Subproject commit dcd2cee3af82ce8e4de407801636637f7cdcef3c
+Subproject commit 17e0dbb819839d9b65b711efb085b38bcbb5eae9
diff --git a/dependencies.md b/dependencies.md
index 1da917c4b..dae2d4947 100644
--- a/dependencies.md
+++ b/dependencies.md
@@ -1,6 +1,6 @@
-# Dependencies of `io.spine:spine-time:2.0.0-SNAPSHOT.231`
+# Dependencies of `io.spine:spine-time:2.0.0-SNAPSHOT.232`
## Runtime
1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2.
@@ -1006,14 +1006,14 @@
The dependencies distributed under several licenses, are used according their commercial-use-friendly license.
-This report was generated on **Fri Dec 26 17:25:17 WET 2025** using
+This report was generated on **Tue Mar 10 19:57:35 WET 2026** using
[Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under
[Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE).
-# Dependencies of `io.spine:spine-time-java:2.0.0-SNAPSHOT.231`
+# Dependencies of `io.spine:spine-time-java:2.0.0-SNAPSHOT.232`
## Runtime
1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2.
@@ -1849,14 +1849,14 @@ This report was generated on **Fri Dec 26 17:25:17 WET 2025** using
The dependencies distributed under several licenses, are used according their commercial-use-friendly license.
-This report was generated on **Fri Dec 26 17:25:17 WET 2025** using
+This report was generated on **Tue Mar 10 19:57:35 WET 2026** using
[Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under
[Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE).
-# Dependencies of `io.spine:spine-time-kotlin:2.0.0-SNAPSHOT.231`
+# Dependencies of `io.spine:spine-time-kotlin:2.0.0-SNAPSHOT.232`
## Runtime
1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2.
@@ -2704,14 +2704,14 @@ This report was generated on **Fri Dec 26 17:25:17 WET 2025** using
The dependencies distributed under several licenses, are used according their commercial-use-friendly license.
-This report was generated on **Fri Dec 26 17:25:17 WET 2025** using
+This report was generated on **Tue Mar 10 19:57:35 WET 2026** using
[Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under
[Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE).
-# Dependencies of `io.spine.tools:spine-time-testlib:2.0.0-SNAPSHOT.231`
+# Dependencies of `io.spine.tools:spine-time-testlib:2.0.0-SNAPSHOT.232`
## Runtime
1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2.
@@ -3547,6 +3547,6 @@ This report was generated on **Fri Dec 26 17:25:17 WET 2025** using
The dependencies distributed under several licenses, are used according their commercial-use-friendly license.
-This report was generated on **Fri Dec 26 17:25:17 WET 2025** using
+This report was generated on **Tue Mar 10 19:57:35 WET 2026** using
[Gradle-License-Report plugin](https://github.com/jk1/Gradle-License-Report) by Evgeny Naumenko, licensed under
[Apache 2.0 License](https://github.com/jk1/Gradle-License-Report/blob/master/LICENSE).
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index f8e1ee312..61285a659 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 23449a2b5..dbc3ce4a0 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/pom.xml b/pom.xml
index 35febca6e..d5696a46a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject.
-->
io.spine
spine-time
-2.0.0-SNAPSHOT.231
+2.0.0-SNAPSHOT.232
2015
@@ -56,7 +56,7 @@ all modules and does not describe the project structure per-subproject.
io.spine
spine-validation-jvm-runtime
- 2.0.0-SNAPSHOT.391
+ 2.0.0-SNAPSHOT.402
compile
@@ -193,12 +193,12 @@ all modules and does not describe the project structure per-subproject.
io.spine.tools
compiler-cli-all
- 2.0.0-SNAPSHOT.037
+ 2.0.0-SNAPSHOT.039
io.spine.tools
compiler-protoc-plugin
- 2.0.0-SNAPSHOT.037
+ 2.0.0-SNAPSHOT.039
io.spine.tools
@@ -218,7 +218,7 @@ all modules and does not describe the project structure per-subproject.
io.spine.tools
validation-java-bundle
- 2.0.0-SNAPSHOT.391
+ 2.0.0-SNAPSHOT.402
net.sourceforge.pmd
diff --git a/time/src/main/java/io/spine/time/validation/When.java b/time/src/main/java/io/spine/time/validation/When.java
deleted file mode 100644
index c3e85ba87..000000000
--- a/time/src/main/java/io/spine/time/validation/When.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * 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.time.validation;
-
-import com.google.protobuf.Timestamp;
-import io.spine.code.proto.FieldContext;
-import io.spine.validation.Constraint;
-import io.spine.validation.option.FieldValidatingOption;
-
-/**
- * A validating option that specified the point in time which
- * a {@link Timestamp} field value has.
- */
-final class When extends FieldValidatingOption {
-
- private When() {
- super(TimeOptionsProto.when);
- }
-
- /** Creates a new instance of this option. */
- public static When create() {
- return new When();
- }
-
- @Override
- public Constraint constraintFor(FieldContext value) {
- return new WhenConstraint(optionValue(value), value.targetDeclaration());
- }
-}
diff --git a/time/src/main/java/io/spine/time/validation/WhenConstraint.java b/time/src/main/java/io/spine/time/validation/WhenConstraint.java
deleted file mode 100644
index dfc2d8917..000000000
--- a/time/src/main/java/io/spine/time/validation/WhenConstraint.java
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * 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.time.validation;
-
-import com.google.common.collect.ImmutableList;
-import com.google.protobuf.Message;
-import com.google.protobuf.Timestamp;
-import io.spine.code.proto.FieldContext;
-import io.spine.code.proto.FieldDeclaration;
-import io.spine.time.Temporal;
-import io.spine.time.Temporals;
-import io.spine.validation.ConstraintViolation;
-import io.spine.validation.CustomConstraint;
-import io.spine.validation.FieldValue;
-import io.spine.validation.MessageValue;
-import io.spine.validation.TemplateString;
-import io.spine.validation.TemplateStrings;
-import io.spine.validation.diags.ViolationText;
-import io.spine.validation.option.FieldConstraint;
-
-import java.util.Locale;
-
-import static io.spine.time.validation.Time.FUTURE;
-import static io.spine.time.validation.Time.TIME_UNDEFINED;
-
-/**
- * A constraint that, when applied to a {@link Timestamp} field value, checks for whether the
- * actual value is in the future or in the past, defined by the value of the field option.
- */
-final class WhenConstraint extends FieldConstraint implements CustomConstraint {
-
- /**
- * The name of the placeholder for reporting the value of the {@link TimeOption#getIn in}
- * constraint on a temporal field.
- */
- private static final String TIME_PLACEHOLDER = "field.time";
-
- WhenConstraint(TimeOption optionValue, FieldDeclaration field) {
- super(optionValue, field);
- }
-
- @Override
- public ImmutableList validate(MessageValue message) {
- var fieldValue = message.valueOf(field());
- var when = optionValue().getIn();
- if (when == TIME_UNDEFINED) {
- return ImmutableList.of();
- }
- var violations =
- fieldValue.values()
- .map(msg -> Temporals.from((Message) msg))
- .filter(temporalValue -> isTimeInvalid(temporalValue, when))
- .findFirst()
- .map(invalidValue -> ImmutableList.of(
- newTimeViolation(fieldValue, invalidValue)
- ))
- .orElse(ImmutableList.of());
- return violations;
- }
-
- @Override
- public TemplateString errorMessage(FieldContext field) {
- var option = optionValue();
- var errorMsg = ViolationText.errorMessage(option, option.getErrorMsg());
- var time = option.getIn().name().toLowerCase(Locale.ENGLISH);
- var builder = TemplateString.newBuilder()
- .setWithPlaceholders(errorMsg)
- .putPlaceholderValue(TIME_PLACEHOLDER, time);
- TemplateStrings.withField(builder, field.targetDeclaration());
- return builder.build();
- }
-
- @Override
- public String formattedErrorMessage(FieldContext field) {
- var templateString = errorMessage(field);
- return TemplateStrings.format(templateString);
- }
-
- /**
- * Checks the time.
- *
- * @param temporalValue
- * a time point to check
- * @param whenExpected
- * the time when the checked timestamp should be
- * @return {@code true} if the time is valid according to {@code whenExpected} parameter,
- * {@code false} otherwise
- */
- private static boolean isTimeInvalid(Temporal> temporalValue, Time whenExpected) {
- var valid = (whenExpected == FUTURE)
- ? temporalValue.isInFuture()
- : temporalValue.isInPast();
- return !valid;
- }
-
- private ConstraintViolation newTimeViolation(FieldValue fieldValue, Temporal> value) {
- var msg = errorMessage(fieldValue.context());
- var fieldPath = fieldValue.context().fieldPath();
- var violation = ConstraintViolation.newBuilder()
- .setMessage(msg)
- .setFieldPath(fieldPath)
- .setFieldValue(value.packed())
- .build();
- return violation;
- }
-}
diff --git a/time/src/main/java/io/spine/time/validation/WhenFactory.kt b/time/src/main/java/io/spine/time/validation/WhenFactory.kt
deleted file mode 100644
index 00fee6deb..000000000
--- a/time/src/main/java/io/spine/time/validation/WhenFactory.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * 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.time.validation
-
-import com.google.auto.service.AutoService
-import com.google.common.collect.ImmutableSet
-import com.google.errorprone.annotations.Immutable
-import io.spine.annotation.Internal
-import io.spine.validation.option.FieldValidatingOption
-import io.spine.validation.option.ValidatingOptionFactory
-
-/**
- * An implementation of [ValidatingOptionFactory] which adds the [When] option
- * for message fields.
- */
-@AutoService(ValidatingOptionFactory::class)
-@Internal
-@Immutable
-public class WhenFactory : ValidatingOptionFactory {
-
- override fun forMessage(): Set> {
- return typeOptions
- }
-
- private companion object {
-
- private val typeOptions by lazy {
- ImmutableSet.of>(When.create())
- }
- }
-}
diff --git a/time/src/main/kotlin/io/spine/time/validation/LocalDateValidator.kt b/time/src/main/kotlin/io/spine/time/validation/LocalDateValidator.kt
new file mode 100644
index 000000000..5a3fb83c1
--- /dev/null
+++ b/time/src/main/kotlin/io/spine/time/validation/LocalDateValidator.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2026, 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.time.validation
+
+import com.google.auto.service.AutoService
+import io.spine.base.FieldPath
+import io.spine.time.LocalDate
+import io.spine.time.Month
+import io.spine.validation.DetectedViolation
+import io.spine.validation.FieldViolation
+import io.spine.validation.MessageValidator
+import io.spine.validation.RuntimeErrorPlaceholder.FIELD_PATH
+import io.spine.validation.RuntimeErrorPlaceholder.RANGE_VALUE
+import io.spine.validation.templateString
+import java.time.Year
+import java.time.YearMonth
+
+/**
+ * Validates [LocalDate] messages.
+ *
+ * Ensures that the day of a month is within the range allowed for the given month.
+ * This takes into account the number of days in February in leap years.
+ */
+@AutoService(MessageValidator::class)
+public class LocalDateValidator : MessageValidator {
+
+ @Suppress("ReturnCount")
+ override fun validate(message: LocalDate): List {
+ val year = message.year
+ val month = message.month
+ val day = message.day
+
+ if (month == Month.MONTH_UNDEFINED || month == Month.UNRECOGNIZED) {
+ // There is nothing we can do in such a situation because `Month` is an enum.
+ // We do not restrict enum field values because it does not have much sense
+ // from the domain language point of view.
+ return emptyList()
+ }
+
+ if (year < Year.MIN_VALUE || year > Year.MAX_VALUE) {
+ // This is a safety net for the `YearMonth.of()` call which fails
+ // when the year is out of range defined by Java Time.
+ // We return an empty list because we have an option-based constraint
+ // on the `year` field for these values, and validation will fail in the generated code.
+ // We do not want to duplicate the error message for the `year` being out of range.
+ return emptyList()
+ }
+
+ val daysInMonth = YearMonth.of(year, month.number).lengthOfMonth()
+ if (day > daysInMonth) {
+ return listOf(invalidDay(day, daysInMonth))
+ }
+ return emptyList()
+ }
+}
+
+/**
+ * Creates a violation for an invalid day of the month.
+ *
+ * @param day the invalid day value.
+ * @param maxDays the maximum allowed days for the month.
+ */
+private fun invalidDay(day: Int, maxDays: Int): FieldViolation = FieldViolation(
+ message = templateString {
+ withPlaceholders = "The \${$FIELD_PATH} value is out of range" +
+ " (\${$RANGE_VALUE}): $day."
+ placeholderValue.put(FIELD_PATH.value, "day")
+ placeholderValue.put(RANGE_VALUE.value, "1..$maxDays")
+ },
+ fieldPath = FieldPath.newBuilder()
+ .addFieldName("day")
+ .build(),
+ fieldValue = day
+)
diff --git a/time/src/main/proto/spine/time/time.proto b/time/src/main/proto/spine/time/time.proto
index 7da7156ea..17c7bd157 100644
--- a/time/src/main/proto/spine/time/time.proto
+++ b/time/src/main/proto/spine/time/time.proto
@@ -66,7 +66,7 @@ message YearMonth {
};
// A year with `1` being year 1 CE and `-1` being 1 BC.
- int32 year = 1;
+ int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"];
// One of 12 Gregorian calendar months specified by `Month`.
Month month = 2 [(required) = true];
@@ -102,12 +102,16 @@ message LocalDate {
};
// A year with `1` being year 1 CE and `-1` being 1 BC.
- int32 year = 1;
+ int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"];
// One of 12 Gregorian calendar months specified by `Month`.
Month month = 2 [(required) = true];
// A day which must be from 1 to 31 and valid for the year and month.
+ //
+ // In generated code for Kotlin/Java this is checked by
+ // `io.spine.time.validation.LocalDateValidator`.
+ //
int32 day = 3 [(min).value = "1", (max).value = "31"];
}
@@ -150,8 +154,8 @@ message LocalDateTime {
field: "time"
};
- LocalDate date = 1 [(required) = true, (validate) = true];
- LocalTime time = 2 [(validate) = true];
+ LocalDate date = 1 [(required) = true];
+ LocalTime time = 2;
}
// A time-zone offset from UTC, such as `+02:00`.
@@ -171,7 +175,7 @@ message OffsetTime {
option deprecated = true;
// The local time.
- LocalTime time = 1 [(validate) = true];
+ LocalTime time = 1;
// The offset of the time-zone from UTC.
ZoneOffset offset = 2;
@@ -187,7 +191,7 @@ message OffsetDateTime {
option (is).java_type = "OffsetDateTimeTemporal";
// The local date-time.
- LocalDateTime date_time = 1 [(required) = true, (validate) = true];
+ LocalDateTime date_time = 1 [(required) = true];
// The offset of the time-zone from UTC.
ZoneOffset offset = 2;
@@ -210,7 +214,7 @@ message ZonedDateTime {
option (is).java_type = "ZonedDateTimeTemporal";
// The local date-time.
- LocalDateTime date_time = 1 [(required) = true, (validate) = true];
+ LocalDateTime date_time = 1 [(required) = true];
// The time-zone.
ZoneId zone = 2 [(required) = true];
diff --git a/time/src/test/java/io/spine/time/validation/WhenFactoryTest.java b/time/src/test/java/io/spine/time/validation/WhenFactoryTest.java
deleted file mode 100644
index 157cfab0a..000000000
--- a/time/src/test/java/io/spine/time/validation/WhenFactoryTest.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * 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.time.validation;
-
-import com.google.common.collect.Iterables;
-import io.spine.validation.option.ValidatingOptionFactory;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-import java.util.ServiceLoader;
-
-import static com.google.common.collect.Lists.newArrayList;
-import static com.google.common.truth.Truth.assertThat;
-
-@DisplayName("`WhenFactory` should")
-class WhenFactoryTest {
-
- @Test
- @DisplayName("be discoverable to `ServiceLoader`")
- void beLoaded() {
- var loader = ServiceLoader.load(ValidatingOptionFactory.class);
- List optionFactories = newArrayList(loader);
- assertThat(optionFactories.size()).isAtLeast(1);
- var timeOptionFactory = optionFactories.stream()
- .filter(WhenFactory.class::isInstance)
- .findAny();
- assertThat(timeOptionFactory).isPresent();
- }
-
- @Test
- @DisplayName("declare (when) option")
- void declareWhen() {
- ValidatingOptionFactory factory = new WhenFactory();
- var messageOptions = factory.forMessage();
- assertThat(messageOptions).hasSize(1);
- var option = Iterables.getOnlyElement(messageOptions);
- assertThat(option).isInstanceOf(When.class);
- }
-}
diff --git a/time/src/test/kotlin/io/spine/time/LocalDateSpec.kt b/time/src/test/kotlin/io/spine/time/LocalDateSpec.kt
new file mode 100644
index 000000000..a880ec0f5
--- /dev/null
+++ b/time/src/test/kotlin/io/spine/time/LocalDateSpec.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2026, 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.time
+
+import io.kotest.matchers.optional.shouldBePresent
+import io.kotest.matchers.shouldBe
+import io.spine.protobuf.TypeConverter
+import io.spine.validation.ConstraintViolation
+import java.time.Year.MAX_VALUE
+import java.time.Year.MIN_VALUE
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertDoesNotThrow
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+@DisplayName("`LocalDate` should")
+internal class LocalDateSpec {
+
+ @Nested
+ @DisplayName("validate `year` within range")
+ inner class YearRange {
+
+ @ParameterizedTest
+ @ValueSource(ints = [MIN_VALUE, 0, 2024, MAX_VALUE])
+ fun `allow valid year values`(yearValue: Int) {
+ assertDoesNotThrow {
+ localDate {
+ year = yearValue
+ month = Month.JANUARY
+ day = 1
+ }
+ }
+ }
+
+ @Test
+ fun `detect year value below the minimum`() {
+ val yearValue = MIN_VALUE - 1
+ val date = LocalDate.newBuilder()
+ .setYear(yearValue)
+ .setMonth(Month.JANUARY)
+ .setDay(1)
+ .buildPartial()
+ val error = date.validate()
+ error.shouldBePresent()
+ val violation = error.get().constraintViolationList[0]
+ violation.matches(yearValue, "min.value", MIN_VALUE.toString())
+ }
+
+ @Test
+ fun `detect year value above the maximum`() {
+ val yearValue = MAX_VALUE + 1
+ val date = LocalDate.newBuilder()
+ .setYear(yearValue)
+ .setMonth(Month.JANUARY)
+ .setDay(1)
+ .buildPartial()
+ val error = date.validate()
+ error.shouldBePresent()
+ val violation = error.get().constraintViolationList[0]
+ violation.matches(yearValue, "max.value", MAX_VALUE.toString())
+ }
+ }
+
+ private fun ConstraintViolation.matches(
+ yearValue: Int,
+ limitKey: String,
+ limitValue: String
+ ) {
+ fieldPath.getFieldName(0) shouldBe "year"
+ val value = TypeConverter.toObject(fieldValue, Int::class.javaObjectType)
+ value shouldBe yearValue
+ message.placeholderValueMap[limitKey] shouldBe limitValue
+ }
+}
diff --git a/time/src/test/kotlin/io/spine/time/validation/LocalDateValidatorSpec.kt b/time/src/test/kotlin/io/spine/time/validation/LocalDateValidatorSpec.kt
new file mode 100644
index 000000000..9f82a71bf
--- /dev/null
+++ b/time/src/test/kotlin/io/spine/time/validation/LocalDateValidatorSpec.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2026, 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.time.validation
+
+import io.kotest.matchers.collections.shouldBeEmpty
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+import io.spine.time.LocalDate
+import io.spine.time.Month
+import io.spine.time.localDate
+import io.spine.validation.FieldViolation
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertDoesNotThrow
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.CsvSource
+
+@DisplayName("`LocalDateValidator` should")
+internal class LocalDateValidatorSpec {
+
+ private val validator = LocalDateValidator()
+
+ @Test
+ fun `allow valid dates`() {
+ assertDoesNotThrow {
+ localDate {
+ year = 2026
+ month = Month.JANUARY
+ day = 31
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @DisplayName("detect invalid day for a month")
+ @CsvSource(
+ "2024, APRIL, 31, 30",
+ "2024, JUNE, 31, 30",
+ "2024, SEPTEMBER, 31, 30",
+ "2024, NOVEMBER, 31, 30",
+ "2023, FEBRUARY, 29, 28",
+ "2024, FEBRUARY, 30, 29"
+ )
+ fun detectInvalidDay(year: Int, month: Month, day: Int, maxDays: Int) {
+ val date = LocalDate.newBuilder()
+ .setYear(year)
+ .setMonth(month)
+ .setDay(day)
+ .buildPartial()
+ val violations = validator.validate(date)
+ violations shouldHaveSize 1
+ val violation = violations[0] as FieldViolation
+ violation.run {
+ fieldPath!!.fieldNameList[0] shouldBe "day"
+ fieldValue shouldBe day
+ message.withPlaceholders shouldBe
+ "The \${field.path} value is out of range (\${range.value}): $day."
+ message.placeholderValueMap["range.value"] shouldBe "1..$maxDays"
+ }
+ }
+
+ @Nested
+ @DisplayName("handle leap years and")
+ inner class LeapYear {
+
+ @Test
+ fun `allow Feb 29 in a leap year`() {
+ val date = LocalDate.newBuilder()
+ .setYear(2024)
+ .setMonth(Month.FEBRUARY)
+ .setDay(29)
+ .buildPartial()
+ validator.validate(date).shouldBeEmpty()
+ }
+
+ @Test
+ fun `detect invalid Feb 29 in a non-leap year`() {
+ val date = LocalDate.newBuilder()
+ .setYear(2023)
+ .setMonth(Month.FEBRUARY)
+ .setDay(29)
+ .buildPartial()
+ val violations = validator.validate(date)
+ violations shouldHaveSize 1
+ val violation = violations[0] as FieldViolation
+ violation.message.placeholderValueMap["range.value"] shouldBe "1..28"
+ }
+ }
+
+ /**
+ * The test verifies that if a month is not defined the `LocalDate` instance
+ * is considered valid.
+ *
+ * There is nothing we can do in such a situation because `Month` is an enum.
+ * We do not restrict enum field values because it does not have much sense
+ * from the domain language point of view.
+ *
+ * We still want `MONTH_UNDEFINED` item to support the "unset" notion for a month
+ * as we have such a thing for other enums in the code.
+ */
+ @Test
+ fun `ignore undefined month`() {
+ val date = LocalDate.newBuilder()
+ .setYear(2024)
+ .setMonth(Month.MONTH_UNDEFINED)
+ .setDay(31)
+ .buildPartial()
+ validator.validate(date).shouldBeEmpty()
+ }
+}
diff --git a/version.gradle.kts b/version.gradle.kts
index 4772111ca..398a68f00 100644
--- a/version.gradle.kts
+++ b/version.gradle.kts
@@ -29,4 +29,4 @@
*
* For dependencies on Spine modules please see [io.spine.dependency.local.Spine].
*/
-val versionToPublish by extra("2.0.0-SNAPSHOT.231")
+val versionToPublish by extra("2.0.0-SNAPSHOT.232")