|
| 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 | +} |
0 commit comments