Quellcode durchsuchen

fix: close non-constant default review gaps

- Accept SQLite's canonical parenthesized spelling. Both the whitelist
  check and stored-expression evaluation unwrap ParenExpr, so
  DEFAULT (CURRENT_TIMESTAMP) and DEFAULT (datetime('now')) work at
  CREATE TABLE and ALTER TABLE ADD COLUMN.
- Validate ADD COLUMN ... UNIQUE for a clock-reading default inside the
  rewrite. The old prediction scan and the rewrite sample the clock at
  different moments; across a second boundary that could publish a
  UNIQUE index over existing duplicate values. Constant defaults keep
  the cheap pre-scan, expression defaults collect the values the
  rewrite writes and abort before publishAddedColumn.
- Sample the statement instant once and evaluate every clock default in
  the row at it, so created_at and updated_at cannot disagree.
- Register the storage default evaluator as a closure over the
  executor so the injected session clock also drives ADD COLUMN
  backfill; the per-row test no longer patches the schema manager.
- Use the two-value type assertion when reading the parsed-expression
  cache.
- Document that Column.DefaultExpr is an additive catalog field an
  older engine ignores on rollback.
- Document the confirmed SQLite deviation: SQLite 3.45.1 rejects
  INSERT INTO t (n) DEFAULT VALUES ("0 values for 1 columns");
  PizzaSQL accepts it and fills all columns from schema defaults.
Danilo Fragoso vor 1 Tag
Ursprung
Commit
0324e5d736

+ 5 - 0
PKBFI_STORAGE_MIGRATION.md

@@ -30,6 +30,11 @@ The migration command exits after writing and verifying the destination. It refu
 - Migration copies the final live key/value state. Legacy journal history is not imported, and imported records begin at the migration baseline LSN.
 - Existing PizzaSQL row values remain readable as legacy JSON after migration. New and updated rows use the versioned binary tuple encoding, so an eager row rewrite is unnecessary.
 - Schema and catalog values remain JSON because they are cold metadata.
+- `Column.DefaultExpr` is an additive schema-catalog field (`default_expr`)
+  holding the SQL text of a write-time default. An engine without it ignores the
+  field, so rolling back to a pre-`default_expr` binary silently loses write-time
+  defaults: nullable columns read as NULL and NOT NULL columns reject the insert.
+  Constant defaults in the `default` field are unaffected.
 - Successful PKBFI writes are acknowledged only after PizzaKV's durability sync completes.
 - A `.pkvdb` file must have one PizzaSQL process owner. Optimistic scan and
   predicate conflict detection uses process-local generations and is not safe

+ 3 - 0
pkg/analyzer/analyzer.go

@@ -537,6 +537,9 @@ func (a *Analyzer) analyzeInsert(stmt *parser.InsertStmt) error {
 
 	// Validate VALUES. INSERT ... DEFAULT VALUES supplies no values at all, so
 	// the placeholder empty row intentionally does not match the column count.
+	// SQLite rejects the column-list form (INSERT INTO t (n) DEFAULT VALUES with
+	// "0 values for 1 columns"); PizzaSQL accepts it and fills every column from
+	// the schema defaults. This is a documented compatibility deviation.
 	for _, row := range stmt.Values {
 		if stmt.DefaultValues {
 			continue

+ 1 - 1
pkg/capabilities/capabilities.go

@@ -176,7 +176,7 @@ func Current() *Document {
 
 		DDL: DDL{
 			AddColumn:              Feature{Supported: true, Status: "materializes_defaults", Notes: "adds one nullable column; a constant DEFAULT is written into every existing row, and a whitelisted clock default is evaluated per rewritten row, so the rewrite cost is O(rows) and holds the table's exclusive gate"},
-			AddColumnBackfill:      Feature{Supported: true, Status: "rewrites_rows", Notes: "a DEFAULT is materialized into existing rows during the rewrite: constants as their stored value, whitelisted clock defaults evaluated per row; ADD COLUMN ... NOT NULL without a DEFAULT fails with 23502 on a non-empty table, ADD COLUMN ... UNIQUE conflicts with 23505 when backfilling a non-NULL default into more than one row, and the column is published together with its UNIQUE index in one atomic schema write"},
+			AddColumnBackfill:      Feature{Supported: true, Status: "rewrites_rows", Notes: "a DEFAULT is materialized into existing rows during the rewrite: constants as their stored value, whitelisted clock defaults evaluated per row; ADD COLUMN ... NOT NULL without a DEFAULT fails with 23502 on a non-empty table, ADD COLUMN ... UNIQUE conflicts with 23505 when backfilling a non-NULL default into more than one row (a constant default is predicted before the rewrite, a clock default is validated against the values the rewrite writes), and the column is published together with its UNIQUE index in one atomic schema write"},
 			DropColumn:             Feature{Supported: true, Status: "rewrites_rows"},
 			RenameColumn:           Feature{Supported: true, Status: "rewrites_rows"},
 			RenameColumnIndexes:    Feature{Supported: true, Status: "rewrites_index_metadata", Notes: "column references in index definitions are rewritten in place, including composite and unique indexes; index names are unchanged; RENAME COLUMN is rejected when an expression index references the column (drop that index, rename, then recreate it)"},

+ 36 - 17
pkg/executor/ddl_defaults.go

@@ -64,6 +64,11 @@ func isConstantExpr(expr parser.Expr) bool {
 // are the forms that SQLite applications declare, and each maps to a value the
 // engine already produces for the equivalent expression.
 func isWhitelistedDefaultExpr(expr parser.Expr) bool {
+	// SQLite applications commonly wrap a default in parentheses, for example
+	// DEFAULT (datetime('now')). The wrapper does not change the expression.
+	if paren, ok := expr.(*parser.ParenExpr); ok {
+		return isWhitelistedDefaultExpr(paren.Expr)
+	}
 	switch ex := expr.(type) {
 	case *parser.ColumnRef:
 		if ex.Table != "" {
@@ -95,7 +100,9 @@ var defaultExprCache sync.Map // map[string]parser.Expr
 
 func parseDefaultExpr(text string) (parser.Expr, error) {
 	if cached, ok := defaultExprCache.Load(text); ok {
-		return cached.(parser.Expr), nil
+		if expr, ok := cached.(parser.Expr); ok {
+			return expr, nil
+		}
 	}
 	expr, err := parser.ParseExpr(text)
 	if err != nil {
@@ -105,14 +112,6 @@ func parseDefaultExpr(text string) (parser.Expr, error) {
 	return expr, nil
 }
 
-// EvalDefaultExpression evaluates a persisted non-constant DEFAULT expression
-// at the current time. It is the storage layer's evaluator for columns added
-// with ALTER TABLE ADD COLUMN and for row images that predate such a column; an
-// INSERT or UPDATE evaluates the expression once per statement itself.
-func EvalDefaultExpression(text string) (interface{}, error) {
-	return evalDefaultExpressionAt(text, time.Now().UTC())
-}
-
 // evalDefaultExpressionAt evaluates a persisted default with an explicit clock.
 // The value shapes match what the same expression returns in a query, so a
 // defaulted timestamp column compares equal to CURRENT_TIMESTAMP:
@@ -126,6 +125,13 @@ func evalDefaultExpressionAt(text string, now time.Time) (interface{}, error) {
 	if err != nil {
 		return nil, err
 	}
+	for {
+		paren, ok := expr.(*parser.ParenExpr)
+		if !ok {
+			break
+		}
+		expr = paren.Expr
+	}
 	if !isWhitelistedDefaultExpr(expr) {
 		return nil, pgerror.Unsupported(
 			"stored DEFAULT expression %q is not supported; PizzaSQL only evaluates CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, datetime('now'), date('now'), and time('now') at write time",
@@ -153,29 +159,42 @@ func evalDefaultExpressionAt(text string, now time.Time) (interface{}, error) {
 // value, while a later statement reads the clock again. Constant defaults stay
 // in the catalog and are applied by the storage layer.
 func (e *Executor) statementDefaults(schema *storage.Schema) (map[string]interface{}, error) {
+	// One instant per statement: two clock-default columns in the same row must
+	// not disagree because the loop sampled the clock twice. The clock is read
+	// lazily so a schema without expression defaults never samples it.
+	var now time.Time
 	var defaults map[string]interface{}
 	for _, column := range schema.Columns {
 		if column.DefaultExpr == "" {
 			continue
 		}
-		value, err := e.evalDefaultExpr(column.DefaultExpr)
-		if err != nil {
-			return nil, fmt.Errorf("evaluate DEFAULT for column %q: %w", column.Name, err)
-		}
 		if defaults == nil {
+			now = e.statementNow()
 			defaults = make(map[string]interface{})
 		}
+		value, err := evalDefaultExpressionAt(column.DefaultExpr, now)
+		if err != nil {
+			return nil, fmt.Errorf("evaluate DEFAULT for column %q: %w", column.Name, err)
+		}
 		defaults[column.Name] = value
 	}
 	return defaults, nil
 }
 
-func (e *Executor) evalDefaultExpr(text string) (interface{}, error) {
-	now := time.Now().UTC()
+// evalStorageDefault is the storage-facing evaluator. ADD COLUMN evaluates a
+// default once per rewritten row, so every call samples the executor clock;
+// that keeps a storage-side backfill on the same clock as the session.
+func (e *Executor) evalStorageDefault(expression string) (interface{}, error) {
+	return evalDefaultExpressionAt(expression, e.statementNow())
+}
+
+// statementNow returns the instant one statement's expression defaults use.
+// Tests inject e.clock to make that instant deterministic.
+func (e *Executor) statementNow() time.Time {
 	if e.clock != nil {
-		now = e.clock().UTC()
+		return e.clock().UTC()
 	}
-	return evalDefaultExpressionAt(text, now)
+	return time.Now().UTC()
 }
 
 // applyRowDefaults fills the expression-defaulted columns a statement omitted.

+ 131 - 17
pkg/executor/ddl_defaults_test.go

@@ -2,7 +2,6 @@ package executor
 
 import (
 	"errors"
-	"fmt"
 	"testing"
 	"time"
 
@@ -25,6 +24,18 @@ func newTestClock() *testClock {
 	return &testClock{now: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}
 }
 
+// steppingClock advances one second per call, so a test can see whether a
+// value was evaluated once per statement or once per row.
+type steppingClock struct {
+	now time.Time
+}
+
+func (c *steppingClock) Now() time.Time {
+	current := c.now
+	c.now = c.now.Add(time.Second)
+	return current
+}
+
 func TestCreateTableWithCurrentTimestampDefaultAppliesOnInsert(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
@@ -194,28 +205,22 @@ func TestMultiRowInsertSharesStatementDefault(t *testing.T) {
 func TestAlterTableAddColumnBackfillsNonConstantDefaultPerRow(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
+	// The storage-side evaluator samples the executor clock for every rewritten
+	// row, so a stepping clock shows per-row evaluation without patching the
+	// schema manager.
+	e.clock = (&steppingClock{now: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}).Now
+
 	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
 	execMust(t, e, "INSERT INTO t (id) VALUES (1), (2)")
-
-	// The storage-level evaluator is the per-row injection point for the
-	// rewrite; a counting evaluator proves each rewritten row is stamped again.
-	calls := 0
-	schema.SetDefaultEvaluator(func(expression string) (interface{}, error) {
-		calls++
-		return fmt.Sprintf("stamp-%d", calls), nil
-	})
-
 	execMust(t, e, "ALTER TABLE t ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
 
-	if calls != 2 {
-		t.Fatalf("evaluator calls = %d, want one per rewritten row (2)", calls)
-	}
 	res := execMust(t, e, "SELECT id, created_at FROM t ORDER BY id")
-	if got, want := res.Rows[0][1], "stamp-1"; got != want {
-		t.Fatalf("row 1 created_at = %v, want %q", got, want)
+	first, second := res.Rows[0][1], res.Rows[1][1]
+	if first == second {
+		t.Fatalf("both rows were stamped %v; the ADD COLUMN rewrite must evaluate each row", first)
 	}
-	if got, want := res.Rows[1][1], "stamp-2"; got != want {
-		t.Fatalf("row 2 created_at = %v, want %q", got, want)
+	if compare(first, second) >= 0 {
+		t.Fatalf("timestamps %v then %v are not ordered", first, second)
 	}
 
 	col, ok := e.schemaColumn(t, "t", "created_at")
@@ -230,6 +235,115 @@ func TestAlterTableAddColumnBackfillsNonConstantDefaultPerRow(t *testing.T) {
 	}
 }
 
+func TestMultipleClockDefaultsInOneRowShareInstant(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	// A stepping clock makes per-column sampling visible: the two columns must
+	// still share the single instant sampled for the statement.
+	e.clock = (&steppingClock{now: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}).Now
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+
+	res := execMust(t, e, "SELECT created_at, updated_at FROM t WHERE id = 1")
+	if got := res.Rows[0][0]; got != "2026-01-02 03:04:05" {
+		t.Fatalf("created_at = %v, want the statement instant", got)
+	}
+	if res.Rows[0][0] != res.Rows[0][1] {
+		t.Fatalf("created_at = %v, updated_at = %v; both clock defaults must share one instant", res.Rows[0][0], res.Rows[0][1])
+	}
+}
+
+func TestParenthesizedClockDefaults(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	e.clock = newTestClock().Now
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, a TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), b TIMESTAMP DEFAULT (datetime('now')))")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+	execMust(t, e, "ALTER TABLE t ADD COLUMN c TIMESTAMP DEFAULT (CURRENT_TIMESTAMP)")
+
+	res := execMust(t, e, "SELECT a, b, c FROM t WHERE id = 1")
+	want := "2026-01-02 03:04:05"
+	for i, name := range []string{"a", "b", "c"} {
+		if got := res.Rows[0][i]; got != want {
+			t.Fatalf("%s = %v, want %q", name, got, want)
+		}
+	}
+
+	execMust(t, e, "INSERT INTO t (id) VALUES (2)")
+	res = execMust(t, e, "SELECT a, b, c FROM t WHERE id = 2")
+	if got := res.Rows[0][2]; got != want {
+		t.Fatalf("new row c = %v, want %q", got, want)
+	}
+}
+
+func TestAlterTableAddColumnUniqueClockDefaultRejectsDuplicates(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	e.clock = newTestClock().Now // one fixed instant: both backfilled rows collide
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1), (2)")
+
+	_, err := execSQL(e, "ALTER TABLE t ADD COLUMN tag TEXT DEFAULT CURRENT_TIMESTAMP UNIQUE")
+	var violation *storage.UniqueViolationError
+	if !errors.As(err, &violation) {
+		t.Fatalf("ALTER error = %v, want a UNIQUE violation", err)
+	}
+
+	// The duplicate must abort before the column and index become visible.
+	stored, gerr := storage.NewSchemaManager(schema.GetPool(), schema.GetDatabaseName()).GetSchema("t")
+	if gerr != nil {
+		t.Fatalf("reload schema: %v", gerr)
+	}
+	if _, ok := stored.GetColumn("tag"); ok {
+		t.Fatal("aborted ADD COLUMN published the tag column")
+	}
+	if e.schema.IndexExists("uniq_t_tag") {
+		t.Fatal("aborted ADD COLUMN published the UNIQUE index")
+	}
+}
+
+func TestAlterTableAddColumnUniqueClockDefaultValidatesTheRewrite(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	// The first two samples (the rewrite of rows 1 and 2) are distinct; later
+	// samples collide. A separate prediction scan would have seen the distinct
+	// values and then published an index over the colliding rewrite values.
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	calls := 0
+	e.clock = func() time.Time {
+		calls++
+		if calls == 2 {
+			return base.Add(time.Second)
+		}
+		if calls > 2 {
+			return base.Add(2 * time.Second)
+		}
+		return base
+	}
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1), (2)")
+	execMust(t, e, "ALTER TABLE t ADD COLUMN tag TEXT DEFAULT CURRENT_TIMESTAMP UNIQUE")
+
+	if calls != 2 {
+		t.Fatalf("clock sampled %d times, want once per rewritten row (2); the UNIQUE check must validate the rewrite itself", calls)
+	}
+	res := execMust(t, e, "SELECT id, tag FROM t ORDER BY id")
+	if res.Rows[0][1] == res.Rows[1][1] {
+		t.Fatalf("both rows got tag %v; the published index must cover distinct rewrite values", res.Rows[0][1])
+	}
+
+	// The published index enforces uniqueness for later writes.
+	execMust(t, e, "INSERT INTO t (id) VALUES (3)")
+	if _, err := execSQL(e, "INSERT INTO t (id, tag) VALUES (4, '"+res.Rows[0][1].(string)+"')"); err == nil {
+		t.Fatal("expected the published UNIQUE index to reject a duplicate")
+	}
+}
+
 // schemaColumn reads a column from the durable schema through a fresh manager,
 // which also proves the expression survives a reload.
 func (e *Executor) schemaColumn(t *testing.T, table, column string) (*storage.Column, bool) {

+ 4 - 3
pkg/executor/executor.go

@@ -126,9 +126,10 @@ func New(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
 	table.SetExpressionEvaluator(EvalStoredExpression)
 	// Non-constant DEFAULT expressions are likewise stored as SQL text; storage
 	// evaluates them when it backfills ADD COLUMN rows or completes a row image
-	// that predates the column. Ordinary writes evaluate them once per statement
-	// in this package.
-	schema.SetDefaultEvaluator(EvalDefaultExpression)
+	// that predates the column. The evaluator closes over this executor so the
+	// session clock also drives storage-side evaluation. Ordinary writes
+	// evaluate them once per statement in this package.
+	schema.SetDefaultEvaluator(executor.evalStorageDefault)
 
 	// Register the main database
 	executor.attachedDatabases["main"] = &DatabaseConnection{

+ 3 - 0
pkg/pgserver/compat_suite_test.go

@@ -126,6 +126,9 @@ func TestCompatibilitySuite(t *testing.T) {
 	expectOK("ddl.timestamp_default", `CREATE TABLE stamped (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`)
 	expectOK("ddl.timestamp_default_insert", `INSERT INTO stamped (id) VALUES (1)`)
 	expectRows("ddl.timestamp_default_applied", `SELECT count(*) FROM stamped WHERE created_at IS NOT NULL`, "1")
+	expectOK("ddl.parenthesized_defaults", `CREATE TABLE paren_defaults (id INTEGER PRIMARY KEY, a TIMESTAMP DEFAULT (CURRENT_TIMESTAMP), b TIMESTAMP DEFAULT (datetime('now')))`)
+	expectOK("ddl.parenthesized_defaults_insert", `INSERT INTO paren_defaults (id) VALUES (1)`)
+	expectRows("ddl.parenthesized_defaults_applied", `SELECT count(*) FROM paren_defaults WHERE a IS NOT NULL AND b IS NOT NULL`, "1")
 	expectCode("ddl.nonconstant_default_0A000", `CREATE TABLE bad_default (id INTEGER PRIMARY KEY, token TEXT DEFAULT lower('x'))`, "0A000")
 	expectCode("locking.for_update_0A000", `SELECT * FROM accounts FOR UPDATE`, "0A000")
 	expectCode("arrays.any_0A000", `SELECT * FROM accounts WHERE id = ANY('{1,2}')`, "0A000")

+ 51 - 27
pkg/storage/schema.go

@@ -988,8 +988,8 @@ func (m *SchemaManager) removeFromIndexList(name string) error {
 // AddColumn adds a new column to a table. When unique is non-nil the column's
 // UNIQUE index is registered as part of the same statement.
 //
-// A constant DEFAULT is materialized into every existing row before the schema
-// is published. NOT NULL, UNIQUE, and index validation all read persisted row
+// A DEFAULT is materialized into every existing row before the schema is
+// published. NOT NULL, UNIQUE, and index validation all read persisted row
 // values, so a default that only existed in the catalog would be invisible to
 // them: a UNIQUE index over the new column could not see the duplicate values
 // every existing row shares, and an UPDATE to NULL would look non-null. The
@@ -998,14 +998,19 @@ func (m *SchemaManager) removeFromIndexList(name string) error {
 // rewritten, the transform is idempotent, and the schema is saved last so an
 // interrupted operation resumes against the old schema.
 //
-// The gate also makes the validation authoritative: NOT NULL without a default
-// and duplicate UNIQUE values are rejected before any row is touched, and the
-// column and its index are published together in one batch write, so a crash
-// cannot leave the column visible without the constraint that validates it.
-// A nullable column without a default needs no rewrite because a missing row
-// field already reads as NULL. Like DROP COLUMN, the statement holds the catalog
-// lock for the whole scan, so the cost is one table scan (two for UNIQUE) and a
-// table-wide stall per ADD COLUMN that has a default or an index.
+// The gate also makes the validation authoritative. A constant default is
+// deterministic, so its UNIQUE prediction can run as a cheap pre-scan. A
+// clock-reading default cannot be predicted: the validation must observe the
+// values the rewrite actually writes, so it is folded into the rewrite and a
+// duplicate aborts before the column and index are published. NOT NULL without
+// a default is rejected before any row is touched. The column and its index are
+// published together in one batch write, so a crash cannot leave the column
+// visible without the constraint that validates it. A nullable column without a
+// default needs no rewrite because a missing row field already reads as NULL.
+// Like DROP COLUMN, the statement holds the catalog lock for the whole scan, so
+// the cost is one table scan (plus one table scan for a constant-default UNIQUE
+// prediction) and a table-wide stall per ADD COLUMN that has a default or an
+// index.
 func (m *SchemaManager) AddColumn(table string, column Column, unique *Index) error {
 	tableLock := m.tableLock(table)
 	tableLock.Lock()
@@ -1028,8 +1033,13 @@ func (m *SchemaManager) AddColumn(table string, column Column, unique *Index) er
 		if m.indexExistsUnsafe(unique.Name) {
 			return fmt.Errorf("index already exists: %s", unique.Name)
 		}
-		if err := m.validateAddColumnUnique(table, column, unique); err != nil {
-			return err
+		// A clock-reading default is validated during the rewrite below, which
+		// samples the same values it writes. Predicting them in a separate scan
+		// could disagree across a second boundary and publish duplicates.
+		if column.DefaultExpr == "" {
+			if err := m.validateAddColumnUnique(table, column, unique); err != nil {
+				return err
+			}
 		}
 	}
 
@@ -1045,20 +1055,35 @@ func (m *SchemaManager) AddColumn(table string, column Column, unique *Index) er
 		}
 	}
 
+	// An expression default is evaluated per rewritten row. When the column is
+	// also UNIQUE, collect the values this rewrite writes and abort on a
+	// duplicate, so validation cannot disagree with the published rows.
+	var seen map[string]struct{}
+	if unique != nil && column.DefaultExpr != "" {
+		seen = make(map[string]struct{})
+	}
 	if columnHasDefault(column) {
-		// Each rewritten row evaluates the default at rewrite time. That is
-		// indistinguishable from a constant default except for a clock-reading
-		// default, which stamps the rows this statement backfills.
 		if err := m.applyRows(table, func(row Row) (bool, error) {
-			if _, ok := rowColumnValue(row, column.Name); ok {
-				return false, nil
+			value, present := rowColumnValue(row, column.Name)
+			if !present {
+				var err error
+				value, _, err = m.columnDefaultValue(column)
+				if err != nil {
+					return false, err
+				}
+				row[column.Name] = value
 			}
-			value, _, err := m.columnDefaultValue(column)
-			if err != nil {
-				return false, err
+			if seen != nil {
+				if value == nil {
+					return !present, nil
+				}
+				encoded := encodeUniqueScalar(value)
+				if _, duplicate := seen[encoded]; duplicate {
+					return false, &UniqueViolationError{Constraint: unique.Name}
+				}
+				seen[encoded] = struct{}{}
 			}
-			row[column.Name] = value
-			return true, nil
+			return !present, nil
 		}); err != nil {
 			return err
 		}
@@ -1596,11 +1621,10 @@ func (m *SchemaManager) applyRows(table string, transform func(Row) (bool, error
 // values would duplicate across existing rows. It reads rows as they will look
 // after the rewrite (a row that already carries the field from an interrupted
 // earlier attempt keeps its value) and mirrors ValidateUniqueIndex for a single
-// plain column: NULLs do not collide. An expression default is evaluated once
-// per row here, like the rewrite that follows; a clock-reading default can
-// still cross a second boundary between the two scans, so this prediction is
-// best effort for that combination. The caller holds the table's exclusive
-// gate, so no writer can insert a conflicting row between this check and the
+// plain column: NULLs do not collide. It is used only for a constant or absent
+// default; a clock-reading default is validated by the rewrite itself because
+// its values cannot be predicted. The caller holds the table's exclusive gate,
+// so no writer can insert a conflicting row between this check and the
 // publication of the index.
 func (m *SchemaManager) validateAddColumnUnique(table string, column Column, index *Index) error {
 	seen := make(map[string]struct{})