Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8aed127
feat(schema): implement Schema Migration System (#519)
yuvrajangadsingh Feb 4, 2026
0c0a2c0
style: apply scalafmt formatting
yuvrajangadsingh Feb 4, 2026
63f7d9e
feat: add compile-time validation for schema migrations
yuvrajangadsingh Feb 4, 2026
bf5cd81
style: format Scala 2 migration files
yuvrajangadsingh Feb 4, 2026
39a7536
fix: use format:off for Scala 2 macros and remove unused test imports
yuvrajangadsingh Feb 4, 2026
31edfb9
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 4, 2026
b18bbe9
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 4, 2026
5a8a47f
test: add comprehensive migration tests to meet coverage requirements
yuvrajangadsingh Feb 4, 2026
60810fa
feat: add MigrationExpr and expression-based migration actions
yuvrajangadsingh Feb 4, 2026
a6a7d23
test: add migration law tests for associativity and double-reverse
yuvrajangadsingh Feb 4, 2026
939213d
style: fix scalafmt formatting in MigrationSpec
yuvrajangadsingh Feb 4, 2026
2d1d6a0
test: add DSL operator tests for MigrationExpr to improve coverage
yuvrajangadsingh Feb 4, 2026
9825ccc
test: add more DSL and conversion tests to improve coverage
yuvrajangadsingh Feb 4, 2026
5b12187
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 4, 2026
56c39bd
test: add comprehensive migration coverage tests
yuvrajangadsingh Feb 4, 2026
49fd8e2
test: add extensive conversion tests for improved coverage
yuvrajangadsingh Feb 4, 2026
b2c854c
test: add final coverage tests for ToBigInt/ToBigDecimal and edge cases
yuvrajangadsingh Feb 5, 2026
39a70f7
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 5, 2026
1cf8290
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 6, 2026
8b5a152
test: add more arithmetic coverage tests for Float and Long operations
yuvrajangadsingh Feb 6, 2026
b70f016
test: add error path coverage for MigrationExpr branches
yuvrajangadsingh Feb 6, 2026
bf542f9
style: fix scalafmt formatting
yuvrajangadsingh Feb 6, 2026
01c910f
ci: retrigger after transient 502 failure
yuvrajangadsingh Feb 6, 2026
2720e69
test: add executor error paths and reverse branch coverage
yuvrajangadsingh Feb 6, 2026
f56f333
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 6, 2026
f62d4c0
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 7, 2026
c13f441
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 8, 2026
31d4de4
Merge branch 'main' into feat/schema-migration-519-v2
yuvrajangadsingh Feb 9, 2026
83507cb
refactor: split monolithic MigrationSpec into focused spec files + ad…
yuvrajangadsingh Feb 9, 2026
006c23d
style: fix scalafmt formatting in DynamicMigrationBenchmark
yuvrajangadsingh Feb 9, 2026
0af2d52
feat: add Nest/Unnest actions + 50 new tests (335 total)
yuvrajangadsingh Feb 9, 2026
466dd2f
Merge remote-tracking branch 'upstream/main' into feat/schema-migrati…
yuvrajangadsingh Feb 9, 2026
cfd6847
Merge remote-tracking branch 'upstream/main' into feat/schema-migrati…
yuvrajangadsingh Feb 9, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package zio.blocks.schema

import org.openjdk.jmh.annotations._
import zio.blocks.BaseBenchmark
import zio.blocks.chunk.Chunk
import zio.blocks.schema.migration._

import scala.compiletime.uninitialized

class DynamicMigrationBenchmark extends BaseBenchmark {

var simpleRecord: DynamicValue = uninitialized
var nestedRecord: DynamicValue = uninitialized
var sequenceValue: DynamicValue = uninitialized
var addFieldMigration: DynamicMigration = uninitialized
var renameMigration: DynamicMigration = uninitialized
var composedMigration: DynamicMigration = uninitialized
var nestedMigration: DynamicMigration = uninitialized
var sequenceMigration: DynamicMigration = uninitialized
var nestMigration: DynamicMigration = uninitialized
var unnestMigration: DynamicMigration = uninitialized
var nestableRecord: DynamicValue = uninitialized
var nestedForUnnest: DynamicValue = uninitialized

@Setup
def setup(): Unit = {
simpleRecord = DynamicValue.Record(
Chunk(
"name" -> DynamicValue.Primitive(PrimitiveValue.String("John")),
"email" -> DynamicValue.Primitive(PrimitiveValue.String("john@example.com"))
)
)

nestedRecord = DynamicValue.Record(
Chunk(
"user" -> DynamicValue.Record(
Chunk(
"name" -> DynamicValue.Primitive(PrimitiveValue.String("John")),
"age" -> DynamicValue.Primitive(PrimitiveValue.Int(30))
)
),
"active" -> DynamicValue.Primitive(PrimitiveValue.Boolean(true))
)
)

sequenceValue = DynamicValue.Sequence(
Chunk.fromIterable((1 to 100).map { i =>
DynamicValue.Record(
Chunk(
"id" -> DynamicValue.Primitive(PrimitiveValue.Int(i)),
"name" -> DynamicValue.Primitive(PrimitiveValue.String(s"item$i"))
)
)
})
)

addFieldMigration = DynamicMigration.single(
MigrationAction.AddField(
DynamicOptic.root.field("age"),
DynamicValue.Primitive(PrimitiveValue.Int(0))
)
)

renameMigration = DynamicMigration.single(
MigrationAction.Rename(DynamicOptic.root.field("name"), "fullName")
)

composedMigration = addFieldMigration ++ renameMigration

nestedMigration = DynamicMigration.single(
MigrationAction.AddField(
DynamicOptic.root.field("user").field("role"),
DynamicValue.Primitive(PrimitiveValue.String("user"))
)
)

sequenceMigration = DynamicMigration.single(
MigrationAction.TransformElements(
DynamicOptic.root,
Vector(
MigrationAction.AddField(
DynamicOptic.root.field("active"),
DynamicValue.Primitive(PrimitiveValue.Boolean(true))
)
)
)
)

nestableRecord = DynamicValue.Record(
Chunk(
"name" -> DynamicValue.Primitive(PrimitiveValue.String("John")),
"street" -> DynamicValue.Primitive(PrimitiveValue.String("123 Main St")),
"city" -> DynamicValue.Primitive(PrimitiveValue.String("Springfield")),
"zip" -> DynamicValue.Primitive(PrimitiveValue.String("62701")),
"country" -> DynamicValue.Primitive(PrimitiveValue.String("US"))
)
)

nestedForUnnest = DynamicValue.Record(
Chunk(
"name" -> DynamicValue.Primitive(PrimitiveValue.String("John")),
"address" -> DynamicValue.Record(
Chunk(
"street" -> DynamicValue.Primitive(PrimitiveValue.String("123 Main St")),
"city" -> DynamicValue.Primitive(PrimitiveValue.String("Springfield")),
"zip" -> DynamicValue.Primitive(PrimitiveValue.String("62701")),
"country" -> DynamicValue.Primitive(PrimitiveValue.String("US"))
)
)
)
)

nestMigration = DynamicMigration.single(
MigrationAction.Nest(DynamicOptic.root, "address", Vector("street", "city", "zip", "country"))
)

unnestMigration = DynamicMigration.single(
MigrationAction.Unnest(DynamicOptic.root, "address", Vector("street", "city", "zip", "country"))
)
}

@Benchmark
def addField(): Either[SchemaError, DynamicValue] =
addFieldMigration(simpleRecord)

@Benchmark
def renameField(): Either[SchemaError, DynamicValue] =
renameMigration(simpleRecord)

@Benchmark
def composedMigrationApply(): Either[SchemaError, DynamicValue] =
composedMigration(simpleRecord)

@Benchmark
def nestedFieldMigration(): Either[SchemaError, DynamicValue] =
nestedMigration(nestedRecord)

@Benchmark
def sequenceTransform(): Either[SchemaError, DynamicValue] =
sequenceMigration(sequenceValue)

@Benchmark
def reverseMigration(): DynamicMigration =
composedMigration.reverse

@Benchmark
def nestFields(): Either[SchemaError, DynamicValue] =
nestMigration(nestableRecord)

@Benchmark
def unnestFields(): Either[SchemaError, DynamicValue] =
unnestMigration(nestedForUnnest)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package zio.blocks.schema

import zio.test._

object DynamicMigrationBenchmarkSpec extends SchemaBaseSpec {

def spec: Spec[TestEnvironment, Any] = suite("DynamicMigrationBenchmarkSpec")(
test("has consistent output") {
val benchmark = new DynamicMigrationBenchmark()
benchmark.setup()

val addResult = benchmark.addField()
val renameResult = benchmark.renameField()
val composeResult = benchmark.composedMigrationApply()
val nestedResult = benchmark.nestedFieldMigration()
val seqResult = benchmark.sequenceTransform()
val reversed = benchmark.reverseMigration()
val nestResult = benchmark.nestFields()
val unnestResult = benchmark.unnestFields()

assertTrue(
addResult.isRight,
renameResult.isRight,
composeResult.isRight,
nestedResult.isRight,
seqResult.isRight,
reversed.actions.size == 2,
nestResult.isRight,
unnestResult.isRight
)
}
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package zio.blocks.schema.migration

import scala.language.experimental.macros
import zio.blocks.schema.{DynamicOptic, DynamicValue, Schema}

/**
* Scala 2 version of MigrationBuilder.
*/
// format: off
final class MigrationBuilder[A, B](
val sourceSchema: Schema[A],
val targetSchema: Schema[B],
private[migration] val actions: Vector[MigrationAction]
) {

/** Add a new field to the target with a default value. */
def addField[T](target: B => T, default: T)(implicit targetFieldSchema: Schema[T]): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.addFieldImpl[A, B, T]

/** Add a new field to the target with a DynamicValue default. */
def addFieldDynamic(target: B => Any, default: DynamicValue): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.addFieldDynamicImpl[A, B]

/** Drop a field from the source. */
def dropField[T](source: A => T, defaultForReverse: T)(implicit sourceFieldSchema: Schema[T]): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.dropFieldImpl[A, B, T]

/** Drop a field from the source with a DynamicValue for reverse. */
def dropFieldDynamic(source: A => Any, defaultForReverse: DynamicValue): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.dropFieldDynamicImpl[A, B]

/** Rename a field from source name to target name. */
def renameField(from: A => Any, to: B => Any): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.renameFieldImpl[A, B]

/** Transform a field with a literal new value. */
def transformFieldLiteral[T](at: A => T, newValue: T)(implicit fieldSchema: Schema[T]): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.transformFieldLiteralImpl[A, B, T]

/** Convert an optional field to required. */
def mandateField[T](source: A => Option[T], target: B => T, default: T)(implicit fieldSchema: Schema[T]): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.mandateFieldImpl[A, B, T]

/** Convert a required field to optional. */
def optionalizeField[T](source: A => T, target: B => Option[T]): MigrationBuilder[A, B] =
macro MigrationBuilderMacros.optionalizeFieldImpl[A, B, T]

/**
* Transform a field's value using a serializable expression.
*
* @param at The path to the field to transform
* @param expr The expression that computes the new value
* @param reverseExpr Optional expression for reverse migration
*/
def transformFieldExpr(at: DynamicOptic, expr: MigrationExpr, reverseExpr: Option[MigrationExpr] = None): MigrationBuilder[A, B] =
new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.TransformValueExpr(at, expr, reverseExpr))

/**
* Change the type of a field using a serializable expression.
*
* @param at The path to the field to convert
* @param convertExpr Expression that converts the value
* @param reverseExpr Optional expression for reverse migration
*/
def changeFieldTypeExpr(at: DynamicOptic, convertExpr: MigrationExpr, reverseExpr: Option[MigrationExpr] = None): MigrationBuilder[A, B] =
new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.ChangeTypeExpr(at, convertExpr, reverseExpr))

/**
* Join multiple source fields into a single target field using an expression.
*
* @param target The path to the target field
* @param sourcePaths Paths to the source fields to join
* @param combineExpr Expression that computes the combined value
* @param splitExprs Optional expressions for reverse migration
*/
def joinFields(target: DynamicOptic, sourcePaths: Vector[DynamicOptic], combineExpr: MigrationExpr, splitExprs: Option[Vector[MigrationExpr]] = None): MigrationBuilder[A, B] =
new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.JoinExpr(target, sourcePaths, combineExpr, splitExprs))

/**
* Split a source field into multiple target fields using expressions.
*
* @param source The path to the source field
* @param targetPaths Paths to the target fields
* @param splitExprs Expressions that compute each target value
* @param combineExpr Optional expression for reverse migration
*/
def splitField(source: DynamicOptic, targetPaths: Vector[DynamicOptic], splitExprs: Vector[MigrationExpr], combineExpr: Option[MigrationExpr] = None): MigrationBuilder[A, B] =
new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.SplitExpr(source, targetPaths, splitExprs, combineExpr))

/** Rename an enum case. */
def renameCase(from: String, to: String): MigrationBuilder[A, B] =
new MigrationBuilder(sourceSchema, targetSchema, actions :+ MigrationAction.RenameCase(DynamicOptic.root, from, to))

/** Build the migration with validation. */
def build: Migration[A, B] =
new Migration(sourceSchema, targetSchema, new DynamicMigration(actions))

/** Build the migration without validation. */
def buildPartial: Migration[A, B] =
new Migration(sourceSchema, targetSchema, new DynamicMigration(actions))
}
// format: on

object MigrationBuilder {

/** Create a new migration builder. */
def apply[A, B](implicit
sourceSchema: Schema[A],
targetSchema: Schema[B]
): MigrationBuilder[A, B] =
new MigrationBuilder(sourceSchema, targetSchema, Vector.empty)
}
Loading