Skip to content

Commit 1649e79

Browse files
Implement UUIDv7 identity mapping and update sourceAccountId type to String
1 parent acbae6b commit 1649e79

4 files changed

Lines changed: 202 additions & 11 deletions

File tree

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,14 @@ public enum IdentityStrategy {
3636
REGENERATE_IDS,
3737

3838
/**
39-
* Assign UUIDv7 values as new IDs.
40-
* <p>
41-
* Planned for v3.
39+
* Assign UUIDv7 values (RFC 9562, time-ordered) as new IDs.
40+
*
41+
* <p>A fresh UUID7 is generated for every imported entity. Foreign-key references
42+
* are resolved via the internal ID mapping table ({@code originalId → uuid7}).
43+
*
44+
* <p>Only applicable when the entity {@code id} field is of type {@link java.util.UUID}
45+
* or {@link String}. Entities with {@code Long} IDs will receive a type-mismatch
46+
* error at persist time; use {@code REGENERATE_IDS} for those.
4247
*/
4348
UUID7
4449
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
3+
* Colombia / South America
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*/
11+
package tools.dynamia.modules.saas.migration.identity;
12+
13+
import tools.dynamia.modules.saas.migration.api.IdentityMapper;
14+
import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
15+
16+
import java.util.Map;
17+
import java.util.UUID;
18+
import java.util.concurrent.ThreadLocalRandom;
19+
20+
/**
21+
* Identity mapper that assigns UUIDv7 values as primary keys for all imported entities.
22+
*
23+
* <p>UUIDv7 is a time-ordered UUID (RFC 9562). Each call to {@link #mapId} generates
24+
* a new UUID7 from the current millisecond timestamp plus random bits. Suitable for
25+
* entities whose {@code id} field is of type {@link UUID} or {@link String}.
26+
*
27+
* <p>Foreign-key references are resolved via the running {@code idMappings} table
28+
* ({@code originalId → uuid7}), identical to the {@code REGENERATE_IDS} strategy.
29+
*
30+
* @author Mario Serrano Leones
31+
*/
32+
public class Uuid7IdentityMapper implements IdentityMapper {
33+
34+
@Override
35+
public Object mapId(Object originalId, Class<?> entityClass) {
36+
return generateUuid7();
37+
}
38+
39+
@Override
40+
public Object resolveReferenceId(Object originalRefId, Class<?> refClass,
41+
Map<String, Map<Object, Object>> idMappings) {
42+
if (originalRefId == null) {
43+
return null;
44+
}
45+
Map<Object, Object> classMap = idMappings.get(refClass.getName());
46+
if (classMap != null) {
47+
Object mapped = classMap.get(originalRefId);
48+
if (mapped != null) {
49+
return mapped;
50+
}
51+
}
52+
// Fallback: entity not in the export set (e.g. shared system-level entity)
53+
return originalRefId;
54+
}
55+
56+
@Override
57+
public IdentityStrategy getStrategy() {
58+
return IdentityStrategy.UUID7;
59+
}
60+
61+
/**
62+
* Generates a UUIDv7 (time-ordered) value per RFC 9562.
63+
*
64+
* <p>Layout (128 bits):
65+
* <ul>
66+
* <li>Bits 0–47 : Unix timestamp in milliseconds</li>
67+
* <li>Bits 48–51 : Version = 7</li>
68+
* <li>Bits 52–63 : rand_a (12 random bits)</li>
69+
* <li>Bits 64–65 : Variant = 0b10</li>
70+
* <li>Bits 66–127: rand_b (62 random bits)</li>
71+
* </ul>
72+
*/
73+
static UUID generateUuid7() {
74+
long now = System.currentTimeMillis();
75+
ThreadLocalRandom rng = ThreadLocalRandom.current();
76+
// MSB: 48-bit timestamp | 4-bit version (7) | 12-bit random (rand_a)
77+
long msb = (now << 16) | (7L << 12) | (rng.nextLong() & 0x0FFFL);
78+
// LSB: variant 0b10 in high 2 bits | 62-bit random (rand_b)
79+
long lsb = 0x8000000000000000L | (rng.nextLong() & 0x3FFFFFFFFFFFFFFFL);
80+
return new UUID(msb, lsb);
81+
}
82+
}

extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties;
3636
import tools.dynamia.modules.saas.migration.identity.KeepIdsIdentityMapper;
3737
import tools.dynamia.modules.saas.migration.identity.RegenerateIdsIdentityMapper;
38+
import tools.dynamia.modules.saas.migration.identity.Uuid7IdentityMapper;
3839
import tools.jackson.core.JsonParser;
3940
import tools.jackson.core.JsonToken;
4041
import tools.jackson.databind.JsonNode;
@@ -45,6 +46,7 @@
4546
import java.io.IOException;
4647
import java.io.InputStream;
4748
import java.io.Serializable;
49+
import java.util.UUID;
4850
import java.lang.reflect.Field;
4951
import java.util.ArrayList;
5052
import java.util.HashMap;
@@ -177,14 +179,14 @@ private void logManifestInfo(ZipInputStream zipIn) {
177179
JsonParser parser = objectMapper.createParser(new NoCloseInputStream(zipIn));
178180
// Read only version and sourceAccountId for logging; skip everything else
179181
String version = null;
180-
Long sourceAccountId = null;
182+
String sourceAccountId = null;
181183
if (parser.nextToken() == JsonToken.START_OBJECT) {
182184
while (parser.nextToken() != JsonToken.END_OBJECT) {
183185
String field = parser.currentName();
184186
parser.nextToken();
185187
switch (field) {
186188
case ExportConstants.FIELD_VERSION -> version = parser.getText();
187-
case ExportConstants.FIELD_SOURCE_ACCOUNT_ID -> sourceAccountId = parser.getLongValue();
189+
case ExportConstants.FIELD_SOURCE_ACCOUNT_ID -> sourceAccountId = parser.getValueAsString();
188190
default -> parser.skipChildren();
189191
}
190192
if (version != null && sourceAccountId != null) break;
@@ -469,7 +471,7 @@ private void setField(Object entity, String fieldName, Object value) {
469471
cached.ifPresent(field -> {
470472
try {
471473
field.set(entity, value);
472-
} catch (IllegalAccessException e) {
474+
} catch (IllegalAccessException | IllegalArgumentException e) {
473475
log.debug("[Migration/Import] Cannot set field {}: {}", fieldName, e.getMessage());
474476
}
475477
});
@@ -488,17 +490,16 @@ private static Object coerceId(Object id, Class<?> refClass) {
488490
return Long.parseLong(s);
489491
} catch (NumberFormatException ignored) {
490492
}
493+
try {
494+
return UUID.fromString(s);
495+
} catch (IllegalArgumentException ignored) {
496+
}
491497
}
492498
return id;
493499
}
494500

495501
private IdentityMapper resolveIdentityMapper(AccountImportOptions options) {
496502
IdentityStrategy strategy = options.getIdentityStrategy();
497-
if (strategy == IdentityStrategy.UUID7) {
498-
throw new MigrationException(
499-
"IdentityStrategy.UUID7 is not yet supported (planned for v3). " +
500-
"Use KEEP_IDS or REGENERATE_IDS.");
501-
}
502503
if (customMappers != null) {
503504
for (IdentityMapper mapper : customMappers) {
504505
if (mapper.getStrategy() == strategy) {
@@ -508,6 +509,7 @@ private IdentityMapper resolveIdentityMapper(AccountImportOptions options) {
508509
}
509510
return switch (strategy) {
510511
case KEEP_IDS -> new KeepIdsIdentityMapper();
512+
case UUID7 -> new Uuid7IdentityMapper();
511513
default -> new RegenerateIdsIdentityMapper();
512514
};
513515
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
3+
* Colombia / South America
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*/
11+
package tools.dynamia.modules.saas.migration.identity;
12+
13+
import org.junit.Assert;
14+
import org.junit.Before;
15+
import org.junit.Test;
16+
import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
17+
18+
import java.util.HashMap;
19+
import java.util.Map;
20+
import java.util.UUID;
21+
22+
public class Uuid7IdentityMapperTest {
23+
24+
private Uuid7IdentityMapper mapper;
25+
26+
@Before
27+
public void setUp() {
28+
mapper = new Uuid7IdentityMapper();
29+
}
30+
31+
@Test
32+
public void strategyIsUuid7() {
33+
Assert.assertEquals(IdentityStrategy.UUID7, mapper.getStrategy());
34+
}
35+
36+
@Test
37+
public void mapIdReturnsUuid() {
38+
Object id = mapper.mapId(1L, Object.class);
39+
Assert.assertNotNull(id);
40+
Assert.assertTrue(id instanceof UUID);
41+
}
42+
43+
@Test
44+
public void mapIdReturnsDistinctValuesEachCall() {
45+
UUID a = (UUID) mapper.mapId(1L, Object.class);
46+
UUID b = (UUID) mapper.mapId(1L, Object.class);
47+
Assert.assertNotEquals(a, b);
48+
}
49+
50+
@Test
51+
public void mapIdIgnoresOriginalId() {
52+
// UUID7 strategy always generates a new ID regardless of the original
53+
Assert.assertNotEquals(mapper.mapId(42L, Object.class), 42L);
54+
Assert.assertNotNull(mapper.mapId(null, Object.class));
55+
}
56+
57+
@Test
58+
public void resolveReferenceIdLookupsFromIdMappings() {
59+
UUID newId = UUID.randomUUID();
60+
Map<String, Map<Object, Object>> idMappings = new HashMap<>();
61+
idMappings.put(String.class.getName(), Map.of(10L, newId));
62+
63+
Object resolved = mapper.resolveReferenceId(10L, String.class, idMappings);
64+
Assert.assertEquals(newId, resolved);
65+
}
66+
67+
@Test
68+
public void resolveReferenceIdFallsBackToOriginalWhenNotMapped() {
69+
Map<String, Map<Object, Object>> idMappings = new HashMap<>();
70+
Object resolved = mapper.resolveReferenceId(77L, String.class, idMappings);
71+
Assert.assertEquals(77L, resolved);
72+
}
73+
74+
@Test
75+
public void resolveReferenceIdWithNullReturnsNull() {
76+
Assert.assertNull(mapper.resolveReferenceId(null, String.class, new HashMap<>()));
77+
}
78+
79+
// ── UUIDv7 structure tests ─────────────────────────────────────────────────
80+
81+
@Test
82+
public void generatedUuidHasVersion7() {
83+
UUID uuid = Uuid7IdentityMapper.generateUuid7();
84+
Assert.assertEquals(7, uuid.version());
85+
}
86+
87+
@Test
88+
public void generatedUuidHasVariant2() {
89+
UUID uuid = Uuid7IdentityMapper.generateUuid7();
90+
Assert.assertEquals(2, uuid.variant());
91+
}
92+
93+
@Test
94+
public void generatedUuidsAreTimeOrdered() throws InterruptedException {
95+
UUID a = Uuid7IdentityMapper.generateUuid7();
96+
Thread.sleep(2);
97+
UUID b = Uuid7IdentityMapper.generateUuid7();
98+
// Higher timestamp → higher MSB → natural UUID ordering matches time order
99+
Assert.assertTrue(a.getMostSignificantBits() < b.getMostSignificantBits()
100+
|| a.compareTo(b) < 0);
101+
}
102+
}

0 commit comments

Comments
 (0)