Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/docs/client-java.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ Client `.java` under `/registry/public/<project>/...` is synchronized by `JavaSy

- **Several writes that only make sense together are ONE transaction.** Every store call is otherwise its own transaction, so a multi-write operation that fails halfway leaves the earlier writes behind - an intent create-from committed the invoice header and the source's INVOICED flip and then failed on a line (#7069). `org.eclipse.dirigible.components.data.store.java.repository.UnitOfWork.call(() -> { ... })` runs the block on one session and one transaction (thread-bound, so every repository joins it; nested blocks defer to the outermost), reads see the block's own writes, and the outbox events dispatch only once the whole unit committed - which is why an announcement about the unit's outcome belongs INSIDE the block, recorded through the write it is about (the create-from's `-transitioned` rides its source's status flip): the unit's commit is what makes it true, and the outbox only hands out what committed, so a crash after the commit no longer loses the event (#7160). The `History` trail and document numbering deliberately stay outside - which is why anything that can refuse a create (a required input, a from-status guard, a create-from's source-row rule) is decided BEFORE the target's header is saved: a refusal after the save takes the row back but not the number it spent or the trail row it wrote (#7224). Alongside it, a generated repository now refuses a write that leaves a defaultless NOT NULL column empty with a `ValidationException` naming the property (a 400), instead of letting the statement come back as a driver-specific constraint violation; a column carrying a DEFAULT is exempt, the database supplying its value.

- **`custom/test/**` is a project's unit tests, never part of its client codebase** (#7643). `JavaSynchronizer.isAccepted` skips it (one rule, `ClientTestSources.isTestSource`, shared with the slice), because those tests import JUnit and the slice, which the platform does not ship. They run in-process with `mvn test` through `tests/tests-sdk` (`dirigible-sdk-test`, `@IntentSlice`): the module compiles the project folder as a TEST source root, so `gen/`, `custom/` and `custom/test/` share one `Class` per type, and the slice loads those classes from the test classpath instead of compiling them - a slice compiling into its own `ClientClassLoader` would hand the test a different `InvoiceLineEntity` than the one it imports. The slice is a hand-filled context behind `BeanProvider` (entity store, numbering, tenant, client bean container) on a private H2, reset before each test; the platform services with package-private constructors are built by same-package helpers in its jar (`IntentSlice*` under `data.store.java.manager`/`outbox`, `engine.numbering`, `api.messaging`), so a signature change breaks its compile rather than a downstream run. `sample-intent-money` (its `gen/` checked in) is its reference and test suite; `IntentSliceSampleIT` publishes it. See `tests/tests-sdk/README.md` for what it does not cover.

**Detailed guide:** [`components/engine/engine-java/CLAUDE.md`](components/engine/engine-java/CLAUDE.md). Read it before changing anything under `engine-java`, `data-store-java`, the `sdk.*` annotations, or the `*-java` templates — it covers the container, the consumers, the two handler styles + no-mixing rule, the `JavaHandler`-as-bean path, controller routing / OpenAPI / `@Roles`, `data-store-java` dynamic-map persistence, error surfacing, the **removed** internals (`RepositoryRegistry` / `RepositoryClassConsumer` / `DependencyResolver` / reflective fallback / `@Extension`), and the three-repo (platform + `dirigiblelabs/sample-java-*` + docs) sequencing.

4 changes: 4 additions & 0 deletions components/engine/engine-java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ done | sort > "$PAYLOAD/.compiled"

Only write the marker when the `find` produced at least one class; a payload-only project skips it.

The `test/` exclusions keep the project's unit tests - `custom/test/**`, which the platform never
compiles - out of the module. Run them before packaging, with `mvn test` in the project
([`dirigible-sdk-test`](../../../tests/tests-sdk/README.md) runs them in-process).

### 3. Compile and assemble

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.dirigible.engine.java.runtime;

import java.nio.file.Path;

/**
* Where a project keeps the unit tests of its hand-written Java: {@code custom/test/**}, beside the
* {@code custom/} classes they test (dirigible #7643). Those sources are compiled and run by the
* module's own Maven build against JUnit, which the platform does not ship - so the client codebase
* the platform compiles must never contain them, and the in-process test slice must not mistake
* them for application classes either. Both ask this one rule.
*/
public final class ClientTestSources {

private static final String CUSTOM_FOLDER = "custom";
private static final String TEST_FOLDER = "test";

private ClientTestSources() {}

/**
* Tells whether a source file lies under a {@code custom/test} folder.
*
* @param file the source file - absolute, or relative to any folder above {@code custom}
* @return {@code true} for a unit-test source of the project's hand-written Java
*/
public static boolean isTestSource(Path file) {
String previous = null;
for (Path element : file) {
String name = element.toString();
if (CUSTOM_FOLDER.equals(previous) && TEST_FOLDER.equals(name)) {
return true;
}
previous = name;
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
package org.eclipse.dirigible.engine.java.synchronizer;

import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
Expand All @@ -26,6 +28,7 @@
import org.eclipse.dirigible.components.base.synchronizer.SynchronizerCallback;
import org.eclipse.dirigible.components.api.platform.ProblemsFacade;
import org.eclipse.dirigible.engine.java.domain.JavaFile;
import org.eclipse.dirigible.engine.java.runtime.ClientTestSources;
import org.eclipse.dirigible.engine.java.runtime.CompileDiagnostic;
import org.eclipse.dirigible.engine.java.runtime.JavaLoader;
import org.eclipse.dirigible.engine.java.runtime.JavaSourceParser;
Expand Down Expand Up @@ -117,6 +120,17 @@ public boolean isAccepted(String type) {
return JavaFile.ARTEFACT_TYPE.equals(type);
}

/**
* Every {@code .java} file except a project's unit tests ({@code custom/test/**}): those are
* compiled by the module's own Maven build against JUnit, which the platform does not ship, so
* compiling them here could only fail, and report a compilation problem for a file that is not
* broken.
*/
@Override
public boolean isAccepted(Path file, BasicFileAttributes attrs) {
return super.isAccepted(file, attrs) && !ClientTestSources.isTestSource(file);
}

@Override
public String getFileExtension() {
return FILE_EXTENSION;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.dirigible.engine.java.synchronizer;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;

import java.nio.file.Path;

import org.eclipse.dirigible.engine.java.runtime.JavaLoader;
import org.eclipse.dirigible.engine.java.service.JavaFileService;
import org.junit.jupiter.api.Test;

/**
* A project's unit tests under {@code custom/test/**} are compiled by its own Maven build against
* JUnit (dirigible #7643); the platform must leave them out of the client codebase it compiles.
*/
class JavaSynchronizerTestSourcesTest {

private static final Path REGISTRY = Path.of("/dirigible", "registry", "public");

private final JavaSynchronizer synchronizer = new JavaSynchronizer(mock(JavaFileService.class), mock(JavaLoader.class));

@Test
void acceptsTheApplicationSources() {
assertTrue(accepted("money", "custom", "LineVatAction.java"));
assertTrue(accepted("money", "gen", "money", "data", "invoiceline", "InvoiceLineRepository.java"));
}

@Test
void skipsTheUnitTestsOfTheHandWrittenJava() {
assertFalse(accepted("money", "custom", "test", "LineVatActionTest.java"));
assertFalse(accepted("money", "custom", "test", "pricing", "DiscountTest.java"));
}

@Test
void keepsAFolderNamedTestElsewhere() {
assertTrue(accepted("money", "gen", "test", "Fixture.java"));
assertTrue(accepted("money", "test", "Probe.java"));
assertTrue(accepted("money", "custom", "testing", "Helper.java"));
}

private boolean accepted(String... segments) {
Path file = REGISTRY;
for (String segment : segments) {
file = file.resolve(segment);
}
return synchronizer.isAccepted(file, null);
}
}
1 change: 1 addition & 0 deletions tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

<modules>
<module>tests-framework</module>
<module>tests-sdk</module>
<module>tests-integrations</module>
</modules>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.dirigible.integration.tests.api;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.comparesEqualTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.math.BigDecimal;
import java.util.List;

import org.eclipse.dirigible.engine.java.domain.JavaFile;
import org.eclipse.dirigible.engine.java.service.JavaFileService;
import org.eclipse.dirigible.tests.base.IntegrationTest;
import org.eclipse.dirigible.tests.base.ProjectDeployer;
import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import io.restassured.config.JsonConfig;
import io.restassured.config.RestAssuredConfig;
import io.restassured.http.ContentType;
import io.restassured.path.json.config.JsonPathConfig;

/**
* The reference project of the in-process unit-test slice (dirigible #7643), published to the
* platform: its unit tests under {@code custom/test/} are compiled by the module's Maven build
* (where {@code dirigible-sdk-test} runs them), never by the platform - which has no JUnit to
* compile them against - while the application they test runs exactly as the slice runs it.
*/
class IntentSliceSampleIT extends IntegrationTest {

private static final String PROJECT = "sample-intent-money";
private static final String LINE_API = "/services/java/" + PROJECT + "/gen/money/api/invoiceline/InvoiceLineController";

@Autowired
private ProjectDeployer projectDeployer;

@Autowired
private RestAssuredExecutor restAssuredExecutor;

@Autowired
private JavaFileService javaFileService;

@Test
void publishesTheApplicationWithoutItsUnitTests() {
projectDeployer.deploy(PROJECT);

List<String> locations = javaFileService.findByProject(PROJECT)
.stream()
.map(JavaFile::getLocation)
.sorted()
.toList();
assertEquals(List.of("/" + PROJECT + "/custom/LineVatAction.java", //
"/" + PROJECT + "/gen/money/api/invoiceline/InvoiceLineController.java", //
"/" + PROJECT + "/gen/money/data/invoiceline/InvoiceLineEntity.java", //
"/" + PROJECT + "/gen/money/data/invoiceline/InvoiceLineRepository.java"), locations,
"the platform compiles the application and leaves custom/test/ to the module's build");
assertTrue(javaFileService.findByProject(PROJECT)
.stream()
.allMatch(file -> file.getError() == null),
"every application class compiled and wired: " + javaFileService.findByProject(PROJECT));

// The money path the slice's LineVatActionTest pins: 1 x 2.90 at 5% is 0.145, half-up 0.15.
RestAssuredConfig exactDecimals = RestAssuredConfig.config()
.jsonConfig(JsonConfig.jsonConfig()
.numberReturnType(
JsonPathConfig.NumberReturnType.BIG_DECIMAL));
restAssuredExecutor.execute(() -> given().config(exactDecimals)
.contentType(ContentType.JSON)
.body("{\"Description\":\"Paper\",\"Quantity\":1,\"UnitPrice\":2.90,\"VatRate\":5}")
.when()
.post(LINE_API)
.then()
.statusCode(200)
.body("VatAmount", comparesEqualTo(new BigDecimal("0.15"))),
25);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The generated Java is checked in on purpose: it is what the unit tests under custom/test compile and
# run against in the in-process slice (dirigible #7643). The repository-wide `**/gen` rule would hide it.
!/gen/
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# sample-intent-money

The reference project of the in-process unit-test slice
([eclipse-dirigible/dirigible#7643](https://github.com/eclipse-dirigible/dirigible/issues/7643)): one
entity whose money field is computed by hand-written Java, and the unit tests of that Java.

- **`app.intent`** - an `InvoiceLine` whose `vatAmount` is calculated on every create and update by
`calculatedActionOnCreate`/`OnUpdate: LineVatAction`.
- **`custom/LineVatAction.java`** - quantity x unit price x rate, half-up to the cent, in
`BigDecimal` throughout.
- **`custom/test/LineVatActionTest.java`** - its unit tests, run in-process by
[`dirigible-sdk-test`](../../../../../tests-sdk/README.md) (`@IntentSlice`): `1 x 2.90 @ 5%` is
exactly 0.145, stored as 0.15; computed in `double` it is 0.14499999... and the test fails on the
0.14 it stores. They run with `mvn test` in `tests/tests-sdk`, whose test suite this folder is.
- **`gen/`** - the Java the intent generates, checked in because it is what the tests compile
against (only the Java: the UI, schema and roles come back on the next Generate).

`IntentSliceSampleIT` publishes the folder to a running platform and asserts that the application
compiles without its unit tests - the platform never compiles `custom/test/**` - and computes the
same VAT over REST.

## Regenerating `gen/`

After a change to `app.intent`, import the folder into a workspace, open `app.intent` and click
**Generate**, then copy the generated `gen/**/*.java` back here and run `mvn test` in
`tests/tests-sdk`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: money
description: The reference project of the in-process unit-test slice (dirigible #7643) - a money field calculated by a hand-written custom/ action, with its unit tests under custom/test

entities:
# One invoice line. Its VAT is the money path under test: calculated on the server by the
# hand-written LineVatAction on every create and update, never accepted from the caller.
- name: InvoiceLine
imports: |
import custom.LineVatAction;
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: description, type: string, required: true, length: 200 }
- { name: quantity, type: decimal, required: true, precision: 18, scale: 3 }
- { name: unitPrice, type: decimal, required: true, precision: 18, scale: 2 }
- { name: vatRate, type: decimal, required: true, precision: 5, scale: 2 }
- { name: vatAmount, type: decimal, precision: 18, scale: 2, readOnly: true,
calculatedActionOnCreate: LineVatAction, calculatedActionOnUpdate: LineVatAction }
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
*
* All rights reserved. This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v20.html
*
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
*/
package custom;

import java.math.BigDecimal;
import java.math.RoundingMode;

import org.eclipse.dirigible.sdk.component.Component;
import org.eclipse.dirigible.sdk.db.CalculatedField;

import gen.money.data.invoiceline.InvoiceLineEntity;

/**
* The VAT of an invoice line: quantity x unit price x rate, rounded half-up to the cent. Computed in
* {@link BigDecimal} from start to finish - a {@code double} evaluation turns {@code 1 x 2.90 @ 5%}
* into {@code 0.14499999...} and rounds it to 0.14 instead of 0.15, which is exactly what
* {@code custom/test/LineVatActionTest} pins.
*/
@Component
public class LineVatAction implements CalculatedField<InvoiceLineEntity, BigDecimal> {

private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private static final int CENTS = 2;

@Override
public BigDecimal calculate(InvoiceLineEntity line) {
// The repository calculates before it validates, so a line missing an input reaches here; the
// validation that follows refuses it with the missing field's name.
if (line.Quantity == null || line.UnitPrice == null || line.VatRate == null) {
return null;
}
return line.Quantity.multiply(line.UnitPrice)
.multiply(line.VatRate)
.divide(HUNDRED)
.setScale(CENTS, RoundingMode.HALF_UP);
}
}
Loading
Loading