Migrating user profile data with nested objects and optional fields.
A web application stores user profiles that evolve over time:
| Version | Changes |
|---|---|
| v1 | Basic profile: username, email |
| v2 | Added address as nested object |
| v3 | Split name into firstName/lastName, added preferences |
{
"username": "john_doe",
"email": "john@example.com",
"fullName": "John Doe"
}{
"username": "john_doe",
"email": "john@example.com",
"fullName": "John Doe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"country": "USA"
}
}{
"username": "john_doe",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"country": "USA"
},
"preferences": {
"theme": "light",
"notifications": true
}
}public final class TypeReferences {
public static final TypeReference USER = TypeReference.of("user");
public static final TypeReference ADDRESS = TypeReference.of("address");
public static final TypeReference PREFERENCES = TypeReference.of("preferences");
private TypeReferences() {}
}public class SchemaV1 extends Schema {
public SchemaV1(Schema parent) {
super(new DataVersion(1), parent);
}
@Override
protected void registerTypes() {
registerType(TypeReferences.USER, DSL.and(
DSL.field("username", DSL.string()),
DSL.field("email", DSL.string()),
DSL.field("fullName", DSL.string()),
DSL.remainder()
));
}
}public class SchemaV2 extends Schema {
public SchemaV2(Schema parent) {
super(new DataVersion(2), parent);
}
@Override
protected void registerTypes() {
registerType(TypeReferences.USER, DSL.and(
DSL.field("username", DSL.string()),
DSL.field("email", DSL.string()),
DSL.field("fullName", DSL.string()),
DSL.optional("address", DSL.and(
DSL.field("street", DSL.string()),
DSL.field("city", DSL.string()),
DSL.field("country", DSL.string()),
DSL.remainder()
)),
DSL.remainder()
));
}
}public class SchemaV3 extends Schema {
public SchemaV3(Schema parent) {
super(new DataVersion(3), parent);
}
@Override
protected void registerTypes() {
registerType(TypeReferences.USER, DSL.and(
DSL.field("username", DSL.string()),
DSL.field("email", DSL.string()),
DSL.field("firstName", DSL.string()),
DSL.field("lastName", DSL.string()),
DSL.optional("address", DSL.and(
DSL.field("street", DSL.string()),
DSL.field("city", DSL.string()),
DSL.field("country", DSL.string()),
DSL.remainder()
)),
DSL.field("preferences", DSL.and(
DSL.field("theme", DSL.string()),
DSL.field("notifications", DSL.bool()),
DSL.remainder()
)),
DSL.remainder()
));
}
}public class UserV1ToV2Fix extends SchemaDataFix {
public UserV1ToV2Fix(SchemaRegistry schemas) {
super(schemas, new DataVersion(1), new DataVersion(2), "user-v1-to-v2");
}
@Override
protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) {
// Add optional address with default empty object
return Rules.transform(TypeReferences.USER, user -> {
if (user.get("address").result().isEmpty()) {
// Add empty address object
Dynamic<?> emptyAddress = user.emptyMap()
.set("street", user.createString(""))
.set("city", user.createString(""))
.set("country", user.createString(""));
return user.set("address", emptyAddress);
}
return user;
});
}
}public class UserV2ToV3Fix extends SchemaDataFix {
public UserV2ToV3Fix(SchemaRegistry schemas) {
super(schemas, new DataVersion(2), new DataVersion(3), "user-v2-to-v3");
}
@Override
protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) {
return Rules.seq(
// Split fullName into firstName and lastName
Rules.transform(TypeReferences.USER, this::splitName),
// Add default preferences
Rules.transform(TypeReferences.USER, this::addDefaultPreferences)
);
}
private Dynamic<?> splitName(Dynamic<?> user) {
String fullName = user.get("fullName").asString().orElse("");
String[] parts = fullName.split(" ", 2);
String firstName = parts.length > 0 ? parts[0] : "";
String lastName = parts.length > 1 ? parts[1] : "";
return user
.set("firstName", user.createString(firstName))
.set("lastName", user.createString(lastName))
.remove("fullName");
}
private Dynamic<?> addDefaultPreferences(Dynamic<?> user) {
if (user.get("preferences").result().isEmpty()) {
Dynamic<?> preferences = user.emptyMap()
.set("theme", user.createString("light"))
.set("notifications", user.createBoolean(true));
return user.set("preferences", preferences);
}
return user;
}
}public class UserDataBootstrap implements DataFixerBootstrap {
public static final DataVersion CURRENT_VERSION = new DataVersion(3);
private SchemaRegistry schemas;
@Override
public void registerSchemas(SchemaRegistry schemas) {
this.schemas = schemas;
schemas.register(new DataVersion(1), SchemaV1::new);
schemas.register(new DataVersion(2), SchemaV2::new);
schemas.register(new DataVersion(3), SchemaV3::new);
}
@Override
public void registerFixes(FixRegistrar fixes) {
fixes.register(TypeReferences.USER, new UserV1ToV2Fix(schemas));
fixes.register(TypeReferences.USER, new UserV2ToV3Fix(schemas));
}
}public class UserProfileMigrator {
private final AetherDataFixer fixer;
private final Gson gson;
public UserProfileMigrator() {
this.fixer = new DataFixerRuntimeFactory()
.create(UserDataBootstrap.CURRENT_VERSION, new UserDataBootstrap());
this.gson = new GsonBuilder().setPrettyPrinting().create();
}
public JsonObject migrateProfile(JsonObject profile, int fromVersion) {
Dynamic<JsonElement> dynamic = new Dynamic<>(GsonOps.INSTANCE, profile);
TaggedDynamic tagged = new TaggedDynamic(TypeReferences.USER, dynamic);
TaggedDynamic migrated = fixer.update(
tagged,
new DataVersion(fromVersion),
UserDataBootstrap.CURRENT_VERSION
);
return (JsonObject) migrated.value().value();
}
public static void main(String[] args) {
UserProfileMigrator migrator = new UserProfileMigrator();
// Old v1 profile
JsonObject v1Profile = new JsonObject();
v1Profile.addProperty("username", "john_doe");
v1Profile.addProperty("email", "john@example.com");
v1Profile.addProperty("fullName", "John Doe");
System.out.println("V1 Profile:");
System.out.println(migrator.gson.toJson(v1Profile));
// Migrate to v3
JsonObject v3Profile = migrator.migrateProfile(v1Profile, 1);
System.out.println("\nMigrated to V3:");
System.out.println(migrator.gson.toJson(v3Profile));
}
}V1 Profile:
{
"username": "john_doe",
"email": "john@example.com",
"fullName": "John Doe"
}
Migrated to V3:
{
"username": "john_doe",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"address": {
"street": "",
"city": "",
"country": ""
},
"preferences": {
"theme": "light",
"notifications": true
}
}
- Optional nested objects - Address may not exist
- Field splitting - fullName → firstName + lastName
- Default values - Preferences added with defaults
- Chained migrations - v1 → v2 → v3 automatic chain