Jelajahi Sumber

Merge pull request #33 from database-pizza/engine/b-nonconstant-defaults

feat: evaluate non-constant column defaults per statement
Danilo Fragoso 1 hari lalu
induk
melakukan
9c4cae4cd7

+ 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.
 - `Schema.Checks` (CHECK constraints) is an additive, optional JSON field. A
   pre-CHECK engine ignores it on read and drops it on the next schema write, so
   rolling back to such an engine silently stops enforcing CHECK constraints;

+ 8 - 1
pkg/analyzer/analyzer.go

@@ -535,8 +535,15 @@ func (a *Analyzer) analyzeInsert(stmt *parser.InsertStmt) error {
 		}
 	}
 
-	// Validate VALUES
+	// 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
+		}
 		if len(row) != len(targetCols) {
 			return &AnalysisError{
 				Type:    ErrTypeMismatch,

+ 3 - 3
pkg/capabilities/capabilities.go

@@ -171,12 +171,12 @@ func Current() *Document {
 			CompositePK:   Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "declare a single-column primary key"},
 			ForeignKey:    Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "inline and table-level FOREIGN KEY are rejected; enforce referential integrity in the application, cascades are not implemented"},
 			Check:         Feature{Supported: true, Status: "enforced", SQLState: "23514", Notes: "inline and table-level CHECK are evaluated on INSERT, multi-row INSERT, UPDATE, and ON CONFLICT DO UPDATE; NULL/unknown results pass; predicates are restricted to deterministic, subquery-free expressions over unqualified columns; exposed in sqlite_master/sqlite_schema table SQL only (pg_constraint and information_schema.check_constraints are not emulated)"},
-			DefaultValues: Feature{Supported: true, Status: "constant_only", SQLState: "0A000", Notes: "non-constant defaults (CURRENT_TIMESTAMP, function calls) are rejected instead of being frozen at DDL time"},
+			DefaultValues: Feature{Supported: true, Status: "evaluated_at_write_time", SQLState: "0A000", Notes: "constant defaults are stored as their evaluated value; CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, datetime('now'), date('now'), and time('now') are stored as SQL text and evaluated once per statement on write (once per ALTER statement for an ADD COLUMN backfill), so they are never frozen at DDL time; any other non-constant default is rejected with 0A000"},
 		},
 
 		DDL: DDL{
-			AddColumn:              Feature{Supported: true, Status: "materializes_constant_default", Notes: "adds one nullable column; a constant DEFAULT is written into every existing row, so the rewrite cost is O(rows) and holds the table's exclusive gate"},
-			AddColumnBackfill:      Feature{Supported: true, Status: "rewrites_rows", Notes: "a constant DEFAULT is materialized into existing rows at DDL time; 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, ADD COLUMN ... CHECK fails with 23514 when an existing row or the backfilled default violates the predicate, and the column is published together with its CHECK constraints and UNIQUE index in one atomic schema write"},
+			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 once per ALTER statement, 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 at one instant per ALTER statement; 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), ADD COLUMN ... CHECK fails with 23514 when an existing row or the backfilled default violates the predicate, and the column is published together with its CHECK constraints and UNIQUE index in one atomic schema write"},
 			DropColumn:             Feature{Supported: true, Status: "rewrites_rows", Notes: "rejected with 0A000 when a persisted CHECK constraint references the column; the stored predicate text cannot be rewritten"},
 			RenameColumn:           Feature{Supported: true, Status: "rewrites_rows", Notes: "rejected with 0A000 when a persisted CHECK constraint or expression index references the column; the stored expression text cannot be rewritten"},
 			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)"},

+ 17 - 1
pkg/capabilities/capabilities_test.go

@@ -68,7 +68,23 @@ func TestAddColumnBackfillIsAdvertised(t *testing.T) {
 		t.Fatalf("add_column_backfill status = %q, want rewrites_rows", doc.DDL.AddColumnBackfill.Status)
 	}
 	if doc.DDL.AddColumn.Status == "metadata_only" {
-		t.Fatalf("add_column status = %q, but a constant DEFAULT rewrites rows", doc.DDL.AddColumn.Status)
+		t.Fatalf("add_column status = %q, but a DEFAULT rewrites rows", doc.DDL.AddColumn.Status)
+	}
+}
+
+// The capability document must describe the current default semantics: constant
+// and whitelisted clock defaults are supported, the clock ones are evaluated on
+// write, and only the remaining non-constant forms are rejected.
+func TestNonConstantDefaultsAreAdvertisedAsEvaluatedAtWriteTime(t *testing.T) {
+	defaults := Current().Constraints.DefaultValues
+	if !defaults.Supported {
+		t.Fatalf("default_values = %+v, want supported", defaults)
+	}
+	if defaults.Status != "evaluated_at_write_time" {
+		t.Fatalf("default_values status = %q, want evaluated_at_write_time", defaults.Status)
+	}
+	if defaults.SQLState != "0A000" {
+		t.Fatalf("default_values SQLSTATE = %q, want 0A000 for the remaining rejected forms", defaults.SQLState)
 	}
 }
 

+ 117 - 0
pkg/executor/check_constraint_test.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
@@ -259,6 +260,122 @@ func TestAlterTableAddColumnCheckValidatesExistingRows(t *testing.T) {
 	}
 }
 
+// A clock-reading default must reach an ADD COLUMN ... CHECK predicate as the
+// value the backfill writes, not as NULL (which CHECK always passes). Validation
+// resolves the default at the statement instant, and the rewrite writes that
+// same instant, so every backfilled row carries the value the predicate checked.
+func TestAlterTableAddColumnCheckSeesClockDefault(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	calls := 0
+	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
+		calls++
+		return at
+	}
+
+	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 ts TEXT DEFAULT CURRENT_TIMESTAMP CHECK (ts IS NOT NULL)")
+
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want once per ALTER statement", calls)
+	}
+	res := execMust(t, e, "SELECT id, ts FROM t ORDER BY id")
+	if res.RowCount != 2 {
+		t.Fatalf("rows = %d, want 2", res.RowCount)
+	}
+	if got, want := res.Rows[0][1], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("row 1 ts = %v, want the statement instant %q", got, want)
+	}
+	if res.Rows[0][1] != res.Rows[1][1] {
+		t.Fatalf("timestamps %v and %v differ; the backfill must use one statement instant", res.Rows[0][1], res.Rows[1][1])
+	}
+
+	stored, err := schema.GetSchema("t")
+	if err != nil {
+		t.Fatalf("get schema: %v", err)
+	}
+	if _, ok := stored.GetColumn("ts"); !ok {
+		t.Fatal("ADD COLUMN ... CHECK published no column")
+	}
+	if len(stored.Checks) != 1 {
+		t.Fatalf("checks = %+v, want 1", stored.Checks)
+	}
+
+	// A clock default that violates the predicate rejects the DDL. NULL passes
+	// CHECK, so this also proves the predicate saw the evaluated value.
+	e.clock = newTestClock().Now
+	_, err = execSQL(e, "ALTER TABLE t ADD COLUMN bad_ts TEXT DEFAULT CURRENT_TIMESTAMP CHECK (bad_ts < '2026-01-02 03:04:05')")
+	requireCheckViolation(t, err, "")
+	stored, err = schema.GetSchema("t")
+	if err != nil {
+		t.Fatalf("get schema after rejected DDL: %v", err)
+	}
+	if _, ok := stored.GetColumn("bad_ts"); ok {
+		t.Fatal("rejected ADD COLUMN ... CHECK left the clock-default column in the schema")
+	}
+	if len(stored.Checks) != 1 {
+		t.Fatalf("checks after rejected DDL = %+v, want the original one", stored.Checks)
+	}
+}
+
+// One instant is sampled per ALTER statement and shared by the CHECK validation
+// and the rewrite. A stepping clock reproduces the old race: a second sample in
+// the rewrite could cross a literal boundary and publish a row that violates the
+// predicate the statement just validated.
+func TestAlterTableAddColumnCheckUsesOneInstantForValidationAndRewrite(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	boundary := "2026-01-02 03:04:06"
+	calls := 0
+	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
+		calls++
+		return at
+	}
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+	execMust(t, e, "ALTER TABLE t ADD COLUMN ts TEXT DEFAULT CURRENT_TIMESTAMP CHECK (ts < '"+boundary+"')")
+
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want one instant per ALTER statement", calls)
+	}
+	res := execMust(t, e, "SELECT ts FROM t WHERE id = 1")
+	if got := res.Rows[0][0]; got == nil || got == boundary || compare(got, boundary) >= 0 {
+		t.Fatalf("backfilled ts = %v, want the pre-boundary statement instant %q", got, base.Format("2006-01-02 15:04:05"))
+	}
+	if len(execMust(t, e, "SELECT ts FROM t").Rows) != 1 {
+		t.Fatal("ADD COLUMN ... CHECK published an unexpected row set")
+	}
+}
+
+// A constant default that violates an ADD COLUMN ... CHECK still fails with
+// 23514 and publishes nothing.
+func TestAlterTableAddColumnCheckRejectsViolatingConstantDefault(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+
+	_, err := execSQL(e, "ALTER TABLE t ADD COLUMN n INTEGER DEFAULT -1 CHECK (n >= 0)")
+	requireCheckViolation(t, err, "")
+
+	stored, err := schema.GetSchema("t")
+	if err != nil {
+		t.Fatalf("get schema: %v", err)
+	}
+	if _, ok := stored.GetColumn("n"); ok {
+		t.Fatal("rejected ADD COLUMN ... CHECK left the column in the schema")
+	}
+	if len(stored.Checks) != 0 {
+		t.Fatalf("checks = %+v, want none", stored.Checks)
+	}
+}
+
 // A transaction that stages a row before a concurrent ADD COLUMN ... CHECK is
 // validated at commit, so the backfilled default cannot bypass the predicate.
 func TestAlterTableAddColumnCheckRejectsRowStagedBeforeDDL(t *testing.T) {

+ 11 - 4
pkg/executor/compat.go

@@ -69,14 +69,21 @@ func formatSQLiteReal(f float64) string {
 // column of that name) to support DEFAULT current_timestamp and expressions
 // like strftime('%Y', current_timestamp).
 func sqliteCurrentTimeValue(name string) (interface{}, bool) {
-	now := time.Now().UTC()
+	return sqliteCurrentTimeValueAt(name, time.Now().UTC())
+}
+
+// sqliteCurrentTimeValueAt resolves the SQLite date/time keywords against now.
+// The values are UTC text in the engine's datetime storage convention, which
+// is also what a non-constant DEFAULT stores and what the timestamptz OID path
+// sends.
+func sqliteCurrentTimeValueAt(name string, now time.Time) (interface{}, bool) {
 	switch strings.ToLower(name) {
 	case "current_timestamp":
-		return now.Format("2006-01-02 15:04:05"), true
+		return now.UTC().Format("2006-01-02 15:04:05"), true
 	case "current_date":
-		return now.Format("2006-01-02"), true
+		return now.UTC().Format("2006-01-02"), true
 	case "current_time":
-		return now.Format("15:04:05"), true
+		return now.UTC().Format("15:04:05"), true
 	}
 	return nil, false
 }

+ 168 - 18
pkg/executor/ddl_defaults.go

@@ -2,38 +2,45 @@ package executor
 
 import (
 	"fmt"
+	"strings"
+	"sync"
+	"time"
 
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
 
-// constantDefaultValue evaluates a DEFAULT expression that cannot depend on the
-// row, session clock, or other mutable state.
+// defaultColumnValue resolves a DEFAULT declaration into the representation the
+// schema catalog persists. A constant is evaluated once and stored as its value.
+// The whitelist of clock-reading defaults is stored as SQL text instead, so it
+// is evaluated on every write rather than frozen at CREATE/ALTER time.
 //
-// The schema catalog stores the evaluated value, not the expression SQL, so a
-// non-constant default such as CURRENT_TIMESTAMP would be frozen at DDL time and
-// every later INSERT would silently receive the same value. Rejecting the
-// declaration is safer than accepting it with different semantics. Per-INSERT
-// evaluation is not implemented.
-func constantDefaultValue(e *Executor, column string, expr parser.Expr) (interface{}, error) {
-	if !isConstantExpr(expr) {
-		return nil, pgerror.Unsupported(
-			"DEFAULT for column %q is not a constant expression: %s; PizzaSQL stores the evaluated default in the schema catalog, so a non-constant default would be frozen at CREATE/ALTER time. Omit the default and supply the value on INSERT",
-			column, parser.FormatExpr(expr),
-		)
+// Every other non-constant expression is rejected: the catalog would have to
+// store its SQL text and a write would have to evaluate it, and silently
+// freezing the DDL-time value is worse than refusing the declaration.
+func defaultColumnValue(e *Executor, column string, expr parser.Expr) (interface{}, string, error) {
+	if isConstantExpr(expr) {
+		value, err := e.evalExpr(expr, nil)
+		if err != nil {
+			return nil, "", fmt.Errorf("evaluate DEFAULT for column %q: %w", column, err)
+		}
+		return value, "", nil
 	}
-	value, err := e.evalExpr(expr, nil)
-	if err != nil {
-		return nil, fmt.Errorf("evaluate DEFAULT for column %q: %w", column, err)
+	if isWhitelistedDefaultExpr(expr) {
+		return nil, parser.FormatExpr(expr), nil
 	}
-	return value, nil
+	return nil, "", pgerror.Unsupported(
+		"DEFAULT for column %q is not a constant expression: %s; PizzaSQL evaluates a non-constant default at write time only for CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, datetime('now'), date('now'), and time('now'). Omit the default and supply the value on INSERT",
+		column, parser.FormatExpr(expr),
+	)
 }
 
 // isConstantExpr reports whether expr is a literal, a unary sign applied to a
 // literal, a parenthesized constant, or a cast of a constant. Everything else
 // (column references including CURRENT_TIMESTAMP, function calls, subqueries,
-// arithmetic) is rejected because its value can change between statements.
+// arithmetic) is non-constant.
 func isConstantExpr(expr parser.Expr) bool {
 	switch ex := expr.(type) {
 	case *parser.LiteralExpr:
@@ -51,3 +58,146 @@ func isConstantExpr(expr parser.Expr) bool {
 		return false
 	}
 }
+
+// isWhitelistedDefaultExpr reports whether expr is one of the clock-reading
+// defaults PizzaSQL evaluates per write. The list is deliberately small: these
+// 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 != "" {
+			return false
+		}
+		switch strings.ToLower(ex.Column) {
+		case "current_timestamp", "current_date", "current_time":
+			return true
+		}
+	case *parser.FunctionCall:
+		if len(ex.Args) != 1 {
+			return false
+		}
+		switch strings.ToLower(ex.Name) {
+		case "datetime", "date", "time":
+		default:
+			return false
+		}
+		lit, ok := ex.Args[0].(*parser.LiteralExpr)
+		return ok && lit.Type == lexer.TokenString && strings.EqualFold(lit.Value, "now")
+	}
+	return false
+}
+
+// defaultExprCache holds parsed default expressions. The stored form is SQL
+// text validated at DDL time, so parsing it again per statement uses the same
+// parser path and keeps the catalog independent of the AST.
+var defaultExprCache sync.Map // map[string]parser.Expr
+
+func parseDefaultExpr(text string) (parser.Expr, error) {
+	if cached, ok := defaultExprCache.Load(text); ok {
+		if expr, ok := cached.(parser.Expr); ok {
+			return expr, nil
+		}
+	}
+	expr, err := parser.ParseExpr(text)
+	if err != nil {
+		return nil, fmt.Errorf("invalid stored DEFAULT expression %q: %w", text, err)
+	}
+	defaultExprCache.Store(text, expr)
+	return expr, nil
+}
+
+// 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:
+// CURRENT_TIMESTAMP and datetime('now') yield "2006-01-02 15:04:05" UTC,
+// CURRENT_DATE and date('now') yield "2006-01-02", and CURRENT_TIME and
+// time('now') yield "15:04:05". That space-separated UTC text is what the
+// engine already stores and what the timestamptz OID path emits, so clients
+// that decode timestamptz accept it.
+func evalDefaultExpressionAt(text string, now time.Time) (interface{}, error) {
+	expr, err := parseDefaultExpr(text)
+	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",
+			text,
+		)
+	}
+	switch ex := expr.(type) {
+	case *parser.ColumnRef:
+		value, _ := sqliteCurrentTimeValueAt(ex.Column, now)
+		return value, nil
+	case *parser.FunctionCall:
+		keyword := map[string]string{
+			"datetime": "current_timestamp",
+			"date":     "current_date",
+			"time":     "current_time",
+		}[strings.ToLower(ex.Name)]
+		value, _ := sqliteCurrentTimeValueAt(keyword, now)
+		return value, nil
+	}
+	return nil, pgerror.Unsupported("stored DEFAULT expression %q is not supported", text)
+}
+
+// statementDefaults evaluates every expression default in the schema once for
+// the statement. A multi-row INSERT therefore stamps all its rows with one
+// 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
+		}
+		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
+}
+
+// statementNow returns the instant one statement's expression defaults use.
+// The executor samples it once per INSERT/UPDATE; the storage layer samples it
+// once per ALTER TABLE statement. Tests inject e.clock to make it deterministic.
+func (e *Executor) statementNow() time.Time {
+	if e.clock != nil {
+		return e.clock().UTC()
+	}
+	return time.Now().UTC()
+}
+
+// applyRowDefaults fills the expression-defaulted columns a statement omitted.
+// An explicit NULL is already present in the row, so it wins over the default.
+func applyRowDefaults(row storage.Row, defaults map[string]interface{}) {
+	for name, value := range defaults {
+		if _, present := lookupRowValue(row, name); present {
+			continue
+		}
+		row[name] = value
+	}
+}

+ 390 - 9
pkg/executor/ddl_defaults_test.go

@@ -3,21 +3,402 @@ package executor
 import (
 	"errors"
 	"testing"
+	"time"
 
 	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
 
-// Non-constant defaults cannot be stored in the schema catalog without freezing
-// their value at DDL time, so they must be rejected instead of silently
-// producing a constant.
-func TestCreateTableRejectsNonConstantDefaults(t *testing.T) {
+// testClock lets a test move the clock forward deterministically between
+// statements, so per-statement default evaluation is observable without
+// depending on wall-clock resolution.
+type testClock struct {
+	now time.Time
+}
+
+func (c *testClock) Now() time.Time { return c.now }
+
+func (c *testClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
+
+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)
+	clock := newTestClock()
+	e.clock = clock.Now
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+
+	res := execMust(t, e, "SELECT created_at FROM t WHERE id = 1")
+	if res.RowCount != 1 {
+		t.Fatalf("row count = %d, want 1", res.RowCount)
+	}
+	want := "2026-01-02 03:04:05"
+	if got := res.Rows[0][0]; got != want {
+		t.Fatalf("created_at = %v, want %q", got, want)
+	}
+}
+
+func TestCurrentDateAndTimeDefaults(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, d DATE DEFAULT CURRENT_DATE, tm TEXT DEFAULT CURRENT_TIME)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+
+	res := execMust(t, e, "SELECT d, tm FROM t WHERE id = 1")
+	if got, want := res.Rows[0][0], "2026-01-02"; got != want {
+		t.Fatalf("date = %v, want %q", got, want)
+	}
+	if got, want := res.Rows[0][1], "03:04:05"; got != want {
+		t.Fatalf("time = %v, want %q", got, want)
+	}
+}
+
+func TestWhitelistedDatetimeNowDefaults(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, ts TIMESTAMP DEFAULT datetime('now'), d DATE DEFAULT date('now'), tm TEXT DEFAULT time('now'))")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+
+	res := execMust(t, e, "SELECT ts, d, tm FROM t WHERE id = 1")
+	if got, want := res.Rows[0][0], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("ts = %v, want %q", got, want)
+	}
+	if got, want := res.Rows[0][1], "2026-01-02"; got != want {
+		t.Fatalf("d = %v, want %q", got, want)
+	}
+	if got, want := res.Rows[0][2], "03:04:05"; got != want {
+		t.Fatalf("tm = %v, want %q", got, want)
+	}
+}
+
+func TestExplicitNullWinsOverNonConstantDefault(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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t (id, created_at) VALUES (1, NULL)")
+
+	res := execMust(t, e, "SELECT created_at FROM t WHERE id = 1")
+	if got := res.Rows[0][0]; got != nil {
+		t.Fatalf("created_at = %v, want NULL (an explicit NULL wins)", got)
+	}
+}
+
+func TestUpdateOmittingColumnDoesNotReevaluateDefault(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	clock := newTestClock()
+	e.clock = clock.Now
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t (id, label) VALUES (1, 'first')")
+
+	clock.Advance(time.Hour)
+	execMust(t, e, "UPDATE t SET label = 'second' WHERE id = 1")
+
+	res := execMust(t, e, "SELECT label, created_at FROM t WHERE id = 1")
+	if got := res.Rows[0][0]; got != "second" {
+		t.Fatalf("label = %v, want second", got)
+	}
+	if got, want := res.Rows[0][1], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("created_at = %v, want the INSERT-time %q (UPDATE must not re-evaluate)", got, want)
+	}
+}
+
+func TestInsertDefaultValuesUsesNonConstantDefault(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, label TEXT DEFAULT 'new', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t DEFAULT VALUES")
+
+	res := execMust(t, e, "SELECT label, created_at FROM t")
+	if res.RowCount != 1 {
+		t.Fatalf("row count = %d, want 1", res.RowCount)
+	}
+	if got := res.Rows[0][0]; got != "new" {
+		t.Fatalf("label = %v, want new", got)
+	}
+	if got, want := res.Rows[0][1], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("created_at = %v, want %q", got, want)
+	}
+}
+
+func TestNonConstantDefaultEvaluatedOncePerStatement(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	clock := newTestClock()
+	e.clock = clock.Now
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+	clock.Advance(time.Minute)
+	execMust(t, e, "INSERT INTO t (id) VALUES (2)")
+
+	res := execMust(t, e, "SELECT id, created_at FROM t ORDER BY id")
+	first := res.Rows[0][1]
+	second := res.Rows[1][1]
+	if first == second {
+		t.Fatalf("two INSERT statements produced the same timestamp %v; each statement must evaluate its own default", first)
+	}
+	if compare(first, second) >= 0 {
+		t.Fatalf("timestamps %v then %v are not ordered", first, second)
+	}
+}
+
+func TestNonConstantDefaultAppliesInsideTransaction(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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+	execMust(t, e, "COMMIT")
+
+	// Read through a fresh executor so the value is the committed row, not the
+	// transaction's staged overlay.
+	e2 := newExec(schema, table)
+	res := execMust(t, e2, "SELECT created_at FROM t WHERE id = 1")
+	if got, want := res.Rows[0][0], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("created_at = %v, want %q", got, want)
+	}
+}
+
+func TestMultiRowInsertSharesStatementDefault(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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1), (2)")
+
+	res := execMust(t, e, "SELECT created_at FROM t")
+	if res.Rows[0][0] != res.Rows[1][0] {
+		t.Fatalf("multi-row INSERT timestamps = %v and %v, want one evaluation per statement", res.Rows[0][0], res.Rows[1][0])
+	}
+}
+
+func TestAlterTableAddColumnBackfillsNonConstantDefaultOncePerStatement(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	// A stepping clock makes a per-row evaluation visible: every backfilled row
+	// must share the one instant sampled for the ALTER statement.
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	calls := 0
+	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
+		calls++
+		return at
+	}
+
+	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 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
+
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want once per ALTER statement", calls)
+	}
+	res := execMust(t, e, "SELECT id, created_at FROM t ORDER BY id")
+	first, second := res.Rows[0][1], res.Rows[1][1]
+	if first != second {
+		t.Fatalf("timestamps %v and %v differ; the backfill must use one statement instant", first, second)
+	}
+	if got, want := first, "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("created_at = %v, want the statement instant %q", got, want)
+	}
+
+	col, ok := e.schemaColumn(t, "t", "created_at")
+	if !ok {
+		t.Fatal("created_at column missing from schema")
+	}
+	if col.DefaultExpr == "" {
+		t.Fatal("ADD COLUMN did not persist the default expression")
+	}
+	if col.Default != nil {
+		t.Fatalf("ADD COLUMN stored an evaluated default %v instead of an expression", col.Default)
+	}
+}
+
+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 TestAlterTableAddColumnUniqueClockDefaultUsesStatementInstant(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	calls := 0
+	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
+		calls++
+		return at
+	}
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+	execMust(t, e, "ALTER TABLE t ADD COLUMN tag TEXT DEFAULT CURRENT_TIMESTAMP UNIQUE")
+
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want once per ALTER statement", calls)
+	}
+	res := execMust(t, e, "SELECT id, tag FROM t ORDER BY id")
+	if got, want := res.Rows[0][1], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("tag = %v, want the statement instant %q", got, want)
+	}
+
+	// A later INSERT samples its own statement instant, so it does not collide.
+	execMust(t, e, "INSERT INTO t (id) VALUES (2)")
+	next := execMust(t, e, "SELECT tag FROM t WHERE id = 2")
+	if next.Rows[0][0] == res.Rows[0][1] {
+		t.Fatalf("later INSERT tag = %v, want a new statement instant", next.Rows[0][0])
+	}
+
+	// The published index still rejects an explicit duplicate.
+	if _, err := execSQL(e, "INSERT INTO t (id, tag) VALUES (3, '"+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) {
+	t.Helper()
+	reloaded := storage.NewSchemaManager(e.schema.GetPool(), e.schema.GetDatabaseName())
+	schema, err := reloaded.GetSchema(table)
+	if err != nil {
+		t.Fatalf("reload schema: %v", err)
+	}
+	return schema.GetColumn(column)
+}
+
+func TestNonConstantDefaultSurvivesReload(t *testing.T) {
+	pool, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+
+	reloaded := storage.NewSchemaManager(pool, "testdb")
+	reloadedTable := storage.NewTableManager(pool, reloaded, "testdb")
+	e2 := newExec(reloaded, reloadedTable)
+	clock := newTestClock()
+	e2.clock = clock.Now
+
+	col, ok := e2.schemaColumn(t, "t", "created_at")
+	if !ok {
+		t.Fatal("created_at column missing after reload")
+	}
+	if col.DefaultExpr != "CURRENT_TIMESTAMP" {
+		t.Fatalf("reloaded DefaultExpr = %q, want CURRENT_TIMESTAMP", col.DefaultExpr)
+	}
+	if col.Default != nil {
+		t.Fatalf("reloaded Default = %v, want nil", col.Default)
+	}
+
+	execMust(t, e2, "INSERT INTO t (id) VALUES (1)")
+	res := execMust(t, e2, "SELECT created_at FROM t WHERE id = 1")
+	if got, want := res.Rows[0][0], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("created_at after reload = %v, want %q", got, want)
+	}
+}
+
+func TestUnsupportedNonConstantDefaultsAreRejected(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
 
 	for _, sql := range []string{
-		"CREATE TABLE t1 (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)",
-		"CREATE TABLE t2 (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT datetime('now'))",
-		"CREATE TABLE t3 (id INTEGER PRIMARY KEY, token TEXT DEFAULT lower('X'))",
+		"CREATE TABLE t1 (id INTEGER PRIMARY KEY, token TEXT DEFAULT lower('X'))",
+		"CREATE TABLE t2 (id INTEGER PRIMARY KEY, token TEXT DEFAULT upper('x'))",
+		"CREATE TABLE t3 (id INTEGER PRIMARY KEY, n INTEGER DEFAULT (2 + 3))",
 	} {
 		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
 			t.Fatalf("%s: error = %v, want ErrFeatureNotSupported", sql, err)
@@ -25,12 +406,12 @@ func TestCreateTableRejectsNonConstantDefaults(t *testing.T) {
 	}
 }
 
-func TestAlterTableRejectsNonConstantDefault(t *testing.T) {
+func TestAlterTableRejectsUnsupportedNonConstantDefault(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
 	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
 
-	if _, err := execSQL(e, "ALTER TABLE t ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+	if _, err := execSQL(e, "ALTER TABLE t ADD COLUMN token TEXT DEFAULT lower('X')"); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
 		t.Fatalf("ALTER error = %v, want ErrFeatureNotSupported", err)
 	}
 }

+ 35 - 8
pkg/executor/executor.go

@@ -9,6 +9,7 @@ import (
 	"sort"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
@@ -83,6 +84,11 @@ type Executor struct {
 	lastInsertRowID int64 // rowid of the most recent successful INSERT
 	changes         int64 // rows changed by the most recent INSERT/UPDATE/DELETE
 	totalChanges    int64 // rows changed since this connection opened (monotonic)
+
+	// clock supplies the time for non-constant DEFAULT evaluation. Nil means
+	// time.Now; tests inject it to observe per-statement evaluation without
+	// depending on the wall clock.
+	clock func() time.Time
 }
 
 type correlatedAggCache struct {
@@ -124,6 +130,14 @@ func New(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
 	// evaluator is a package function with no per-connection state, so
 	// registering it here is idempotent and safe across concurrent executors.
 	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. Storage samples the clock once per ALTER
+	// statement through this executor, so the session clock also drives
+	// storage-side evaluation; ordinary writes evaluate defaults once per
+	// statement in this package.
+	schema.SetDefaultEvaluator(evalDefaultExpressionAt)
+	schema.SetDefaultClock(executor.statementNow)
 	schema.SetCheckEvaluator(EvalCheckExpression)
 
 	// Register the main database
@@ -2736,6 +2750,13 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 			}
 		}
 	}
+	// Expression defaults are evaluated once per statement, so all rows of a
+	// multi-row INSERT share one value and the next statement reads the clock
+	// again. Constant defaults are applied by storage from the catalog.
+	defaults, err := e.statementDefaults(schema)
+	if err != nil {
+		return nil, err
+	}
 	force := stmt.OnConflict == parser.ConflictReplace
 
 	// INSERT ... SELECT: materialise the SELECT result and bulk-insert.
@@ -2767,6 +2788,7 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 					dst++
 				}
 			}
+			applyRowDefaults(row, defaults)
 			if len(generated) > 0 {
 				if err := e.ensureGeneratedRowID(tableName, schema, row); err != nil {
 					return nil, err
@@ -2844,6 +2866,7 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 				}
 			}
 
+			applyRowDefaults(row, defaults)
 			if len(generated) > 0 {
 				if err := e.ensureGeneratedRowID(tableName, schema, row); err != nil {
 					return err
@@ -3709,11 +3732,12 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 				col.Nullable = false
 			case parser.ConstraintDefault:
 				if constraint.Default != nil {
-					val, err := constantDefaultValue(e, col.Name, constraint.Default)
+					val, exprText, err := defaultColumnValue(e, col.Name, constraint.Default)
 					if err != nil {
 						return nil, err
 					}
 					col.Default = val
+					col.DefaultExpr = exprText
 				}
 			case parser.ConstraintAutoIncrement:
 				schema.AutoIncrement = true
@@ -4125,10 +4149,12 @@ func (e *Executor) executeAlterTable(stmt *parser.AlterTableStmt) (*Result, erro
 	}
 }
 
-// executeAlterTableAddColumn adds a column to a table. A constant DEFAULT is
-// written into every existing row (see SchemaManager.AddColumn), so the column
-// reads the default on rows that predate the statement as well as on later
-// INSERTs that omit it.
+// executeAlterTableAddColumn adds a column to a table. A DEFAULT is written
+// into every existing row (see SchemaManager.AddColumn), so the column reads
+// the default on rows that predate the statement as well as on later INSERTs
+// that omit it. A constant default is copied from the catalog; a non-constant
+// one is evaluated at a single instant sampled for the ALTER statement, so
+// validation and the backfill write the same CURRENT_TIMESTAMP value.
 func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddColumnAction) (*Result, error) {
 	schema, err := e.schema.GetSchema(table)
 	if err != nil {
@@ -4162,11 +4188,12 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 			uniqueConstraint = &unique
 		case parser.ConstraintDefault:
 			if constraint.Default != nil {
-				val, err := constantDefaultValue(e, col.Name, constraint.Default)
+				val, exprText, err := defaultColumnValue(e, col.Name, constraint.Default)
 				if err != nil {
 					return nil, err
 				}
 				col.Default = val
+				col.DefaultExpr = exprText
 			}
 		case parser.ConstraintCheck:
 			// Validated with the other CHECK predicates once the column shape is
@@ -4192,7 +4219,7 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 	// staged in an open transaction are invisible to that scan, so reject them
 	// here as well; the commit re-validates them against the schema that is
 	// current by then.
-	if !col.Nullable && col.Default == nil {
+	if !col.Nullable && col.Default == nil && col.DefaultExpr == "" {
 		stagedRows, err := e.session.CountFast(table)
 		if err != nil {
 			return nil, err
@@ -4732,7 +4759,7 @@ func (e *Executor) pragmaTableInfo(tableName string) (*Result, error) {
 		if col.PrimaryKey {
 			pk = 1
 		}
-		result.AddRow(int64(i), col.Name, col.Type, int64(notnull), col.Default, int64(pk))
+		result.AddRow(int64(i), col.Name, col.Type, int64(notnull), col.DefaultLiteral(), int64(pk))
 	}
 
 	return result, nil

+ 16 - 18
pkg/executor/goatcounter_migration_test.go

@@ -1,10 +1,7 @@
 package executor
 
 import (
-	"errors"
 	"testing"
-
-	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 )
 
 // TestGoatCounterSQLiteMigrations runs the shapes of GoatCounter's SQLite
@@ -85,12 +82,10 @@ func TestGoatCounterMigrationRebuildShape(t *testing.T) {
 		user_id INTEGER PRIMARY KEY AUTOINCREMENT,
 		site_id INTEGER NOT NULL,
 		email VARCHAR NOT NULL,
-		last_report_at TIMESTAMP NOT NULL DEFAULT ''
+		last_report_at TIMESTAMP NOT NULL DEFAULT current_timestamp
 	)`)
-	// GoatCounter's real migration declares DEFAULT current_timestamp. That is
-	// rejected (see TestGoatCounterMigrationRejectsFrozenTimestampDefault), so
-	// this rebuild-shape test uses a constant default to keep exercising the
-	// copy/drop/rename/index steps.
+	// GoatCounter's real migration declares DEFAULT current_timestamp; the rows
+	// copied below omit it and receive the INSERT ... SELECT evaluation.
 	execMust(t, e, `INSERT INTO users2 (user_id, site_id, email)
 		SELECT user_id, site_id, email FROM users`)
 	execMust(t, e, "DROP TABLE users")
@@ -108,22 +103,25 @@ func TestGoatCounterMigrationRebuildShape(t *testing.T) {
 	}
 }
 
-// TestGoatCounterMigrationRejectsFrozenTimestampDefault records the deliberate
-// break: the GoatCounter schema uses a non-constant timestamp default, which the
-// engine now rejects instead of freezing at DDL time. A fresh GoatCounter tenant
-// cannot be bootstrapped until that schema is changed or a legacy import mode is
-// designed.
-func TestGoatCounterMigrationRejectsFrozenTimestampDefault(t *testing.T) {
+// TestGoatCounterMigrationAcceptsTimestampDefault covers the shape the real
+// 2021-12-09 migration declares: a NOT NULL column with a non-constant
+// timestamp default. The default is evaluated on write, not frozen at DDL time,
+// so every inserted row gets a value.
+func TestGoatCounterMigrationAcceptsTimestampDefault(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
-	_, err := execSQL(e, `CREATE TABLE users2 (
+	if _, err := execSQL(e, `CREATE TABLE users2 (
 		user_id INTEGER PRIMARY KEY AUTOINCREMENT,
 		site_id INTEGER NOT NULL,
 		email VARCHAR NOT NULL,
 		last_report_at TIMESTAMP NOT NULL DEFAULT current_timestamp
-	)`)
-	if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
-		t.Fatalf("error = %v, want ErrFeatureNotSupported", err)
+	)`); err != nil {
+		t.Fatalf("CREATE TABLE: %v", err)
+	}
+	execMust(t, e, "INSERT INTO users2 (site_id, email) VALUES (1, 'a@x')")
+	res := execMust(t, e, "SELECT count(*) FROM users2 WHERE last_report_at IS NOT NULL")
+	if got := res.Rows[0][0]; got != int64(1) {
+		t.Fatalf("rows with a timestamp default = %v, want 1", got)
 	}
 }
 

+ 6 - 1
pkg/executor/sqlite_catalog.go

@@ -303,7 +303,7 @@ func (e *Executor) pragmaTableXInfo(table string) (*Result, error) {
 				hidden = 2
 			}
 		}
-		result.AddRow(int64(i), col.Name, col.Type, notnull, col.Default, pk, hidden)
+		result.AddRow(int64(i), col.Name, col.Type, notnull, col.DefaultLiteral(), pk, hidden)
 	}
 	return result, nil
 }
@@ -351,6 +351,11 @@ func recreateColumnDef(s *storage.Schema, col storage.Column) string {
 	if col.Default != nil {
 		b.WriteString(" DEFAULT ")
 		b.WriteString(sqlLiteral(col.Default))
+	} else if col.DefaultExpr != "" {
+		// The catalog stores the expression text, so the rebuilt DDL keeps the
+		// default non-constant instead of freezing the value it had at DDL time.
+		b.WriteString(" DEFAULT ")
+		b.WriteString(col.DefaultExpr)
 	}
 	if col.GeneratedExpr != "" {
 		b.WriteString(" GENERATED ALWAYS AS (")

+ 35 - 0
pkg/executor/sqlite_catalog_test.go

@@ -190,6 +190,41 @@ func TestRecreateCreateTableSQLQuotesIdentifiers(t *testing.T) {
 	}
 }
 
+func TestRecreateCreateTableSQLKeepsDefaultExpression(t *testing.T) {
+	s := &storage.Schema{
+		Name: "t",
+		Columns: []storage.Column{
+			{Name: "created_at", Type: "TIMESTAMP", Nullable: false, DefaultExpr: "CURRENT_TIMESTAMP"},
+		},
+	}
+	got := recreateCreateTableSQL(s)
+	if !strings.Contains(got, "DEFAULT CURRENT_TIMESTAMP") {
+		t.Fatalf("rebuilt DDL lost the default expression: %q", got)
+	}
+}
+
+// The catalog read must expose the persisted expression text, exactly as SQLite
+// reports it for a non-constant default.
+func TestPragmaTableInfoReportsDefaultExpression(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+
+	res := execMust(t, e, "PRAGMA table_info('t')")
+	if len(res.Rows) != 2 {
+		t.Fatalf("table_info rows = %d, want 2", len(res.Rows))
+	}
+	if got := res.Rows[1][4]; got != "CURRENT_TIMESTAMP" {
+		t.Fatalf("dflt_value = %v, want CURRENT_TIMESTAMP", got)
+	}
+
+	master := execMust(t, e, "SELECT sql FROM sqlite_master WHERE type='table' AND name='t'")
+	sql, _ := master.Rows[0][0].(string)
+	if !strings.Contains(sql, "DEFAULT CURRENT_TIMESTAMP") {
+		t.Fatalf("sqlite_master SQL = %q, want the default expression", sql)
+	}
+}
+
 func TestRecreateCreateTableSQLStripsRowID(t *testing.T) {
 	s := &storage.Schema{
 		Name:       "t",

+ 1 - 1
pkg/httpserver/handler.go

@@ -387,7 +387,7 @@ func (s *Server) handleSchemaTable(w http.ResponseWriter, r *http.Request) {
 			"type":       col.Type,
 			"nullable":   col.Nullable,
 			"primaryKey": col.PrimaryKey,
-			"default":    col.Default,
+			"default":    col.DefaultLiteral(),
 		}
 	}
 

+ 7 - 3
pkg/parser/ast.go

@@ -137,9 +137,13 @@ const (
 
 // InsertStmt represents an INSERT statement.
 type InsertStmt struct {
-	Table             *TableRef
-	Columns           []string
-	Values            [][]Expr
+	Table   *TableRef
+	Columns []string
+	Values  [][]Expr
+	// DefaultValues is true for SQLite's INSERT ... DEFAULT VALUES, which
+	// supplies no values. Values then holds one empty row so the executor
+	// follows the normal single-row insert path.
+	DefaultValues     bool
 	Select            *SelectStmt    // INSERT ... SELECT
 	OnConflict        ConflictAction // OR REPLACE/IGNORE/etc.
 	ConflictTarget    []string

+ 11 - 1
pkg/parser/parser.go

@@ -873,6 +873,16 @@ func (p *Parser) parseInsert() (*InsertStmt, error) {
 			return nil, err
 		}
 		stmt.Values = values
+	} else if p.curTokenIs(lexer.TokenDEFAULT) {
+		// INSERT ... DEFAULT VALUES supplies no values; one row is built from
+		// the schema defaults.
+		p.nextToken()
+		if !p.curTokenIs(lexer.TokenVALUES) {
+			return nil, p.curError("expected VALUES after DEFAULT")
+		}
+		p.nextToken()
+		stmt.DefaultValues = true
+		stmt.Values = [][]Expr{{}}
 	} else if p.curTokenIs(lexer.TokenSELECT) {
 		sel, err := p.parseSelect()
 		if err != nil {
@@ -890,7 +900,7 @@ func (p *Parser) parseInsert() (*InsertStmt, error) {
 		}
 		stmt.Select = sel
 	} else {
-		return nil, p.curError("expected VALUES, SELECT, or WITH")
+		return nil, p.curError("expected VALUES, SELECT, WITH, or DEFAULT VALUES")
 	}
 
 	if p.curTokenIs(lexer.TokenON) {

+ 14 - 0
pkg/parser/parser_test.go

@@ -305,6 +305,20 @@ func TestParseInsertMultipleRows(t *testing.T) {
 	}
 }
 
+func TestParseInsertDefaultValues(t *testing.T) {
+	stmt := parse(t, "INSERT INTO users DEFAULT VALUES")
+	ins, ok := stmt.(*InsertStmt)
+	if !ok {
+		t.Fatalf("expected InsertStmt, got %T", stmt)
+	}
+	if !ins.DefaultValues {
+		t.Fatal("expected DefaultValues to be set")
+	}
+	if len(ins.Values) != 1 || len(ins.Values[0]) != 0 {
+		t.Fatalf("values = %v, want one empty row", ins.Values)
+	}
+}
+
 func TestParseInsertOrReplace(t *testing.T) {
 	stmt := parse(t, "INSERT OR REPLACE INTO users (id, name) VALUES (1, 'John')")
 	ins := stmt.(*InsertStmt)

+ 7 - 1
pkg/pgserver/compat_suite_test.go

@@ -123,7 +123,13 @@ func TestCompatibilitySuite(t *testing.T) {
 
 	expectCode("constraints.unique_23505", `INSERT INTO accounts (id, email) VALUES (3, 'a@x')`, "23505")
 	expectCode("ddl.composite_pk_0A000", `CREATE TABLE bad_pk (a INTEGER, b INTEGER, PRIMARY KEY (a, b))`, "0A000")
-	expectCode("ddl.nonconstant_default_0A000", `CREATE TABLE bad_default (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`, "0A000")
+	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")
 

+ 1 - 1
pkg/pgserver/connection.go

@@ -765,7 +765,7 @@ func (c *Connection) catalogResult(sql string) (*executor.Result, bool, error) {
 			for i, column := range schema.Columns {
 				rows = append(rows, map[string]interface{}{
 					"table_catalog": c.database, "table_schema": "public", "table_name": table,
-					"column_name": column.Name, "ordinal_position": int64(i + 1), "column_default": column.Default,
+					"column_name": column.Name, "ordinal_position": int64(i + 1), "column_default": column.DefaultLiteral(),
 					"is_nullable": yesNo(column.Nullable), "data_type": strings.ToLower(column.Type),
 				})
 			}

+ 6 - 6
pkg/pgserver/unsupported_default_test.go

@@ -23,26 +23,26 @@ func runExtendedMessage(t *testing.T, c *Connection, client net.Conn, msg *Messa
 	return m
 }
 
-func TestNonConstantDefaultRejectedOverSimpleQuery(t *testing.T) {
+func TestUnsupportedDefaultRejectedOverSimpleQuery(t *testing.T) {
 	c, client := newDataConnection(t)
-	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"), 0)})
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY, token TEXT DEFAULT lower('x'))"), 0)})
 	if len(msgs) != 2 {
 		t.Fatalf("got %d messages, want ErrorResponse+ReadyForQuery", len(msgs))
 	}
 	if code := errorCode(msgs[0]); code != ErrCodeFeatureNotSupported {
 		t.Fatalf("error code = %q, want %q", code, ErrCodeFeatureNotSupported)
 	}
-	if message := errorField(t, msgs[0], ErrorFieldMessage); !bytes.Contains([]byte(message), []byte("constant")) {
-		t.Fatalf("error message = %q, want an explanation about constant defaults", message)
+	if message := errorField(t, msgs[0], ErrorFieldMessage); !bytes.Contains([]byte(message), []byte("non-constant")) {
+		t.Fatalf("error message = %q, want an explanation about non-constant defaults", message)
 	}
 }
 
-func TestNonConstantDefaultRejectedOverExtendedQuery(t *testing.T) {
+func TestUnsupportedDefaultRejectedOverExtendedQuery(t *testing.T) {
 	c, client := newDataConnection(t)
 
 	// Parse does not parse the SQL (it is parsed at Execute), so install the
 	// portal directly; this exercises the extended-protocol execution error path.
-	c.portals["p1"] = &portal{query: "CREATE TABLE t (id INTEGER PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"}
+	c.portals["p1"] = &portal{query: "CREATE TABLE t (id INTEGER PRIMARY KEY, token TEXT DEFAULT lower('x'))"}
 
 	execute := NewMessageBuilder()
 	execute.WriteString("p1")

+ 6 - 2
pkg/sqlexport/export.go

@@ -132,8 +132,12 @@ func generateCreateTable(schema *storage.Schema) string {
 			sb.WriteString(" NOT NULL")
 		}
 
-		// Add DEFAULT value
-		if col.Default != nil {
+		// Add DEFAULT value. A non-constant default is emitted as its stored SQL
+		// text so the dump keeps evaluating it instead of freezing a value.
+		if col.DefaultExpr != "" {
+			sb.WriteString(" DEFAULT ")
+			sb.WriteString(col.DefaultExpr)
+		} else if col.Default != nil {
 			sb.WriteString(" DEFAULT ")
 			sb.WriteString(formatValue(col.Default, col.Type))
 		}

+ 47 - 0
pkg/sqlexport/export_test.go

@@ -0,0 +1,47 @@
+package sqlexport
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+)
+
+// A schema dump must keep a non-constant default as SQL text; emitting the
+// value evaluated at export time would freeze it.
+func TestExportPreservesDefaultExpression(t *testing.T) {
+	kv := testkv.New(t)
+	pool := kv.Pool(4)
+	t.Cleanup(func() { pool.Close() })
+
+	schemaManager := storage.NewSchemaManager(pool, "testdb")
+	tableManager := storage.NewTableManager(pool, schemaManager, "testdb")
+
+	if err := schemaManager.CreateTable(&storage.Schema{
+		Name: "events",
+		Columns: []storage.Column{
+			{Name: "id", Type: "INTEGER", Nullable: false, PrimaryKey: true},
+		},
+	}); err != nil {
+		t.Fatalf("create table: %v", err)
+	}
+	// The table is empty, so the schema manager needs no row evaluator for the
+	// rewrite and the column can be declared with an expression default.
+	if err := schemaManager.AddColumn("events", storage.Column{
+		Name:        "created_at",
+		Type:        "TIMESTAMP",
+		Nullable:    true,
+		DefaultExpr: "CURRENT_TIMESTAMP",
+	}, nil, nil); err != nil {
+		t.Fatalf("add column: %v", err)
+	}
+
+	dump, err := ExportDatabase(schemaManager, tableManager, ExportOptions{Tables: []string{"events"}})
+	if err != nil {
+		t.Fatalf("export: %v", err)
+	}
+	if !strings.Contains(dump, "DEFAULT CURRENT_TIMESTAMP") {
+		t.Fatalf("dump lost the default expression:\n%s", dump)
+	}
+}

+ 38 - 3
pkg/storage/constraints.go

@@ -3,6 +3,7 @@ package storage
 import (
 	"fmt"
 	"strings"
+	"time"
 )
 
 // PrimaryKeyViolationError preserves duplicate-key identity through wrapped
@@ -94,18 +95,52 @@ func (m *SchemaManager) validateRowConstraints(schema *Schema, row Row) error {
 // still does not select a default. Callers validate CHECK predicates separately
 // through validateRowConstraints/validateChecks so each write site can run them
 // against the final row image.
-func applySchemaDefaults(schema *Schema, row Row) error {
+//
+// A constant default is copied from the catalog. An expression default is
+// evaluated here instead, so a row that reaches storage without one (for
+// example a staged transaction row whose column was published later) receives a
+// value rather than freezing the DDL-time one. Ordinary INSERT/UPDATE resolve
+// expression defaults in the executor before the write; this is the fallback
+// for every other path.
+func (m *SchemaManager) applySchemaDefaults(schema *Schema, row Row) error {
 	for _, column := range schema.Columns {
 		if _, ok := rowColumnValue(row, column.Name); ok {
 			continue
 		}
-		if column.Default != nil {
-			row[column.Name] = column.Default
+		value, ok, err := m.columnDefaultValue(column, m.defaultNow())
+		if err != nil {
+			return err
+		}
+		if ok {
+			row[column.Name] = value
 		}
 	}
 	return validateNotNull(schema, row)
 }
 
+// columnDefaultValue returns the value a row receives for a column it omits.
+// A column with no default reports ok=false. DefaultExpr takes precedence over
+// Default; the two are never both set by DDL, which keeps constant defaults in
+// the catalog as values and clock-reading defaults as SQL text. now is the
+// instant the caller sampled for its statement; a constant default ignores it.
+func (m *SchemaManager) columnDefaultValue(column Column, now time.Time) (interface{}, bool, error) {
+	if column.DefaultExpr != "" {
+		eval := m.defaultEvaluator()
+		if eval == nil {
+			return nil, false, fmt.Errorf("column %q has a DEFAULT expression but no evaluator is installed", column.Name)
+		}
+		value, err := eval(column.DefaultExpr, now)
+		if err != nil {
+			return nil, false, fmt.Errorf("evaluate DEFAULT for column %q: %w", column.Name, err)
+		}
+		return value, true, nil
+	}
+	if column.Default != nil {
+		return column.Default, true, nil
+	}
+	return nil, false, nil
+}
+
 // rowColumnValue returns the value a row holds for a column, matching the column
 // name case-insensitively.
 func rowColumnValue(row Row, name string) (interface{}, bool) {

+ 179 - 43
pkg/storage/schema.go

@@ -50,11 +50,17 @@ func (c CheckConstraint) Label() string {
 
 // Column represents a column definition.
 type Column struct {
-	Name       string      `json:"name"`
-	Type       string      `json:"type"`
-	Nullable   bool        `json:"nullable"`
-	Default    interface{} `json:"default,omitempty"`
-	PrimaryKey bool        `json:"primary_key"`
+	Name     string `json:"name"`
+	Type     string `json:"type"`
+	Nullable bool   `json:"nullable"`
+	// Default holds the evaluated constant DEFAULT. DefaultExpr holds the SQL
+	// text of a non-constant DEFAULT (CURRENT_TIMESTAMP and the other
+	// clock-reading forms) so it is evaluated on every write instead of being
+	// frozen at DDL time. Only one of the two is set; DefaultExpr takes
+	// precedence if a hand-edited catalog sets both.
+	Default     interface{} `json:"default,omitempty"`
+	DefaultExpr string      `json:"default_expr,omitempty"`
+	PrimaryKey  bool        `json:"primary_key"`
 	// GeneratedExpr holds the SQL text of a GENERATED ALWAYS AS (expr) column.
 	// GeneratedStored reports whether the value is materialized on write
 	// (STORED, the only form this engine persists) versus computed on read.
@@ -62,6 +68,19 @@ type Column struct {
 	GeneratedStored bool   `json:"generated_stored,omitempty"`
 }
 
+// DefaultLiteral returns the catalog representation of the column's default:
+// the evaluated constant value, or the SQL text of a non-constant default.
+// DefaultExpr takes precedence when a hand-edited catalog sets both.
+func (c Column) DefaultLiteral() interface{} {
+	if c.DefaultExpr != "" {
+		return c.DefaultExpr
+	}
+	if c.Default != nil {
+		return c.Default
+	}
+	return nil
+}
+
 // Index represents an index definition.
 type Index struct {
 	Name      string        `json:"name"`
@@ -113,6 +132,60 @@ type SchemaManager struct {
 
 	rowIDMu    sync.Mutex
 	rowIDAlloc map[string]*rowIDAllocator
+
+	// defaultEval evaluates a persisted non-constant DEFAULT expression. The
+	// SQL executor registers one so storage can backfill ADD COLUMN rows and
+	// complete row images without importing the parser or executor.
+	// defaultClock supplies the instant a DDL statement samples once for every
+	// expression default it touches.
+	defaultEvalMu sync.RWMutex
+	defaultEval   DefaultEvaluator
+	defaultClock  DefaultClock
+}
+
+// DefaultEvaluator evaluates a persisted non-constant DEFAULT expression, such
+// as CURRENT_TIMESTAMP, at the supplied instant. Expression defaults are never
+// frozen at DDL time; the caller samples one instant per statement so every row
+// it touches sees the same value.
+type DefaultEvaluator func(expression string, now time.Time) (interface{}, error)
+
+// DefaultClock supplies the instant a statement's expression defaults use. The
+// SQL executor registers a clock backed by the session so a storage-side ADD
+// COLUMN backfill matches statement-time now().
+type DefaultClock func() time.Time
+
+// SetDefaultEvaluator installs the evaluator used for expression defaults.
+func (m *SchemaManager) SetDefaultEvaluator(eval DefaultEvaluator) {
+	m.defaultEvalMu.Lock()
+	m.defaultEval = eval
+	m.defaultEvalMu.Unlock()
+}
+
+// SetDefaultClock installs the clock sampled once per DDL statement for
+// expression defaults.
+func (m *SchemaManager) SetDefaultClock(clock DefaultClock) {
+	m.defaultEvalMu.Lock()
+	m.defaultClock = clock
+	m.defaultEvalMu.Unlock()
+}
+
+func (m *SchemaManager) defaultEvaluator() DefaultEvaluator {
+	m.defaultEvalMu.RLock()
+	defer m.defaultEvalMu.RUnlock()
+	return m.defaultEval
+}
+
+// defaultNow samples the registered clock, falling back to the wall clock.
+// Callers sample it once per statement and pass the instant down so validation
+// and the rewrite observe the same value.
+func (m *SchemaManager) defaultNow() time.Time {
+	m.defaultEvalMu.RLock()
+	clock := m.defaultClock
+	m.defaultEvalMu.RUnlock()
+	if clock != nil {
+		return clock().UTC()
+	}
+	return time.Now().UTC()
 }
 
 // CheckEvaluator reports whether a persisted CHECK expression holds for a row.
@@ -994,26 +1067,33 @@ func (m *SchemaManager) removeFromIndexList(name string) error {
 // UNIQUE index is registered as part of the same statement, and checks are
 // persisted with the column in the same schema snapshot.
 //
-// A constant DEFAULT is materialized into every existing row before the schema
-// is published. NOT NULL, UNIQUE, CHECK, 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, a CHECK predicate could not see a
-// violating default, and an UPDATE to NULL would look non-null. The rewrite is
-// the same shape as DropColumn: the table's exclusive gate keeps point writes
-// and CAS commits out while rows are scanned, validated, and rewritten, the
-// transform is idempotent, and the schema is saved last so an interrupted
-// operation resumes against the old schema.
+// A DEFAULT is materialized into every existing row before the schema is
+// published. NOT NULL, UNIQUE, CHECK, 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, a CHECK predicate could not see a violating
+// default, and an UPDATE to NULL would look non-null. The rewrite is the same
+// shape as DropColumn: the table's exclusive gate keeps point writes and CAS
+// commits out while rows are scanned, validated, and 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,
 // duplicate UNIQUE values, and rows a new CHECK predicate rejects are rejected
-// before any row is touched, and the column, its checks, and its index are
-// published together in one batch write, so a crash cannot leave the column
-// visible without the constraints that validate 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 per validating constraint plus a table-wide stall
-// per ADD COLUMN that has a default, an index, or a check.
+// before the column, its checks, and its index are published, and they are
+// published together in one batch write so a crash cannot leave the column
+// visible without the constraints that validate it. A constant default is
+// deterministic, so its UNIQUE prediction can run as a cheap pre-scan. A
+// clock-reading default is evaluated at one instant sampled for this ALTER
+// statement, and that instant is passed to every validation scan and to the
+// rewrite, so a clock crossing between them cannot publish a row that violates
+// the constraint the statement adds; the UNIQUE duplicate set is still
+// collected during the rewrite so it covers exactly the values the index will
+// see. 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 per validating
+// constraint plus a table-wide stall per ADD COLUMN that has a default, an
+// index, or a check.
 func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckConstraint, unique *Index) error {
 	tableLock := m.tableLock(table)
 	tableLock.Lock()
@@ -1022,6 +1102,12 @@ func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckCon
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
+	// One instant for the whole ALTER statement: every validation scan and the
+	// row rewrite must observe the same value, or a clock crossing between them
+	// could publish rows that violate the CHECK or UNIQUE constraint this
+	// statement adds.
+	now := m.defaultNow()
+
 	schema, err := m.getSchemaUnsafe(table)
 	if err != nil {
 		return err
@@ -1036,17 +1122,23 @@ func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckCon
 		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 so the
+		// duplicate set covers exactly the values this statement writes. The
+		// pre-scan is kept for constant and absent defaults, which are
+		// deterministic.
+		if column.DefaultExpr == "" {
+			if err := m.validateAddColumnUnique(table, column, unique, now); err != nil {
+				return err
+			}
 		}
 	}
-	if err := m.validateAddColumnChecks(table, column, checks); err != nil {
+	if err := m.validateAddColumnChecks(table, column, checks, now); err != nil {
 		return err
 	}
 
 	// A required column with nothing to write into rows that already exist
 	// cannot be declared on a populated table.
-	if !column.Nullable && column.Default == nil {
+	if !column.Nullable && !columnHasDefault(column) {
 		hasRows, err := m.tableHasRows(table)
 		if err != nil {
 			return err
@@ -1056,13 +1148,36 @@ func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckCon
 		}
 	}
 
-	if column.Default != nil {
-		if err := m.rewriteRows(table, func(row Row) bool {
-			if _, ok := rowColumnValue(row, column.Name); ok {
-				return false
+	// An expression default is evaluated at the ALTER statement's instant. When
+	// the column is also UNIQUE, collect the values this rewrite writes and
+	// abort on a duplicate, so the index is validated against exactly the values
+	// it will see.
+	var seen map[string]struct{}
+	if unique != nil && column.DefaultExpr != "" {
+		seen = make(map[string]struct{})
+	}
+	if columnHasDefault(column) {
+		if err := m.applyRows(table, func(row Row) (bool, error) {
+			value, present := rowColumnValue(row, column.Name)
+			if !present {
+				var err error
+				value, _, err = m.columnDefaultValue(column, now)
+				if err != nil {
+					return false, err
+				}
+				row[column.Name] = value
+			}
+			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] = column.Default
-			return true
+			return !present, nil
 		}); err != nil {
 			return err
 		}
@@ -1602,15 +1717,21 @@ 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. 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 {
+func (m *SchemaManager) validateAddColumnUnique(table string, column Column, index *Index, now time.Time) error {
 	seen := make(map[string]struct{})
 	return m.applyRows(table, func(row Row) (bool, error) {
 		value, ok := rowColumnValue(row, column.Name)
 		if !ok {
-			value = column.Default
+			var err error
+			value, _, err = m.columnDefaultValue(column, now)
+			if err != nil {
+				return false, err
+			}
 		}
 		if value == nil {
 			return false, nil
@@ -1624,13 +1745,22 @@ func (m *SchemaManager) validateAddColumnUnique(table string, column Column, ind
 	})
 }
 
+// columnHasDefault reports whether the column contributes a value to rows that
+// omit it, whether that default is a stored constant or an expression.
+func columnHasDefault(column Column) bool {
+	return column.Default != nil || column.DefaultExpr != ""
+}
+
 // validateAddColumnChecks rejects an ADD COLUMN ... CHECK whose predicate is
 // already false for an existing row. Like validateAddColumnUnique it reads rows
-// as they will look after the constant-default backfill, and it runs before that
-// rewrite so a rejected DDL leaves no row carrying the new field. The caller
-// holds the table's exclusive gate, so no writer can insert a violating row
-// between this validation and the publication of the check.
-func (m *SchemaManager) validateAddColumnChecks(table string, column Column, checks []CheckConstraint) error {
+// as they will look after the default backfill, and it runs before that rewrite
+// so a rejected DDL leaves no row carrying the new field. The default is
+// resolved through columnDefaultValue at the instant sampled once for the
+// ALTER statement, so a clock-reading default reaches the predicate as the value
+// the backfilled row will receive instead of NULL. The caller holds the table's
+// exclusive gate, so no writer can insert a violating row between this
+// validation and the publication of the check.
+func (m *SchemaManager) validateAddColumnChecks(table string, column Column, checks []CheckConstraint, now time.Time) error {
 	if len(checks) == 0 {
 		return nil
 	}
@@ -1639,8 +1769,14 @@ func (m *SchemaManager) validateAddColumnChecks(table string, column Column, che
 		return fmt.Errorf("cannot validate CHECK constraints on %s: no CHECK evaluator installed", table)
 	}
 	return m.applyRows(table, func(row Row) (bool, error) {
-		if _, ok := rowColumnValue(row, column.Name); !ok && column.Default != nil {
-			row[column.Name] = column.Default
+		if _, ok := rowColumnValue(row, column.Name); !ok {
+			value, hasDefault, err := m.columnDefaultValue(column, now)
+			if err != nil {
+				return false, err
+			}
+			if hasDefault {
+				row[column.Name] = value
+			}
 		}
 		for _, check := range checks {
 			holds, err := eval(check.Expression, row)

+ 20 - 6
pkg/storage/table.go

@@ -537,8 +537,15 @@ func (m *TableManager) prepareInsert(table string, row Row) (Row, string, error)
 		}
 	}
 	for _, col := range schema.Columns {
-		if _, ok := normalizedRow[col.Name]; !ok && col.Default != nil {
-			normalizedRow[col.Name] = col.Default
+		if _, ok := normalizedRow[col.Name]; ok {
+			continue
+		}
+		value, hasDefault, err := m.schema.columnDefaultValue(col, m.schema.defaultNow())
+		if err != nil {
+			return nil, "", err
+		}
+		if hasDefault {
+			normalizedRow[col.Name] = value
 		}
 	}
 
@@ -595,7 +602,7 @@ func (m *TableManager) InsertWithRowID(table string, row Row) (int64, error) {
 	if err != nil {
 		return 0, err
 	}
-	if err := applySchemaDefaults(schema, nr); err != nil {
+	if err := m.schema.applySchemaDefaults(schema, nr); err != nil {
 		return 0, err
 	}
 	if err := m.schema.validateChecks(schema, nr); err != nil {
@@ -718,8 +725,15 @@ func (m *TableManager) InsertBulkWithLastRowID(table string, rows []Row) (int, i
 			}
 		}
 		for _, col := range schema.Columns {
-			if _, ok := nr[col.Name]; !ok && col.Default != nil {
-				nr[col.Name] = col.Default
+			if _, ok := nr[col.Name]; ok {
+				continue
+			}
+			value, hasDefault, err := m.schema.columnDefaultValue(col, m.schema.defaultNow())
+			if err != nil {
+				return 0, 0, err
+			}
+			if hasDefault {
+				nr[col.Name] = value
 			}
 		}
 		var rowid int64
@@ -1102,7 +1116,7 @@ func (m *TableManager) UpdateByPK(table, pk string, updateFn func(Row) (Row, err
 		}
 	}
 
-	if err := applySchemaDefaults(schema, row); err != nil {
+	if err := m.schema.applySchemaDefaults(schema, row); err != nil {
 		return nil, false, err
 	}
 	if err := m.schema.validateChecks(schema, row); err != nil {

+ 1 - 1
pkg/storage/tx.go

@@ -426,7 +426,7 @@ func (s *Session) applyOverlaySchemaDefaults() error {
 			if entry.absent {
 				continue
 			}
-			if err := applySchemaDefaults(schema, entry.row); err != nil {
+			if err := s.schema.applySchemaDefaults(schema, entry.row); err != nil {
 				return err
 			}
 			if err := s.schema.validateChecks(schema, entry.row); err != nil {