Sfoglia il codice sorgente

feat: evaluate non-constant column defaults per statement

The schema catalog stores an evaluated constant default, so a
non-constant default such as CURRENT_TIMESTAMP used to be rejected with
0A000 instead of being frozen at DDL time.

Persist the whitelisted clock-reading defaults as SQL text in a new
Column.DefaultExpr field and evaluate them when a row is written:

- CREATE TABLE and ALTER TABLE ADD COLUMN accept CURRENT_TIMESTAMP,
  CURRENT_DATE, CURRENT_TIME, datetime('now'), date('now'), and
  time('now'). Every other non-constant expression is still rejected
  with 0A000.
- INSERT evaluates expression defaults once per statement, so all rows
  of a multi-row INSERT share one value and a later statement reads the
  clock again. Constant defaults keep their catalog representation.
- ADD COLUMN rewrites existing rows and evaluates the default per row,
  so the backfill is stamped at rewrite time.
- INSERT ... DEFAULT VALUES is parsed and uses the schema defaults.
- An explicit NULL still wins over a default.
- PRAGMA table_info/table_xinfo, sqlite_master, information_schema,
  the table-schema HTTP payload, and SQL export report the stored
  expression instead of dropping it.

Storage gets a SchemaManager default evaluator (registered by the
executor) so ADD COLUMN backfill and row completion do not depend on
the SQL front end.

Values use the engine's existing datetime convention: UTC text
"2006-01-02 15:04:05", "2006-01-02", and "15:04:05", which the
timestamptz OID path already emits and clients decode.
Danilo Fragoso 1 giorno fa
parent
commit
4d23d7e606

+ 5 - 1
pkg/analyzer/analyzer.go

@@ -535,8 +535,12 @@ 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.
 	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: false, Status: "rejected", SQLState: "0A000", Notes: "inline and table-level CHECK are rejected; enforce the rule in the application"},
-			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 (per row 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, and the column is published together with its 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 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"},
 			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)"},

+ 17 - 1
pkg/capabilities/capabilities_test.go

@@ -66,6 +66,22 @@ 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)
 	}
 }

+ 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
 }

+ 155 - 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,133 @@ 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 {
+	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 {
+		return cached.(parser.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
+}
+
+// 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:
+// 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
+	}
+	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) {
+	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 {
+			defaults = make(map[string]interface{})
+		}
+		defaults[column.Name] = value
+	}
+	return defaults, nil
+}
+
+func (e *Executor) evalDefaultExpr(text string) (interface{}, error) {
+	now := time.Now().UTC()
+	if e.clock != nil {
+		now = e.clock().UTC()
+	}
+	return evalDefaultExpressionAt(text, now)
+}
+
+// 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
+	}
+}

+ 270 - 9
pkg/executor/ddl_defaults_test.go

@@ -2,22 +2,283 @@ package executor
 
 import (
 	"errors"
+	"fmt"
 	"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)}
+}
+
+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 TestAlterTableAddColumnBackfillsNonConstantDefaultPerRow(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), (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)
+	}
+	if got, want := res.Rows[1][1], "stamp-2"; got != want {
+		t.Fatalf("row 2 created_at = %v, want %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)
+	}
+}
+
+// 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 +286,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)
 	}
 }

+ 32 - 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"
@@ -77,6 +78,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 {
@@ -118,6 +124,11 @@ func New(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
 	// 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. Ordinary writes evaluate them once per statement
+	// in this package.
+	schema.SetDefaultEvaluator(EvalDefaultExpression)
 
 	// Register the main database
 	executor.attachedDatabases["main"] = &DatabaseConnection{
@@ -2729,6 +2740,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.
@@ -2760,6 +2778,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
@@ -2837,6 +2856,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
@@ -3702,11 +3722,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
@@ -4113,10 +4134,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 per rewritten row, so CURRENT_TIMESTAMP stamps the backfill
+// at rewrite time.
 func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddColumnAction) (*Result, error) {
 	schema, err := e.schema.GetSchema(table)
 	if err != nil {
@@ -4149,11 +4172,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:
 			return nil, pgerror.Unsupported(
@@ -4179,7 +4203,7 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 	// validation and the publication. Rows this session 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
@@ -4708,7 +4732,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
 }
@@ -341,6 +341,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

@@ -385,7 +385,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)

+ 4 - 1
pkg/pgserver/compat_suite_test.go

@@ -123,7 +123,10 @@ 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")
+	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

@@ -739,7 +739,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); 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)
+	}
+}

+ 36 - 3
pkg/storage/constraints.go

@@ -47,18 +47,51 @@ func validateNotNull(schema *Schema, row Row) error {
 // durable NOT NULL violation, and gives it the default every other row received
 // from the backfill. An absent key is the only thing filled, so an explicit NULL
 // still does not select a default.
-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)
+		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.
+func (m *SchemaManager) columnDefaultValue(column Column) (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)
+		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) {

+ 76 - 13
pkg/storage/schema.go

@@ -29,11 +29,17 @@ type Schema struct {
 
 // 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.
@@ -41,6 +47,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"`
@@ -86,6 +105,30 @@ 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.
+	defaultEvalMu sync.RWMutex
+	defaultEval   DefaultEvaluator
+}
+
+// DefaultEvaluator evaluates a persisted non-constant DEFAULT expression, such
+// as CURRENT_TIMESTAMP, to the value a row receives. Expression defaults are
+// never frozen at DDL time: each write reads the clock again.
+type DefaultEvaluator func(expression string) (interface{}, error)
+
+// SetDefaultEvaluator installs the evaluator used for expression defaults.
+func (m *SchemaManager) SetDefaultEvaluator(eval DefaultEvaluator) {
+	m.defaultEvalMu.Lock()
+	m.defaultEval = eval
+	m.defaultEvalMu.Unlock()
+}
+
+func (m *SchemaManager) defaultEvaluator() DefaultEvaluator {
+	m.defaultEvalMu.RLock()
+	defer m.defaultEvalMu.RUnlock()
+	return m.defaultEval
 }
 
 // BeginTransaction is retained for API compatibility. Buffered per-session
@@ -992,7 +1035,7 @@ func (m *SchemaManager) AddColumn(table string, column Column, unique *Index) er
 
 	// 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
@@ -1002,13 +1045,20 @@ func (m *SchemaManager) AddColumn(table string, column Column, unique *Index) er
 		}
 	}
 
-	if column.Default != nil {
-		if err := m.rewriteRows(table, func(row Row) bool {
+	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
+				return false, nil
+			}
+			value, _, err := m.columnDefaultValue(column)
+			if err != nil {
+				return false, err
 			}
-			row[column.Name] = column.Default
-			return true
+			row[column.Name] = value
+			return true, nil
 		}); err != nil {
 			return err
 		}
@@ -1546,7 +1596,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. The caller holds the table's exclusive
+// 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
 // publication of the index.
 func (m *SchemaManager) validateAddColumnUnique(table string, column Column, index *Index) error {
@@ -1554,7 +1607,11 @@ func (m *SchemaManager) validateAddColumnUnique(table string, column Column, ind
 	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)
+			if err != nil {
+				return false, err
+			}
 		}
 		if value == nil {
 			return false, nil
@@ -1568,6 +1625,12 @@ 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 != ""
+}
+
 // tableHasRows reports whether a table holds at least one durable row.
 func (m *SchemaManager) tableHasRows(table string) (bool, error) {
 	prefix := []byte(fmt.Sprintf("%s:_data:%s:", m.database, strings.ToLower(table)))

+ 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)
+		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
 	}
 	data, err := encodeRow(nr)
@@ -715,8 +722,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)
+			if err != nil {
+				return 0, 0, err
+			}
+			if hasDefault {
+				nr[col.Name] = value
 			}
 		}
 		var rowid int64
@@ -1099,7 +1113,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
 	}
 	data, err := encodeRow(row)

+ 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
 			}
 		}