Skip to content

Latest commit

 

History

History
823 lines (606 loc) · 22 KB

File metadata and controls

823 lines (606 loc) · 22 KB
id title
dynamic-value
DynamicValue

DynamicValue is a schema-less, dynamically-typed representation of any structured value in ZIO Blocks. It provides a universal data model that can represent any value without requiring compile-time type information, serving as an intermediate representation for serialization, schema evolution, data transformation, and cross-format conversion.

Overview

The DynamicValue type represents all structured values with six cases:

DynamicValue
 ├── DynamicValue.Primitive   (scalar values: strings, numbers, booleans, temporal types, etc.)
 ├── DynamicValue.Record      (named fields, analogous to case classes or JSON objects)
 ├── DynamicValue.Variant     (tagged unions, analogous to sealed traits)
 ├── DynamicValue.Sequence    (ordered collections: lists, arrays, vectors)
 ├── DynamicValue.Map         (key-value pairs where keys are also DynamicValues)
 └── DynamicValue.Null        (absence of a value)

Key design decisions:

  • Type-agnostic — Works without compile-time type information
  • Preserves structure — Maintains full fidelity of the original data
  • Supports rich primitives — All Java time types, BigDecimal, UUID, Currency, etc.
  • Path-based navigation — Uses DynamicOptic for traversal and modification
  • EJSON toString — Human-readable output format with type annotations

DynamicValue Variants

Primitive

Wraps scalar values in a PrimitiveValue:

import zio.blocks.schema.DynamicValue

// Using convenience constructors
val str = DynamicValue.string("hello")
val num = DynamicValue.int(42)
val flag = DynamicValue.boolean(true)
val pi = DynamicValue.double(3.14159)

// Using the Primitive case directly
import zio.blocks.schema.PrimitiveValue
val instant = DynamicValue.Primitive(
  PrimitiveValue.Instant(java.time.Instant.now())
)

Record

A collection of named fields, analogous to case classes or JSON objects:

import zio.blocks.schema.DynamicValue
import zio.blocks.chunk.Chunk

// Using varargs constructor
val person = DynamicValue.Record(
  "name" -> DynamicValue.string("Alice"),
  "age" -> DynamicValue.int(30),
  "active" -> DynamicValue.boolean(true)
)

// Using Chunk constructor
val point = DynamicValue.Record(Chunk(
  ("x", DynamicValue.int(10)),
  ("y", DynamicValue.int(20))
))

// Empty record
val empty = DynamicValue.Record.empty

Field order is preserved and significant for equality. Use sortFields to normalize for order-independent comparison.

Variant

A tagged union value, analogous to sealed traits:

import zio.blocks.schema.DynamicValue

// A Some variant containing a value
val some = DynamicValue.Variant(
  "Some",
  DynamicValue.string("hello")
)

// A None variant with an empty record
val none = DynamicValue.Variant("None", DynamicValue.Record.empty)

// Access case information
some.caseName   // Some("Some")
some.caseValue  // Some(DynamicValue.Primitive(...))

Sequence

An ordered collection of values:

import zio.blocks.schema.DynamicValue
import zio.blocks.chunk.Chunk

// Using varargs constructor
val numbers = DynamicValue.Sequence(
  DynamicValue.int(1),
  DynamicValue.int(2),
  DynamicValue.int(3)
)

// Using Chunk constructor
val items = DynamicValue.Sequence(Chunk(
  DynamicValue.string("a"),
  DynamicValue.string("b")
))

// Empty sequence
val empty = DynamicValue.Sequence.empty

Map

Key-value pairs where both keys and values are DynamicValue:

import zio.blocks.schema.DynamicValue
import zio.blocks.chunk.Chunk

// String keys (common case)
val config = DynamicValue.Map(
  DynamicValue.string("host") -> DynamicValue.string("localhost"),
  DynamicValue.string("port") -> DynamicValue.int(8080)
)

// Non-string keys (unlike Record)
val mapping = DynamicValue.Map(
  DynamicValue.int(1) -> DynamicValue.string("one"),
  DynamicValue.int(2) -> DynamicValue.string("two")
)

// Empty map
val empty = DynamicValue.Map.empty

Unlike Record which uses String keys, Map supports arbitrary DynamicValue keys.

Null

Represents the absence of a value:

import zio.blocks.schema.DynamicValue

val absent = DynamicValue.Null

PrimitiveValue Types

PrimitiveValue is a sealed trait representing all scalar values that can be wrapped in DynamicValue.Primitive. Each case preserves full type information:

Type Description Example
Unit Unit value PrimitiveValue.Unit
Boolean Boolean PrimitiveValue.Boolean(true)
Byte 8-bit integer PrimitiveValue.Byte(127)
Short 16-bit integer PrimitiveValue.Short(32767)
Int 32-bit integer PrimitiveValue.Int(42)
Long 64-bit integer PrimitiveValue.Long(9999999999L)
Float 32-bit float PrimitiveValue.Float(3.14f)
Double 64-bit float PrimitiveValue.Double(3.14159)
Char Unicode character PrimitiveValue.Char('A')
String Text PrimitiveValue.String("hello")
BigInt Arbitrary precision integer PrimitiveValue.BigInt(BigInt("999..."))
BigDecimal Arbitrary precision decimal PrimitiveValue.BigDecimal(BigDecimal("3.14159"))
Instant Timestamp PrimitiveValue.Instant(Instant.now())
LocalDate Date without time PrimitiveValue.LocalDate(LocalDate.now())
LocalDateTime Date and time PrimitiveValue.LocalDateTime(LocalDateTime.now())
LocalTime Time without date PrimitiveValue.LocalTime(LocalTime.now())
Duration Time duration PrimitiveValue.Duration(Duration.ofHours(1))
Period Date-based period PrimitiveValue.Period(Period.ofDays(30))
DayOfWeek Day of week PrimitiveValue.DayOfWeek(DayOfWeek.MONDAY)
Month Month PrimitiveValue.Month(Month.JANUARY)
Year Year PrimitiveValue.Year(Year.of(2024))
YearMonth Year and month PrimitiveValue.YearMonth(YearMonth.of(2024, 1))
MonthDay Month and day PrimitiveValue.MonthDay(MonthDay.of(1, 15))
ZoneId Time zone PrimitiveValue.ZoneId(ZoneId.of("UTC"))
ZoneOffset Time zone offset PrimitiveValue.ZoneOffset(ZoneOffset.UTC)
ZonedDateTime Date/time with zone PrimitiveValue.ZonedDateTime(ZonedDateTime.now())
OffsetDateTime Date/time with offset PrimitiveValue.OffsetDateTime(OffsetDateTime.now())
OffsetTime Time with offset PrimitiveValue.OffsetTime(OffsetTime.now())
UUID Universally unique ID PrimitiveValue.UUID(UUID.randomUUID())
Currency Currency PrimitiveValue.Currency(Currency.getInstance("USD"))

Creating DynamicValues from Typed Values

Use Schema.toDynamicValue to convert typed Scala values to DynamicValue:

import zio.blocks.schema.{Schema, DynamicValue}

case class Person(name: String, age: Int)
object Person {
  implicit val schema: Schema[Person] = Schema.derived
}

val person = Person("Alice", 30)
val dynamic: DynamicValue = Schema[Person].toDynamicValue(person)
// Record with "name" and "age" fields

// Works with any type that has a Schema
val listDynamic = Schema[List[Int]].toDynamicValue(List(1, 2, 3))
// Sequence of Primitive(Int) values

Converting DynamicValues Back to Typed Values

Use Schema.fromDynamicValue to convert DynamicValue back to typed Scala values:

import zio.blocks.schema.{Schema, DynamicValue, SchemaError}

case class Person(name: String, age: Int)
object Person {
  implicit val schema: Schema[Person] = Schema.derived
}

val dynamic = DynamicValue.Record(
  "name" -> DynamicValue.string("Bob"),
  "age" -> DynamicValue.int(25)
)

val result: Either[SchemaError, Person] = Schema[Person].fromDynamicValue(dynamic)
// Right(Person("Bob", 25))

// Type mismatch produces an error
val badDynamic = DynamicValue.string("not a person")
val error = Schema[Person].fromDynamicValue(badDynamic)
// Left(SchemaError(...))

Type Information

DynamicValueType

Each DynamicValue has a corresponding DynamicValueType for runtime type checking:

import zio.blocks.schema.{DynamicValue, DynamicValueType}

val dv = DynamicValue.Record("x" -> DynamicValue.int(1))

// Check type
dv.is(DynamicValueType.Record)    // true
dv.is(DynamicValueType.Sequence)  // false

// Narrow to specific type
val record: Option[DynamicValue.Record] = dv.as(DynamicValueType.Record)
// Some(Record(...))

// Extract underlying value
import zio.blocks.chunk.Chunk
val fields: Option[Chunk[(String, DynamicValue)]] = 
  dv.unwrap(DynamicValueType.Record)

Extracting Primitive Values

import zio.blocks.schema.{DynamicValue, PrimitiveType, Validation}

val dv = DynamicValue.int(42)

// Extract with specific primitive type
val intValue: Option[Int] = dv.asPrimitive(PrimitiveType.Int(Validation.None))
// Some(42)

val stringValue: Option[String] = dv.asPrimitive(PrimitiveType.String(Validation.None))
// None (type mismatch)

Navigation

Simple Navigation

Navigate using get methods that return DynamicValueSelection:

import zio.blocks.schema.DynamicValue

val data = DynamicValue.Record(
  "users" -> DynamicValue.Sequence(
    DynamicValue.Record("name" -> DynamicValue.string("Alice")),
    DynamicValue.Record("name" -> DynamicValue.string("Bob"))
  )
)

// Navigate to a field
val users = data.get("users")  // DynamicValueSelection

// Navigate to an array element
val firstUser = data.get("users").apply(0)

// Chain navigation
val firstName = data.get("users").apply(0).get("name")

// Extract the value
val name = firstName.one  // Either[SchemaError, DynamicValue]

Path-Based Navigation with DynamicOptic

Use DynamicOptic for complex path expressions:

import zio.blocks.schema.{DynamicValue, DynamicOptic}

val data = DynamicValue.Record(
  "company" -> DynamicValue.Record(
    "employees" -> DynamicValue.Sequence(
      DynamicValue.Record("name" -> DynamicValue.string("Alice"))
    )
  )
)

// Build a path
val path = DynamicOptic.root.field("company").field("employees").at(0).field("name")

// Navigate using the path
val result = data.get(path).one  // Right(DynamicValue.Primitive(String("Alice")))

DynamicValueSelection

DynamicValueSelection wraps navigation results and provides fluent chaining:

import zio.blocks.schema.{DynamicValue, DynamicValueSelection}

val selection: DynamicValueSelection = ???

// Terminal operations
selection.one      // Either[SchemaError, DynamicValue] - exactly one value
selection.any      // Either[SchemaError, DynamicValue] - first of many
selection.all      // Either[SchemaError, DynamicValue] - wrap multiple in Sequence
selection.toChunk  // Chunk[DynamicValue] - empty on error

// Type filtering
selection.primitives  // Only Primitive values
selection.records     // Only Record values
selection.sequences   // Only Sequence values
selection.maps        // Only Map values

// Combinators
selection.map(dv => ???)      // Transform values
selection.filter(dv => ???)   // Filter values
selection.flatMap(dv => ???)  // Chain selections

Path-Based Modification

Modify

Update values at a path:

import zio.blocks.schema.{DynamicValue, DynamicOptic}

val data = DynamicValue.Record(
  "user" -> DynamicValue.Record(
    "name" -> DynamicValue.string("Alice")
  )
)

val path = DynamicOptic.root.field("user").field("name")

// Modify value at path
val updated = data.modify(path)(dv => DynamicValue.string("Bob"))
// Record("user" -> Record("name" -> "Bob"))

Set

Replace a value at a path:

import zio.blocks.schema.{DynamicValue, DynamicOptic}

val data = DynamicValue.Record("x" -> DynamicValue.int(1))
val path = DynamicOptic.root.field("x")

val updated = data.set(path, DynamicValue.int(99))
// Record("x" -> 99)

Delete

Remove a value at a path:

import zio.blocks.schema.{DynamicValue, DynamicOptic}

val data = DynamicValue.Record(
  "a" -> DynamicValue.int(1),
  "b" -> DynamicValue.int(2)
)

val updated = data.delete(DynamicOptic.root.field("a"))
// Record("b" -> 2)

Insert

Add a value at a path (fails if path exists):

import zio.blocks.schema.{DynamicValue, DynamicOptic}

val data = DynamicValue.Record("a" -> DynamicValue.int(1))

val updated = data.insert(
  DynamicOptic.root.field("b"),
  DynamicValue.int(2)
)
// Record("a" -> 1, "b" -> 2)

Fallible Operations

Use *OrFail variants for operations that should fail explicitly:

import zio.blocks.schema.{DynamicValue, DynamicOptic, SchemaError}

val data = DynamicValue.Record("x" -> DynamicValue.int(1))
val badPath = DynamicOptic.root.field("nonexistent")

val result: Either[SchemaError, DynamicValue] = 
  data.setOrFail(badPath, DynamicValue.int(99))
// Left(SchemaError("Path not found"))

EJSON-like toString Format

DynamicValue.toString produces an EJSON (Extended JSON) format that:

  • Uses unquoted field names for Records (like Scala syntax)
  • Uses quoted string keys for Maps
  • Adds @ {tag: "..."} annotations for Variants
  • Adds @ {type: "..."} annotations for typed primitives (Instant, Duration, etc.)
import zio.blocks.schema.{DynamicValue, PrimitiveValue}

val person = DynamicValue.Record(
  "name" -> DynamicValue.string("Alice"),
  "age" -> DynamicValue.int(30)
)

println(person.toString)
// {
//   name: "Alice",
//   age: 30
// }

val variant = DynamicValue.Variant(
  "Some",
  DynamicValue.string("hello")
)

println(variant.toString)
// "hello" @ {tag: "Some"}

val timestamp = DynamicValue.Primitive(
  PrimitiveValue.Instant(java.time.Instant.ofEpochMilli(1700000000000L))
)

println(timestamp.toString)
// 1700000000000 @ {type: "instant"}

Use toEjson(indent) to control indentation level.

Merging Strategies

Merge two DynamicValue structures using configurable strategies:

import zio.blocks.schema.{DynamicValue, DynamicValueMergeStrategy}

val left = DynamicValue.Record(
  "a" -> DynamicValue.int(1),
  "b" -> DynamicValue.int(2)
)

val right = DynamicValue.Record(
  "b" -> DynamicValue.int(99),
  "c" -> DynamicValue.int(3)
)

// Deep merge (default): recursively merge containers
val merged = left.merge(right, DynamicValueMergeStrategy.Auto)
// Record("a" -> 1, "b" -> 99, "c" -> 3)

Available Strategies

Strategy Behavior
Auto Deep merge: Records by field, Sequences by index, Maps by key. Right wins at leaves.
Replace Complete replacement: right value replaces left entirely
KeepLeft Always keep left value
Shallow Merge only at root level, nested containers replaced
Concat Concatenate Sequences instead of merging by index
Custom(f, r) Custom function with custom recursion control
import zio.blocks.schema.{DynamicValue, DynamicValueMergeStrategy}

val list1 = DynamicValue.Sequence(DynamicValue.int(1), DynamicValue.int(2))
val list2 = DynamicValue.Sequence(DynamicValue.int(3))

// Concat sequences instead of index-based merge
val concatted = list1.merge(list2, DynamicValueMergeStrategy.Concat)
// Sequence(1, 2, 3)

Normalization

Transform DynamicValue structures for comparison or serialization:

import zio.blocks.schema.DynamicValue

val data = DynamicValue.Record(
  "z" -> DynamicValue.int(1),
  "a" -> DynamicValue.Null,
  "m" -> DynamicValue.int(2)
)

// Sort fields alphabetically
data.sortFields
// Record("a" -> null, "m" -> 2, "z" -> 1)

// Remove null values
data.dropNulls
// Record("z" -> 1, "m" -> 2)

// Remove empty containers
data.dropEmpty

// Remove Unit primitives
data.dropUnits

// Apply all normalizations
data.normalize
// Sorted, no nulls, no units, no empty containers

Transformation

Transform Up/Down

Apply functions to all values in a structure:

import zio.blocks.schema.{DynamicValue, DynamicOptic, PrimitiveValue}

val data = DynamicValue.Record(
  "values" -> DynamicValue.Sequence(
    DynamicValue.int(1),
    DynamicValue.int(2)
  )
)

// Bottom-up: children transformed before parents
val doubled = data.transformUp { (path, dv) =>
  dv match {
    case DynamicValue.Primitive(pv: PrimitiveValue.Int) =>
      DynamicValue.int(pv.value * 2)
    case other => other
  }
}

// Top-down: parents transformed before children
val topDown = data.transformDown { (path, dv) => ??? }

Transform Field Names

Rename all record fields:

import zio.blocks.schema.{DynamicValue, DynamicOptic}

val data = DynamicValue.Record(
  "first_name" -> DynamicValue.string("Alice"),
  "last_name" -> DynamicValue.string("Smith")
)

// Convert snake_case to camelCase
val camelCase = data.transformFields { (path, name) =>
  name.split("_").zipWithIndex.map {
    case (word, 0) => word
    case (word, _) => word.capitalize
  }.mkString
}
// Record("firstName" -> "Alice", "lastName" -> "Smith")

Folding

Aggregate values from a DynamicValue tree:

import zio.blocks.schema.{DynamicValue, DynamicOptic, PrimitiveValue}

val data = DynamicValue.Record(
  "a" -> DynamicValue.int(1),
  "b" -> DynamicValue.int(2),
  "c" -> DynamicValue.int(3)
)

// Sum all integers
val sum = data.foldUp(0) { (path, dv, acc) =>
  dv match {
    case DynamicValue.Primitive(pv: PrimitiveValue.Int) => acc + pv.value
    case _ => acc
  }
}
// 6

Converting to/from JSON

To JSON

import zio.blocks.schema.DynamicValue
import zio.blocks.schema.json.Json

val dynamic = DynamicValue.Record(
  "name" -> DynamicValue.string("Alice"),
  "age" -> DynamicValue.int(30)
)

val json: Json = dynamic.toJson
// Json.Object with "name" and "age" fields

From JSON

import zio.blocks.schema.DynamicValue
import zio.blocks.schema.json.Json

val json = Json.parseUnsafe("""{"name": "Alice", "age": 30}""")

val dynamic: DynamicValue = json.toDynamicValue
// DynamicValue.Record with "name" and "age" fields

Querying

Search recursively for values matching a predicate:

import zio.blocks.schema.{DynamicValue, DynamicValueType, PrimitiveValue}

val data = DynamicValue.Record(
  "users" -> DynamicValue.Sequence(
    DynamicValue.Record("name" -> DynamicValue.string("Alice"), "active" -> DynamicValue.boolean(true)),
    DynamicValue.Record("name" -> DynamicValue.string("Bob"), "active" -> DynamicValue.boolean(false))
  )
)

// Find all string values
val strings = data.select.query(_.is(DynamicValueType.Primitive))
  .filter(_.primitiveValue.exists(_.isInstanceOf[PrimitiveValue.String]))

// Query with path predicate
val atDepth2 = data.select.queryPath(path => path.nodes.length == 2)

Use Cases

Schema-less Operations

Work with data when the schema isn't known at compile time:

import zio.blocks.schema.{DynamicValue, PrimitiveValue}

def processAnyData(data: DynamicValue): DynamicValue = {
  // Add a timestamp to any record
  data match {
    case r: DynamicValue.Record =>
      DynamicValue.Record(
        r.fields :+ ("processedAt" -> DynamicValue.Primitive(
          PrimitiveValue.Instant(java.time.Instant.now())
        ))
      )
    case other => other
  }
}

Schema Migrations

Transform data between schema versions:

import zio.blocks.schema.{DynamicValue, DynamicOptic}

def migrateV1toV2(data: DynamicValue): DynamicValue = {
  data.transformFields { (path, name) =>
    // Rename deprecated field
    if (name == "userName") "name"
    else name
  }.transformUp { (path, dv) =>
    // Add default for new required field
    dv match {
      case r: DynamicValue.Record if path.nodes.isEmpty =>
        DynamicValue.Record(r.fields :+ ("version" -> DynamicValue.int(2)))
      case other => other
    }
  }
}

Dynamic Queries

Build queries at runtime:

import zio.blocks.schema.{DynamicValue, DynamicOptic}

def buildPath(fields: List[String]): DynamicOptic =
  fields.foldLeft(DynamicOptic.root)(_.field(_))

def getValue(data: DynamicValue, path: List[String]): Option[DynamicValue] =
  data.get(buildPath(path)).one.toOption

// Usage
val data = DynamicValue.Record(
  "user" -> DynamicValue.Record(
    "profile" -> DynamicValue.Record(
      "email" -> DynamicValue.string("alice@example.com")
    )
  )
)

val email = getValue(data, List("user", "profile", "email"))
// Some(DynamicValue.Primitive(String("alice@example.com")))

Cross-Format Conversion

Use DynamicValue as an intermediate format:

import zio.blocks.schema.{Schema, DynamicValue}
import zio.blocks.schema.json.Json

case class Person(name: String, age: Int)
object Person {
  implicit val schema: Schema[Person] = Schema.derived
}

// JSON -> DynamicValue -> Typed
val json = Json.parseUnsafe("""{"name": "Alice", "age": 30}""")
val dynamic = json.toDynamicValue
val person = Schema[Person].fromDynamicValue(dynamic)

// Typed -> DynamicValue -> JSON
val dynamic2 = Schema[Person].toDynamicValue(Person("Bob", 25))
val json2 = dynamic2.toJson

Comparison and Ordering

DynamicValue has a total ordering for sorting and comparison:

import zio.blocks.schema.DynamicValue

val a = DynamicValue.int(1)
val b = DynamicValue.int(2)

a.compare(b)  // negative
a < b         // true
a >= b        // false

// Type ordering: Primitive < Record < Variant < Sequence < Map < Null
val primitive = DynamicValue.int(1)
val record = DynamicValue.Record.empty
primitive < record  // true

Diff and Patch

Compute differences between DynamicValue instances:

import zio.blocks.schema.DynamicValue
import zio.blocks.schema.patch.DynamicPatch

val old = DynamicValue.Record(
  "name" -> DynamicValue.string("Alice"),
  "age" -> DynamicValue.int(30)
)

val new_ = DynamicValue.Record(
  "name" -> DynamicValue.string("Alice"),
  "age" -> DynamicValue.int(31)
)

val patch: DynamicPatch = old.diff(new_)
// Patch that updates "age" from 30 to 31