Skip to content

feat(schema): add migration system for schema evolution (#519)#1209

Closed
pegasus1134 wants to merge 1 commit intozio:mainfrom
pegasus1134:feat/schema-migration-system-519
Closed

feat(schema): add migration system for schema evolution (#519)#1209
pegasus1134 wants to merge 1 commit intozio:mainfrom
pegasus1134:feat/schema-migration-system-519

Conversation

@pegasus1134
Copy link

/claim #519

What this does

Implements the schema migration system specified in #519. Two-layer design: a serializable DynamicMigration core that operates on DynamicValue, and a typed Migration[A, B] wrapper with macro-validated selectors for the user-facing API.

Architecture

  • DynamicMigration holds a Vector[MigrationAction] and applies them sequentially to DynamicValue trees
  • Migration[A, B] wraps source/target schemas and delegates to DynamicMigration under the hood
  • MigrationBuilder[A, B] provides the fluent API with selector macros that extract DynamicOptic paths at compile time
  • DynamicSchemaExpr is the expression ADT for value transforms (no closures, fully serializable)
  • MigrationValidation checks at build time that all source fields are accounted for in the target schema

MigrationAction ADT (16 cases)

Record: AddField, DropField, Rename, TransformValue, Mandate, Optionalize, Join, Split, ChangeType
Enum: RenameCase, TransformCase
Collection: TransformElements, TransformKeys, TransformValues
Meta: ApplyMigration, Irreversible

Every action carries a DynamicOptic path and implements reverse.

Selector macros

Scala 3 uses inline + scala.quoted to parse selector lambdas into DynamicOptic paths. Scala 2 uses whitebox.Context macros with quasiquotes. Both support field access, .when[T] case selection, .each collection traversal, .atKey(k) map access, and .wrapped[T].

Literal API

literal[A](value)(implicit Schema[A]) converts typed values to DynamicSchemaExpr eagerly. No raw DynamicValue.Primitive(...) construction in user code.

What changed

20 new files, 3,722 lines. No existing files modified.

Shared (6 files): DynamicMigration, DynamicSchemaExpr, Migration, MigrationAction, MigrationValidation, package object
Scala 3 (4 files): MigrationBuilder, SelectorMacros, MigrationSelectorSyntax, MigrationCompanionVersionSpecific
Scala 2 (4 files): Same as Scala 3 with equivalent macro implementations
Tests (6 files): DynamicMigrationSpec, DynamicSchemaExprSpec, MigrationActionSpec, MigrationSpec, MigrationValidationSpec, MigrationIntegrationSpec

Tests

191 migration tests across 6 test suites. All pass on both Scala 2.13.18 and 3.7.4. Zero regressions across the full test suite (15,909 total tests).

Screenshot 2026-03-12 at 12 36 54 PM Screenshot 2026-03-12 at 12 37 07 PM Screenshot 2026-03-12 at 12 37 20 PM Screenshot 2026-03-12 at 12 37 39 PM

Success criteria from #519

  • DynamicMigration fully serializable (no closures, no functions)
  • Migration[A, B] wraps schemas and actions
  • All actions path-based via DynamicOptic
  • User API uses selector functions (S => A) via macros
  • Macro validation in .build
  • .buildPartial supported
  • Structural reverse implemented
  • Identity and associativity laws hold (tested)
  • Enum rename/transform supported
  • Errors include path information
  • Comprehensive tests (191 tests, 6 suites)
  • Scala 2.13 and Scala 3.5+ supported

@CLAassistant
Copy link

CLAassistant commented Mar 12, 2026

CLA assistant check
All committers have signed the CLA.

def size: Int = actions.size
}

object DynamicMigration {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imperative while loop here instead of foldLeft to avoid allocating intermediate Either values on every iteration. Since migrations can have many actions, this keeps the hot path allocation-free when all actions succeed.

def execute(action: MigrationAction, value: DynamicValue): Either[SchemaError, DynamicValue] =
action match {
case a: AddField => executeAddField(a, value)
case a: DropField => executeDropField(a, value)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxPathDepth of 64 guards against circular or deeply nested optic paths. Realistically no schema has 64 levels of nesting, but this prevents stack issues if someone accidentally constructs a cycle.

action: ApplyMigration,
value: DynamicValue
): Either[SchemaError, DynamicValue] =
modifyAt(action.at, value, action.at) { nestedValue =>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransformElements, TransformKeys, and TransformValues all evaluate the expression per-element inside the foldLeft. This is intentional: the expression may reference the element itself (e.g. a Select on the element's fields), so it must be re-evaluated for each element rather than evaluated once against the parent value.

val parentPath = DynamicOptic(at.nodes.dropRight(1))
Rename(parentPath.field(to), from)
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename.reverse rebuilds the path by replacing the last Field node. This means renaming "name" to "fullName" and then reversing gives a Rename from "fullName" back to "name" at the correct parent path.


final case class TransformValue(at: DynamicOptic, transform: DynamicSchemaExpr) extends MigrationAction {
override def reverse: MigrationAction = Irreversible(at, "TransformValue")
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransformValue, Join, Split, ChangeType, and the collection transforms all reverse to Irreversible. These are lossy operations where the original value cannot be recovered from the output alone. The issue spec calls this "best-effort" semantic inverse.

*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

literalA(implicit Schema[A]) converts typed Scala values to SchemaExpr eagerly, so users write literal[Int](0) instead of manually constructing DynamicValue.Primitive(PrimitiveValue.Int(0)). This matches the API shape requested in the issue review.

* limitations under the License.
*/

package zio.blocks.schema.migration
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime validation instead of phantom type-level tracking. Uses schema.reflect.asRecord to extract field names from the actual Schema instances and checks that the migration actions fully bridge source to target. This keeps the builder API simple (no 4+ type parameter phantom types) while still catching missing/invalid field operations at .build time.

case class FieldNode(name: String) extends PathNode
case object ElementsNode extends PathNode
case object MapKeysNode extends PathNode
case object MapValuesNode extends PathNode
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The macro walks the Term AST recursively, building up path nodes from the leaf back to the root. Pattern matches cover field access, .each, .when[T], .wrapped[T], .at(i), .atKey(k), .eachKey, .eachValue, and selectDynamic for structural types. Unsupported expressions fail at compile time with a clear error.

sourceSchema,
targetSchema,
actions :+ MigrationAction.ChangeType(fromPath, converter.toDynamic)
)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changeFieldType and transformField both take a target selector. The target selector exists to verify the field is present on the target type at compile time (the macro parses it). Only the source path is stored in the action since both refer to the same field position. The @unused annotation suppresses the warning.

* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DynamicSchemaExpr is the serializable expression ADT. No closures, no functions, just case classes. This is what makes the migration system fully introspectable and suitable for DDL generation, registry storage, etc. Operations are limited to primitives (arithmetic, relational, logical, string concat/length, type conversions).

Path-based migration system for evolving schemas across versions.
Cross-compiled for Scala 2.13 and 3.5+.

- DynamicMigration: serializable migration operating on DynamicValue
- Migration[A, B]: typed wrapper with schema-driven encode/decode
- MigrationAction: 16-variant sealed trait with structural reverse
- DynamicSchemaExpr: expression ADT for transforms and conversions
- MigrationBuilder: macro-validated selectors for both Scala versions
- MigrationValidation: build-time field coverage checking
- MigrationSelectorSyntax: .each, .when[T], .atKey, .eachKey, .eachValue
- 191 tests across 6 spec files, 0 regressions
@pegasus1134 pegasus1134 force-pushed the feat/schema-migration-system-519 branch from 604344c to e68ccff Compare March 12, 2026 09:55
@jdegoes jdegoes closed this Mar 13, 2026
@pegasus1134
Copy link
Author

@jdegoes Why?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants