diff --git a/.agents/skills/writer/SKILL.md b/.agents/skills/writer/SKILL.md index 30cb51e38b..6b9d86f88e 100644 --- a/.agents/skills/writer/SKILL.md +++ b/.agents/skills/writer/SKILL.md @@ -49,6 +49,15 @@ description: > - Follow `.agents/documentation-guidelines.md` and `.agents/documentation-tasks.md`. - Use fenced code blocks for commands and examples; format file/dir names as code. +- When referencing a documentation page or section in body prose, use typographic + double quotation marks only if the visible reference text is the actual page or + section title, such as the “Getting started” page or the “Troubleshooting” + section. The title normally starts with a capital letter. Do not add these + quotes around generic or descriptive links such as “this page”, “the next + section”, “declaring constraints”, or `4.3`, even if they point to a page or + section. Do not add these quotes in “What’s next” sections or navigation + elements. Keep file paths, identifiers, frontmatter values, navigation labels, + and Markdown link labels in their expected syntax. - In Markdown files, prefer footnote-style reference links for external `https://` targets instead of inline links. Write readable body text like `[label][short-id]`, then place the URL definition near the end of the file, diff --git a/.gitignore b/.gitignore index 5f85d295e8..8d537b3167 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ pubspec.lock # Python cache __pycache__/ *.pyc +/.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 72e0ad2277..38753d02a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Agent Guidelines Please read and follow all guidelines in the project's agent documentation: -- Start with the table of contents: `./agents/_TOC.md`. +- Start with the table of contents: `.agents/_TOC.md`. - Follow all linked documents from the TOC. - Apply all coding standards, formatting rules, and project conventions found in these documents. diff --git a/README.md b/README.md index 4bec24fd7f..8fcad4f7c9 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,6 @@ Spine Validation solves this by modifying the code generated by the Protobuf com At build time, Spine Validation injects assertions directly into the generated Java classes, enabling automatic enforcement of constraints without explicit API calls in application code. -## Table of Contents - -- [Prerequisites](#prerequisites) -- [Validation in Action](#validation-in-action) -- [Architecture](#architecture) -- [Extending the Library](#extending-the-library) - ## Prerequisites This library is built with Java 17. @@ -78,12 +71,12 @@ optionalError.ifPresent(err -> { }); ``` -### Validation Options +## Validation Options Validation options are defined by the following files: -1. [options.proto](https://github.com/SpineEventEngine/base-libraries/blob/master/base/src/main/proto/spine/options.proto). -2. [time_options.proto](https://github.com/SpineEventEngine/time/blob/master/time/src/main/proto/spine/time_options.proto). +1. [`options.proto`][options-proto] +2. [`time_options.proto`][time-options-proto] Users must import these .proto files to use the options they define. @@ -92,77 +85,12 @@ import "spine/options.proto"; // Brings all options, except for time-related one import "spine/time_options.proto"; // Brings time-related options. ``` -# Adding custom validation +## Adding custom validation Users can extend the library by providing custom Protobuf options and code generation logic. -Follow these steps to create a custom option: - -1. Declare a Protobuf [extension](https://protobuf.dev/programming-guides/proto3/#customoptions) - in your `.proto` file. -2. Register it via `io.spine.option.OptionsProvider`. -3. Implement the following entities: - - Policy (`MyOptionPolicy`) – discovers and validates the option. - - View (`MyOptionView`) – accumulates valid option applications. - - Generator (`MyOptionGenerator`) – generates Java code for the option. -4. Register them via `io.spine.tools.validation.java.ValidationOption`. - -Below is a workflow diagram for a typical option: - -![Typical custom option](.github/readme/typical_custom_option.jpg) - -Take a look at the `:java-tests:extensions` module that contains a full example of -implementation of the custom `(currency)` option. - -Note that a custom option can provide several policies and views, but only one generator. -This allows building more complex models, using more entities and events. - -Let's take a closer look at each entity. - -### Policy - -Usually, this is an entry point to the option handling. - -The policy subscribes to one of `*OptionDiscovered` events: - -- `FileOptionDiscovered`. -- `MessageOptionDiscovered`. -- `FieldOptionDiscovered`. -- `OneofOptionDiscovered`. - -It filters incoming events, taking only those who contain the option of the interest. The policy -may validate the option application, query `TypeSystem`, extract and transform data arrived with -the option, if any. Once ready, it emits an event signaling that the discovered option is valid -and ready for the code generation. - -The policy may report a compilation warning or an error, failing the whole compilation if it -finds an illegal application of the option. - -For example: - -1. An unsupported field type. -2. Illegal option content (invalid regex, parameter, signature). - -The policy may just ignore the discovered option and emit `NoReaction`. A typical example -of this is a boolean option, such as `(required)`, which does nothing when it is set to `false`. - -The desired behavior depends on the option itself. - -### View - -Views accumulate events from policies, serving as data providers for the validation model -used by code generators. Views are typically simple and only accumulate data; for more complex -logic, use policies. - -Usually, one view represents a single application of an option. - -### Generator - -The generator is an entity that provides an actual implementation of the option behavior. -The generator produces Java code for every application of that option within the message type. - -It has access to the `Querying` interface and can query views to find those belonging -to the processed message type. +See the [Custom validation][custom-validation] section +of the User Guide for details. [codecov]: https://codecov.io/gh/SpineEventEngine/validation [codecov-badge]: https://codecov.io/gh/SpineEventEngine/validation/branch/master/graph/badge.svg @@ -170,3 +98,7 @@ to the processed message type. [license]: http://www.apache.org/licenses/LICENSE-2.0 [gh-actions]: https://github.com/SpineEventEngine/validation/actions [ubuntu-build-badge]: https://github.com/SpineEventEngine/validation/actions/workflows/build-on-ubuntu.yml/badge.svg + +[options-proto]: https://github.com/SpineEventEngine/base-libraries/blob/master/base/src/main/proto/spine/options.proto +[time-options-proto]: https://github.com/SpineEventEngine/time/blob/master/time/src/main/proto/spine/time_options.proto +[custom-validation]: docs/content/docs/validation/user/05-custom-validation/_index.md diff --git a/buildSrc/src/main/kotlin/fat-jar.gradle.kts b/buildSrc/src/main/kotlin/fat-jar.gradle.kts index e8391553ac..eebb6478c3 100644 --- a/buildSrc/src/main/kotlin/fat-jar.gradle.kts +++ b/buildSrc/src/main/kotlin/fat-jar.gradle.kts @@ -1,11 +1,11 @@ /* - * Copyright 2023, TeamDev. All rights reserved. + * 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 * - * http://www.apache.org/licenses/LICENSE-2.0 + * 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 @@ -55,14 +55,6 @@ tasks.publish { tasks.shadowJar { exclude( - /* - * Excluding this type to avoid it being located in the fat JAR. - * - * Locating this type in its own `io:spine:protodata` artifact is crucial - * for obtaining proper version values from the manifest file. - * This file is only present in the `io:spine:protodata` artifact. - */ - "io/spine/protodata/gradle/plugin/Plugin.class", "META-INF/gradle-plugins/io.spine.tools.compiler.properties", /* 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 121d7aac24..600deeda75 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.414" + const val version = "2.0.0-SNAPSHOT.415" /** * The last version of Validation compatible with ProtoData. diff --git a/context/src/main/kotlin/io/spine/tools/validation/ErrorPlaceholder.kt b/context/src/main/kotlin/io/spine/tools/validation/ErrorPlaceholder.kt index 58393e9712..1401accf00 100644 --- a/context/src/main/kotlin/io/spine/tools/validation/ErrorPlaceholder.kt +++ b/context/src/main/kotlin/io/spine/tools/validation/ErrorPlaceholder.kt @@ -38,7 +38,7 @@ package io.spine.tools.validation * We have the same items in this enum as in `io.spine.validation.RuntimeErrorPlaceholder` * in the runtime library, which is exactly as this one. Please keep them in sync. * This duplication is done intentionally to prevent clash between the runtime library, - * which is added to the classpath of the Compiler and the runtime library, which is part + * which is added to the classpath of the Compiler, and the runtime library, which is part * of the Compiler itself because it is a part of Spine. As we complete our migration * of validation to codegen, the runtime library will either be significantly simplified, * or even its content may be moved to `base`. Then, the duplicate enum should be removed. diff --git a/context/src/main/kotlin/io/spine/tools/validation/ValidationPlugin.kt b/context/src/main/kotlin/io/spine/tools/validation/ValidationPlugin.kt index 00cb483a7e..736645d1b4 100644 --- a/context/src/main/kotlin/io/spine/tools/validation/ValidationPlugin.kt +++ b/context/src/main/kotlin/io/spine/tools/validation/ValidationPlugin.kt @@ -105,4 +105,4 @@ public abstract class ValidationPlugin( IfSetAgainReaction(), RequireReaction() ) -) +) // Plugin diff --git a/dependencies.md b/dependencies.md index 69792730a1..d96134b470 100644 --- a/dependencies.md +++ b/dependencies.md @@ -1,6 +1,6 @@ -# Dependencies of `io.spine.tools:validation-context:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-context:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -1090,14 +1090,14 @@ The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:11:00 WEST 2026** using +This report was generated on **Thu May 07 19:54:44 WEST 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:validation-context-tests:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-context-tests:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -1791,28 +1791,28 @@ This report was generated on **Mon May 04 21:11:00 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:42 WEST 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:validation-docs:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-docs:2.0.0-SNAPSHOT.416` ## Runtime ## Compile, tests, and tooling The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:58 WEST 2026** using +This report was generated on **Thu May 07 19:54:38 WEST 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:validation-gradle-plugin:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-gradle-plugin:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -2864,14 +2864,14 @@ This report was generated on **Mon May 04 21:10:58 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:11:00 WEST 2026** using +This report was generated on **Thu May 07 19:54:44 WEST 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:validation-java:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-java:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -3961,14 +3961,14 @@ This report was generated on **Mon May 04 21:11:00 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:11:00 WEST 2026** using +This report was generated on **Thu May 07 19:54:44 WEST 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:validation-java-bundle:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-java-bundle:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : org.jetbrains. **Name** : annotations. **Version** : 13.0. @@ -4015,14 +4015,14 @@ This report was generated on **Mon May 04 21:11:00 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:58 WEST 2026** using +This report was generated on **Thu May 07 19:54:39 WEST 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-validation-jvm-runtime:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine:spine-validation-jvm-runtime:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -4822,14 +4822,14 @@ This report was generated on **Mon May 04 21:10:58 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:11:00 WEST 2026** using +This report was generated on **Thu May 07 19:54:44 WEST 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:validation-consumer:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-consumer:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -5511,14 +5511,14 @@ This report was generated on **Mon May 04 21:11:00 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:42 WEST 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:validation-consumer-dependency:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-consumer-dependency:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -5976,14 +5976,14 @@ This report was generated on **Mon May 04 21:10:59 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:42 WEST 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:validation-extensions:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-extensions:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -6602,14 +6602,14 @@ This report was generated on **Mon May 04 21:10:59 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:42 WEST 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:validation-runtime:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-runtime:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -7170,14 +7170,14 @@ This report was generated on **Mon May 04 21:10:59 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:42 WEST 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:validation-validating:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-validating:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -7781,14 +7781,14 @@ This report was generated on **Mon May 04 21:10:59 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:42 WEST 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:validation-validator:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-validator:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.fasterxml.jackson. **Name** : jackson-bom. **Version** : 2.20.0. @@ -8526,14 +8526,14 @@ This report was generated on **Mon May 04 21:10:59 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:42 WEST 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:validation-validator-dependency:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-validator-dependency:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -8766,14 +8766,14 @@ This report was generated on **Mon May 04 21:10:59 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:41 WEST 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:validation-vanilla:2.0.0-SNAPSHOT.415` +# Dependencies of `io.spine.tools:validation-vanilla:2.0.0-SNAPSHOT.416` ## Runtime 1. **Group** : com.google.code.findbugs. **Name** : jsr305. **Version** : 3.0.2. @@ -9116,6 +9116,6 @@ This report was generated on **Mon May 04 21:10:59 WEST 2026** using The dependencies distributed under several licenses, are used according their commercial-use-friendly license. -This report was generated on **Mon May 04 21:10:59 WEST 2026** using +This report was generated on **Thu May 07 19:54:40 WEST 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/docs/_bin/embed-code-linux b/docs/_bin/embed-code-linux index 49d4dbb840..95d29c36df 100755 Binary files a/docs/_bin/embed-code-linux and b/docs/_bin/embed-code-linux differ diff --git a/docs/_bin/embed-code-macos b/docs/_bin/embed-code-macos index dd5dde24d6..329e7bd761 100755 Binary files a/docs/_bin/embed-code-macos and b/docs/_bin/embed-code-macos differ diff --git a/docs/_bin/embed-code-windows.exe b/docs/_bin/embed-code-windows.exe index 1065d78799..0366b6ad40 100755 Binary files a/docs/_bin/embed-code-windows.exe and b/docs/_bin/embed-code-windows.exe differ diff --git a/docs/_examples b/docs/_examples index bc2c4a547f..262aab0ffa 160000 --- a/docs/_examples +++ b/docs/_examples @@ -1 +1 @@ -Subproject commit bc2c4a547fa600e01a1f67c0529c18c9d81939f4 +Subproject commit 262aab0ffad0968f601b4d483e2d4b5beadf0d01 diff --git a/docs/_preview/content/_index.md b/docs/_preview/content/_index.md index 84d6c9fecf..5c7657a8ab 100644 --- a/docs/_preview/content/_index.md +++ b/docs/_preview/content/_index.md @@ -4,7 +4,7 @@ title: Validation Documentation preview # Welcome to Validation Documentation -Go to the [docs/validation/](docs/validation/00-intro/) to preview the rendered documentation. +Go to the [docs/validation/](docs/validation/) to preview the rendered documentation. Read the [Authoring][authoring] guide on adding the content to the documentation. diff --git a/docs/_preview/go.mod b/docs/_preview/go.mod index 3d83edfef3..7443952b80 100644 --- a/docs/_preview/go.mod +++ b/docs/_preview/go.mod @@ -3,8 +3,6 @@ module spine.io/validation/docs/preview go 1.25.6 require ( - github.com/SpineEventEngine/site-commons v0.0.0-20260226201948-3aec673b8046 // indirect - github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20400 // indirect - github.com/gohugoio/hugo-mod-jslibs-dist/popperjs/v2 v2.21100.20000 // indirect - github.com/twbs/bootstrap v5.3.8+incompatible // indirect + github.com/SpineEventEngine/site-commons v0.0.0-20260507130158-84db050dfe11 // indirect + github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20800 // indirect ) diff --git a/docs/_preview/go.sum b/docs/_preview/go.sum index f70140dc27..a0140275a3 100644 --- a/docs/_preview/go.sum +++ b/docs/_preview/go.sum @@ -1,9 +1,6 @@ -github.com/SpineEventEngine/site-commons v0.0.0-20260226201948-3aec673b8046 h1:qx3XD7j5i3xhtDu/iRLdqwT16BxHg+9Z7wtXae1VWRU= -github.com/SpineEventEngine/site-commons v0.0.0-20260226201948-3aec673b8046/go.mod h1:tkAl4StIREKmz9r5PiJtuDhvwMMkFXKWcaTyxhIikho= -github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20400 h1:L6+F22i76xmeWWwrtijAhUbf3BiRLmpO5j34bgl1ggU= -github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20400/go.mod h1:uekq1D4ebeXgduLj8VIZy8TgfTjrLdSl6nPtVczso78= -github.com/gohugoio/hugo-mod-jslibs-dist/popperjs/v2 v2.21100.20000 h1:GZxx4Hc+yb0/t3/rau1j8XlAxLE4CyXns2fqQbyqWfs= +github.com/SpineEventEngine/site-commons v0.0.0-20260507130158-84db050dfe11 h1:NTO0ua9IaaO6xmn9m11i4IJjciPXhxKTW+/pAKTOlZw= +github.com/SpineEventEngine/site-commons v0.0.0-20260507130158-84db050dfe11/go.mod h1:tkAl4StIREKmz9r5PiJtuDhvwMMkFXKWcaTyxhIikho= +github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20800 h1:j5myhhzYwIHTr5ctK96Elfgp5uRROvrlTzYwwe1nF8o= +github.com/gohugoio/hugo-mod-bootstrap-scss/v5 v5.20300.20800/go.mod h1:P5GGyhdxi00C5zW7vkRo/IS532gZY/YS2TS395Xaxho= github.com/gohugoio/hugo-mod-jslibs-dist/popperjs/v2 v2.21100.20000/go.mod h1:mFberT6ZtcchrsDtfvJM7aAH2bDKLdOnruUHl0hlapI= -github.com/twbs/bootstrap v5.3.3+incompatible/go.mod h1:fZTSrkpSf0/HkL0IIJzvVspTt1r9zuf7XlZau8kpcY0= -github.com/twbs/bootstrap v5.3.8+incompatible h1:eK1fsXP7R/FWFt+sSNmmvUH9usPocf240nWVw7Dh02o= github.com/twbs/bootstrap v5.3.8+incompatible/go.mod h1:fZTSrkpSf0/HkL0IIJzvVspTt1r9zuf7XlZau8kpcY0= diff --git a/docs/_settings/embed-code.yml b/docs/_settings/embed-code.yml index 71da18049e..38fd9a97c6 100644 --- a/docs/_settings/embed-code.yml +++ b/docs/_settings/embed-code.yml @@ -1,6 +1,20 @@ code-path: + - name: "root" + path: "../.." - name: "examples" path: "../_examples" - name: "runtime" path: "../../jvm-runtime" + - name: "java" + path: "../../java" + - name: "context" + path: "../../context" docs-path: "../content/docs/" +code-includes: + - ".gitignore" + - "**/*.kts" + - "**/*.md" + - "**/*.proto" + - "**/*.java" + - "**/*.kt" + - "**/*.yml" diff --git a/docs/content/docs/validation/05-custom-validation/typical_custom_option.jpg b/docs/content/docs/validation/05-custom-validation/typical_custom_option.jpg deleted file mode 100644 index 5880f4e216..0000000000 Binary files a/docs/content/docs/validation/05-custom-validation/typical_custom_option.jpg and /dev/null differ diff --git a/docs/content/docs/validation/09-developers-guide/_index.md b/docs/content/docs/validation/09-developers-guide/_index.md deleted file mode 100644 index f4b7650ced..0000000000 --- a/docs/content/docs/validation/09-developers-guide/_index.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Developer's Guide -description: Deep dive into Spine Validation architecture and internals. -headline: Documentation ---- - -# Developer’s guide - -This section explains how Spine Validation is structured internally and where to look when -you need to extend it. - -## Topics - -- [Architecture](architecture.md) -- [Key modules](key-modules.md) diff --git a/docs/content/docs/validation/09-developers-guide/architecture.md b/docs/content/docs/validation/09-developers-guide/architecture.md deleted file mode 100644 index 0a4240b081..0000000000 --- a/docs/content/docs/validation/09-developers-guide/architecture.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: Architecture -description: Internal design of the Spine Validation framework. -headline: Documentation ---- - -# Architecture - -The library is a set of plugins for [Spine Compiler](https://github.com/SpineEventEngine/compiler). - -Each target language is a separate Compiler plugin. - -Take a look at the following diagram to grasp a high-level library structure: - -![High-level library structure overview](high_level_overview.png) - -The workflow is the following: - -- (1), (2) – user defines Protobuf messages with validation options. -- (3) – Protobuf compiler generates Java classes. -- (4), (5) – policies and views build the validation model. -- (6), (7) – Java plugin generates and injects validation code. - -## What’s next - -- See the project layout: [Key modules](key-modules.md). -- If you need organization-specific rules: [Custom validation](../05-custom-validation/). diff --git a/docs/content/docs/validation/09-developers-guide/high_level_overview.png b/docs/content/docs/validation/09-developers-guide/high_level_overview.png deleted file mode 100644 index 4224c18700..0000000000 Binary files a/docs/content/docs/validation/09-developers-guide/high_level_overview.png and /dev/null differ diff --git a/docs/content/docs/validation/09-developers-guide/key-modules.md b/docs/content/docs/validation/09-developers-guide/key-modules.md deleted file mode 100644 index 38fd4a20a9..0000000000 --- a/docs/content/docs/validation/09-developers-guide/key-modules.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Key Modules -description: Overview of the main modules in the Spine Validation project. -headline: Documentation ---- - -# Key modules - -This repository is a Gradle multi-project build. Module names below are shown as Gradle -project paths (e.g. `:java`, `:tests:vanilla`). - -## Core modules - -| **Module** | **Description** | -|------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `:context` | Language-agnostic validation model and built-in option handling (views/reactions) shared by language plugins. | -| `:java` | Spine Compiler plugin for Java: generates/injects validation code; loads custom options via `ValidationOption` SPI and custom validators discovered by `:ksp`. | -| `:ksp` | KSP processor that discovers classes annotated with `@io.spine.validation.Validator` and writes a message→validator mapping resource consumed by `:java`. | -| `:jvm-runtime` | Runtime library used by generated code: `ValidationException`, validation/constraint APIs, `MessageValidator`, and error Protobuf types. | -| `:java-bundle` | Fat JAR bundling `:java` for distribution (the artifact typically used as the compiler plugin dependency). | -| `:gradle-plugin` | Gradle plugin (`io.spine.validation`) that configures Spine Compiler to run the Validation compiler for consumer projects. | -| `:docs` | Documentation site (Hugo) sources, scripts, and example projects used in docs. | - -## Test modules - -| **Module** | **Description** | -|-------------------------------|----------------------------------------------------------------------------------------------------------------------------| -| `:context-tests` | Compilation tests for `:context` (Prototap-based), focusing on invalid option usage and error reporting. | -| `:tests` | Parent project for integration tests that run the compiler plugins and exercise generated code. | -| `:tests:vanilla` | “Vanilla” integration tests: validation without any custom extensions. | -| `:tests:extensions` | Example custom option (`(currency)`) implementation used by test suites (custom reactions/views/generator). | -| `:tests:consumer` | Integration tests for a consuming project that uses validation plus custom extensions. | -| `:tests:consumer-dependency` | A dependency module with `.proto` sources used by `:tests:consumer` to verify “protos in dependencies” scenarios. | -| `:tests:validator` | Integration tests for custom `MessageValidator`s annotated with `@Validator` (covers `:ksp` discovery and `:java` wiring). | -| `:tests:validator-dependency` | A dependency module used by `:tests:validator` for validator-related dependency scenarios. | -| `:tests:runtime` | Tests focused on runtime behavior of validation APIs and error messages. | -| `:tests:validating` | Shared fixtures and tests for validation behavior across multiple scenarios (includes `testFixtures`). | - -## What’s next - -- Build custom validation rules: [Custom validation](../05-custom-validation/). diff --git a/docs/content/docs/validation/_index.md b/docs/content/docs/validation/_index.md index 6082e60873..5de724c426 100644 --- a/docs/content/docs/validation/_index.md +++ b/docs/content/docs/validation/_index.md @@ -11,12 +11,12 @@ options, and runs those checks automatically when you build messages. ## Start here -- New to the library: [Overview](00-intro/) -- Ready to try it: [Getting started](01-getting-started/) +- New to the library: [Overview](user/00-intro/) +- Ready to try it: [Getting started](user/01-getting-started/) ## Deeper topics -- [Built-in options](03-built-in-options/) -- [Using validators](04-validators/) -- How it works: [Architecture](09-developers-guide/architecture.md) -- Extension points: [Custom validation](05-custom-validation/) +- [Built-in options](user/03-built-in-options/) +- [Using validators](user/04-validators/) +- Internals: [Developer's guide](developer/overview-and-audience/) +- Extension points: [Custom validation](user/05-custom-validation/) diff --git a/docs/content/docs/validation/developer/adding-a-built-in-option.md b/docs/content/docs/validation/developer/adding-a-built-in-option.md new file mode 100644 index 0000000000..dc544328a0 --- /dev/null +++ b/docs/content/docs/validation/developer/adding-a-built-in-option.md @@ -0,0 +1,542 @@ +--- +title: Adding a new built-in validation option +description: How contributors add a new standard validation option to the Spine Validation library. +headline: Documentation +--- + +# Adding a new built-in validation option + +This page is the contributor-side counterpart to the User's Guide +“[Custom validation](../user/05-custom-validation/)” section. Where that section explains how a +*consumer* wires a custom option into their own project, this page explains how a +*contributor* adds a new **standard** option to the Validation library — one that ships in +[`spine/options.proto`][options-proto] and is recognised by every consumer of the library +without further configuration. + +The mechanics are similar but the locations differ: + +| Aspect | Custom option | Built-in option | +|--------------------|-----------------------------------------------|-------------------------------------------------------------------------| +| Option declaration | A `.proto` file in the consumer's repository. | [`spine/options.proto`][options-proto] in the Base Libraries repo. | +| Reaction and view | Modules in the consumer's repository. | [`:context`][context-pkg] in this repository. | +| Generator | Module in the consumer's repository. | [`:java`][java-pkg] in this repository. | +| Discovery | `ValidationOption` SPI via `ServiceLoader`. | Direct registration in `ValidationPlugin` and `JavaValidationRenderer`. | +| Distribution | Consumer project, optionally a Gradle plugin. | Ships with the Validation library and `:java-bundle`. | + +The walkthrough below uses `(required)` as the recurring concrete reference. It is the +built-in whose model and codegen are most thoroughly described elsewhere in the guide +(see “[The validation model](validation-model.md)” and +“[Java code generation](java-code-generation.md)”), so each step links to the section that +explains that step in depth. + +## Before you start + +Adding a built-in option is a coordinated change across two repositories — Validation and +[Base Libraries][base-libraries] — and across at least three modules in this repository. Before the +implementation, decide: + +- **Whether the option is general enough to belong in the base library.** Built-in + options are part of the Spine vocabulary every consumer inherits. An option that is + meaningful only inside one domain belongs in that domain's library as a custom option, + exactly the way Spine Time ships `(when)` (see + “[Custom validation](../user/05-custom-validation/)”). + The “[Extension points](extension-points.md#the-two-surfaces-at-a-glance)” page + describes when to choose `ValidationOption` over `MessageValidator`; the same discipline + applies to built-ins. +- **The option's declaration site.** A field-level option becomes a `FieldOptions` + extension; an option on a `oneof` group becomes a `OneofOptions` extension; a + message-level option becomes a `MessageOptions` extension. The reaction's input event + and the projection's identity follow from this choice — see + “[The validation model](validation-model.md#the-bounded-context-shape)”. +- **Whether the option is primary or companion.** A companion option overrides one aspect + of a primary — `(if_missing)` overrides `(required)`'s error message, `(if_invalid)` + overrides `(validate)`'s. A companion has its own reaction and event but contributes to + the *primary's* projection. See + “[Companion options](validation-model.md#companion-options)”. +- **Whether the option needs runtime helpers.** Most options compile down to inline Java + that uses only types already in `:jvm-runtime`. A few — for example, `(pattern)` — + introduce new placeholders or share a runtime helper class. Plan this up front; it + affects which modules you change. + +## 1. Declare the option in Base Libraries + +The Protobuf extension that defines the option's name, target descriptor type, and +extension number lives in [`spine/options.proto`][options-proto] in the +[Base Libraries][base-libraries] repository, not in this repository. Every built-in extension number is +allocated from the same range as the options that already ship there, and the file is the +single point at which `protoc` learns about the option. + +A field-level option declaration follows the same shape as the existing built-ins — +illustrated below with an `EXT_NUMBER` placeholder for the field number that the +Base Libraries maintainers allocate: + +```protobuf +extend google.protobuf.FieldOptions { + // A boolean option that requires a field to be set. + bool required = EXT_NUMBER [(default_message) = "The field must be set."]; +} +``` + +Three points worth highlighting: + +- The **extension number** must be unique within `FieldOptions`/`MessageOptions`/ + `OneofOptions`. Allocate it in coordination with the maintainers of Base Libraries + rather than picking a number locally. +- The **`(default_message)` annotation** is the fallback error template. It is read by + [`defaultErrorMessage`][default-message] in `:context` and recorded on the discovery + event, so the projection picks it up only when no companion has overridden it. See + “[Error message templates and placeholders](validation-model.md#error-message-templates-and-placeholders)”. +- For options that carry structured data (rather than a bare `bool` or `string`), declare + a separate Protobuf message type in `options.proto` and use it as the extension's type — + the way `IfMissingOption`, `PatternOption`, and `RequireOption` are declared. + +The change to `options.proto` ships in the next Base Libraries release. Until that +release is available, the matching changes in this repository will not compile against +the published artefact: coordinate the version bump with the Base Libraries maintainers +and merge the two changes in lock-step. + +## 2. Add the option name constant + +`:context` matches incoming `FieldOptionDiscovered` / `OneofOptionDiscovered` / +`MessageOptionDiscovered` events by the option's textual name. The constants live in +[`OptionNames.kt`][option-names]: + +```kotlin +/** + * The name of the `(required)` option. + */ +public const val REQUIRED: String = "required" +``` + +Add a new constant for every option you introduce, primary or companion. The reaction +uses it in its `@Where(field = OPTION_NAME, equals = …)` filter, and so does the +generator if it needs to refer to the option name in error messages or compilation +diagnostics. + +## 3. Model the option in `:context` + +This step is the heart of the work. The first four substeps mirror the artefacts a +Bounded Context combines (see +“[The Bounded Context shape](validation-model.md#the-bounded-context-shape)”); the fifth +wires those artefacts into the plugin: + +### 3.1. Declare the discovery event + +Add a `*Discovered` message to +[`context/src/main/proto/spine/validation/events.proto`][events-proto]. The event carries +the data the projection will record: + + + +```protobuf +message RequiredFieldDiscovered { + + compiler.FieldRef id = 1; + + // The field in which the option was discovered. + compiler.Field subject = 2; + + // The default error message template. + string default_error_message = 3; +} +``` + +The `id` field must be the first declared field and must match the projection's identity +type — `compiler.FieldRef` for field-level options, the corresponding declaration type +for `oneof` and message options. Companion events typically carry only the override they +contribute (for example, `IfMissingOptionDiscovered` carries just the custom message); +see “[The discovered event](validation-model.md#the-discovered-event)”. + +### 3.2. Declare the projection + +Add a view to [`context/src/main/proto/spine/validation/views.proto`][views-proto]. The +shape mirrors the event, with `(entity).kind = PROJECTION` to mark it as a Bounded Context +projection: + + + +```protobuf +message RequiredField { + option (entity).kind = PROJECTION; + + compiler.FieldRef id = 1; + + // The field in which the option was discovered. + compiler.Field subject = 2; + + // The error message template. + string error_message = 3; +} +``` + +A primary option owns its projection. A companion folds into the *primary's* +projection: `IfMissingOption` does not declare its own view, it only contributes to +`RequiredField`. See “[The projection](validation-model.md#the-projection)”. + +### 3.3. Implement the reaction + +The reaction subscribes to the upstream `*OptionDiscovered` event, filters by the option +name constant, validates applicability, and emits the `*Discovered` domain event: + + + +```kotlin +internal class RequiredReaction : Reaction() { + + @React + override fun whenever( + @External @Where(field = OPTION_NAME, equals = REQUIRED) + event: FieldOptionDiscovered, + ): EitherOf2 { + val field = event.subject + val file = event.file + checkFieldType(field, file) + + if (!event.option.boolValue) { + return ignore() + } + + val defaultMessage = defaultErrorMessage() + return requiredFieldDiscovered { + id = field.ref + subject = field + defaultErrorMessage = defaultMessage + }.asA() + } +} +``` + +The reaction is the only place where applicability is checked. By the time the +discovery event is emitted, the option has been confirmed valid for this declaration site. + +A few conventions all built-in reactions follow: + +- **Use `EitherOf2<…, NoReaction>` when the option can be disabled at the declaration + site** (`(required) = false` is a correctly applied but disabled option, so the + reaction returns `NoReaction` and no projection is created). Use `Just<…>` when every + application of the option must produce a discovery event. +- **Report misapplication through `Compilation.check` / `Compilation.error`**, never + through exceptions. The lambda is evaluated only on failure, so detailed diagnostics + are cheap. See + “[Error reporting conventions](validation-model.md#error-reporting-conventions)”. +- **For companion options, call `checkPrimaryApplied` first.** It fails compilation if + the companion is used without the primary it modifies (see + “[Companion options](validation-model.md#companion-options)”). +- **Validate the error template's placeholders against a fixed set.** This applies to + reactions whose option carries a custom message template — typically a companion such + as `(if_missing)` — not to a primary like `(required)` whose template is fixed by + `(default_message)`. Such a reaction declares a `SUPPORTED_PLACEHOLDERS` set and calls + `checkPlaceholders` on the supplied message; this is what lets the generator assume + every placeholder it later reads is known. See `IfMissingReaction` for the running + reference. + +### 3.4. Implement the projection + +The projection is a Kotlin `View` parameterised by its identity, state, and builder +types. It subscribes to the discovery event and folds it into state: + + + +```kotlin +internal class RequiredFieldView : View() { + + @Subscribe + fun on(e: RequiredFieldDiscovered) { + val currentMessage = state().errorMessage + val message = currentMessage.ifEmpty { e.defaultErrorMessage } + alter { + subject = e.subject + errorMessage = message + } + } + + @Subscribe + fun on(e: IfMissingOptionDiscovered) = alter { + errorMessage = e.customErrorMessage + } +} +``` + +A projection may subscribe to multiple events: `RequiredFieldView` folds both +`RequiredFieldDiscovered` (the primary) and `IfMissingOptionDiscovered` (the companion). +Either order works — the projection picks the default only if no custom message has been +recorded yet. + +### 3.5. Register the reaction and view + +[`ValidationPlugin`][validation-plugin] is the language-agnostic entry point that lists +every built-in. Add the new reaction and view to its `views` and `reactions` sets: + +```kotlin +public abstract class ValidationPlugin( + // ... +) : Plugin( + // ... + views = views + setOf( + RequiredFieldView::class.java, + // ... other existing views + YourNewView::class.java, // the view you are adding + ), + reactions = reactions + setOf>( + RequiredReaction(), + IfMissingReaction(), + // ... other existing reactions + YourNewReaction(), // the reaction(s) you are adding + ) +) +``` + +For a primary plus its companion, both reactions go in the `reactions` set; only the +primary's view goes in `views`. + +## 4. Implement code generation + +The Java side reads from the populated projection and emits inline Java. Typically this +is one generator class (often supported by a small helper that builds the per-application +`CodeBlock`) plus one line of registration; see [4.3](#43-builder-mutating-options) for +the exception that needs a separate renderer instead. + +### 4.1. Write the `OptionGenerator` + +Place the generator under +[`java/src/main/kotlin/io/spine/tools/validation/java/generate/option/`][option-generator-pkg]. +Built-ins extend `OptionGenerator` directly, or `OptionGeneratorWithConverter` when the +emitted code needs `JavaValueConverter` for default-value comparison: + + + +```kotlin +internal class RequiredGenerator : OptionGeneratorWithConverter() { + + /** + * All `(required)` fields in the current compilation process. + */ + private val allRequiredFields by lazy { + querying.select() + .all() + } + + override fun codeFor(type: TypeName): List = + allRequiredFields + .filter { it.id.type == type } + .map { GenerateRequired(it, converter).code() } +} +``` + +The pattern is uniform across the built-ins: + +- Query the projection lazily — `querying` is not available until `inject()` returns, + see “[The render lifecycle](java-code-generation.md#the-render-lifecycle)”. +- Filter views by the message type currently being processed. +- Delegate per-application code construction to a small helper class + (`GenerateRequired` here). The helper produces a `CodeBlock` that runs inside the + validate scope described in “[The validate scope](java-code-generation.md#the-validate-scope)” — + `violations`, + `parentPath`, `parentName` are in scope and the helper appends a + `ConstraintViolation` to `violations` when the constraint fails. + +The constraint block follows the same shape every built-in uses: derive the field path +from `parentPath`, derive the type name from `parentName.orElse(declaringType)`, build a +`ConstraintViolation` through the `constraintViolation` expression helper, and add it to +`violations`. See “[What the generator produces](java-code-generation.md#what-the-generator-produces)” +for the full anatomy of a `SingleOptionCode`. + +### 4.2. Register the generator + +[`JavaValidationRenderer`][java-validation-renderer] keeps the list of built-in +generators in `builtInGenerators()`. Append the new generator there: + +```kotlin +private fun builtInGenerators(): List = listOf( + RequiredGenerator(), + PatternGenerator(), + GoesGenerator(), + DistinctGenerator(), + ValidateGenerator(), + RangeGenerator(), + MaxGenerator(), + MinGenerator(), + ChoiceGenerator(), + RequireOptionGenerator(), + // ... add the new generator here +) +``` + +Because the option ships as a built-in, no `ValidationOption` SPI implementation is +involved: the generator is registered directly. The +“[Extension points](extension-points.md#the-validationoption-spi-end-to-end)” page +describes how a custom option reaches the same `JavaValidationRenderer` through +`ServiceLoader` instead. + +### 4.3. Builder-mutating options + +`(set_once)` is the one built-in whose semantics modify builder behaviour rather than +adding a check to `validate()`. It is rendered by a separate +[`SetOnceRenderer`][set-once-renderer] and does not implement `OptionGenerator` at all. +A new built-in with similar semantics — for example, an option that should reject a +setter call rather than report a violation at `build()` time — needs its own renderer +following the `SetOnceRenderer` pattern, not a generator slot. See +“[The `(set_once)` renderer](java-code-generation.md#the-set_once-renderer)”. + +## 5. Add runtime support if needed + +Most options compile to inline Java that uses only types already exported by +`:jvm-runtime`: `ConstraintViolation`, `TemplateString`, `FieldPath`, the +`Validate` entry points, and the placeholders enumerated in +[`RuntimeErrorPlaceholder`][runtime-error-placeholder]. No runtime change is required for +the average new option. + +A new option needs runtime work in three cases: + +- **It introduces a new error placeholder.** Both + [`ErrorPlaceholder`][error-placeholder] in `:context` and + [`RuntimeErrorPlaceholder`][runtime-error-placeholder] in `:jvm-runtime` enumerate the + placeholder names; the two enums must stay in sync. The duplication is documented in + KDoc on `ErrorPlaceholder` and is expected to disappear once the Compiler and the + runtime share a base. +- **It needs a shared runtime helper.** If the option's generated code would otherwise + inline a non-trivial routine into every `validate()` body, factor it out as a `static` + helper in `:jvm-runtime` and call it from the generated code instead. This is rare; + the generated `if (…) { violations.add(…) }` blocks are deliberately self-contained. +- **It changes the violation schema.** New fields on `ConstraintViolation` or + `ValidationError` are wire-visible — these Protobuf types cross process boundaries, + see “[Constraints on the runtime surface](runtime-library.md#constraints-on-the-runtime-surface)”. + Coordinate any change to the schema with the maintainers and respect Protobuf + field-number stability. + +The runtime must not parse `.proto` descriptors to recover what the model already knew. +If the option needs more runtime support than a placeholder or a helper, recheck whether +the work belongs at build time instead. + +## 6. Test the option + +The repository ships several test modules, each with a different scope. New built-ins +typically touch three of them. The test modules are catalogued in +“[Key modules](key-modules.md#test-modules)”; choosing the right one is covered in +“[Testing strategy](testing-strategy.md)”. + +- **`:context-tests`** — Prototap-based compilation tests for `:context`. Add a spec + here for every diagnostic the reaction can raise (unsupported field type, unsupported + placeholder, companion-without-primary, invalid syntax, …). Specs sit alongside + existing ones such as + [`IfMissingReactionSpec.kt`][if-missing-reaction-spec], with `.proto` fixtures under + `context-tests/src/testFixtures/proto/spine/validation/`. +- **`:tests:validating`** — end-to-end behaviour for the option in generated code. + Existing `(required)` integration tests live under + [`tests/validating/src/test/kotlin/io/spine/test/options/required/`][required-itest-pkg]; + shared `.proto` fixtures live under + `tests/validating/src/testFixtures/proto/spine/test/tools/validate/`. Add a fixture + message that exercises every supported field type and a Kotest spec that builds the + message, asserts the violation report shape, and verifies the placeholders resolve. +- **`:tests:vanilla`** — baseline integration without custom extensions. Add a smoke + test here only if the option introduces an interaction with the broader Java codegen + pipeline that the more focused `:tests:validating` cases would not catch. + +`:tests:extensions` and `:tests:consumer*` exist for *custom* options and consumer-side +scenarios; a new built-in should not need additions there. `:tests:validator*` covers +`MessageValidator` discovery and is unrelated. `:tests:runtime` is the right home for +behaviour that is purely about runtime types — `Validate.check`, `ValidatorRegistry`, +exception formatting — independent of any specific option. + +## 7. Document the option in the User guide + +A built-in option is part of the public Validation vocabulary, so its consumer-facing +documentation lives in the User's Guide +“[Built-in options](../user/03-built-in-options/)” section, not in the Developer's Guide. +Pick the page that matches the option's declaration site and add an entry consistent with +the surrounding conventions: + +- Field-level options — [`field-level-options.md`](../user/03-built-in-options/field-level-options.md). +- `oneof` options — [`oneof-fields.md`](../user/03-built-in-options/oneof-fields.md). +- Message-level options — [`message-level-options.md`](../user/03-built-in-options/message-level-options.md). + +The option's primary entry goes on exactly one of the three pages above, keyed to the +declaration site. [`repeated-and-map-fields.md`](../user/03-built-in-options/repeated-and-map-fields.md) +is a cross-cutting reference, not a fourth declaration-site target: if your option has +notable behaviour on `repeated` or `map` fields (non-empty checks, per-element +validation, distinctness, …), additionally cross-reference the new entry from that page. + +Each entry follows the same shape: a one-sentence purpose, the **Applies to** list of +supported field types, a **Minimal example** snippet, and a **Custom message** snippet +when the option supports `(if_…).error_msg`. Update the `_index.md` summary line if the +new option falls outside the existing categories. + +If the option ships with an associated companion (`(if_missing)`-style), document them as +a pair on the same page; do not give a companion its own section. + +## How this differs from a custom option + +The contributor-facing flow above and the consumer-facing flow in +“[Custom validation](../user/05-custom-validation/)” share most of their substance: declare an +option, model it as a reaction plus a view, generate Java, register the pieces. The +differences are concentrated at the boundaries: + +- **No `OptionsProvider`.** Built-in options live in `spine/options.proto` in the base + library, which is registered with the global `ExtensionRegistry` by the base library + itself. A custom option registers its own provider; a built-in does not. +- **No `ValidationOption` SPI implementation.** Built-ins are listed directly in + `ValidationPlugin` and `JavaValidationRenderer.builtInGenerators()`. The  + `ValidationOption` SPI is the discovery mechanism for custom options only. +- **No `META-INF/services` entry, no `@AutoService`.** The plugin loads built-ins + through ordinary class references; `ServiceLoader` is involved only for custom + contributions. +- **No separate Gradle-plugin step.** Built-ins ship in `:java-bundle` and are placed on + the Compiler's classpath by `:gradle-plugin` together with the rest of the library + (see “[Build, packaging, and release](build-and-release.md)”). A custom option's module + must explicitly register itself with the Compiler — see + “[Pass the option to the Compiler](../user/05-custom-validation/pass-to-compiler.md)”. +- **The option declaration crosses repositories.** The `.proto` change lives in + [Base Libraries][base-libraries] and ships in that library's release; the model and codegen changes + live here. The two changes must be coordinated. + +The User's Guide +“[Custom validation](../user/05-custom-validation/)” section is still worth reading end-to-end +before contributing a built-in: it describes the same architectural ideas from the +consumer's perspective, and the running `(when)` example illustrates patterns — disabled +sentinel values, message-typed options, repeated and map handling — that built-ins use +too. + +## What's next + +- [The validation model](validation-model.md) — the full anatomy of events, projections, + and reactions in `:context`. +- [Java code generation](java-code-generation.md) — `OptionGenerator`, the validate + scope, and how `SingleOptionCode` is injected into generated classes. +- [Runtime library](runtime-library.md) — what is on the runtime classpath when a + generated `validate()` runs, and where to put runtime helpers. +- [Extension points](extension-points.md) — the public extension surfaces and the + constraints that govern them. +- [Testing strategy](testing-strategy.md) — choosing the right test module for each + layer of a new option. + +[options-proto]: https://github.com/SpineEventEngine/base-libraries/blob/master/base/src/main/proto/spine/options.proto +[base-libraries]: https://github.com/SpineEventEngine/base-libraries +[context-pkg]: https://github.com/SpineEventEngine/validation/tree/master/context +[java-pkg]: https://github.com/SpineEventEngine/validation/tree/master/java +[option-names]: https://github.com/SpineEventEngine/validation/blob/master/context/src/main/kotlin/io/spine/tools/validation/option/OptionNames.kt +[events-proto]: https://github.com/SpineEventEngine/validation/blob/master/context/src/main/proto/spine/validation/events.proto +[views-proto]: https://github.com/SpineEventEngine/validation/blob/master/context/src/main/proto/spine/validation/views.proto +[validation-plugin]: https://github.com/SpineEventEngine/validation/blob/master/context/src/main/kotlin/io/spine/tools/validation/ValidationPlugin.kt +[default-message]: https://github.com/SpineEventEngine/validation/blob/master/context/src/main/kotlin/io/spine/tools/validation/DefaultErrorMessage.kt +[error-placeholder]: https://github.com/SpineEventEngine/validation/blob/master/context/src/main/kotlin/io/spine/tools/validation/ErrorPlaceholder.kt +[option-generator-pkg]: https://github.com/SpineEventEngine/validation/tree/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/option +[java-validation-renderer]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/JavaValidationRenderer.kt +[set-once-renderer]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/setonce/SetOnceRenderer.kt +[runtime-error-placeholder]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/RuntimeErrorPlaceholder.kt +[if-missing-reaction-spec]: https://github.com/SpineEventEngine/validation/blob/master/context-tests/src/test/kotlin/io/spine/tools/validation/IfMissingReactionSpec.kt +[required-itest-pkg]: https://github.com/SpineEventEngine/validation/tree/master/tests/validating/src/test/kotlin/io/spine/test/options/required diff --git a/docs/content/docs/validation/developer/architecture.md b/docs/content/docs/validation/developer/architecture.md new file mode 100644 index 0000000000..36c977ce10 --- /dev/null +++ b/docs/content/docs/validation/developer/architecture.md @@ -0,0 +1,209 @@ +--- +title: Architecture +description: How the Spine Validation modules collaborate at build time and at runtime. +headline: Documentation +--- + +# Architecture + +Spine Validation has two main responsibilities, and the codebase is organized around the +boundary between them: + +1. **At build time** — translate validation options declared in `.proto` files into Java + code that enforces those constraints inside the generated message and builder classes. +2. **At runtime** — provide the small library of types that the generated code calls into + to evaluate constraints and report violations. + +This page describes how the modules in this repository implement that split. For an +inventory of every module, see “[Key modules](key-modules.md)”. + +## The compile-time / runtime split + +The build-time work is performed by a Spine Compiler plugin. The Spine Compiler runs +during the consumer project's build, after `protoc` produces the initial Java sources. +The plugin inspects the Protobuf model, detects validation options on fields and messages, +and injects validation logic into the generated classes. + +The runtime library is small on purpose. It exposes the SPI that user code or generated +code calls (`MessageValidator`, `ValidatorRegistry`), the exception type +(`ValidationException`), and the Protobuf types used to describe violations +(`ConstraintViolation`, `ValidationError`, `TemplateString`). Everything else — the +constraint logic itself — lives in the generated code, not in a runtime evaluator. + +This split is the main architectural decision in the project. Constraints are *compiled*, +not *interpreted*. There is no rule engine running at message construction time; there is +only the inlined Java code that the compiler plugin produced. + +## The build-time pipeline + +The compiler plugin is structured as two layers: + +- **`:context`** — a language-agnostic model of the validation rules discovered in a set of + `.proto` files. This module is a Spine Bounded Context: validation options become + events, events feed projections (views), and reactions wire them together. +- **`:java`** — a `ValidationPlugin` subclass that consumes the model from `:context` and + emits Java code. Code emission is performed by two renderers: + `JavaValidationRenderer` for assertion-style options, and `SetOnceRenderer` for + `(set_once)`, whose semantics modify builder behavior rather than add a check. + +The base plugin class lives in +[`ValidationPlugin.kt`](https://github.com/SpineEventEngine/validation/blob/master/context/src/main/kotlin/io/spine/tools/validation/ValidationPlugin.kt) +and registers the built-in views and reactions: + + + +```kotlin +public abstract class ValidationPlugin( + renderers: List> = emptyList(), + views: Set>> = setOf(), + viewRepositories: Set> = setOf(), + reactions: Set> = setOf(), +) : Plugin( + renderers = renderers, + views = views + setOf( + RequiredFieldView::class.java, + PatternFieldView::class.java, + GoesFieldView::class.java, + DistinctFieldView::class.java, + ValidatedFieldView::class.java, + RangeFieldView::class.java, + MaxFieldView::class.java, + MinFieldView::class.java, + SetOnceFieldView::class.java, + ChoiceGroupView::class.java, + RequireMessageView::class.java, + ), + viewRepositories = viewRepositories, + reactions = reactions + setOf>( + RequiredReaction(), + IfMissingReaction(), + RangeReaction(), + MinReaction(), + MaxReaction(), + DistinctReaction(), + IfHasDuplicatesReaction(), + ValidateReaction(), + IfInvalidReaction(), + PatternReaction(), + ChoiceReaction(), + IsRequiredReaction(), + GoesReaction(), + SetOnceReaction(), + IfSetAgainReaction(), + RequireReaction() + ) +) // Plugin +``` + +The Java implementation in +[`JavaValidationPlugin.kt`](https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/JavaValidationPlugin.kt) +adds the Java renderers and folds in any custom options discovered through the SPI: + + + +```kotlin +public open class JavaValidationPlugin : ValidationPlugin( + renderers = listOf( + JavaValidationRenderer(customGenerators = customOptions.map { it.generator }), + SetOnceRenderer() + ), + views = customOptions.flatMap { it.view }.toSet(), + reactions = customOptions.flatMap { it.reactions }.toSet(), +) +``` + +`customOptions` is loaded via `ServiceLoader`, which is what makes the +plugin extensible without recompiling the Validation library. See +[`ValidationOption.kt`](https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/ValidationOption.kt) +for the SPI itself, and the “[Custom validation](../user/05-custom-validation/)” section of +the User's Guide for the consumer-facing walkthrough. + +## The runtime library + +Generated validation code depends only on `:jvm-runtime`. The most important entry points +are: + +- [`MessageValidator`](https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/MessageValidator.kt) + — SPI for attaching custom validators to message types, including types declared in + third-party `.proto` files. See the User's Guide “[Using validators](../user/04-validators/)” + section. +- [`ValidatorRegistry`](https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/ValidatorRegistry.kt) + — discovers and applies `MessageValidator` implementations. +- [`validation_error.proto`](https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/proto/spine/validation/validation_error.proto) + — defines `ValidationError` and `ConstraintViolation`, the structured shape of violation + reports. +- [`error_message.proto`](https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/proto/spine/validation/error_message.proto) + — defines `TemplateString`, the placeholder format used by error messages. + +The runtime library does not parse `.proto` files, does not maintain a rule registry, and +does not interpret constraints. It contains only the types that the generated code and +user code share. + +## Distribution and consumer wiring + +Two small modules exist purely to make the plugin usable from a consumer project: + +- **`:java-bundle`** — repackages `:java` and its non-shared transitive dependencies as a + single fat JAR. The Spine Compiler loads validation as a single classpath entry, so + bundling avoids dependency resolution surprises in the compiler classloader. +- **`:gradle-plugin`** — the `io.spine.validation` Gradle plugin. When applied to a + consumer project it registers `:java-bundle` on the Spine Compiler's user classpath, + inserts `JavaValidationPlugin` into the compiler's plugin list, and adds `:jvm-runtime` + to the consumer's `implementation` configuration so generated code compiles and runs. + See + [`ValidationGradlePlugin.kt`](https://github.com/SpineEventEngine/validation/blob/master/gradle-plugin/src/main/kotlin/io/spine/tools/validation/gradle/ValidationGradlePlugin.kt). + +## The end-to-end picture + +The diagram below shows what happens from the moment a developer writes a `.proto` file +with validation options through to the runtime check that fires when a message is built. + +```mermaid +%%{init: {"flowchart": {"subGraphTitleMargin": {"top": 10, "bottom": 15}}}}%% +flowchart TD + proto[".proto with validation options"] + subgraph build["Consumer build (Gradle)"] + gradle[":gradle-plugin
io.spine.validation"] + compiler["Spine Compiler"] + bundle[":java-bundle
(JavaValidationPlugin)"] + ctx[":context
views + reactions"] + renderers["JavaValidationRenderer
SetOnceRenderer"] + gen["Generated Java
message + builder classes"] + end + subgraph rt["Runtime"] + runtime[":jvm-runtime
MessageValidator,
ValidationException,
ConstraintViolation"] + app["Application code
builder.build()"] + end + + proto --> compiler + gradle --> compiler + gradle -. registers .-> bundle + compiler --> bundle + bundle --> ctx + ctx --> renderers + renderers --> gen + gen --> app + app --> runtime + runtime -- on violation --> app +``` + +At a glance: + +- The Gradle plugin is the only thing the consumer applies. It pulls in the bundle and the + runtime library transparently. +- The Spine Compiler invokes `JavaValidationPlugin`, which uses `:context` to build a + language-agnostic model of the constraints, then runs Java renderers to inject code into + the classes that `protoc` generated. +- At runtime, the application calls into generated code, typically through + `Builder.build()` or a generated `validate()` method. + The generated code uses types from `:jvm-runtime` to report violations. + +## What's next + +- [Key modules](key-modules.md) — the full module inventory, including the test modules. diff --git a/docs/content/docs/validation/developer/build-and-release.md b/docs/content/docs/validation/developer/build-and-release.md new file mode 100644 index 0000000000..3e83dfdbe0 --- /dev/null +++ b/docs/content/docs/validation/developer/build-and-release.md @@ -0,0 +1,303 @@ +--- +title: Build, packaging, and release +description: How the multi-project build is wired and how Spine Validation artifacts are produced and shipped. +headline: Documentation +--- + +# Build, packaging, and release + +This page describes how the Gradle multi-project build is wired, what each +publishable module produces, and how the resulting artifacts reach downstream +consumers. It is the contributor-side counterpart to “[Adding Validation to your +build](../user/01-getting-started/adding-to-build/)” in the User's Guide: +where that page shows how a *consumer* applies the plugin, this page shows how +the plugin and its dependencies are *produced*. + +For day-to-day commands (`./gradlew build`, `./gradlew dokka`, …) see +[`running-builds.md`][running-builds] in the `.agents/` directory; this page +focuses on the structure rather than the commands. + +## The multi-project layout + +The repository is a single Gradle build. Subprojects are declared in +[`settings.gradle.kts`][settings] and split into three groups: + +| Group | Modules | Role | +|----------------|---------------------------------------|----------------------------------------------------------------------------------| +| Core library | `:context`, `:java`, `:jvm-runtime` | The validation model, the Java code generator, and the runtime library. | +| Distribution | `:java-bundle`, `:gradle-plugin` | Packaging layer that turns the core library into something a consumer can apply. | +| Tests and docs | `:context-tests`, `:tests:*`, `:docs` | Compilation tests, integration suites, and the documentation site. | + +Every published JVM subproject applies the [`module`][module-convention] +convention plugin from `buildSrc/`, which sets up Java + Kotlin compilation, +the Detekt and PMD analyzers, the Dokka and Javadoc tasks, the Spine BOMs, and +`maven-publish`. The [`fat-jar`][fat-jar-convention] convention layers shadow +JAR packaging on top of `module` for `:java-bundle` (see below). The `:tests:*` +modules do not publish, so they apply `id("module-testing")` instead — a +lighter convention that wires up JUnit, Kotest, and Truth as test dependencies +and registers the test tasks without bringing in the publishing machinery. + +The single source of truth for the project version is +[`version.gradle.kts`][version-gradle], which exposes `validationVersion` +through Gradle's `extra` properties: + + + +```kotlin +val validationVersion by extra("2.0.0-SNAPSHOT.416") +``` + +The root build script applies this file under `allprojects { … }` and assigns +`validationVersion` to `project.version` for every subproject, so a single bump +in `version.gradle.kts` moves every artifact this repository publishes — there +is no per-module version state to keep in sync. + +## Publishable modules + +The core library and the distribution layer publish; the test modules and +`:docs` do not. The publishing setup lives in [`build.gradle.kts`][root-build]: + + + +```kotlin +spinePublishing { + artifactPrefix = "spine-validation-" + toolArtifactPrefix = "validation-" + modules = setOf( + "context", + "java", + "java-bundle", + "jvm-runtime", + ) + modulesWithCustomPublishing = setOf( + "java-bundle", + "gradle-plugin", + ) + destinations = with(PublishingRepos) { + setOf( + gitHub("validation"), + cloudArtifactRegistry + ) + } +} +``` + +Two artifact prefixes coexist because `:java-bundle` and `:gradle-plugin` belong +to the `io.spine.tools` group and use the `validation-` prefix, while the rest +of the modules publish under the `io.spine` group with the `spine-validation-` +prefix. The `MavenArtifact` declarations in +[`ValidationSdk.kt`][validation-sdk] inside `:gradle-plugin` reflect both +conventions: + + + +```kotlin +val jvmRuntime: MavenArtifact = Meta.dependency( + Module("io.spine", "spine-$infix-jvm-runtime") +) +``` + + + +```kotlin +val javaCodegenBundle: MavenArtifact = Meta.dependency( + Module(toolsGroup, "$infix-java-bundle") +) +``` + +`modulesWithCustomPublishing` lists the two modules whose publication is set up +inside the module itself rather than by the root convention. Their stories are +worth a closer look. + +## Why `:java-bundle` exists + +The Spine Compiler loads each compiler plugin from a single classpath entry on +its *user classpath*. If `:java` was published as a normal Maven artifact, every +consumer would have to resolve its full transitive dependency graph onto the +Compiler user classpath, where Gradle's resolution rules no longer apply and +version mismatches are not easy to diagnose. `:java-bundle` solves this by +shipping `:java` and its non-shared dependencies as one shadow JAR. + +The `:java-bundle` build applies the [`fat-jar`][fat-jar-convention] +convention, a [Shadow][shadow]-based wrapper that configures `tasks.shadowJar` +to exclude everything that the Spine Compiler's own classloader already +provides — Gradle internals, Kotlin stdlib, IntelliJ Platform annotations, +third-party plugin declarations — and publishes the resulting JAR as a +`MavenPublication` named `fatJar`: + + + +```kotlin +publishing { + publications { + create("fatJar", MavenPublication::class) { + artifact(tasks.shadowJar) + } + } +} +``` + +The `:java-bundle` build script +([`java-bundle/build.gradle.kts`][java-bundle-build]) further excludes groups of +dependencies that the Compiler backend already exposes — Protobuf, Guava, ASM, +Roaster, Compiler modules, JavaPoet, and the Spine `Base`, `Logging`, `Time`, +`Reflect`, and `CoreJvm` libraries. These exclusions are not optional: pulling +two copies of these libraries into the compiler classloader produces hard-to- +diagnose `LinkageError`s during code generation. + +## Why `:gradle-plugin` is separate from `:java-bundle` + +The Gradle plugin and the bundle play different roles, and conflating them would +force every consumer to put compiler-internal classes on Gradle's *build* +classpath: + +- **`:gradle-plugin`** runs *during Gradle configuration*. It is loaded into + Gradle's classloader when the consumer applies `id("io.spine.validation")`. + Its job is to register the bundle on the Spine Compiler's user classpath, add + the runtime as an `implementation` dependency, and apply the Protobuf and + Spine Compiler Gradle plugins so the consumer does not have to. The relevant + source is [`ValidationGradlePlugin.kt`][validation-gradle-plugin]. +- **`:java-bundle`** runs *inside the Spine Compiler*, not inside Gradle. It is + invoked through the user classpath the plugin set up. + +Because the two layers run in different classloaders, they need different +dependency closures. `:gradle-plugin` depends only on Gradle and Spine Compiler +APIs needed for configuration; `:java-bundle` carries the renderers, +the model, and the Spine Compiler libraries needed to *execute* code generation. + +`:gradle-plugin` declares its own publication through `gradlePlugin { … }` and +uses the `io.spine.artifact-meta` plugin to record the resolved coordinates of +the bundle and the runtime as metadata on the published plugin JAR. The +[`Meta`][gradle-plugin-meta] object inside the plugin reads that metadata at +apply time and turns it into the `MavenArtifact` instances seen above. As a +result, the version of the bundle and runtime that a consumer pulls in is +fixed by the version of `:gradle-plugin` they apply — there is no separate +coordination step. + +## The build pipeline at a glance + +```mermaid +flowchart LR + ctx[":context
views, reactions, model"] + java[":java
renderers, SPI"] + bundle[":java-bundle
shadow JAR"] + runtime[":jvm-runtime
runtime library"] + plugin[":gradle-plugin
io.spine.validation"] + portal["Gradle Plugin Portal"] + maven["Maven repositories
(GitHub, GCAR)"] + consumer["Consumer project"] + + ctx --> java + java --> bundle + bundle --> maven + runtime --> maven + plugin --> portal + plugin -. records coords of .-> bundle + plugin -. records coords of .-> runtime + portal --> consumer + maven --> consumer +``` + +For consumers, the important release contract is this: they apply the plugin +from the Gradle Plugin Portal, and that plugin version selects the recorded +bundle and runtime coordinates resolved from Maven repositories. + +## Publication destinations + +The publication targets are configured in the `spinePublishing { destinations }` +block above. The library artifacts go to: + +- The repository's [GitHub Packages registry][github-packages-validation] + (`gitHub("validation")` resolves to `maven.pkg.github.com/SpineEventEngine/validation`). +- Spine's Google Cloud Artifact Registry (`cloudArtifactRegistry`). + +The Gradle plugin has an additional destination — the [Gradle Plugin +Portal][gradle-plugin-portal] — configured by the `java-gradle-plugin` and +`com.gradle.plugin-publish` plugins applied via the publishing convention. +That is the destination consumers reach when they write +`id("io.spine.validation")` in their `plugins { … }` block. + +Publication is automated by the +[`publish.yml`][publish-workflow] GitHub Actions workflow, which runs on every +push to `master` after PR checks have already verified the build: + + + +```yaml +- name: Publish artifacts to Maven + # Since we're in the `master` branch already, this means that tests of a PR passed. + # So, no need to run the tests again when publishing. + run: ./gradlew publish -x test --stacktrace +``` + +`-x test` relies on the per-PR build to keep publication fast. Every merge to +`master` emits a new version of the artifacts for downstream consumers to +refresh against. + +## Downstream consumers + +Two kinds of downstream consumer pick up Validation artifacts: + +- **Application projects** apply `id("io.spine.validation")` in their + `plugins { … }` block. They never reference `:java-bundle` or `:jvm-runtime` + directly — the Gradle plugin adds both, and `version.gradle.kts` in *this* + repository determines which versions they get. The consumer-side flow is + documented in “[Adding Validation to your build](../user/01-getting-started/adding-to-build/)”. + +- **Tooling projects** that bundle the Validation Compiler into their own + Spine Compiler distribution. The most prominent example is the + [CoreJvm Compiler][core-jvm-compiler]: it depends on `:java-bundle` and + `:jvm-runtime` directly so the validation pipeline runs as part of the + CoreJvm code-generation flow without the consumer needing to apply + `io.spine.validation` separately. The `Validation.javaBundle(version)` and + `Validation.runtime(version)` references in + [`gradle-plugin/build.gradle.kts`][gradle-plugin-build] declare the + coordinates that downstream tooling can resolve symmetrically. + +The bundle's coordinates and the runtime's coordinates are deliberately stable +across this repository's lifetime: renaming either would force a coordinated +release across every downstream tool that resolves it by name. + +## What's next + +- “[Key modules](key-modules.md)” — one-line descriptions of every module shown + on this page, plus the test modules. +- “[Architecture](architecture.md)” — the compile-time/runtime split that + motivates the `:java`/`:java-bundle`/`:jvm-runtime` separation. +- “[Testing strategy](testing-strategy.md)” — what each `:tests:*` module + exists to verify, and how they are wired into the build. + +[settings]: https://github.com/SpineEventEngine/validation/blob/master/settings.gradle.kts +[root-build]: https://github.com/SpineEventEngine/validation/blob/master/build.gradle.kts +[version-gradle]: https://github.com/SpineEventEngine/validation/blob/master/version.gradle.kts +[module-convention]: https://github.com/SpineEventEngine/validation/blob/master/buildSrc/src/main/kotlin/module.gradle.kts +[fat-jar-convention]: https://github.com/SpineEventEngine/validation/blob/master/buildSrc/src/main/kotlin/fat-jar.gradle.kts +[java-bundle-build]: https://github.com/SpineEventEngine/validation/blob/master/java-bundle/build.gradle.kts +[gradle-plugin-build]: https://github.com/SpineEventEngine/validation/blob/master/gradle-plugin/build.gradle.kts +[validation-sdk]: https://github.com/SpineEventEngine/validation/blob/master/gradle-plugin/src/main/kotlin/io/spine/tools/validation/gradle/ValidationSdk.kt +[validation-gradle-plugin]: https://github.com/SpineEventEngine/validation/blob/master/gradle-plugin/src/main/kotlin/io/spine/tools/validation/gradle/ValidationGradlePlugin.kt +[gradle-plugin-meta]: https://github.com/SpineEventEngine/validation/blob/master/gradle-plugin/src/main/kotlin/io/spine/tools/validation/gradle/Meta.kt +[publish-workflow]: https://github.com/SpineEventEngine/validation/blob/master/.github/workflows/publish.yml +[shadow]: https://gradleup.com/shadow/ +[github-packages-validation]: https://github.com/SpineEventEngine/validation/packages +[gradle-plugin-portal]: https://plugins.gradle.org/plugin/io.spine.validation +[core-jvm-compiler]: https://github.com/SpineEventEngine/core-jvm-compiler +[running-builds]: https://github.com/SpineEventEngine/validation/blob/master/.agents/running-builds.md diff --git a/docs/content/docs/validation/developer/extension-points.md b/docs/content/docs/validation/developer/extension-points.md new file mode 100644 index 0000000000..cbbae478dc --- /dev/null +++ b/docs/content/docs/validation/developer/extension-points.md @@ -0,0 +1,266 @@ +--- +title: Extension points +description: The public extension surface of Spine Validation, viewed end-to-end. +headline: Documentation +--- + +# Extension points + +Spine Validation exposes two extension points and only two. They sit on opposite sides of +the compile-time / runtime split: + +- The [`ValidationOption`][validation-option-spi] SPI (build time) — adds a *new + validation option* with its own model, codegen, and runtime helpers. +- The [`MessageValidator`][message-validator] SPI (runtime) — adds a *custom check on a + specific message type*, executed alongside the compiled constraints. + +Each surface has a corresponding User's Guide section that explains how to *use* it: +“[Custom validation](../user/05-custom-validation/)” for `ValidationOption`, and +“[Using validators](../user/04-validators/)” for `MessageValidator`. This page is the +contributor-side view: what each surface guarantees, how discovery works, what an +implementation may and may not do, and why. + +The earlier sections of the Developer's Guide cover each surface in detail — +“[The validation model](validation-model.md)” and “[Java code generation](java-code-generation.md)” +for the build-time half, and “[Runtime library](runtime-library.md)” for the runtime half. +This page consolidates the two into a single picture. + +## The two surfaces at a glance + +| Aspect | `ValidationOption` | `MessageValidator` | +|--------------|---------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| +| Granularity | A new `.proto` option, applicable to many messages. | A custom check on one specific message type. | +| When it runs | Build time (codegen) plus optional runtime helpers it ships itself. | Runtime: after compiled checks for local messages; via `ValidatorRegistry` for external/direct validation. | +| Inputs | Reads the model in `:context`, emits Java via `:java`. | Receives a built `Message`, returns `List`. | +| Discovery | `ServiceLoader` on the Compiler user classpath. | `ServiceLoader` in the consumer's classpath. | +| Required by | Adding a new constraint vocabulary (`(when)`, `(currency)`, …). | Constraints that cannot be expressed declaratively, or external types. | +| Lives in | Defined in `:java`; implementations live in their own modules. | Defined in `:jvm-runtime`; implementations live in any consumer module. | + +The two are deliberately not interchangeable. A `ValidationOption` is the right choice +when the same constraint vocabulary applies across many messages and benefits from +declarative configuration in `.proto` files. A `MessageValidator` is the right choice +when the constraint is specific to one message type, or when the message type is external +and cannot carry options at all. + +## The `ValidationOption` SPI end-to-end + +The [`ValidationOption`][validation-option-spi] SPI is intentionally narrow. A custom +option contributes exactly three things, matching the build-time pipeline: + +1. `reactions` — reaction instances that subscribe to the upstream + `FieldOptionDiscovered` / `OneofOptionDiscovered` / `MessageOptionDiscovered` events, + filter by `OPTION_NAME`, validate applicability, and emit a `*Discovered` domain + event. See “[The validation model](validation-model.md#the-lifecycle-of-an-option)”. +2. `view` — Protobuf-declared projections that fold those domain events into queryable + state. See “[The validation model](validation-model.md#the-projection)”. +3. `generator` — an [`OptionGenerator`][option-generator] subclass that queries the + projection and emits one `SingleOptionCode` per option application. See + “[Java code generation](java-code-generation.md#the-optiongenerator-spi)”. + +`JavaValidationPlugin` discovers SPI implementations through `ServiceLoader` and folds +them into the same plugin registration that brings in the built-ins: + +```kotlin +public open class JavaValidationPlugin : ValidationPlugin( + renderers = listOf( + JavaValidationRenderer(customGenerators = customOptions.map { it.generator }), + SetOnceRenderer() + ), + views = customOptions.flatMap { it.view }.toSet(), + reactions = customOptions.flatMap { it.reactions }.toSet(), +) + +private val customOptions: List by lazy { + ServiceLoader.load(ValidationOption::class.java) + .filterNotNull() +} +``` + +From the model's point of view, custom reactions and views are indistinguishable from +the built-ins. From the renderer's point of view, the custom `generator` receives the +same `Querying` and `TypeSystem` as the built-ins and contributes to the same +`validate()` method. Built-ins and custom options share one pipeline, not two. + +### Discovery + +A `ValidationOption` implementation is discovered through the standard Java +`ServiceLoader` SPI: + +- The implementing class must be on the **Spine Compiler user classpath**, not merely + on the application runtime classpath. In a Gradle build that consumes Validation, this + means the module declaring the option is added to the Spine Compiler user classpath (see + “[Pass the option to the Compiler](../user/05-custom-validation/pass-to-compiler.md)”). +- A `META-INF/services/io.spine.tools.validation.java.ValidationOption` entry must list + the implementing class. The conventional way to generate it is the + `@AutoService(ValidationOption::class)` annotation processor; any other mechanism that + produces the same descriptor is equivalent. +- The class must have a public no-arg constructor — the `ServiceLoader` contract. + +In addition to the SPI implementation itself, two more pieces of build-time wiring are +required for the option to work: the option's Protobuf descriptor must be discoverable +through `OptionsProvider` (so the Compiler recognises the option when parsing +`.proto` files), and the consumer's build must place the option's module on the +Compiler user classpath. Both are covered in the User's Guide; the SPI itself does not +attempt to encode them. + +### Lifecycle + +Discovered implementations are constructed when a `JavaValidationPlugin` instance is +created — its constructor dereferences the top-level `customOptions` lazy while collecting +generators, views, and reactions. From that point on, the same instance is used for the +entire build: + +- Each `Reaction` instance returned by `reactions` is registered with the Bounded + Context exactly once. Reactions are stateless by contract. +- Each `View` class returned by `view` is registered once and instantiated by the + framework as needed. Views accumulate state per projection key. +- The single `generator` instance is the one passed to `JavaValidationRenderer`. The + renderer calls `inject(querying, typeSystem)` on it before the first `codeFor()` + invocation, and `codeFor(type)` is called once per message type in the + `SourceFileSet`. The instance must therefore be safe to reuse across messages within a + single build — see “[Java code generation](java-code-generation.md#the-render-lifecycle)”. + +### Ordering + +The order in which custom generators contribute to a generated `validate()` is +unspecified. Generators must not rely on running before or after any built-in or any +other custom generator: each contribution is a self-contained `if (…) { violations.add(…) }` +block, and accumulating violations (rather than short-circuiting) is what lets the +ordering stay free. + +The same is true on the model side. Reactions and views run in event-delivery order; a +custom view that needs to fold both its primary event and a companion event must accept +either ordering, exactly the way `RequiredFieldView` accepts `RequiredFieldDiscovered` +and `IfMissingOptionDiscovered` in either order (see +“[Companion options](validation-model.md#companion-options)”). + +## The `MessageValidator` SPI + +The runtime extension surface is [`MessageValidator`][message-validator]. Use it for +checks that cannot be expressed in `.proto` options at all — because the rule depends on +multiple fields, on external state, or on a message type whose source the consumer cannot +modify. The registry API, the `${validator}` placeholder, and the `DetectedViolation` +shape are covered in “[Runtime library](runtime-library.md#the-validator-extension-hook)” +and “[Using `ValidatorRegistry`](../user/04-validators/validator-registry.md)”; this section +keeps to the extension contract. + +### Discovery + +For automatic discovery, the implementation must be on the consumer application's runtime +classpath and listed in +`META-INF/services/io.spine.validation.MessageValidator`. `@AutoService` is only a +convenient way to produce that descriptor; there is no Validation-specific discovery +annotation. + +The class must have a public no-arg constructor, and its concrete `M` type parameter must +be recoverable from the validator class. Direct implementations such as +`MessageValidator` are the clearest shape; base classes are fine as long as +Guava's `TypeToken` can still resolve `M` to a concrete `Message` class. + +### Lifecycle + +`MessageValidator` instances are constructed by `ServiceLoader` when `ValidatorRegistry` +is initialized, or explicitly by application code before registration. Once registered, +an instance is retained and invoked on every matching validation. The registry itself is +annotated `@ThreadSafe` and dispatches concurrently: implementations must therefore be +safe to invoke from multiple threads at once. + +### Ordering and composition + +`ValidatorRegistry` stores validators keyed by qualified message class name. When a +message of type `M` is validated: + +- For a **local** message (one whose generated class is produced by the Java renderer in + this build), the generated `validate()` first runs every compiled constraint and then + consults `ValidatorRegistry` for any registered validators on `M`. Compiled checks and + validators all contribute to the same `ValidationError`. +- For an **external** message (one whose generated class is not produced in this build), + the registry is the only entry point. A local message reaches external validators only + through fields marked `(validate) = true`; a standalone external instance is not + validated unless the caller invokes `Validate.check` or `ValidatorRegistry.validate` + directly. + +The order in which validators of the same message type run is unspecified. Validators +must report independently of their peers because the registry concatenates all reports. + +## Constraints on what extensions can do + +Both SPIs are deliberately narrow. The constraints below are not arbitrary; they fall +out of the compile-time / runtime split that the rest of the architecture is built on +(see “[Architecture](architecture.md)”). + +### `ValidationOption` + +- **No file I/O at generation time.** The generator must derive everything from the view + state populated by reactions. Reading `.proto` files, querying descriptors at runtime, + or scanning the file system from inside `codeFor()` defeats the model's reason to + exist — the renderer is supposed to be replaceable with a renderer for another target + language without touching `:context`. +- **No mutation of the message PSI directly.** Return constraints, supporting fields, and + supporting methods through `SingleOptionCode`; placement is the + [`ValidationCodeInjector`][validation-code-injector]'s job. Directly adding methods, + fields, or interface implementations from inside a generator bypasses the conventions + for the shape of generated validators (see + “[Java code generation](java-code-generation.md#injecting-the-code-into-the-psi)”). +- **No silent failure.** A misapplied option must fail compilation through + `Compilation.check` / `Compilation.error`, not be quietly skipped. Reactions that + decide an option does not apply must return `NoReaction`, not throw. +- **No interpreting at runtime.** If an option needs runtime helpers, they must ship in + a separate module that the generated code calls into — like `:jvm-runtime` does for + the built-ins. The runtime must not parse `.proto` descriptors to recover what the + generator already knew. + +### `MessageValidator` + +- **No descriptor scanning.** The runtime is intentionally free of descriptor-driven + rule discovery. A validator that wants to apply different rules to different fields + must do so in code, not by re-deriving a model at runtime. +- **No reflection-driven dispatch in the hot path.** The registry does a single + `ConcurrentHashMap` lookup keyed by class name. The reflection that recovers `M` from + the validator's class runs once, at registration. Validators must not extend that + reflection cost into per-call dispatch. +- **No assumptions about ordering or peers.** A validator must produce a correct report + regardless of which other validators (built-in, custom, registered explicitly, + registered through `ServiceLoader`) run alongside it. +- **Thread-safety is on the implementer.** The registry is `@ThreadSafe`; validators + must be too. Per-call mutable state must be local to the call. +- **Use `DetectedViolation`, not `ConstraintViolation`.** The registry is responsible + for translating `DetectedViolation` to `ConstraintViolation`, packing values into + `Any`, prefixing field paths with the parent path, and stamping the type name. A + validator that bypasses `DetectedViolation` cannot participate in nested validation + correctly. + +### Why these constraints exist + +The compile-time / runtime split is what lets the runtime stay small (see +“[Runtime library](runtime-library.md#constraints-on-the-runtime-surface)”) and the +language-agnostic model stay portable (see +“[The validation model](validation-model.md)”). Both extension points are designed so +that a well-behaved implementation reinforces that split: + +- A `ValidationOption` adds a new constraint vocabulary without forcing a runtime rule + engine into existence — the constraint becomes inlined Java like every built-in. +- A `MessageValidator` adds a runtime-only check without leaking knowledge of the + Compiler, the model, or codegen — it is opaque to everything below the + `MessageValidator` boundary. + +The constraints above are how each SPI keeps that property; they are also why neither +SPI exposes more knobs than it does. New extension points should be evaluated against +the same split before they are added. + +## What's next + +- [Adding a new built-in validation option](adding-a-built-in-option.md) — the + contributor walkthrough that exercises the `ValidationOption` SPI end-to-end. +- [The validation model](validation-model.md) — what a custom reaction and view look + like in detail. +- [Java code generation](java-code-generation.md) — what a custom `OptionGenerator` + hands back to the renderer. +- [Runtime library](runtime-library.md) — what a `MessageValidator` is plugged into. +- User's Guide — [Custom validation](../user/05-custom-validation/) and + [Using validators](../user/04-validators/) for the consumer-facing view of the same SPIs. + +[validation-option-spi]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/ValidationOption.kt +[option-generator]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/OptionGenerator.kt +[validation-code-injector]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/ValidationCodeInjector.kt +[message-validator]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/MessageValidator.kt diff --git a/docs/content/docs/validation/developer/java-code-generation.md b/docs/content/docs/validation/developer/java-code-generation.md new file mode 100644 index 0000000000..efe3e5d55d --- /dev/null +++ b/docs/content/docs/validation/developer/java-code-generation.md @@ -0,0 +1,350 @@ +--- +title: Java code generation +description: How the Spine Compiler plugin in `:java` produces validation code from the model. +headline: Documentation +--- + +# Java code generation + +The `:java` module turns the populated projections produced by `:context` (see +“[The validation model](validation-model.md)”) into Java code that is injected into +Protobuf-generated message and builder classes. By the time the Spine Compiler invokes +this module, every constraint discovered in the consumer's `.proto` files already lives +in a view, and `:java`'s job is to ask each view what it knows and translate that into +Java. + +The work is split across two renderers and a small set of supporting types: + +- [`JavaValidationRenderer`][java-validation-renderer] — the main renderer. It walks + every compilation message, asks each registered [`OptionGenerator`][option-generator] + for the code that implements its option on that message, and hands the result to + [`ValidationCodeInjector`][validation-code-injector] for placement inside the message + and its builder. +- [`SetOnceRenderer`][set-once-renderer] — a separate renderer for `(set_once)`. The + option does not contribute a check to the `validate()` method; instead it modifies the + builder so that setters refuse to overwrite an already-assigned value. The render + pipeline is therefore different enough that it lives apart from + `JavaValidationRenderer`. + +The two renderers and the SPI that lets custom options plug into them are described on +this page. + +## The plugin entry point + +`JavaValidationPlugin` extends the language-agnostic `ValidationPlugin` from `:context`, +adds the two Java renderers, and folds in any custom options discovered through the +`ValidationOption` SPI: + + + +```kotlin +public open class JavaValidationPlugin : ValidationPlugin( + renderers = listOf( + JavaValidationRenderer(customGenerators = customOptions.map { it.generator }), + SetOnceRenderer() + ), + views = customOptions.flatMap { it.view }.toSet(), + reactions = customOptions.flatMap { it.reactions }.toSet(), +) +``` + +Custom options contribute three kinds of artefacts: views and reactions for `:context` +(covered in “[The validation model](validation-model.md)”) and a `generator` for `:java`. +The plugin is the single point where all three are registered. + +## The render lifecycle + +`JavaValidationRenderer` runs once per `SourceFileSet`. The Spine Compiler invokes it +with the Java sources that `protoc` has already produced. The renderer iterates over +every message in the set, asks every generator what it has to contribute for that +message, and emits a single [`MessageValidationCode`][message-validation-code] bundle +per message: + +```kotlin +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() + .forEach { message -> + val code = generateCode(message) + val file = sources.javaFileOf(message) + file.render(code) + } +} +``` + +Three properties of this loop are worth highlighting: + +- The renderer visits **every** message, not just messages with declared constraints. + A message with no options still becomes a `ValidatableMessage` whose generated + `validate()` consults `ValidatorRegistry` (see + “[Runtime library](runtime-library.md)”). +- The list of generators is fixed during one renderer run. Built-ins come from + `builtInGenerators()`; custom generators arrive through the constructor. They are + composed once and `inject(querying, typeSystem)` is called on each before the first + `codeFor()` invocation. +- All collaboration with the model happens through `Querying`. The renderer never + reads `.proto` files; it only reads the projections that `:context` populated. + +## The `OptionGenerator` SPI + +`OptionGenerator` is the abstraction that decouples the renderer from the specifics of +any single option. Every built-in option handled by `JavaValidationRenderer`, and every +custom Java validation option, is implemented as a subclass: + +```kotlin +public abstract class OptionGenerator { + + protected lateinit var querying: Querying + protected lateinit var typeSystem: TypeSystem + + public abstract fun codeFor(type: TypeName): List + + public fun inject(querying: Querying, typeSystem: TypeSystem) { + check(!::querying.isInitialized) { + "`inject()` must be called exactly once on `${this::class.simpleName}`." + } + this.querying = querying + this.typeSystem = typeSystem + } +} +``` + +A typical generator queries its own projection, filters by the message currently being +processed, and emits one `SingleOptionCode` per option application. `RequiredGenerator` +is representative: + + + +```kotlin +internal class RequiredGenerator : OptionGeneratorWithConverter() { + + /** + * All `(required)` fields in the current compilation process. + */ + private val allRequiredFields by lazy { + querying.select() + .all() + } + + override fun codeFor(type: TypeName): List = + allRequiredFields + .filter { it.id.type == type } + .map { GenerateRequired(it, converter).code() } +} +``` + +One convenience subclass of `OptionGenerator` exists today: + +- [`OptionGeneratorWithConverter`][option-generator-with-converter] — adds a lazily + constructed `JavaValueConverter` for translating Protobuf default values into Java + literals. Generators that need to compare a field against its type-specific default + (`(required)`, `(distinct)`, the bound options) extend this class. + +Generators may also keep their own per-run state, as long as nothing that depends on +`Querying` or `TypeSystem` is touched before `inject()` returns. + +## What the generator produces + +A generator returns a list of [`SingleOptionCode`][single-option-code] objects, one per +option application in the message. The shape is intentionally minimal: + + + +```kotlin +public class SingleOptionCode( + public val constraint: CodeBlock, + public val fields: List> = emptyList(), + public val methods: List = emptyList(), +) +``` + +- `constraint` is the body that goes into the generated `validate()` method. It runs in + a known scope that exposes a few well-defined variables (see “[The validate scope](#the-validate-scope)” + below). +- `fields` and `methods` are class-level declarations. They are how an option can carry + precomputed state — for example, `PatternGenerator` declares one + `private static final java.util.regex.Pattern` field per `(pattern)` application so the + pattern is compiled once and reused across calls. + +The generated `constraint` block is plain Java text built from typed expressions. The +following snippet from `RequiredGenerator` is typical: + +```kotlin +val constraint = CodeBlock( + """ + if (${field.hasDefaultValue()}) { + var fieldPath = ${parentPath.resolve(field.name)}; + var typeName = ${parentName.orElse(declaringType)}; + var violation = ${violation(ReadVar("fieldPath"), ReadVar("typeName"))}; + $violations.add(violation); + } + """.trimIndent() +) +``` + +There is no template engine. Generators interpolate Kotlin values for class names, field +references, and helper expressions into a Java code string. The expression types under +[`expression/`][expression-pkg] (`FieldPaths`, `TemplateStrings`, `ConstraintViolations`, +`ClassNames`, `Strings`, `UnsetValue`, …) are the building blocks; they expose typed +methods like `parentPath.resolve(field.name)` so the interpolation reads as code rather +than string concatenation. + +### The validate scope + +Every constraint block is injected into the same enclosing method, so the generated +code can rely on a fixed set of in-scope variables. They are declared in +[`ValidateScope`][validate-scope]: + +| Variable | Java type | Role | +|---------------|----------------------------------------|---------------------------------------------------------------------------------| +| `violations` | `ArrayList` | Accumulator. A constraint adds a violation by `violations.add(violation)`. | +| `parentPath` | `io.spine.base.FieldPath` | Path from the validation root to the current message. Empty for top-level use. | +| `parentName` | `io.spine.type.TypeName?` (nullable) | Name of the type that triggered validation. Non-null only for nested messages. | + +The companion [`MessageScope`][message-scope] exposes an implicit `this` reference for +generators that need to read the message's own fields. Together, the two scopes are the +only state a constraint block can assume; everything else must be derived from view +state at generation time or carried by class-level fields the generator declares. + +## Injecting the code into the PSI + +After `JavaValidationRenderer` has assembled a `MessageValidationCode` for a message, it +hands the bundle to [`ValidationCodeInjector`][validation-code-injector]. The injector +operates on the [IntelliJ PSI][intellij-psi] representation of the already-generated Java +file. In the +main validation renderer, it is the component that mutates the message and builder PSI: + + + +```kotlin +fun inject(code: MessageValidationCode, messageClass: PsiClass) { + val builderClass = messageClass.nested("Builder") + execute { + messageClass.apply { + implementValidatableMessage() + declareValidateMethod(code.constraints) + declareSupportingFields(code.fields) + declareSupportingMethods(code.methods) + } + builderClass.apply { + implementValidatingBuilder(messageClass) + injectValidationIntoBuildMethod() + annotateBuildReturnType() + annotateBuildPartialReturnType() + } + } +} +``` + +The injector encodes the conventions for the shape of every generated validator: + +- The message class is made to implement `ValidatableMessage`, gaining a + `validate(parentPath, parentName)` method whose body concatenates every constraint + block produced by the generators and finishes with a call into `ValidatorRegistry` + (see “[Runtime library](runtime-library.md)”). The method returns + `Optional` rather than throwing, so a built message can be + re-validated without paying for an exception. +- The builder is made to implement `ValidatingBuilder`. Its `build()` method is wrapped: + the existing return is preceded by a call to `validate()`, and any violation is thrown + as `ValidationException`. For constraints produced by `OptionGenerator`s, this is where + validation errors become exceptions; `(set_once)` is handled separately and throws from + builder mutators. +- The `build()` return type is annotated `@Validated` and `buildPartial()` is annotated + `@NonValidated`. These markers are how downstream code (and IDE tooling) tell the two + results apart at a glance. + +Because the injector controls placement, generators are not allowed to write methods, +fields, or interface declarations directly into the file. They contribute snippets and +declarations through `SingleOptionCode`; the injector decides where each lands. + +## The `(set_once)` renderer + +`SetOnceRenderer` is a `JavaRenderer` in its own right, registered alongside +`JavaValidationRenderer` by `JavaValidationPlugin`. It exists because `(set_once)` +semantics modify builder behavior rather than add a check to `validate()`: the option +must reject any setter call that would change an already-assigned value, and that +rejection has to fire from inside the setter itself. + +The renderer queries `SetOnceField` projections, dispatches to a field-type-specific +implementation of [`SetOnceJavaConstraints`][set-once-constraints] +(`SetOnceMessageField`, `SetOnceEnumField`, `SetOnceStringField`, +`SetOnceBooleanField`, `SetOnceBytesField`, `SetOnceNumberField`), and lets that +implementation modify every relevant setter, merge method, and the +`mergeFrom(CodedInputStream, …)` switch in the builder. The mechanics differ enough +between primitive, string, bytes, enum, and message fields that the per-type split is +worthwhile. + +From the renderer's point of view, the result is a builder whose mutating entry points +all call `throwIfNotDefaultAndNotSame` before assigning. The `(set_once)` constraint +therefore never appears in the generated `validate()` method; it is a property of the +builder, not of the message. This is also why `(set_once)` does not participate in the +`OptionGenerator` SPI — a custom option that needed similar semantics would need its +own renderer and its own per-type logic, not a generator slot. + +## Plugging in custom options + +Custom options participate in code generation through the third member of the +`ValidationOption` SPI: + +```kotlin +public interface ValidationOption { + + public val reactions: Set> + public val view: Set>> + public val generator: OptionGenerator +} +``` + +`reactions` and `view` contribute model-side artefacts (see +“[The validation model](validation-model.md)”). `generator` is the Java-side +contribution. `JavaValidationPlugin` discovers `ValidationOption` implementations +through `ServiceLoader` and passes each `generator` to `JavaValidationRenderer`, which +appends them to the built-in list. Custom generators are therefore indistinguishable from +built-ins at run time: they receive the same `Querying` and `TypeSystem`, query their +own projections, and return `SingleOptionCode` instances that are merged into the same +`validate()` method with the rest. + +The end-to-end walkthrough — declaring the option, modeling it in `:context`, writing a +generator, and wiring it through `META-INF/services` — lives in +“[Adding a new built-in validation option](adding-a-built-in-option.md)”. The +consumer-facing variant of the same SPI is covered by +“[Custom validation](../user/05-custom-validation/)” in the User's Guide. + +## What's next + +- [Runtime library](runtime-library.md) — the types in `:jvm-runtime` that the + generated code calls into at execution time. +- [Extension points](extension-points.md) — the public extension surface built around + `ValidationOption` and `MessageValidator`. +- [Adding a new built-in validation option](adding-a-built-in-option.md) — the + contributor walkthrough that ties this page to the rest of the guide. + +[java-validation-renderer]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/JavaValidationRenderer.kt +[set-once-renderer]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/setonce/SetOnceRenderer.kt +[option-generator]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/OptionGenerator.kt +[option-generator-with-converter]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/OptionGeneratorWithConverter.kt +[single-option-code]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/SingleOptionCode.kt +[message-validation-code]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/MessageValidationCode.kt +[validation-code-injector]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/ValidationCodeInjector.kt +[validate-scope]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/ValidateScope.kt +[message-scope]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/generate/MessageScope.kt +[set-once-constraints]: https://github.com/SpineEventEngine/validation/blob/master/java/src/main/kotlin/io/spine/tools/validation/java/setonce/SetOnceJavaConstraints.kt +[expression-pkg]: https://github.com/SpineEventEngine/validation/tree/master/java/src/main/kotlin/io/spine/tools/validation/java/expression +[intellij-psi]: https://plugins.jetbrains.com/docs/intellij/psi.html diff --git a/docs/content/docs/validation/developer/key-modules.md b/docs/content/docs/validation/developer/key-modules.md new file mode 100644 index 0000000000..0e85b9797a --- /dev/null +++ b/docs/content/docs/validation/developer/key-modules.md @@ -0,0 +1,34 @@ +--- +title: Key Modules +description: Overview of the main modules in the Spine Validation project. +headline: Documentation +--- + +# Key modules + +This repository is a Gradle multi-project build. Module names below are shown as Gradle +project paths like `:java` and `:tests:vanilla`. + +## Core modules + +- **`:context`**: Language-agnostic validation model and built-in option handling for views and reactions shared by language plugins. +- **`:java`**: Spine Compiler plugin for Java: generates/injects validation code; loads custom options via `ValidationOption` SPI. +- **`:jvm-runtime`**: Runtime library used by generated code: `ValidationException`, validation/constraint APIs, `MessageValidator`, and error Protobuf types. +- **`:java-bundle`**: Fat JAR bundling `:java` for distribution, which is the artifact typically used as the compiler plugin dependency. +- **`:gradle-plugin`**: The `io.spine.validation` Gradle plugin that configures Spine Compiler to run the Validation compiler for consumer projects. +- **`:docs`**: Sources for the Hugo documentation site, scripts, and example projects used in docs. + +## Test modules + +- **`:context-tests`**: [ProtoTap][prototap]-based compilation tests for `:context`, focusing on invalid option usage and error reporting. +- **`:tests`**: Parent project for integration tests that run the Compiler plugins and exercise generated code. +- **`:tests:vanilla`**: “Vanilla” integration tests: validation without any custom extensions. +- **`:tests:extensions`**: Example implementation of the `(currency)` custom option used by test suites to verify custom reactions, views, and generators. +- **`:tests:consumer`**: Integration tests for a consuming project that uses validation plus custom extensions. +- **`:tests:consumer-dependency`**: A dependency module with `.proto` sources used by `:tests:consumer` to verify “protos in dependencies” scenarios. +- **`:tests:validator`**: Integration tests for custom `MessageValidator`s discovered through `ServiceLoader`. +- **`:tests:validator-dependency`**: A dependency module used by `:tests:validator` for validator-related dependency scenarios. +- **`:tests:runtime`**: Tests focused on runtime behavior of validation APIs and error messages. +- **`:tests:validating`**: Shared fixtures and tests for validation behavior across multiple scenarios, including `testFixtures`. + +[prototap]: https://github.com/SpineEventEngine/ProtoTap diff --git a/docs/content/docs/validation/developer/overview-and-audience.md b/docs/content/docs/validation/developer/overview-and-audience.md new file mode 100644 index 0000000000..65ee26a73a --- /dev/null +++ b/docs/content/docs/validation/developer/overview-and-audience.md @@ -0,0 +1,90 @@ +--- +title: Overview and audience +description: Deep dive into Spine Validation architecture and internals. +headline: Documentation +--- + +# Overview and audience + +This guide is for contributors to Spine Validation and for readers who want a deep +understanding of how the library works internally. It complements the User's Guide, +which targets consumers of the library: where the User's Guide shows *how to use* +Validation, this guide shows *how Validation is built*. + +## Who this guide is for + +Read this guide if you are: + +- contributing changes to the Validation library itself, +- adding a new built-in validation option, +- working on a fork or a downstream tool that integrates with Validation's internals, +- or simply curious about how the pieces fit together. + +If you only want to apply validation rules to your own `.proto` files, the +“[User guide](../user/00-intro/)” is the right starting point. The two guides are designed to +be read independently: this one assumes you have already met Validation as a *user*. + +## The mental model + +Spine Validation has a single, load-bearing architectural decision: constraints declared +in `.proto` files are *compiled*, not *interpreted*. There is no rule engine running at +message construction time. Two stages cooperate: + +1. **At build time** — a Spine Compiler plugin reads validation options from your + Protobuf model, builds a language-agnostic representation of the constraints, and + emits Java code that enforces them. This work happens once, during the consumer + project's build. + +2. **At runtime** — generated code calls into a small library (`:jvm-runtime`) that + provides the validator entry points, the violation Protobuf types, and the exception + raised on failure. No reflection, no interpretation. + +Almost everything in this guide is, ultimately, an explanation of one half of that split +or of the seam between them. Holding the picture in mind makes the rest of the guide +easier to navigate; the “[Architecture](architecture.md)” page expands it in detail. + +## Prerequisite knowledge + +This guide assumes working familiarity with: + +- **Protocol Buffers** — message definitions, custom options, descriptors. +- **Gradle** — multi-project builds, plugin application, configuration phases. +- **Spine Compiler** — its role as a `protoc` post-processor and its plugin model. + See the [Spine Compiler documentation][spine-compiler] for an introduction. +- **Spine Bounded Contexts** — events, views (projections), and reactions. The + validation model in `:context` is itself a Bounded Context, and several sections + describe it in those terms. + +You do not need to be an expert in any of these, but you should not be meeting them for +the first time here. + +## How to read this guide + +The pages are arranged so that earlier sections introduce vocabulary used by later ones. +Read [Architecture](architecture.md) first; the rest can be consulted in order or by +need. + +1. [Architecture](architecture.md) — the compile-time/runtime split and the modules + that implement it. +2. [Key modules](key-modules.md) — one-line descriptions of every module in the + repository, including the test modules. Use it as a reference. +3. [The validation model](validation-model.md) — the language-agnostic model in + `:context`: views, events, reactions, and the `ValidationOption` SPI from the model side. +4. [Java code generation](java-code-generation.md) — how the Spine Compiler plugin in + `:java` turns the model into Java validators. +5. [Runtime library](runtime-library.md) — `MessageValidator`, `ValidationException`, + the violation Protobuf types, and runtime hooks in `:jvm-runtime`. +6. [Extension points](extension-points.md) — `MessageValidator`, the validator + registry, and the `ValidationOption` SPI end-to-end. +7. [Adding a new built-in validation option](adding-a-built-in-option.md) — the + contributor walkthrough that exercises the model, codegen, and runtime sections. +8. [Testing strategy](testing-strategy.md) — a map of the test modules and how to + choose the right one for a new test. +9. [Build, packaging, and release](build-and-release.md) — the multi-project build, + `:java-bundle`, and the Gradle plugin distribution flow. + +Each section assumes the architecture page has already been read. Sections 3–6 can be +read in order for a top-down tour of the internals, or jumped into directly when +diagnosing a specific area. + +[spine-compiler]: https://github.com/SpineEventEngine/compiler/ diff --git a/docs/content/docs/validation/developer/runtime-library.md b/docs/content/docs/validation/developer/runtime-library.md new file mode 100644 index 0000000000..369bb44bf5 --- /dev/null +++ b/docs/content/docs/validation/developer/runtime-library.md @@ -0,0 +1,391 @@ +--- +title: Runtime library +description: What ships in `:jvm-runtime` and how generated code uses it at execution time. +headline: Documentation +--- + +# Runtime library + +The `:jvm-runtime` module is the only thing the generated validation code depends on at +execution time. It is deliberately small: there is no rule engine, no descriptor scanning, +and no reflection-driven dispatch. The module ships the contracts that generated message +and builder classes implement, the Protobuf types used to describe violations, the +exception raised when a `build()` call fails, and a single registry through which custom +validators reach generated code. + +This page is the reverse view of “[Java code generation](java-code-generation.md)”. That +page describes what the renderer emits; this one describes the surface that the emitted +code calls into. + +## What ships in `:jvm-runtime` + +Five groups of types live in the runtime library: + +| Group | Types | Role | +|------------------------|--------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| Generated-class mixins | `ValidatableMessage`, `ValidatingBuilder` | Interfaces the generated message and its builder implement. | +| Violation Protobuf | `ValidationError`, `ConstraintViolation`, `TemplateString` | The structured shape of a violation report. | +| Exception | `ValidationException` | Thrown by `Builder.build()` when validation fails. | +| Markers | `@Validated`, `@NonValidated` | Documentary annotations placed on `build()` and `buildPartial()` return types; not retained at runtime. | +| Validator extension | `MessageValidator`, `ValidatorRegistry`, `DetectedViolation`, `RuntimeErrorPlaceholder` | Runtime SPI for attaching custom checks to a message type, including third-party messages. | + +Two utility entry points round the surface out: the static method `Validate.check(message)` +and the Kotlin extensions `M.checkValid()` and `M.copy { … }` in +[`MessageExtensions.kt`][message-extensions]. + +## The generated-class contracts + +Every message that goes through the Java renderer becomes a `ValidatableMessage`, and its +builder becomes a `ValidatingBuilder`. These two interfaces are the seam between +generated code and runtime code. + +`ValidatableMessage` declares the `validate(parentPath, parentName)` method whose body +the renderer assembles from the snippets that each `OptionGenerator` produced. It returns +`Optional` rather than throwing, so a built message can be re-validated +without paying for an exception: + +```java +public interface ValidatableMessage extends Message { + + default Optional validate() { + var noParentPath = FieldPath.getDefaultInstance(); + return validate(noParentPath, null); + } + + Optional validate(FieldPath parentPath, @Nullable TypeName parentName); +} +``` + +The two-argument overload is what `(validate)`-driven nested validation calls. When the +outer message's generated code descends into a nested field, it passes its own +`parentPath` and `parentName` so that any violation reported by the nested type carries +the path back to the validation root. The single-argument default is what application code +typically calls when it wants to verify an already-built message. + +`ValidatingBuilder` is the builder-side counterpart: + +```java +public interface ValidatingBuilder extends Message.Builder { + + @Override + @Validated M build() throws ValidationException; + + @Override + @NonValidated M buildPartial(); + + @Deprecated + default @Validated M vBuild() throws ValidationException { + return build(); + } +} +``` + +The injector wraps the existing `build()` so it calls `validate()` and throws +`ValidationException` if any violation is reported. `buildPartial()` is deliberately left +unwrapped — it is the documented escape hatch for callers who need to assemble a message +that does not yet satisfy its constraints. The `@Validated` and `@NonValidated` markers +make the difference visible at every call site. + +The `vBuild()` method is left deprecated for backward compatibility with the code +generated by Validation 1.x. + +## Source markers: `@Validated` and `@NonValidated` + +Both annotations have `RetentionPolicy.CLASS` and `TYPE_USE`/`TYPE_PARAMETER` targets. +They are documentation, not behavior — they are not consulted by the runtime, the +generator, or any classloader. Their job is to tell readers and IDEs that one method +returns a checked message and another does not. The injector applies them to the +generated `build()` and `buildPartial()` signatures; user code is free to apply them to +its own methods that wrap a builder. + +Because the retention is `CLASS`, they are recorded in the bytecode but not visible at +runtime via reflection. Treat them as a typed comment. + +## The validation entry point + +For a message that is *already* built, application code typically goes through +[`Validate`][validate-class]. `Validate.check(message)` is the throwing form; +`Validate.violationsOf(message)` is the non-throwing form: + + + +```java +public static M check(M message) throws ValidationException { + checkNotNull(message); + var violations = violationsOf(message); + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return message; +} +``` + +`violationsOf` distinguishes two cases: + +- If the message implements `ValidatableMessage`, `Validate` calls its `validate()` and + returns the violations from the resulting `ValidationError`. All built-in and custom + options run; so do any registered `MessageValidator`s, because the generated + `validate()` consults `ValidatorRegistry` at the end. +- Otherwise — typically a message generated outside this build — `Validate` skips + straight to `ValidatorRegistry.validate(message)`, which applies registered validators + but no compiled options. + +If the input is a packed `google.protobuf.Any`, `Validate` unpacks it before dispatching, +falling back to a stderr warning when the type URL is not in `KnownTypes`. + +The Kotlin extension `M.checkValid()` in [`MessageExtensions.kt`][message-extensions] +delegates to `Validate.check`. The same file also provides `M.copy { … }`, a helper that +creates a builder from an existing message, applies a configuration block, and calls +`build()` — so any modification made through `copy` is validated like any other build. + +## How violations are surfaced + +A violation is described by `ConstraintViolation` (in +[`validation_error.proto`][validation-error-proto]). The message is intentionally +self-contained so that violations can be returned across processes: + +```proto +message ConstraintViolation { + TemplateString message = 8; + string type_name = 7; + base.FieldPath field_path = 3; + google.protobuf.Any field_value = 4; + // ... deprecated fields elided +} +``` + +- `message` is a `TemplateString` — the placeholder format that the generator and runtime + both speak. Values are filled into the template's `placeholder_value` map at the moment + the violation is created; rendering to a final string happens later, when a reader calls + `TemplateString.format()`. Carrying values rather than rendered text keeps the violation + inspectable: a caller can still read `field.value` or `regex.pattern` without parsing + the message. +- `type_name` and `field_path` describe **where** the violation occurred. For nested + validation triggered by `(validate)`, `type_name` is the *root* type that initiated the + walk and `field_path` is the full dotted path to the offending field. This is why + `validate(parentPath, parentName)` exists on `ValidatableMessage`: a nested call uses + the outer caller's path and name as the prefix. +- `field_value` is the offending value, packed into `Any`. Primitive fields use the + matching wrapper type (`StringValue`, `Int32Value`, …). Message-valued fields are + packed directly. + +`ValidationError` is the multi-violation envelope: + +```proto +message ValidationError { + repeated ConstraintViolation constraint_violation = 1; +} +``` + +Violation accumulation is additive. A generated `validate()` does not short-circuit on the +first failure; it appends to a `List` and only at the end decides +whether to wrap the list in a `ValidationError`. This is what lets the runtime report +every problem in one pass. + +`TemplateString` (in [`error_message.proto`][error-message-proto]) is the format every +generator emits and every reader resolves. Substitution happens via +`TemplateString.format()` (in Kotlin, [`TemplateStringExts.kt`][template-string-exts]) or +the static `TemplateStrings.format(...)` (in Java). The placeholder names the runtime +itself fills in are enumerated by [`RuntimeErrorPlaceholder`][runtime-error-placeholder] +— `field.path`, `field.value`, `field.type`, `message.type`, `parent.type`, plus +option-specific entries such as `regex.pattern` and `range.value`. Note that this enum +mirrors `io.spine.tools.validation.ErrorPlaceholder` in `:context`; the two must be kept +in sync. + +`ViolationText` ([`ViolationText.java`][violation-text]) is the diagnostic formatter the +exception uses to produce a human-readable string from a list of `ConstraintViolation`s. + +## `ValidationException` + +`ValidationException` is what falls out of a failing `Builder.build()`. It is a +`RuntimeException`, so the `throws` declaration on generated `build()` methods is +documentation, not a checked contract. + +The exception stores an immutable copy of the reported `ConstraintViolation`s, exposes +them through `getConstraintViolations()`, formats diagnostics with `ViolationText`, and +implements `ErrorWithMessage`. Framework code can therefore obtain the +serialisable Protobuf form via `asMessage()` and ship the report across a wire without +forcing clients to link against `:jvm-runtime`. + +For richer error envelopes — for example, attaching an error code or a typed +`MessageClass` to the report — `:jvm-runtime` provides +[`ExceptionFactory`][exception-factory]. It is `@Internal` and intended for frameworks +built on top of Validation (Spine Server uses it to raise `CommandValidationError` and +`EventValidationError`); application code should keep using `ValidationException`. + +## The validator extension hook + +The runtime extension surface is a single SPI: [`MessageValidator`][message-validator]. +A `MessageValidator` knows nothing about the generator, the model, or `.proto` options. It +receives a built message and returns a list of `DetectedViolation`s. + +```kotlin +@SPI +public interface MessageValidator { + + public fun validate(message: M): List +} +``` + +[`DetectedViolation`][detected-violation] is the validator-side analogue of +`ConstraintViolation`. It carries a `TemplateString`, an optional `FieldPath`, and an +optional offending value: + + + +```kotlin +public abstract class DetectedViolation( + public val message: TemplateString, + public val fieldPath: FieldPath?, + public val fieldValue: Any?, +) +``` + +Two concrete subclasses cover the common cases: `FieldViolation` (a violation tied to a +specific field) and `MessageViolation` (a message-level rule that does not point at a +single field). The library converts each `DetectedViolation` to a `ConstraintViolation` +before reporting; the validator does not need to know about `Any`-packing, parent paths, +or type names. + +### `ValidatorRegistry` + +`ValidatorRegistry` is the singleton through which generated code reaches every +registered validator. It is loaded eagerly from `ServiceLoader` on first +access: + +```kotlin +@VisibleForTesting +internal fun loadFromServiceLoader() { + val loader = ServiceLoader.load(MessageValidator::class.java) + loader.forEach { validator -> + @Suppress("UNCHECKED_CAST") + val casted = validator as MessageValidator + val messageType = casted.messageClass() + add(messageType, casted) + } +} +``` + +An internal extension function, `MessageValidator.messageClass()` (declared in +[`ValidatorRegistry.kt`][validator-registry]), recovers the `M` type parameter via Guava's +`TypeToken`. It is not part of the SPI surface that implementers see. For `ServiceLoader` +discovery, the concrete message type must be recoverable from the validator class: direct +implementations such as `MessageValidator` are the clearest shape, and +base classes are fine as long as `TypeToken` still resolves `M` to a concrete message +class. + +The registry exposes `add`, `remove`, `get`, `clear`, and two `validate` overloads — +`validate(message)` for top-level use and `validate(message, parentPath, parentName)` for +the nested case used by generated code. The two-argument variant prefixes every reported +field path with `parentPath` and stamps the report with `parentName ?: TypeName.of(message)`, +mirroring what `ValidatableMessage.validate(parentPath, parentName)` does for compiled +constraints. Generated code therefore uses the registry uniformly whether the validated +type is locally defined or external. + +A reserved placeholder, `VALIDATOR_PLACEHOLDER` (the literal `"validator"`), is filled in +automatically with the fully-qualified class name of the validator that produced the +violation. Validators whose template strings reference `${validator}` see that name in +the rendered diagnostic without having to look it up themselves. + +### Local versus external messages + +The KDoc on `MessageValidator` is explicit about the two scenarios it serves and is the +canonical reference for behavior questions; the short version is: + +- **Local messages** — types defined in the consumer's own `.proto` files. The generated + `validate()` for a local message both runs its compiled constraints and consults + `ValidatorRegistry` at the end. Adding a `MessageValidator` therefore layers + a custom check on top of the generated one. +- **External messages** — types whose generated classes are out of the consumer's reach + (third-party Protobufs, well-known types). They never go through the Java renderer, so + there is no compiled `validate()` to invoke. A local message reaches their validators + only through fields marked with `(validate) = true`; the generated `(validate)` code + calls `ValidatorRegistry.validate(...)` for singular fields, repeated fields, and map + values of that external type. A standalone instance of an external type passed to a + non-local API is **not** validated automatically; callers must invoke `Validate.check` + or `ValidatorRegistry.validate(...)` themselves. + +The bundled `TimestampValidator` ([`TimestampValidator.kt`][timestamp-validator]) is a +small, real example that ships with `:jvm-runtime`: it is `@AutoService`-registered for +`com.google.protobuf.Timestamp`, returns `FieldViolation`s when seconds or nanos fall +outside `Timestamps.MIN_VALUE`/`MAX_VALUE`, and otherwise stays out of the way. A consumer +that marks a `Timestamp` field in a local message with `(validate) = true` gets the check +for free as soon as `:jvm-runtime` is on the classpath. + +### Discovery and registration + +`ValidatorRegistry` accepts validators in three ways: + +1. **`ServiceLoader`** — the registry's `init` block calls `loadFromServiceLoader()`. The + convenient way to wire this up on the JVM is `@AutoService(MessageValidator::class)`, + which generates the `META-INF/services/io.spine.validation.MessageValidator` entry at + compile time. This is what the bundled `TimestampValidator` uses, and what the User's + Guide recommends. +2. **Explicit `add(...)`** — `ValidatorRegistry.add(MyMessage::class, MyValidator())` (or + the `Class` overload for Java callers). Useful in tests or in startup code that + wants tight control over which validators are active. +3. **Removal and replacement** — `remove(cls)` clears all validators for a type; + `clear()` resets the registry. Several validators per message type are allowed; their + ordering is unspecified, and their reports are concatenated. + +Validators discovered through `ServiceLoader` must have a public no-arg constructor. +Whether discovered or registered explicitly, validator instances must be safe to invoke +concurrently: `ValidatorRegistry` is annotated `@ThreadSafe` and makes no per-call +locking guarantees beyond the registry's own bookkeeping. + +There is no `@Validator` annotation in the library itself; the discovery contract is the +`ServiceLoader` SPI plus the `MessageValidator` interface. `@AutoService(MessageValidator::class)` +from Google AutoService is the convenient way to generate the corresponding +`META-INF/services` entry on the JVM, but any other mechanism that produces the same +service descriptor works equivalently. + +## Constraints on the runtime surface + +A few invariants are worth keeping in mind when working on `:jvm-runtime`: + +- **No descriptor scanning.** The runtime never reads `.proto` descriptors to discover + rules. Everything that can be known at build time lives in generated code; the runtime + carries only what must be carried (registered validators, the violation Protobuf types, + the exception). +- **No reflection-driven dispatch in the hot path.** `ValidatorRegistry` does a single + `ConcurrentHashMap` lookup keyed by qualified class name. The reflection in + `messageClass()` runs once at registration time, not per validation. +- **Stable wire shape.** `ValidationError`, `ConstraintViolation`, and `TemplateString` + are public Protobuf types with type URLs at `type.spine.io`. They cross process + boundaries; field numbers are not free to reshuffle. +- **No dependency on logging.** `Validate` deliberately uses `System.err` for the rare + warning path so that `:jvm-runtime` does not pull Spine Logging into a consumer's + classpath. + +These constraints are why the runtime stays small and why the design centre of gravity is +in `:context` and `:java`: anything that *can* be decided at build time *should* be. + +## What's next + +- [Extension points](extension-points.md) — how `MessageValidator`, `ValidatorRegistry`, + and the `ValidationOption` SPI together form Validation's public extension surface. +- [Adding a new built-in validation option](adding-a-built-in-option.md) — the + contributor walkthrough that touches the runtime when an option needs new helpers or + error placeholders. +- User's Guide — [`MessageValidator` overview](../user/04-validators/) and + [Using `ValidatorRegistry`](../user/04-validators/validator-registry.md) for the + consumer-facing view of the same APIs. + +[message-validator]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/MessageValidator.kt +[validator-registry]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/ValidatorRegistry.kt +[detected-violation]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/DetectedViolation.kt +[validation-error-proto]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/proto/spine/validation/validation_error.proto +[error-message-proto]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/proto/spine/validation/error_message.proto +[violation-text]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/java/io/spine/validation/ViolationText.java +[validate-class]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/java/io/spine/validation/Validate.java +[exception-factory]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/java/io/spine/validation/ExceptionFactory.java +[message-extensions]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/MessageExtensions.kt +[template-string-exts]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/TemplateStringExts.kt +[runtime-error-placeholder]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/RuntimeErrorPlaceholder.kt +[timestamp-validator]: https://github.com/SpineEventEngine/validation/blob/master/jvm-runtime/src/main/kotlin/io/spine/validation/TimestampValidator.kt diff --git a/docs/content/docs/validation/developer/testing-strategy.md b/docs/content/docs/validation/developer/testing-strategy.md new file mode 100644 index 0000000000..a4812d0346 --- /dev/null +++ b/docs/content/docs/validation/developer/testing-strategy.md @@ -0,0 +1,266 @@ +--- +title: Testing strategy +description: Map of the test modules in Spine Validation and guidance on choosing the right one. +headline: Documentation +--- + +# Testing strategy + +The repository has a deliberately wide test layout: ten modules dedicated to tests +(or to fixtures consumed by tests), plus per-module unit tests where they pay off. +This is not over-engineering — each module isolates one concern that the others +cannot exercise without polluting their own classpath. The compile-time/runtime +split described in “[Architecture](architecture.md)” is reflected in how the test +modules are organised, and so is the built-in/custom split documented in +“[Extension points](extension-points.md)”. + +This page maps the modules to those concerns and tells you which one to extend +when you add a test. The full inventory of modules — including non-test modules — +lives in “[Key modules](key-modules.md)”; this page focuses on *what to do* with +the test modules rather than restating their one-line descriptions. + +## The shape of the test layout + +Tests in this repository fall into four bands. Picking the right module is mostly +a question of which band a new test belongs to. + +| Band | Concern | Modules | +|------|---------|---------| +| Compile-time diagnostics | The Compiler must reject invalid uses of a built-in option with a clear message. | `:context-tests` | +| Runtime library | `:jvm-runtime` types behave correctly without involving the Compiler at all. | `:jvm-runtime` (in-module tests) | +| End-to-end on built-ins | A `.proto` with built-in options compiles and the generated `validate()` produces the expected report. | `:tests:vanilla`, `:tests:validating`, `:tests:runtime` | +| Custom extensions | The `ValidationOption` and `MessageValidator` SPIs work end-to-end against a realistic consumer setup. | `:tests:extensions`, `:tests:consumer`, `:tests:consumer-dependency`, `:tests:validator`, `:tests:validator-dependency` | + +The extension tests use consumer modules for assertions and dependency modules +for realistic cross-module inputs. `:tests:consumer` also depends on +`:tests:extensions`, which contributes the custom `(currency)` `ValidationOption`; +`:tests:consumer-dependency` contributes imported `.proto` types. In the +validator pair, `:tests:validator` owns both the assertions and the +`MessageValidator` implementations; `:tests:validator-dependency` contributes the +dependency-owned `.proto` types used by the suite. + +## Compile-time diagnostics + +`:context-tests` is the home for tests that assert the Compiler *fails* on a +malformed use of a built-in option, with a specific diagnostic. The module is +built around [ProtoTap][prototap], a Spine test harness that runs `protoc` plus +the Spine Compiler against intentionally-broken `.proto` fixtures and exposes the +captured diagnostics back to the test code. + +The shape of a typical spec — see +[`RangeReactionSpec.kt`][range-reaction-spec] for a full example: + +- A spec class extends [`CompilationErrorTest`][compilation-error-test], which + invokes `tapConsole` from `Logging.testLib` and the ProtoTap entry points. +- The spec asserts both that compilation failed and that the diagnostic carries + the expected message — for example, that pointing `(range)` at an unsupported + field type names the field and the rejected type. +- The matching `.proto` fixture lives under + `context-tests/src/testFixtures/proto/spine/validation/`. The naming + convention pairs the spec with its fixture: `range_bad_field_type.proto`, + `range_bad_overflow.proto`, and so on. + +Add a spec here for every diagnostic a reaction can raise. The reaction-side +conventions in “[The validation model](validation-model.md#error-reporting-conventions)” list +the diagnostic categories that built-ins are expected to cover: unsupported field +type, unsupported placeholder, companion-without-primary, malformed range, +overflow, and so forth. + +`:context-tests` is *not* the place for tests that compile successfully and +inspect generated code or runtime behaviour. Those belong in `:tests:validating` +or `:tests:runtime`. + +## Runtime library in-module tests + +`:jvm-runtime` ships with its own `src/test/` source set covering the runtime +types in isolation: `ValidatorRegistry`, `ExceptionFactory`, +`ValidationException`, `TemplateString` rendering, `TimestampValidator`, and the +violation diagnostics under `io.spine.validation.diags`. No Spine Compiler or +validation code generation is involved — these are unit tests on the public +runtime surface described in “[Runtime library](runtime-library.md)”. + +Add a test here when you change a type that is part of the runtime API and the +behaviour can be exercised by constructing a `Message`, a `ConstraintViolation`, +or a `ValidationException` directly. If your test needs a generated `validate()` +method to fire, you are in the wrong module — go to `:tests:runtime` or +`:tests:validating` instead. + +`:context`, `:java`, and `:gradle-plugin` do not currently have dedicated +unit-test suites: their behaviour is a function of the model and the renderer, +both of which are covered by the integration modules below. A unit test that +needed to instantiate a renderer or a reaction in isolation would have to rebuild +most of the Spine Compiler harness around it; the integration modules already do +that. + +## End-to-end on built-ins + +Three modules cover the case that matters most for built-in options: a `.proto` +file is compiled by the Spine Compiler, generated Java is produced, and the test +exercises the generated `validate()`. They differ in scope and in what they make +easy. + +### The primary integration suite + +`:tests:validating` is the module to reach for first. It uses +`java-test-fixtures` to keep `.proto` fixtures and Kotlin helpers in one place, +shared across many specs. + +What sits where: + +- **`testFixtures/proto/spine/test/tools/validate/`** — the shared `.proto` + surface: `required.proto`, `numbers.proto`, `goes_*`, `set_once_*`, + `external_constraint.proto`, plus rejection and command types used by + cross-cutting specs. New built-ins typically add one fixture file here that + exercises every supported field type. +- **`testFixtures/kotlin/`** — assertion helpers, placeholder builders, and + test environments used across specs. +- **`src/test/kotlin/io/spine/test/options/`** — the specs themselves, organised + by option (`required/`, `goes/`, `setonce/`) or by cross-cutting concern + (`ChoiceITest.kt`, `ExternalConstraintITest.kt`, + `CustomOptionsLoadingITest.kt`). + +The framework stack is JUnit 5 plus Kotest assertions plus [Google Truth][truth] +for the classes that already used it. Specs typically build a message, catch +`ValidationException`, and assert on the shape of the resulting `ValidationError` +— including placeholder resolution and the `field_path` of each violation. + +This is the module used by the “[Adding a new built-in validation option](adding-a-built-in-option.md#6-test-the-option)” +walkthrough, and it should be your default for any test that asks “does this +option do what its consumer-facing documentation says it does?” + +### Runtime behaviour and constraint matrices + +`:tests:runtime` covers runtime *behaviour* that is independent of any specific +option — `ValidationOfConstraintTest`, `OneofSpec`, `EnclosedMessageValidationSpec`, +`AnyValidationSpec`, `EntityIdSpec`, `MessageExtensionsSpec`, `FieldAwareMessageSpec` +— alongside per-option constraint matrices under +`src/test/kotlin/io/spine/validation/option/`. The build applies +`CoreJvmCompiler.pluginId` (the McJava Compiler) so that test messages benefit +from the Spine Java extras (entity columns, message extensions, field-aware +generated code) the runtime types are designed to interoperate with. + +Use this module when: + +- The behaviour you need to exercise depends on Spine's Java extras and not just + on validation, or +- You are adding cross-cutting runtime behaviour (`Validate.check` semantics, + enclosed-message dispatch, oneof handling) rather than the behaviour of one + specific option. + +`:tests:runtime` does not use `testFixtures` — `.proto` fixtures live alongside +the specs. + +### Baseline integration without extensions + +`:tests:vanilla` is the smallest end-to-end suite: a stock build with no custom +extensions, exercising a handful of constraints (`JavaValidationSpec`, +`IsRequiredSpec`, `GoesConstraintSpec`, `DistinctConstraintSpec`). Its purpose +is to catch breakage that would otherwise be masked by the richer setup in +`:tests:validating` or by the McJava plugin in `:tests:runtime`. + +Add a test here only when your change interacts with the Java codegen pipeline +in a way that the more focused suites would not catch — typically a baseline +smoke test for a new built-in, or a regression test for a defect that involved +the plain Spine Compiler classpath. + +## Custom extensions + +Two pairs of modules cover the extension SPIs end-to-end. Both use the +*consumer + dependency* split: the consumer module hosts the assertions, and the +dependency module supplies the `.proto` types or service registrations that the +consumer needs to pull in from somewhere other than its own source tree. + +### `ValidationOption` SPI end-to-end + +`:tests:extensions` is a tiny Kotlin module (no `src/test`) that implements the +running `(currency)` example referenced throughout the documentation. It +contributes a reaction, a view, a generator, and a `ValidationOption` +implementation registered through `@AutoService`. Its purpose is to be a +realistic, third-party-shaped `ValidationOption` that the consumer modules can +depend on. + +`:tests:consumer` consumes that extension: it applies the Compiler, lists +`:tests:extensions` on the Compiler user classpath, and asserts that the +custom option is discovered, runs in the model, and produces the expected +generated code and runtime violations. `:tests:consumer-dependency` provides +`.proto` types that `:tests:consumer` imports, so the test setup matches the +realistic case where a consumer's protos live across several Gradle modules. + +Add to these modules when you change something that affects how a custom +`ValidationOption` is discovered, instantiated, or wired into the plugin — see +“[Extension points: discovery](extension-points.md#discovery)”. Do *not* add +generic option-behaviour tests here; if a built-in change happens to break custom +options, the diagnostic will show up in the `:tests:consumer*` suite, but the +authoritative test for the built-in still lives in `:tests:validating`. + +### `MessageValidator` SPI end-to-end + +The validator pair covers the runtime SPI. `:tests:validator` declares +`MessageValidator` implementations discovered through `ServiceLoader` and hosts +the assertions. `:tests:validator-dependency` contributes dependency-owned +`.proto` types used by these tests. The suite asserts that the registry picks up +custom validators, that they run alongside compiled checks for local messages, +and that they are the only entry point for external messages — the contract documented in +“[Extension points: ordering and composition](extension-points.md#ordering-and-composition)”. + +Add to these modules when you change `ValidatorRegistry`, `MessageValidator` +discovery, the `DetectedViolation` → `ConstraintViolation` translation, or the +generated wiring that calls into the registry from local messages. + +## Choosing the right module + +When in doubt, work down this list: + +1. Is the test asserting that the **Compiler should fail** on a malformed `.proto`? → **`:context-tests`**. +2. Is the test exercising a runtime type **without involving the Compiler**? → **`:jvm-runtime` in-module tests**. +3. Is the test exercising a **built-in option's behaviour** through generated code? → **`:tests:validating`**. +4. Is the test about **runtime semantics that span options** — `validate()` mechanics, oneof dispatch, enclosed messages, McJava interop? → **`:tests:runtime`**. +5. Is the test about a **custom `ValidationOption`** being discovered and applied? → **`:tests:consumer`** (with fixtures from `:tests:extensions` and `:tests:consumer-dependency`). +6. Is the test about a **custom `MessageValidator`** being discovered and applied? → **`:tests:validator`** (with fixtures from `:tests:validator-dependency`). +7. Is the change a **broad pipeline regression** that does not fit any of the above? → **`:tests:vanilla`**. + +If two modules look plausible, prefer the one with narrower scope. A test that +ends up in `:tests:vanilla` because it wanted to be near the Java-compilation +pipeline but really tests `(required)` behaviour will rot — the vanilla suite +intentionally stays small, and the test is more discoverable next to its +peers in `:tests:validating`. + +## Conventions + +A handful of conventions keep specs across the suites consistent: + +- **Kotest matchers**, JUnit 5 lifecycle. Specs use the `*Spec` or `*ITest` + naming convention; pick the suffix the surrounding directory already uses. +- **Parameterised tests** with `@MethodSource` for per-field-type matrices. The + built-in option specs in `:tests:validating` and `:tests:runtime` use this + pattern liberally; copy it rather than handwriting per-type test methods. +- **Fixtures are shared, specs are not.** Move `.proto` files into + `testFixtures` when more than one spec needs them; keep specs in + `src/test/kotlin/`. Inverting this — sharing specs across modules — is not a + pattern this repository uses, and it tends to mask which module owns the + behaviour. +- **Inline comments are welcome in tests.** The general rule against inline + comments in production code does not apply to specs — see + `.agents/documentation-guidelines.md`. Use them to explain non-obvious setup + in fixtures or to flag the violation shape a spec is asserting. +- **One concern per spec class.** A spec named after an option covers that + option; cross-cutting behaviour goes in its own spec. The + `ValidationOfConstraintTest`/`OneofSpec` split in `:tests:runtime` is the + reference shape. + +## What's next + +- [Key modules](key-modules.md) — one-line descriptions of every module in the + repository, including the ones not shown above. +- [Adding a new built-in validation option](adding-a-built-in-option.md#6-test-the-option) + — the contributor walkthrough that shows how the test modules above are + combined for a concrete change. +- [Extension points](extension-points.md) — the SPIs that the + `:tests:consumer*` and `:tests:validator*` modules exercise. +- [Build, packaging, and release](build-and-release.md) — how the test modules + fit into the multi-project build. + +[prototap]: https://github.com/SpineEventEngine/ProtoTap +[range-reaction-spec]: https://github.com/SpineEventEngine/validation/blob/master/context-tests/src/test/kotlin/io/spine/tools/validation/RangeReactionSpec.kt +[compilation-error-test]: https://github.com/SpineEventEngine/validation/blob/master/context-tests/src/test/kotlin/io/spine/tools/validation/CompilationErrorTest.kt +[truth]: https://truth.dev/ diff --git a/docs/content/docs/validation/developer/validation-model.md b/docs/content/docs/validation/developer/validation-model.md new file mode 100644 index 0000000000..3787c8e16a --- /dev/null +++ b/docs/content/docs/validation/developer/validation-model.md @@ -0,0 +1,385 @@ +--- +title: The validation model +description: Deep dive into the language-agnostic validation model implemented as a Spine Bounded Context. +headline: Documentation +--- + +# The validation model + +The `:context` module is the language-agnostic half of the Compiler plugin. It does not +emit code. It builds a description of the constraints declared in a set of `.proto` files +and exposes that description as a Bounded Context: an event-sourced model whose state can +be queried by language-specific renderers. + +A code-generating renderer in `:java` (see “[Java code generation](java-code-generation.md)”) +consumes that state to produce validation logic. A renderer for another target language +could consume the same state without changes to `:context`. + +## The Bounded Context shape + +The model is built from four kinds of artefacts that a Spine Bounded Context combines: + +- **Domain events** (in `events.proto`) — a closed set of `*Discovered` events, one per + built-in validation option. Each event carries the data that the rest of the model + needs to know about a single discovered constraint. +- **Views (projections)** (in `views.proto`) — one projection per option, keyed by the + field, oneof group, or message that owns the constraint. Each view holds the data the + renderer will read. +- **Reactions** (Kotlin classes under `option/` and `bound/`) — handlers that subscribe + to the upstream `FieldOptionDiscovered`, `OneofOptionDiscovered`, and + `MessageOptionDiscovered` events emitted by the Spine Compiler, validate the option's + applicability, and emit the matching `*Discovered` domain event. +- **Plugin wiring** — `ValidationPlugin` registers all built-in views and reactions with + the Compiler, so they are instantiated and connected for every consumer build. + +`ValidationPlugin` is the entry point that pulls the four pieces together. It is +language-agnostic and is extended by `JavaValidationPlugin` in `:java` (which only adds +renderers and folds in custom-option contributions, see +“[Architecture](architecture.md)”). + +## The lifecycle of an option + +The model never reads `.proto` files directly. The Spine Compiler does the parsing and +publishes generic option events. A reaction in `:context` matches each event by option +name, decides whether the option applies, and either emits a domain event or stays +silent. The matching projection then folds the event into its state. By the time the +build leaves `:context`, the model is just a set of populated views. + +The flow for a single field-level option looks like this: + +```mermaid +sequenceDiagram + participant Compiler as Spine Compiler + participant Bus as EventBus + participant Reaction + participant View as Projection + + Compiler->>Bus: FieldOptionDiscovered + Bus->>Reaction: deliver + Note over Reaction: Filter by option.name
Check applicability
Validate placeholders + Reaction->>Bus: SomethingDiscovered + Bus->>View: deliver + Note over View: alter — state ready for renderer +``` + +`OneofOptionDiscovered` and `MessageOptionDiscovered` follow the same shape, identifying +the projection by the corresponding declaration in the parsed Protobuf file. + +### Event filtering by option name + +Reactions select their input by matching the upstream event's `option.name` field. The +constant [`OPTION_NAME`][option-name] is the field path; the option-name constants in +[`OptionNames.kt`][option-names] are the equality values: + + + +```kotlin +internal class RequiredReaction : Reaction() { + + @React + override fun whenever( + @External @Where(field = OPTION_NAME, equals = REQUIRED) + event: FieldOptionDiscovered, + ): EitherOf2 { + val field = event.subject + val file = event.file + checkFieldType(field, file) + + if (!event.option.boolValue) { + return ignore() + } + + val defaultMessage = defaultErrorMessage() + return requiredFieldDiscovered { + id = field.ref + subject = field + defaultErrorMessage = defaultMessage + }.asA() + } +} +``` + +Two details are worth highlighting: + +- The reaction's return type uses `EitherOf2<…, NoReaction>`. The `(required) = false` + case returns `NoReaction`, which is how the model communicates *“the option is applied + correctly but disabled”*. No domain event is emitted, so no projection is created, and + the renderer sees the field as if the option were absent. +- The reaction is the only place where applicability is checked. By the time the + matching `*Discovered` event reaches the bus, the option has been confirmed valid for + this field. Projections trust this contract and never re-check. + +### The discovered event + +For every option there is a `