Skip to content

Commit d88eeb1

Browse files
authored
Merge pull request #99 from teaql/feat/business-id-runtime-98
feat: add scoped Business ID runtime
2 parents 46d998e + d3b099d commit d88eeb1

29 files changed

Lines changed: 903 additions & 1 deletion
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Business ID focused runtime example
2+
3+
This example verifies the new Java runtime Business ID boundary without running
4+
the full multi-module regression suite. It covers the default eight-digit
5+
daily sequence, controlled Context date, root scoping, Mutation Ledger retry
6+
stability, explicit SQLite schema setup, cross-instance allocation, restart,
7+
strong `OrderNumber` parsing, and immutable established aggregates.
8+
9+
```bash
10+
mvn -pl examples/business-id-runtime -am \
11+
-Dtest=BusinessIdRuntimeExampleTest \
12+
-Dsurefire.failIfNoSpecifiedTests=false test
13+
```
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
<parent>
7+
<groupId>io.teaql</groupId>
8+
<artifactId>teaql-java-parent</artifactId>
9+
<version>1.547-RELEASE</version>
10+
<relativePath>../../pom.xml</relativePath>
11+
</parent>
12+
<artifactId>teaql-business-id-example</artifactId>
13+
<name>TeaQL Business ID focused example</name>
14+
<dependencies>
15+
<dependency>
16+
<groupId>io.teaql</groupId>
17+
<artifactId>teaql-runtime</artifactId>
18+
</dependency>
19+
<dependency>
20+
<groupId>io.teaql</groupId>
21+
<artifactId>teaql-business-id-jdbc</artifactId>
22+
<version>${project.version}</version>
23+
</dependency>
24+
<dependency>
25+
<groupId>org.xerial</groupId>
26+
<artifactId>sqlite-jdbc</artifactId>
27+
<version>3.53.2.1</version>
28+
<scope>test</scope>
29+
</dependency>
30+
<dependency>
31+
<groupId>junit</groupId>
32+
<artifactId>junit</artifactId>
33+
<scope>test</scope>
34+
</dependency>
35+
</dependencies>
36+
</project>
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package io.teaql.examples.businessid;
2+
3+
import io.teaql.businessid.jdbc.JdbcBusinessIdAllocator;
4+
import io.teaql.core.BaseEntity;
5+
import io.teaql.core.EntityKey;
6+
import io.teaql.core.UserContext;
7+
import io.teaql.core.businessid.*;
8+
import io.teaql.core.meta.EntityDescriptor;
9+
import io.teaql.core.meta.EntityMetaFactory;
10+
import io.teaql.core.sql.portable.TeaQLDatabase;
11+
import io.teaql.runtime.DefaultUserContext;
12+
import io.teaql.runtime.TeaQLRuntime;
13+
import io.teaql.runtime.businessid.*;
14+
import java.nio.file.Files;
15+
import java.nio.file.Path;
16+
import java.sql.*;
17+
import java.time.LocalDate;
18+
import java.util.*;
19+
import java.util.concurrent.*;
20+
import org.junit.Assert;
21+
import org.junit.Test;
22+
23+
public class BusinessIdRuntimeExampleTest {
24+
private static final BusinessIdDefinition ORDER_NUMBER =
25+
BusinessIdDefinition.dailySequence("order_number", "CO", "commerce_order");
26+
27+
public record OrderNumber(String value) {
28+
public OrderNumber {
29+
if (value == null || !value.matches("CO-\\d{8}-\\d{8}")) {
30+
throw new IllegalArgumentException("invalid OrderNumber: " + value);
31+
}
32+
}
33+
}
34+
35+
private static final class Order extends BaseEntity implements BusinessIdSlot {
36+
private OrderNumber orderNumber;
37+
38+
private Order(long id) {
39+
updateId(id);
40+
}
41+
42+
@Override
43+
public String typeName() {
44+
return "CommerceOrder";
45+
}
46+
47+
@Override
48+
public String currentValue() {
49+
return orderNumber == null ? null : orderNumber.value();
50+
}
51+
52+
@Override
53+
public boolean newAggregate() {
54+
return newItem();
55+
}
56+
57+
@Override
58+
public void assignCanonicalValue(String value) {
59+
OrderNumber next = new OrderNumber(value);
60+
OrderNumber previous = orderNumber;
61+
orderNumber = next;
62+
handleUpdate("order_number", previous, next);
63+
}
64+
65+
private OrderNumber orderNumber() {
66+
return orderNumber;
67+
}
68+
69+
private void markPersisted() {
70+
set$status(io.teaql.core.EntityStatus.PERSISTED);
71+
}
72+
}
73+
74+
@Test
75+
public void inMemoryProfileIsScopedTypedAndRetryStable() {
76+
UserContext context = context(LocalDate.of(2026, 9, 20));
77+
DefaultBusinessIdService service =
78+
new DefaultBusinessIdService(new InMemoryBusinessIdAllocator());
79+
context.putAttribute(BusinessIdService.class.getName(), service);
80+
81+
Order first = new Order(101);
82+
BusinessIdValue initial = context.businessIds().ensure(
83+
context, ORDER_NUMBER, "tenant-a", "commerce_order", first);
84+
Assert.assertEquals("CO-20260920-00000001", initial.value());
85+
Assert.assertEquals(new OrderNumber(initial.value()), first.orderNumber());
86+
Assert.assertEquals(
87+
first.orderNumber(),
88+
first.getEntityMutationLedger().get(
89+
new EntityKey("CommerceOrder", 101L), "order_number"));
90+
91+
// Simulated provider failure: the same pending entity/ledger is retried.
92+
BusinessIdValue retry = context.businessIds().ensure(
93+
context, ORDER_NUMBER, "tenant-a", "commerce_order", first);
94+
Assert.assertEquals(initial, retry);
95+
96+
Order second = new Order(102);
97+
Assert.assertEquals(
98+
"CO-20260920-00000002",
99+
context.businessIds().ensure(
100+
context, ORDER_NUMBER, "tenant-a", "commerce_order", second).value());
101+
102+
Order otherTenant = new Order(103);
103+
Assert.assertEquals(
104+
"CO-20260920-00000001",
105+
context.businessIds().ensure(
106+
context, ORDER_NUMBER, "tenant-b", "commerce_order", otherTenant).value());
107+
108+
UserContext nextDay = context(LocalDate.of(2026, 9, 21));
109+
nextDay.putAttribute(BusinessIdService.class.getName(), service);
110+
Assert.assertEquals(
111+
"CO-20260921-00000001",
112+
nextDay.businessIds().ensure(
113+
nextDay, ORDER_NUMBER, "tenant-a", "commerce_order", new Order(104)).value());
114+
115+
Order establishedWithoutNumber = new Order(105);
116+
establishedWithoutNumber.markPersisted();
117+
BusinessIdException immutable = Assert.assertThrows(
118+
BusinessIdException.class,
119+
() -> context.businessIds().ensure(
120+
context,
121+
ORDER_NUMBER,
122+
"tenant-a",
123+
"commerce_order",
124+
establishedWithoutNumber));
125+
Assert.assertEquals(BusinessIdErrorCode.BUSINESS_ID_IMMUTABLE, immutable.getCode());
126+
}
127+
128+
@Test
129+
public void sqliteAllocatorIsExplicitCrossInstanceAndRestartSafe() throws Exception {
130+
Path databasePath = Files.createTempFile("teaql-business-id", ".db");
131+
try {
132+
BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan(
133+
new BusinessIdGenerationRequest(
134+
ORDER_NUMBER,
135+
"tenant-a",
136+
"commerce_order",
137+
LocalDate.of(2026, 9, 20)));
138+
139+
try (Connection schemaConnection = open(databasePath)) {
140+
new JdbcBusinessIdAllocator(database(schemaConnection)).ensureSchema(null);
141+
}
142+
143+
CountDownLatch start = new CountDownLatch(1);
144+
ExecutorService workers = Executors.newFixedThreadPool(2);
145+
try {
146+
List<Future<List<Long>>> futures = new ArrayList<>();
147+
for (int worker = 0; worker < 2; worker++) {
148+
futures.add(workers.submit(() -> {
149+
try (Connection connection = open(databasePath)) {
150+
JdbcBusinessIdAllocator allocator =
151+
new JdbcBusinessIdAllocator(database(connection));
152+
start.await();
153+
List<Long> allocated = new ArrayList<>();
154+
for (int index = 0; index < 25; index++) {
155+
allocated.add(allocator.allocate(plan).sequence());
156+
}
157+
return allocated;
158+
}
159+
}));
160+
}
161+
start.countDown();
162+
Set<Long> allocated = new HashSet<>();
163+
for (Future<List<Long>> future : futures) {
164+
allocated.addAll(future.get(15, TimeUnit.SECONDS));
165+
}
166+
Assert.assertEquals(50, allocated.size());
167+
} finally {
168+
workers.shutdownNow();
169+
}
170+
171+
try (Connection restarted = open(databasePath)) {
172+
JdbcBusinessIdAllocator allocator =
173+
new JdbcBusinessIdAllocator(database(restarted));
174+
Assert.assertEquals(51, allocator.allocate(plan).sequence());
175+
}
176+
} finally {
177+
Files.deleteIfExists(databasePath);
178+
}
179+
}
180+
181+
private static UserContext context(LocalDate date) {
182+
TeaQLRuntime runtime = TeaQLRuntime.builder()
183+
.metadata(new EntityMetaFactory() {
184+
@Override
185+
public EntityDescriptor resolveEntityDescriptor(String type) {
186+
return null;
187+
}
188+
189+
@Override
190+
public void register(EntityDescriptor type) {}
191+
192+
@Override
193+
public List<EntityDescriptor> allEntityDescriptors() {
194+
return List.of();
195+
}
196+
})
197+
.executionLogging(false)
198+
.build();
199+
DefaultUserContext context = new DefaultUserContext(runtime);
200+
context.putAttribute(BusinessClock.class.getName(),
201+
(BusinessClock) ignored -> date);
202+
context.putAttribute(BusinessIdProfileFactory.class.getName(),
203+
new DefaultBusinessIdProfileFactory());
204+
return context;
205+
}
206+
207+
private static Connection open(Path path) throws SQLException {
208+
Connection connection = DriverManager.getConnection("jdbc:sqlite:" + path);
209+
try (Statement statement = connection.createStatement()) {
210+
statement.execute("PRAGMA busy_timeout=5000");
211+
}
212+
return connection;
213+
}
214+
215+
private static TeaQLDatabase database(Connection connection) {
216+
return new TeaQLDatabase() {
217+
@Override
218+
public List<Map<String, Object>> query(String sql, Object[] args) {
219+
try (PreparedStatement statement = connection.prepareStatement(sql)) {
220+
bind(statement, args);
221+
try (ResultSet resultSet = statement.executeQuery()) {
222+
List<Map<String, Object>> rows = new ArrayList<>();
223+
ResultSetMetaData metadata = resultSet.getMetaData();
224+
while (resultSet.next()) {
225+
Map<String, Object> row = new HashMap<>();
226+
for (int column = 1; column <= metadata.getColumnCount(); column++) {
227+
row.put(metadata.getColumnLabel(column).toLowerCase(Locale.ROOT),
228+
resultSet.getObject(column));
229+
}
230+
rows.add(row);
231+
}
232+
return rows;
233+
}
234+
} catch (SQLException error) {
235+
throw new RuntimeException(error);
236+
}
237+
}
238+
239+
@Override
240+
public int executeUpdate(String sql, Object[] args) {
241+
try (PreparedStatement statement = connection.prepareStatement(sql)) {
242+
bind(statement, args);
243+
return statement.executeUpdate();
244+
} catch (SQLException error) {
245+
throw new RuntimeException(error);
246+
}
247+
}
248+
249+
@Override
250+
public int[] batchUpdate(String sql, List<Object[]> batchArgs) {
251+
throw new UnsupportedOperationException();
252+
}
253+
254+
@Override
255+
public void execute(String sql) {
256+
try (Statement statement = connection.createStatement()) {
257+
statement.execute(sql);
258+
} catch (SQLException error) {
259+
throw new RuntimeException(error);
260+
}
261+
}
262+
263+
@Override
264+
public void executeInTransaction(Runnable action) {
265+
try {
266+
boolean original = connection.getAutoCommit();
267+
connection.setAutoCommit(false);
268+
try {
269+
action.run();
270+
connection.commit();
271+
} catch (RuntimeException error) {
272+
connection.rollback();
273+
throw error;
274+
} finally {
275+
connection.setAutoCommit(original);
276+
}
277+
} catch (SQLException error) {
278+
throw new RuntimeException(error);
279+
}
280+
}
281+
282+
@Override
283+
public List<Map<String, Object>> getTableColumns(String tableName) {
284+
throw new UnsupportedOperationException();
285+
}
286+
287+
private void bind(PreparedStatement statement, Object[] args) throws SQLException {
288+
if (args == null) return;
289+
for (int index = 0; index < args.length; index++) {
290+
statement.setObject(index + 1, args[index]);
291+
}
292+
}
293+
};
294+
}
295+
}

‎pom.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
<module>teaql-dm8</module>
7575
<module>teaql-android</module>
7676
<module>teaql-business-id-jdbc</module>
77+
<module>examples/business-id-runtime</module>
7778
<module>teaql-tfp-endpoint</module>
7879
<module>teaql-opentelemetry</module>
7980
<module>examples/conformance/lib</module>

‎scripts/verify-examples.sh‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ set -euo pipefail
44
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
55
verification_dir="$(mktemp -d)"
66
trap 'rm -rf -- "$verification_dir"' EXIT
7-
expected=(conformance order-management school-management)
7+
expected=(business-id-runtime conformance order-management school-management)
88
mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
99
if [[ "${actual[*]}" != "${expected[*]}" ]]; then
1010
echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2
@@ -13,6 +13,8 @@ fi
1313

1414
cd "$repo"
1515
mvn -q -DskipTests install
16+
mvn -q -pl examples/business-id-runtime \
17+
-Dtest=BusinessIdRuntimeExampleTest test
1618
mvn -q -f examples/conformance/lib/pom.xml install -DskipTests
1719
mvn -q -f examples/conformance/pom.xml spring-boot:run \
1820
-Dspring-boot.run.arguments="--spring.main.web-application-type=none --spring.datasource.url=jdbc:sqlite:$verification_dir/conformance.db"

0 commit comments

Comments
 (0)