Schema Validation

Saferis provides runtime schema validation to verify that your table definitions match the actual database schema. This is useful for detecting schema drift, validating migrations, and ensuring consistency between code and database.

Basic Verification

Use Schema(instance).verify to validate a schema against the database:

{
  val schema = Schema[VerifyUser].build
  xa.run(for
    _ <- ddl.createTable(schema)
    // Verify succeeds when schema matches
    _ <- Schema(schema).verify
  yield "Schema verification passed")
    .either
}
Right(Schema verification passed)

When verification fails, it returns a SaferisError.SchemaValidation containing a list of issues:

{
  val schema = Schema[VerifyUserIndexed].withIndex(_.email).named("idx_verify_email").build
  xa.run(for
    // Create table without the index
    _ <- ddl.createTable[VerifyUserIndexed](ifNotExists = true)
    // Verification will find the missing index
    result <- Schema(schema).verify.either
  yield result match
    case Left(SaferisError.SchemaValidation(issues)) =>
      issues.map(_.description).mkString("\n")
    case Left(e)  => s"Unexpected error: ${e.message}"
    case Right(_) => "Verification passed")
    .either
}
Right(Missing index 'idx_verify_email' on (email) in table 'verify_users_indexed')

VerifyOptions

Customize verification behavior with VerifyOptions:

{
  // Available options
  val options = VerifyOptions(
    checkExtraColumns = true,      // Report columns in DB not defined in schema
    checkIndexes = true,           // Verify indexes exist
    checkUniqueConstraints = true, // Verify unique constraints exist
    checkForeignKeys = true,       // Verify foreign keys exist
    checkNullability = true,       // Verify column nullable/NOT NULL matches
    checkTypes = true,             // Verify column types match
    strictTypeMatching = false,    // Require exact type match (not just compatible)
    strictNameMatching = false,    // Fail if index/constraint name differs
  )
  options
}
VerifyOptions(true,true,true,true,true,true,false,false)

Preset Options

PresetDescription
VerifyOptions.defaultCheck everything except strict name matching
VerifyOptions.minimalOnly check table and columns exist
VerifyOptions.strictCheck everything including exact names
{
  val schema = Schema[OptionsUser].build
  xa.run(
    for
      _ <- ddl.createTable[OptionsUser](ifNotExists = true)
      // Add an extra column to the database
      _ <- sql"ALTER TABLE options_users ADD COLUMN IF NOT EXISTS extra VARCHAR(100)".execute

      // Default verification fails due to extra column
      defaultResult <- Schema(schema).verify.either

      // Minimal verification ignores extra columns
      minimalResult <- Schema(schema).verifyWith(VerifyOptions.minimal).either
    yield (
      defaultResult.fold(e => s"Default failed: ${e.message.take(60)}...", _ => "passed"),
      minimalResult.fold(_.message, _ => "Minimal passed"),
    )
  ).either
}
Right((Default failed: Schema validation failed with 1 issue:
  - Extra column 'ext...,Minimal passed))

SchemaIssue Types

Verification returns specific issue types defined in SchemaIssue.scala:

Column Issues

IssueDescription
TableNotFoundTable does not exist in database
MissingColumnExpected column is missing
TypeMismatchColumn has wrong type
NullabilityMismatchColumn nullable/NOT NULL differs
ExtraColumnColumn in DB not in schema
PrimaryKeyMismatchPrimary key columns don't match

Constraint Issues

IssueDescription
MissingIndexExpected index not found
IndexNameMismatchIndex exists but with different name
MissingUniqueConstraintExpected unique constraint not found
UniqueConstraintNameMismatchConstraint exists but with different name
MissingForeignKeyExpected foreign key not found
ForeignKeyNameMismatchForeign key exists but with different name

Verifying Complex Schemas

Verify schemas with indexes, unique constraints, and foreign keys:

{
  // Build a schema with index and foreign key
  val ordersSchema = Schema[VerifyOrder]
    .withIndex(_.status)
    .named("idx_order_status")
    .withForeignKey(_.userId)
    .references[VerifyCustomer](_.id)
    .onDelete(Cascade)
    .build

  xa.run(for
    _ <- ddl.createTable[VerifyCustomer](ifNotExists = true)
    _ <- ddl.createTable(ordersSchema)
    // Full verification including FK
    _ <- Schema(ordersSchema).verify
  yield "All constraints verified")
    .either
}
Right(All constraints verified)

Type Compatibility

By default, type checking uses "type families" for compatibility. Types in the same family are considered compatible:

FamilyCompatible Types
Integerinteger, int, int4, serial, bigint, int8, bigserial, smallint
Textvarchar, text, character varying, char, bpchar
Numericnumeric, decimal, real, float, double precision
Booleanboolean, bool, bit
Timestamptimestamp, timestamptz, timestamp with time zone
JSONjson, jsonb

Use strictTypeMatching = true to require exact type matches.

Application Startup Validation

A common pattern is to verify schemas at application startup. The program below is exactly the shape you'd put in ZIOAppDefault.run, here validating two schemas against the live database:

{
  def validateSchemas: ZIO[Any, SaferisError, Unit] =
    val customerSchema = Schema[StartupCustomer].build
    val orderSchema    = Schema[StartupOrder].build
    xa.run(for
      _ <- ddl.createTable(customerSchema)
      _ <- ddl.createTable(orderSchema)
      _ <- Schema(customerSchema).verify
      _ <- Schema(orderSchema).verify
    yield ())

  validateSchemas
    .tapError(e => ZIO.logError(s"Schema validation failed: ${e.message}"))
    .as("All schemas validated successfully")
    .either
}
Right(All schemas validated successfully)