Sfoglia il codice sorgente

add SQLite compatibility surface for the GoatCounter backend

Danilo Fragoso 9 ore fa
parent
commit
42c188c917

BIN
bin/pizzasql


+ 130 - 25
pkg/analyzer/analyzer.go

@@ -89,6 +89,9 @@ func (a *Analyzer) Analyze(stmt parser.Statement) error {
 		*parser.SavepointStmt, *parser.ReleaseStmt:
 		// Transaction statements don't need semantic analysis
 		return nil
+	case *parser.AnalyzeStmt:
+		// ANALYZE is a documented no-op; nothing to validate.
+		return nil
 	case *parser.CreateIndexStmt, *parser.DropIndexStmt:
 		// Index statements don't need semantic analysis
 		return nil
@@ -481,7 +484,10 @@ func (a *Analyzer) analyzeInsert(stmt *parser.InsertStmt) error {
 		}
 	}
 
-	// Validate column list if specified
+	a.scope.DefineTable(&TableInfo{Name: table.Name, Columns: table.Columns, IsView: table.IsView})
+
+	// Validate column list if specified. Generated columns may never be written
+	// explicitly.
 	var targetCols []ColumnInfo
 	if len(stmt.Columns) > 0 {
 		for _, colName := range stmt.Columns {
@@ -492,10 +498,21 @@ func (a *Analyzer) analyzeInsert(stmt *parser.InsertStmt) error {
 					Message: fmt.Sprintf("column not found: %s", colName),
 				}
 			}
+			if col.Generated {
+				return &AnalysisError{
+					Type:    ErrTypeMismatch,
+					Message: fmt.Sprintf("cannot INSERT into generated column %s", colName),
+				}
+			}
 			targetCols = append(targetCols, *col)
 		}
 	} else {
-		targetCols = table.Columns
+		// Positional values map to the table's non-generated columns.
+		for _, col := range table.Columns {
+			if !col.Generated {
+				targetCols = append(targetCols, col)
+			}
+		}
 	}
 
 	// Validate VALUES
@@ -507,31 +524,47 @@ func (a *Analyzer) analyzeInsert(stmt *parser.InsertStmt) error {
 			}
 		}
 
-		for i, expr := range row {
-			info, err := a.analyzeExpr(expr)
+		for _, expr := range row {
+			_, err := a.analyzeExpr(expr)
 			if err != nil {
 				return err
 			}
-
-			// Check type compatibility
-			if !info.Type.IsComparable(targetCols[i].Type) && info.Type != TypeNull {
-				return &AnalysisError{
-					Type: ErrTypeMismatch,
-					Message: fmt.Sprintf("type mismatch for column %s: expected %s, got %s",
-						targetCols[i].Name, targetCols[i].Type, info.Type),
-				}
-			}
 		}
 	}
 
 	// Analyze INSERT ... SELECT
 	if stmt.Select != nil {
-		a.scope.DefineTable(&TableInfo{Name: table.Name, Columns: table.Columns, IsView: table.IsView})
 		if err := a.analyzeSelect(stmt.Select); err != nil {
 			return err
 		}
 	}
 
+	// Analyze RETURNING in the target table's scope.
+	if err := a.analyzeReturning(stmt.Returning); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// analyzeReturning validates a RETURNING projection. Wildcards are accepted; a
+// projection may not contain aggregates.
+func (a *Analyzer) analyzeReturning(cols []parser.SelectColumn) error {
+	for _, col := range cols {
+		if col.Star || col.TableStar != "" || col.Expr == nil {
+			continue
+		}
+		info, err := a.analyzeExpr(col.Expr)
+		if err != nil {
+			return err
+		}
+		if info.IsAggregate {
+			return &AnalysisError{
+				Type:    ErrAggregateInWhere,
+				Message: "aggregate functions are not allowed in RETURNING",
+			}
+		}
+	}
 	return nil
 }
 
@@ -547,6 +580,14 @@ func (a *Analyzer) analyzeUpdate(stmt *parser.UpdateStmt) error {
 
 	a.scope.DefineTable(&TableInfo{Name: table.Name, Columns: table.Columns, Alias: stmt.Table.Alias, IsView: table.IsView})
 
+	// UPDATE ... FROM: source tables/derived tables join the target in scope so
+	// SET and WHERE may reference their columns.
+	if len(stmt.From) > 0 {
+		if err := a.resolveFromClause(stmt.From); err != nil {
+			return err
+		}
+	}
+
 	// Validate SET assignments
 	for _, assign := range stmt.Set {
 		col, ok := table.GetColumn(assign.Column)
@@ -556,19 +597,17 @@ func (a *Analyzer) analyzeUpdate(stmt *parser.UpdateStmt) error {
 				Message: fmt.Sprintf("column not found: %s", assign.Column),
 			}
 		}
+		if col.Generated {
+			return &AnalysisError{
+				Type:    ErrTypeMismatch,
+				Message: fmt.Sprintf("cannot UPDATE generated column %s", assign.Column),
+			}
+		}
 
-		info, err := a.analyzeExpr(assign.Value)
+		_, err := a.analyzeExpr(assign.Value)
 		if err != nil {
 			return err
 		}
-
-		if !info.Type.IsComparable(col.Type) && info.Type != TypeNull {
-			return &AnalysisError{
-				Type: ErrTypeMismatch,
-				Message: fmt.Sprintf("type mismatch for column %s: expected %s, got %s",
-					col.Name, col.Type, info.Type),
-			}
-		}
 	}
 
 	// Analyze WHERE clause
@@ -585,6 +624,10 @@ func (a *Analyzer) analyzeUpdate(stmt *parser.UpdateStmt) error {
 		}
 	}
 
+	if err := a.analyzeReturning(stmt.Returning); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -614,6 +657,10 @@ func (a *Analyzer) analyzeDelete(stmt *parser.DeleteStmt) error {
 		}
 	}
 
+	if err := a.analyzeReturning(stmt.Returning); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -651,6 +698,7 @@ func (a *Analyzer) analyzeCreateTable(stmt *parser.CreateTableStmt) error {
 			Type:      TypeFromName(colDef.Type.Name),
 			Nullable:  true,
 			TableName: stmt.Table.Name,
+			Generated: colDef.GeneratedExpr != nil,
 		}
 
 		// Process constraints
@@ -685,6 +733,23 @@ func (a *Analyzer) analyzeCreateTable(stmt *parser.CreateTableStmt) error {
 		}
 	}
 
+	// Validate generated-column expressions against the new table's columns.
+	// Column references resolve within the table being created.
+	for _, colDef := range stmt.Columns {
+		if colDef.GeneratedExpr == nil {
+			continue
+		}
+		genScope := NewScope(nil)
+		genScope.DefineTable(tableInfo)
+		oldScope := a.scope
+		a.scope = genScope
+		_, err := a.analyzeExpr(colDef.GeneratedExpr)
+		a.scope = oldScope
+		if err != nil {
+			return err
+		}
+	}
+
 	// Analysis is validation-only. Publishing the table before the durable
 	// schema write can leave a phantom catalog entry when that write fails.
 	return nil
@@ -736,6 +801,20 @@ func (a *Analyzer) analyzeExpr(expr parser.Expr) (*ExprInfo, error) {
 		return a.analyzeLikeExpr(e)
 	case *parser.IsNullExpr:
 		return a.analyzeIsNullExpr(e)
+	case *parser.IsDistinctExpr:
+		left, err := a.analyzeExpr(e.Left)
+		if err != nil {
+			return nil, err
+		}
+		right, err := a.analyzeExpr(e.Right)
+		if err != nil {
+			return nil, err
+		}
+		return &ExprInfo{
+			Type:        TypeBoolean,
+			IsAggregate: left.IsAggregate || right.IsAggregate,
+			Nullable:    false,
+		}, nil
 	case *parser.ExistsExpr:
 		return a.analyzeExistsExpr(e)
 	case *parser.SubqueryExpr:
@@ -788,6 +867,8 @@ func (a *Analyzer) analyzeLiteral(e *parser.LiteralExpr) (*ExprInfo, error) {
 		}
 	case lexer.TokenString:
 		info.Type = TypeText
+	case lexer.TokenBlob:
+		info.Type = TypeBlob
 	case lexer.TokenNULL:
 		info.Type = TypeNull
 		info.Nullable = true
@@ -889,6 +970,17 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 				Message: fmt.Sprintf("cannot compare %s with %s", left.Type, right.Type),
 			}
 		}
+	case lexer.TokenBitAnd, lexer.TokenBitOr, lexer.TokenShiftLeft, lexer.TokenShiftRight:
+		// Bitwise operators coerce operands to integers.
+		info.Type = TypeInteger
+		for _, operand := range []Type{left.Type, right.Type} {
+			if !operand.IsNumeric() && operand != TypeNull && operand != TypeUnknown && operand != TypeAny {
+				return nil, &AnalysisError{
+					Type:    ErrTypeMismatch,
+					Message: fmt.Sprintf("bitwise operator requires numeric type, got %s", operand),
+				}
+			}
+		}
 	case lexer.TokenAND, lexer.TokenOR:
 		// Logical operators
 		info.Type = TypeBoolean
@@ -928,6 +1020,8 @@ func (a *Analyzer) analyzeUnaryExpr(e *parser.UnaryExpr) (*ExprInfo, error) {
 		}
 	case lexer.TokenNOT:
 		info.Type = TypeBoolean
+	case lexer.TokenBitNot:
+		info.Type = TypeInteger
 	default:
 		info.Type = operand.Type
 	}
@@ -984,8 +1078,9 @@ func (a *Analyzer) analyzeFunctionCall(e *parser.FunctionCall) (*ExprInfo, error
 		}
 	}
 
-	// Special case: MIN/MAX/COALESCE return type depends on argument
-	if sig.ReturnType == TypeAny && len(e.Args) > 0 {
+	// Special case: polymorphic functions (MIN/MAX/COALESCE/...) return a value
+	// whose type follows their first argument.
+	if sig.ReturnType == TypeAny && len(e.Args) > 0 && isPolymorphicFunction(e.Name) {
 		argInfo, _ := a.analyzeExpr(e.Args[0])
 		if argInfo != nil {
 			info.Type = argInfo.Type
@@ -995,6 +1090,16 @@ func (a *Analyzer) analyzeFunctionCall(e *parser.FunctionCall) (*ExprInfo, error
 	return info, nil
 }
 
+// isPolymorphicFunction reports whether a function's result type follows its
+// first argument rather than a fixed signature type.
+func isPolymorphicFunction(name string) bool {
+	switch strings.ToUpper(name) {
+	case "MIN", "MAX", "COALESCE", "IFNULL", "NVL", "NULLIF", "IIF":
+		return true
+	}
+	return false
+}
+
 func (a *Analyzer) analyzeCaseExpr(e *parser.CaseExpr) (*ExprInfo, error) {
 	info := &ExprInfo{
 		Nullable: true, // CASE can return NULL

+ 99 - 0
pkg/analyzer/features_test.go

@@ -0,0 +1,99 @@
+package analyzer
+
+import "testing"
+
+func analyze(t *testing.T, a *Analyzer, sql string) error {
+	t.Helper()
+	return a.Analyze(parse(t, sql))
+}
+
+func TestAnalyzeBitwiseOperators(t *testing.T) {
+	a := New(setupCatalog())
+	for _, sql := range []string{
+		"SELECT age & 3 FROM users",
+		"SELECT age | 3 FROM users",
+		"SELECT age << 1 FROM users",
+		"SELECT age >> 1 FROM users",
+		"SELECT ~age FROM users",
+	} {
+		if err := analyze(t, a, sql); err != nil {
+			t.Errorf("%s: unexpected error: %v", sql, err)
+		}
+	}
+}
+
+func TestAnalyzeJSONAndPercentDiffFunctions(t *testing.T) {
+	a := New(setupCatalog())
+	for _, sql := range []string{
+		`SELECT json('[1,2]')`,
+		`SELECT json_extract('{"a":1}', '$.a')`,
+		`SELECT json_set('{}', '$.a', 1)`,
+		`SELECT json_insert('[]', '$[#]', json('1'))`,
+		`SELECT json_replace('{"a":1}', '$.a', 2)`,
+		`SELECT json_group_array(id) FROM users`,
+		`SELECT percent_diff(1, 2)`,
+	} {
+		if err := analyze(t, a, sql); err != nil {
+			t.Errorf("%s: unexpected error: %v", sql, err)
+		}
+	}
+}
+
+func TestAnalyzeReturning(t *testing.T) {
+	a := New(setupCatalog())
+	for _, sql := range []string{
+		"INSERT INTO users (name) VALUES ('x') RETURNING id, name",
+		"UPDATE users SET name = 'y' RETURNING id",
+		"DELETE FROM users WHERE id = 1 RETURNING *",
+	} {
+		if err := analyze(t, a, sql); err != nil {
+			t.Errorf("%s: unexpected error: %v", sql, err)
+		}
+	}
+}
+
+func TestAnalyzeIsDistinctFromAndAnalyze(t *testing.T) {
+	a := New(setupCatalog())
+	for _, sql := range []string{
+		"SELECT age IS DISTINCT FROM 1 FROM users",
+		"SELECT age IS NOT DISTINCT FROM 1 FROM users",
+		"ANALYZE",
+		"ANALYZE users",
+	} {
+		if err := analyze(t, a, sql); err != nil {
+			t.Errorf("%s: unexpected error: %v", sql, err)
+		}
+	}
+}
+
+func TestAnalyzeUpdateFrom(t *testing.T) {
+	a := New(setupCatalog())
+	// orders.user_id joins users.id; the derived table exposes a count column.
+	err := analyze(t, a, `WITH x AS (SELECT count(*) AS n, id FROM users GROUP BY id)
+		UPDATE orders SET status = 'y' FROM x WHERE x.id = orders.user_id`)
+	if err != nil {
+		t.Fatalf("UPDATE ... FROM should analyze: %v", err)
+	}
+}
+
+func TestAnalyzeGeneratedColumns(t *testing.T) {
+	catalog := setupCatalog()
+	catalog.CreateTable(&TableInfo{
+		Name: "metrics",
+		Columns: []ColumnInfo{
+			{Name: "a", Type: TypeInteger, Nullable: true},
+			{Name: "b", Type: TypeInteger, Nullable: true, Generated: true},
+		},
+	})
+	a := New(catalog)
+
+	if err := analyze(t, a, "INSERT INTO metrics (a, b) VALUES (1, 2)"); err == nil {
+		t.Fatal("expected generated-column insert to be rejected")
+	}
+	if err := analyze(t, a, "UPDATE metrics SET b = 2"); err == nil {
+		t.Fatal("expected generated-column update to be rejected")
+	}
+	if err := analyze(t, a, "INSERT INTO metrics (a) VALUES (1)"); err != nil {
+		t.Fatalf("plain insert should analyze: %v", err)
+	}
+}

+ 31 - 0
pkg/analyzer/types.go

@@ -247,6 +247,36 @@ var builtinFunctions = map[string]FunctionSignature{
 	"UNHEX":    {Name: "UNHEX", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeText}, ReturnType: TypeBlob, IsAggregate: false},
 	"ZEROBLOB": {Name: "ZEROBLOB", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeInteger}, ReturnType: TypeBlob, IsAggregate: false},
 	"QUOTE":    {Name: "QUOTE", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+
+	// GoatCounter compatibility: scalar percentage difference.
+	"PERCENT_DIFF": {Name: "PERCENT_DIFF", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeNumeric, TypeNumeric}, ReturnType: TypeReal, IsAggregate: false},
+
+	// JSON1 scalar functions.
+	"JSON":          {Name: "JSON", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB":         {Name: "JSONB", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_VALID":    {Name: "JSON_VALID", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeInteger, IsAggregate: false},
+	"JSONB_VALID":   {Name: "JSONB_VALID", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeInteger, IsAggregate: false},
+	"JSON_TYPE":     {Name: "JSON_TYPE", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_EXTRACT":  {Name: "JSON_EXTRACT", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_EXTRACT": {Name: "JSONB_EXTRACT", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeAny, IsAggregate: false},
+	"JSON_SET":      {Name: "JSON_SET", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_SET":     {Name: "JSONB_SET", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_INSERT":   {Name: "JSON_INSERT", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_INSERT":  {Name: "JSONB_INSERT", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_REPLACE":  {Name: "JSON_REPLACE", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_REPLACE": {Name: "JSONB_REPLACE", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_REMOVE":   {Name: "JSON_REMOVE", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_REMOVE":  {Name: "JSONB_REMOVE", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_ARRAY":    {Name: "JSON_ARRAY", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_ARRAY":   {Name: "JSONB_ARRAY", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_OBJECT":   {Name: "JSON_OBJECT", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_OBJECT":  {Name: "JSONB_OBJECT", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSON_QUOTE":    {Name: "JSON_QUOTE", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_QUOTE":   {Name: "JSONB_QUOTE", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+
+	// JSON1 aggregate.
+	"JSON_GROUP_ARRAY":  {Name: "JSON_GROUP_ARRAY", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: true},
+	"JSONB_GROUP_ARRAY": {Name: "JSONB_GROUP_ARRAY", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: true},
 }
 
 // LookupFunction returns the function signature for a function name.
@@ -281,6 +311,7 @@ type ColumnInfo struct {
 	PrimaryKey bool
 	Default    interface{}
 	TableName  string // For qualified references
+	Generated  bool   // generated columns cannot be written by the user
 }
 
 // TableInfo describes a table schema.

+ 82 - 0
pkg/executor/compat.go

@@ -0,0 +1,82 @@
+package executor
+
+import (
+	"fmt"
+	"math"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// SQLiteCompatVersion is the SQLite version reported by sqlite_version(). It is
+// deliberately decoupled from PizzaSQL's own version (pizzasql_version()) so
+// clients that gate behavior on a minimum SQLite version see a consistent,
+// documented compatibility floor. 3.35.0 is the first release with
+// INSERT/UPDATE/DELETE ... RETURNING, which GoatCounter's release-2.7 port
+// relies on; the JSON1 and generated-column surface this engine implements is
+// documented against that same floor.
+const SQLiteCompatVersion = "3.35.0"
+
+// evalPercentDiff implements the scalar percent_diff(start, final) function used
+// by GoatCounter's hit_list.DiffTotal query. It matches the existing function:
+// a zero start yields +Inf, and standard SQL NULL propagation applies. Fewer or
+// more than two arguments is an error.
+func evalPercentDiff(args []interface{}) (interface{}, error) {
+	if len(args) != 2 {
+		return nil, fmt.Errorf("percent_diff() requires exactly 2 arguments")
+	}
+	if args[0] == nil || args[1] == nil {
+		return nil, nil
+	}
+	start := toFloat(args[0])
+	final := toFloat(args[1])
+	if start == 0 {
+		return math.Inf(1), nil
+	}
+	return (final - start) / start * 100.0, nil
+}
+
+// formatSQLiteReal renders a REAL the way SQLite's text conversion does for
+// concatenation: the shortest round-tripping decimal, with a fractional part
+// retained for integral values so `1.0 || 'px'` yields "1.0px" like SQLite. The
+// generated-size queries concatenate numeric columns, so this must not fall back
+// to Go's "1" or "true"/"1e+06" spellings.
+func formatSQLiteReal(f float64) string {
+	if math.IsNaN(f) {
+		return "NaN"
+	}
+	if math.IsInf(f, 1) {
+		return "Inf"
+	}
+	if math.IsInf(f, -1) {
+		return "-Inf"
+	}
+	// SQLite's %!.15g keeps 15 significant digits and always shows a decimal
+	// point for non-integral values; integral values get a trailing ".0".
+	if f == math.Trunc(f) && math.Abs(f) < 1e15 {
+		return strconv.FormatFloat(f, 'f', 1, 64)
+	}
+	s := strconv.FormatFloat(f, 'g', 15, 64)
+	if !strings.ContainsAny(s, ".eE") {
+		s += ".0"
+	}
+	return s
+}
+
+// sqliteCurrentTimeValue resolves the SQLite special date/time keywords
+// CURRENT_TIMESTAMP, CURRENT_DATE, and CURRENT_TIME. The lexer treats them as
+// plain identifiers, so they are recognized here (only when the row has no real
+// 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()
+	switch strings.ToLower(name) {
+	case "current_timestamp":
+		return now.Format("2006-01-02 15:04:05"), true
+	case "current_date":
+		return now.Format("2006-01-02"), true
+	case "current_time":
+		return now.Format("15:04:05"), true
+	}
+	return nil, false
+}

+ 25 - 0
pkg/executor/cte_test.go

@@ -72,6 +72,31 @@ func TestCTEChained(t *testing.T) {
 	}
 }
 
+func TestCTECommaCrossJoin(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, `WITH x AS (SELECT 3 AS total),
+		y AS (SELECT 1 AS total_events),
+		z AS (SELECT 3 AS total_utc)
+		SELECT * FROM x, y, z`)
+	if res.RowCount != 1 {
+		t.Fatalf("expected one row, got %d: %v", res.RowCount, res.Rows)
+	}
+	wantColumns := []string{"total", "total_events", "total_utc"}
+	for i, want := range wantColumns {
+		if res.Columns[i] != want {
+			t.Fatalf("column %d = %q, want %q (all: %v)", i, res.Columns[i], want, res.Columns)
+		}
+	}
+	wantValues := []int64{3, 1, 3}
+	for i, want := range wantValues {
+		if res.Rows[0][i] != want {
+			t.Fatalf("value %d = %v, want %d (row: %v)", i, res.Rows[0][i], want, res.Rows[0])
+		}
+	}
+}
+
 // TestRecursiveCTENonCompound verifies a plain CTE under WITH RECURSIVE is
 // treated as non-recursive.
 func TestRecursiveCTENonCompound(t *testing.T) {

File diff suppressed because it is too large
+ 843 - 169
pkg/executor/executor.go


+ 430 - 0
pkg/executor/features_test.go

@@ -0,0 +1,430 @@
+package executor
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+func TestInsertReturningGeneratedIDAndProjections(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)")
+
+	res := execMust(t, e, "INSERT INTO users (name) VALUES ('alice') RETURNING id, name, id + 1 AS next_id")
+	if len(res.Columns) != 3 {
+		t.Fatalf("expected 3 columns, got %v", res.Columns)
+	}
+	if res.Columns[0] != "id" || res.Columns[1] != "name" || res.Columns[2] != "next_id" {
+		t.Fatalf("unexpected columns %v", res.Columns)
+	}
+	if res.RowCount != 1 {
+		t.Fatalf("expected 1 row, got %d", res.RowCount)
+	}
+	if res.Rows[0][0] != int64(1) || res.Rows[0][1] != "alice" || res.Rows[0][2] != int64(2) {
+		t.Fatalf("unexpected row %v", res.Rows[0])
+	}
+	if res.LastInsertID != 1 {
+		t.Fatalf("expected LastInsertID 1, got %d", res.LastInsertID)
+	}
+}
+
+func TestInsertReturningStar(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	res := execMust(t, e, "INSERT INTO t (v) VALUES ('x') RETURNING *")
+	if res.RowCount != 1 || len(res.Columns) != 2 {
+		t.Fatalf("unexpected result %v %v", res.Columns, res.Rows)
+	}
+	if res.Rows[0][1] != "x" {
+		t.Fatalf("unexpected row %v", res.Rows[0])
+	}
+}
+
+func TestUpdateReturning(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a'), (2, 'b')")
+	res := execMust(t, e, "UPDATE t SET v = upper(v) RETURNING id, v")
+	if res.RowCount != 2 {
+		t.Fatalf("expected 2 rows, got %d", res.RowCount)
+	}
+	got := map[interface{}]interface{}{}
+	for _, row := range res.Rows {
+		got[row[0]] = row[1]
+	}
+	if got[int64(1)] != "A" || got[int64(2)] != "B" {
+		t.Fatalf("unexpected returning rows %v", res.Rows)
+	}
+}
+
+func TestDeleteReturning(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a'), (2, 'b')")
+	res := execMust(t, e, "DELETE FROM t WHERE id = 1 RETURNING id, v")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) || res.Rows[0][1] != "a" {
+		t.Fatalf("unexpected returning rows %v", res.Rows)
+	}
+	if res.RowsAffected != 1 {
+		t.Fatalf("expected RowsAffected 1, got %d", res.RowsAffected)
+	}
+}
+
+func TestSQLiteVersionDistinctFromPizzasqlVersion(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	sqlite := execMust(t, e, "SELECT sqlite_version()")
+	psql := execMust(t, e, "SELECT pizzasql_version()")
+	if sqlite.Rows[0][0] != SQLiteCompatVersion {
+		t.Fatalf("sqlite_version = %v, want %s", sqlite.Rows[0][0], SQLiteCompatVersion)
+	}
+	if SQLiteCompatVersion < "3.35.0" {
+		t.Fatalf("SQLite compatibility floor must be >= 3.35.0, got %s", SQLiteCompatVersion)
+	}
+	if sqlite.Rows[0][0] == psql.Rows[0][0] {
+		t.Fatalf("sqlite_version and pizzasql_version must differ")
+	}
+}
+
+func TestGeneratedStoredColumnRecompute(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (a INTEGER, b INTEGER, total INTEGER GENERATED ALWAYS AS (a + b) STORED)")
+	execMust(t, e, "INSERT INTO t (a, b) VALUES (2, 3)")
+	res := execMust(t, e, "SELECT total FROM t")
+	if res.Rows[0][0] != int64(5) {
+		t.Fatalf("generated total = %v, want 5", res.Rows[0][0])
+	}
+	execMust(t, e, "UPDATE t SET a = 10")
+	res = execMust(t, e, "SELECT total FROM t")
+	if res.Rows[0][0] != int64(13) {
+		t.Fatalf("generated total after update = %v, want 13", res.Rows[0][0])
+	}
+}
+
+func TestGeneratedColumnReferencesAutoIncrementID(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, stored INTEGER GENERATED ALWAYS AS (id + 1) STORED)")
+	res := execMust(t, e, "INSERT INTO t (id) VALUES (NULL) RETURNING id, stored")
+	if res.Rows[0][0] != int64(1) || res.Rows[0][1] != int64(2) {
+		t.Fatalf("generated id reference = %v, want [1 2]", res.Rows[0])
+	}
+	res = execMust(t, e, "SELECT stored FROM t WHERE id = 1")
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("persisted generated value = %v, want 2", res.Rows[0][0])
+	}
+}
+
+func TestGeneratedColumnRejectsUserWrites(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a + 1) STORED)")
+
+	if _, err := execSQL(e, "INSERT INTO t (a, b) VALUES (1, 99)"); err == nil {
+		t.Fatal("expected explicit insert into generated column to fail")
+	}
+	if _, err := execSQL(e, "UPDATE t SET b = 5"); err == nil {
+		t.Fatal("expected update of generated column to fail")
+	}
+}
+
+func TestInsertOrReplacePrimaryKey(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a')")
+	execMust(t, e, "INSERT OR REPLACE INTO t VALUES (1, 'b')")
+	res := execMust(t, e, "SELECT v FROM t")
+	if res.RowCount != 1 || res.Rows[0][0] != "b" {
+		t.Fatalf("INSERT OR REPLACE result = %v", res.Rows)
+	}
+}
+
+func TestTableUniqueOnConflictReplace(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (
+		site_id INTEGER,
+		path TEXT,
+		total INTEGER,
+		CONSTRAINT "t#site#path" UNIQUE(site_id, path) ON CONFLICT REPLACE
+	)`)
+	execMust(t, e, "INSERT INTO t (site_id, path, total) VALUES (1, '/a', 10)")
+	execMust(t, e, "INSERT INTO t (site_id, path, total) VALUES (1, '/a', 42)")
+
+	res := execMust(t, e, "SELECT total FROM t WHERE site_id = 1 AND path = '/a'")
+	if res.RowCount != 1 {
+		t.Fatalf("expected 1 row after replace, got %d", res.RowCount)
+	}
+	if res.Rows[0][0] != int64(42) {
+		t.Fatalf("expected replaced total 42, got %v", res.Rows[0][0])
+	}
+}
+
+func TestInsertIgnoreUniqueIndex(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)")
+	execMust(t, e, "INSERT INTO users VALUES (1, 'same@example.com')")
+
+	res := execMust(t, e, "INSERT OR IGNORE INTO users VALUES (2, 'same@example.com')")
+	if res.RowsAffected != 0 {
+		t.Fatalf("INSERT OR IGNORE affected %d rows, want 0", res.RowsAffected)
+	}
+	res = execMust(t, e, "INSERT INTO users VALUES (3, 'same@example.com') ON CONFLICT DO NOTHING")
+	if res.RowsAffected != 0 {
+		t.Fatalf("targetless DO NOTHING affected %d rows, want 0", res.RowsAffected)
+	}
+	res = execMust(t, e, "SELECT id FROM users")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) {
+		t.Fatalf("users = %v, want only id 1", res.Rows)
+	}
+
+	execMust(t, e, "INSERT INTO users VALUES (4, NULL)")
+	execMust(t, e, "INSERT OR IGNORE INTO users VALUES (5, NULL)")
+	execMust(t, e, "INSERT INTO users VALUES (6, NULL) ON CONFLICT DO NOTHING")
+	res = execMust(t, e, "SELECT count(*) FROM users WHERE email IS NULL")
+	if res.Rows[0][0] != int64(3) {
+		t.Fatalf("NULL unique values = %v, want 3 rows", res.Rows[0][0])
+	}
+}
+
+func TestJSON1Functions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, `SELECT json_extract('{"a": 1, "b": [10, 20]}', '$.b[1]')`)
+	if res.Rows[0][0] != int64(20) {
+		t.Fatalf("json_extract = %v, want 20", res.Rows[0][0])
+	}
+
+	res = execMust(t, e, `SELECT json_set('{"a": 1}', '$.a', 2)`)
+	if res.Rows[0][0] != `{"a":2}` {
+		t.Fatalf("json_set = %v", res.Rows[0][0])
+	}
+
+	// The JSON subtype must survive nested calls, matching GoatCounter's
+	// json_insert(json_extract(...), '$[#]', json(...)) pattern.
+	res = execMust(t, e, `SELECT json_insert(json_extract('{"w":[]}', '$.w'), '$[#]', json('{"n":"languages"}'))`)
+	if res.Rows[0][0] != `[{"n":"languages"}]` {
+		t.Fatalf("json_insert with json() = %v", res.Rows[0][0])
+	}
+
+	res = execMust(t, e, `SELECT json_replace('{"collect": 1}', '$.collect', json_extract('{"collect": 1}', '$.collect') | 64)`)
+	if res.Rows[0][0] != `{"collect":65}` {
+		t.Fatalf("json_replace with bitwise = %v", res.Rows[0][0])
+	}
+
+	res = execMust(t, e, `SELECT json_group_array(x) FROM (SELECT 1 AS x UNION ALL SELECT 2 UNION ALL SELECT 3) AS t`)
+	if res.Rows[0][0] != `[1,2,3]` {
+		t.Fatalf("json_group_array = %v", res.Rows[0][0])
+	}
+}
+
+func TestBitwiseOperators(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	cases := map[string]int64{
+		"SELECT 6 & 3":     2,
+		"SELECT 6 | 1":     7,
+		"SELECT 1 << 4":    16,
+		"SELECT 32 >> 2":   8,
+		"SELECT ~0":        -1,
+		"SELECT 1 + 2 | 4": 7, // (1+2)|4
+		"SELECT 2 | 1 * 8": 10,
+	}
+	for sql, want := range cases {
+		res := execMust(t, e, sql)
+		if res.Rows[0][0] != want {
+			t.Errorf("%s = %v, want %d", sql, res.Rows[0][0], want)
+		}
+	}
+}
+
+func TestPercentDiff(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	res := execMust(t, e, "SELECT percent_diff(10, 15)")
+	if res.Rows[0][0] != float64(50) {
+		t.Fatalf("percent_diff(10,15) = %v, want 50", res.Rows[0][0])
+	}
+	res = execMust(t, e, "SELECT percent_diff(0, 5)")
+	if f, ok := res.Rows[0][0].(float64); !ok || f <= 0 {
+		t.Fatalf("percent_diff(0,5) should be +Inf, got %v", res.Rows[0][0])
+	}
+	res = execMust(t, e, "SELECT percent_diff(NULL, 5)")
+	if res.Rows[0][0] != nil {
+		t.Fatalf("percent_diff(NULL,5) should be NULL, got %v", res.Rows[0][0])
+	}
+}
+
+func TestBlobLiteralStorageAndFunctions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB)")
+	execMust(t, e, "INSERT INTO blobs (id, data) VALUES (1, X'00FF10')")
+
+	res := execMust(t, e, "SELECT data FROM blobs WHERE id = 1")
+	b, ok := res.Rows[0][0].([]byte)
+	if !ok || len(b) != 3 || b[0] != 0x00 || b[1] != 0xFF || b[2] != 0x10 {
+		t.Fatalf("blob round-trip = %#v", res.Rows[0][0])
+	}
+
+	res = execMust(t, e, "SELECT hex(data), typeof(data) FROM blobs WHERE id = 1")
+	if res.Rows[0][0] != "00FF10" {
+		t.Fatalf("hex = %v", res.Rows[0][0])
+	}
+	if res.Rows[0][1] != "blob" {
+		t.Fatalf("typeof = %v", res.Rows[0][1])
+	}
+
+	res = execMust(t, e, "SELECT unhex('00FF')")
+	if b, ok := res.Rows[0][0].([]byte); !ok || len(b) != 2 || b[1] != 0xFF {
+		t.Fatalf("unhex = %#v", res.Rows[0][0])
+	}
+
+	res = execMust(t, e, "SELECT CAST('abc' AS BLOB)")
+	if b, ok := res.Rows[0][0].([]byte); !ok || string(b) != "abc" {
+		t.Fatalf("cast to blob = %#v", res.Rows[0][0])
+	}
+}
+
+func TestExpressionUniqueIndexLower(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX users_email_lower ON users (lower(email))")
+	execMust(t, e, "INSERT INTO users (id, email) VALUES (1, 'Alice@Example.com')")
+
+	if _, err := execSQL(e, "INSERT INTO users (id, email) VALUES (2, 'alice@example.com')"); err == nil {
+		t.Fatal("expected expression unique index to reject a case-insensitive duplicate")
+	}
+	// A NULL or distinct value is still allowed.
+	execMust(t, e, "INSERT INTO users (id, email) VALUES (3, 'bob@example.com')")
+}
+
+func TestExpressionUniqueIndexReplace(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, tag TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX users_email_lower ON users (lower(email))")
+	execMust(t, e, "INSERT INTO users (id, email, tag) VALUES (1, 'Alice@Example.com', 'old')")
+	// INSERT OR REPLACE must replace the conflicting row even though the
+	// conflict is on a case-insensitive expression index.
+	execMust(t, e, "INSERT OR REPLACE INTO users (id, email, tag) VALUES (2, 'alice@example.com', 'new')")
+
+	res := execMust(t, e, "SELECT id, tag FROM users")
+	if res.RowCount != 1 {
+		t.Fatalf("expected 1 row after expression replace, got %d: %v", res.RowCount, res.Rows)
+	}
+	if res.Rows[0][0] != int64(2) || res.Rows[0][1] != "new" {
+		t.Fatalf("unexpected replaced row %v", res.Rows[0])
+	}
+}
+
+func TestInsertReturningMetadataTypes(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id BIGINT PRIMARY KEY, name TEXT)")
+	res := execMust(t, e, "INSERT INTO t (id, name) VALUES (7, 'x') RETURNING id, name")
+	if len(res.ColumnTypes) != 2 || res.ColumnTypes[0] != "BIGINT" || res.ColumnTypes[1] != "TEXT" {
+		t.Fatalf("unexpected returning column types %v", res.ColumnTypes)
+	}
+}
+
+func TestGeneratedColumnReturning(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
+	res := execMust(t, e, "INSERT INTO t (a) VALUES (21) RETURNING a, b")
+	if res.Rows[0][1] != int64(42) {
+		t.Fatalf("expected generated b=42 in RETURNING, got %v", res.Rows[0][1])
+	}
+}
+
+func TestGeneratedExpressionIndexText(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (email TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX users_email_lower ON users (lower(email))")
+	idx, err := schema.GetIndex("users_email_lower")
+	if err != nil {
+		t.Fatalf("GetIndex: %v", err)
+	}
+	if len(idx.Columns) != 1 || idx.Columns[0].Expression == "" {
+		t.Fatalf("expected persisted expression, got %#v", idx.Columns)
+	}
+	if !strings.Contains(strings.ToLower(idx.Columns[0].Expression), "lower(") {
+		t.Fatalf("unexpected expression text %q", idx.Columns[0].Expression)
+	}
+}
+
+func TestJoinUsingMultipleColumns(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE counts (site_id INTEGER, path_id INTEGER, total INTEGER)")
+	execMust(t, e, "CREATE TABLE paths (site_id INTEGER, path_id INTEGER, path TEXT)")
+	execMust(t, e, "INSERT INTO counts VALUES (1, 1, 4), (1, 2, 8), (2, 1, 16)")
+	execMust(t, e, "INSERT INTO paths VALUES (1, 1, '/one'), (1, 2, '/two'), (2, 2, '/other')")
+
+	res := execMust(t, e, "SELECT paths.path, counts.total FROM counts JOIN paths USING (site_id, path_id) ORDER BY counts.total")
+	if res.RowCount != 2 || res.Rows[0][0] != "/one" || res.Rows[1][0] != "/two" {
+		t.Fatalf("JOIN USING rows = %v", res.Rows)
+	}
+}
+
+func TestJoinUsingInCommaFromList(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE base (id INTEGER)")
+	execMust(t, e, "CREATE TABLE left_rows (id INTEGER)")
+	execMust(t, e, "CREATE TABLE right_rows (id INTEGER)")
+	execMust(t, e, "INSERT INTO base VALUES (1)")
+	execMust(t, e, "INSERT INTO left_rows VALUES (1)")
+	execMust(t, e, "INSERT INTO right_rows VALUES (1), (2)")
+
+	res := execMust(t, e, "SELECT left_rows.id FROM base, left_rows JOIN right_rows USING (id)")
+	if res.RowCount != 1 {
+		t.Fatalf("mixed JOIN USING returned %d rows, want 1: %v", res.RowCount, res.Rows)
+	}
+}
+
+func TestSQLiteDynamicTypingAssignments(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE settings (value VARCHAR)")
+	execMust(t, e, "INSERT INTO settings VALUES (2)")
+	execMust(t, e, "UPDATE settings SET value = X'0102'")
+	res := execMust(t, e, "SELECT typeof(value), hex(value) FROM settings")
+	if res.Rows[0][0] != "blob" || res.Rows[0][1] != "0102" {
+		t.Fatalf("dynamic value = %v", res.Rows[0])
+	}
+}
+
+func TestRenameTableAboveSingleBatchLimit(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE old_rows (id INTEGER PRIMARY KEY, value TEXT)")
+
+	// A rename writes two KV operations per row; 32,768 rows exceed PizzaKV's
+	// 65,535-operation batch limit.
+	rows := make([]storage.Row, 32768)
+	for i := range rows {
+		rows[i] = storage.Row{"id": int64(i + 1), "value": fmt.Sprintf("v%d", i+1)}
+	}
+	if _, err := table.InsertBulk("old_rows", rows); err != nil {
+		t.Fatalf("insert rows: %v", err)
+	}
+	execMust(t, e, "ALTER TABLE old_rows RENAME TO new_rows")
+	res := execMust(t, e, "SELECT count(*) FROM new_rows")
+	if res.Rows[0][0] != int64(len(rows)) {
+		t.Fatalf("renamed table has %v rows, want %d", res.Rows[0][0], len(rows))
+	}
+}

+ 237 - 0
pkg/executor/generated.go

@@ -0,0 +1,237 @@
+package executor
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// generatedColumnExpr resolves a column's generated expression, if any.
+func (e *Executor) generatedColumnExpr(col storage.Column) (parser.Expr, bool, error) {
+	if col.GeneratedExpr == "" {
+		return nil, false, nil
+	}
+	expr, err := parseStoredExpr(col.GeneratedExpr)
+	if err != nil {
+		return nil, false, err
+	}
+	return expr, true, nil
+}
+
+// applyGeneratedColumns recomputes and stores every generated column value on
+// row. It runs after the base columns of an INSERT/UPDATE have been resolved so
+// STORED generated values are materialized in the durable row.
+func (e *Executor) applyGeneratedColumns(schema *storage.Schema, row storage.Row) error {
+	for _, col := range schema.Columns {
+		expr, ok, err := e.generatedColumnExpr(col)
+		if err != nil {
+			return err
+		}
+		if !ok {
+			continue
+		}
+		val, err := e.evalExpr(expr, row)
+		if err != nil {
+			return fmt.Errorf("evaluating generated column %s: %w", col.Name, err)
+		}
+		row[col.Name] = val
+	}
+	normalizeStoredValues(row)
+	return nil
+}
+
+// normalizeStoredValues converts executor-internal value types (currently the
+// JSON1 subtype) into the plain scalar types the storage codec persists.
+func normalizeStoredValues(row storage.Row) {
+	for k, v := range row {
+		if jt, ok := v.(jsonText); ok {
+			row[k] = string(jt)
+		}
+	}
+}
+
+// generatedColumnSet returns the lowercased names of generated columns.
+func generatedColumnSet(schema *storage.Schema) map[string]bool {
+	set := make(map[string]bool)
+	for _, col := range schema.Columns {
+		if col.GeneratedExpr != "" {
+			set[strings.ToLower(col.Name)] = true
+		}
+	}
+	return set
+}
+
+// ensureGeneratedRowID assigns the primary key of a row before generated-column
+// evaluation when the key is an engine-generated INTEGER PRIMARY KEY, so a
+// generated expression that references the auto-incrementing id (the common
+// `stored = id + 1` shape) does not see NULL. Tables without an explicit
+// integer primary key are left to the storage layer, which assigns the hidden
+// _rowid_ during the insert.
+func (e *Executor) ensureGeneratedRowID(tableName string, schema *storage.Schema, row storage.Row) error {
+	if schema.PrimaryKey == "" || schema.PrimaryKey == "_rowid_" {
+		return nil
+	}
+	if v, ok := lookupRowValue(row, schema.PrimaryKey); ok && v != nil {
+		return nil
+	}
+	pkCol, ok := schema.GetColumn(schema.PrimaryKey)
+	if !ok || !isIntegerColumnType(pkCol.Type) {
+		return nil
+	}
+	id, err := e.schema.GetNextRowID(tableName)
+	if err != nil {
+		return err
+	}
+	row[schema.PrimaryKey] = id
+	return nil
+}
+
+// lookupRowValue resolves a row value case-insensitively.
+func lookupRowValue(row storage.Row, name string) (interface{}, bool) {
+	if v, ok := row[name]; ok {
+		return v, true
+	}
+	for k, v := range row {
+		if strings.EqualFold(k, name) {
+			return v, true
+		}
+	}
+	return nil, false
+}
+
+// isIntegerColumnType reports whether a declared type has integer affinity.
+func isIntegerColumnType(typeName string) bool {
+	return strings.Contains(strings.ToUpper(typeName), "INT")
+}
+
+// conflictMatcher describes one uniqueness constraint used to resolve an
+// INSERT conflict. index is nil for the primary key.
+type conflictMatcher struct {
+	name       string
+	index      *storage.Index
+	primaryKey bool
+}
+
+// insertConflictMatchers returns the constraints that should be replaced for an
+// INSERT. A statement-level INSERT OR REPLACE replaces on every uniqueness
+// constraint; otherwise only indexes declaring ON CONFLICT REPLACE are
+// replaced.
+func (e *Executor) insertConflictMatchers(tableName string, schema *storage.Schema, stmtReplace bool) ([]conflictMatcher, error) {
+	var matchers []conflictMatcher
+	if stmtReplace {
+		matchers = append(matchers, conflictMatcher{name: schema.PrimaryKey, primaryKey: true})
+	}
+	indexes, err := e.schema.ListTableIndexes(tableName)
+	if err != nil {
+		return nil, err
+	}
+	for _, idx := range indexes {
+		if !idx.Unique {
+			continue
+		}
+		if !stmtReplace && !strings.EqualFold(idx.OnConflict, "REPLACE") {
+			continue
+		}
+		matchers = append(matchers, conflictMatcher{name: idx.Name, index: idx})
+	}
+	return matchers, nil
+}
+
+// matcherConflicts returns the durable/overlay rows that the candidate would
+// conflict with on a single matcher, using a primary-key point read or an
+// index-key lookup rather than a full table scan.
+func (e *Executor) matcherConflicts(tableName string, schema *storage.Schema, m conflictMatcher, candidate storage.Row) ([]storage.Row, error) {
+	if m.primaryKey {
+		pkValue, ok := lookupRowValue(candidate, schema.PrimaryKey)
+		if !ok || pkValue == nil {
+			return nil, nil
+		}
+		row, err := e.session.GetByPK(tableName, fmt.Sprintf("%v", pkValue))
+		if err == storage.ErrKeyNotFound {
+			return nil, nil
+		}
+		if err != nil {
+			return nil, err
+		}
+		return []storage.Row{row}, nil
+	}
+	isNull, err := e.table.IndexValueContainsNull(m.index, candidate)
+	if err != nil {
+		return nil, err
+	}
+	if isNull {
+		return nil, nil
+	}
+
+	key, err := e.table.IndexRowKey(m.index, candidate)
+	if err != nil {
+		return nil, err
+	}
+	return e.session.SelectByIndexKey(tableName, m.index, key)
+}
+
+// hasAnyInsertConflict checks every uniqueness constraint. SQLite's
+// statement-level OR IGNORE and targetless DO NOTHING apply to any conflict,
+// not just the primary key.
+func (e *Executor) hasAnyInsertConflict(tableName string, schema *storage.Schema, candidate storage.Row) (bool, error) {
+	matchers := []conflictMatcher{{name: schema.PrimaryKey, primaryKey: true}}
+	indexes, err := e.schema.ListTableIndexes(tableName)
+	if err != nil {
+		return false, err
+	}
+	for _, index := range indexes {
+		if index.Unique {
+			matchers = append(matchers, conflictMatcher{name: index.Name, index: index})
+		}
+	}
+	for _, matcher := range matchers {
+		rows, err := e.matcherConflicts(tableName, schema, matcher, candidate)
+		if err != nil {
+			return false, err
+		}
+		if len(rows) > 0 {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// resolveInsertConflicts removes existing rows that conflict with candidate on
+// any constraint that resolves to REPLACE. It runs inside the statement's
+// atomic DML block so the deletes and the subsequent insert commit together.
+func (e *Executor) resolveInsertConflicts(tableName string, schema *storage.Schema, candidate storage.Row, stmtReplace bool) error {
+	matchers, err := e.insertConflictMatchers(tableName, schema, stmtReplace)
+	if err != nil {
+		return err
+	}
+	if len(matchers) == 0 {
+		return nil
+	}
+
+	// Collect matching rows through point/index lookups, then delete them by
+	// primary key. A row may match several constraints, so deduplicate.
+	toDelete := make(map[string]storage.Row)
+	for _, m := range matchers {
+		rows, err := e.matcherConflicts(tableName, schema, m, candidate)
+		if err != nil {
+			return err
+		}
+		for _, row := range rows {
+			toDelete[fmt.Sprintf("%v", row[schema.PrimaryKey])] = row
+		}
+	}
+	if len(toDelete) == 0 {
+		return nil
+	}
+
+	for _, row := range toDelete {
+		if _, deleted, derr := e.session.DeleteByPK(tableName, fmt.Sprintf("%v", row[schema.PrimaryKey])); derr != nil {
+			return derr
+		} else if !deleted {
+			return fmt.Errorf("ON CONFLICT REPLACE row disappeared during delete")
+		}
+	}
+	return nil
+}

+ 162 - 0
pkg/executor/goatcounter_migration_test.go

@@ -0,0 +1,162 @@
+package executor
+
+import (
+	"testing"
+)
+
+// TestGoatCounterSQLiteMigrations runs the shapes of GoatCounter's SQLite
+// migrations that exercise the engine features added for the release-2.7 port:
+// JSON1 mutation/extraction, bitwise OR, and table-level UNIQUE ... ON CONFLICT
+// REPLACE.
+func TestGoatCounterSQLiteMigrations(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, `CREATE TABLE sites (
+		site_id INTEGER PRIMARY KEY AUTOINCREMENT,
+		settings TEXT NOT NULL DEFAULT '{}',
+		user_defaults TEXT NOT NULL DEFAULT '{}'
+	)`)
+	execMust(t, e, `CREATE TABLE users (
+		user_id INTEGER PRIMARY KEY AUTOINCREMENT,
+		settings TEXT NOT NULL DEFAULT '{}'
+	)`)
+	execMust(t, e, `INSERT INTO sites (site_id, settings, user_defaults) VALUES
+		(1, '{"public": 1, "collect": 0, "widgets": []}', '{"widgets": []}')`)
+	execMust(t, e, `INSERT INTO users (user_id, settings) VALUES (1, '{"widgets": []}')`)
+
+	// db/migrate/2021-06-27-1-public-sqlite.sql
+	execMust(t, e, `UPDATE sites SET settings = json_set(settings, '$.public', 'public') WHERE json_extract(settings, '$.public') = 1`)
+	execMust(t, e, `UPDATE sites SET settings = json_set(settings, '$.public', 'private') WHERE json_extract(settings, '$.public') = 0`)
+
+	res := execMust(t, e, `SELECT json_extract(settings, '$.public') FROM sites WHERE site_id = 1`)
+	if res.Rows[0][0] != "public" {
+		t.Fatalf("public flag = %v, want public", res.Rows[0][0])
+	}
+
+	// db/migrate/2021-12-02-2-language-enable-sqlite.sql
+	execMust(t, e, `UPDATE sites SET
+		settings = json_replace(settings, '$.collect', json_extract(settings, '$.collect') | 64),
+		user_defaults = json_replace(user_defaults, '$.widgets', json_insert(json_extract(user_defaults, '$.widgets'), '$[#]', json('{"n":"languages"}')))`)
+	execMust(t, e, `UPDATE users SET
+		settings = json_replace(settings, '$.widgets', json_insert(json_extract(settings, '$.widgets'), '$[#]', json('{"n":"languages"}')))`)
+
+	res = execMust(t, e, `SELECT json_extract(settings, '$.collect') FROM sites WHERE site_id = 1`)
+	if res.Rows[0][0] != int64(64) {
+		t.Fatalf("collect flag = %v, want 64", res.Rows[0][0])
+	}
+	res = execMust(t, e, `SELECT json_extract(user_defaults, '$.widgets[0].n') FROM sites WHERE site_id = 1`)
+	if res.Rows[0][0] != "languages" {
+		t.Fatalf("widgets[0].n = %v, want languages", res.Rows[0][0])
+	}
+
+	// A GoatCounter stats table as created by 2022-01-13-1-unfk-sqlite.sql:
+	// composite UNIQUE with ON CONFLICT REPLACE and no primary key.
+	execMust(t, e, `CREATE TABLE hit_counts (
+		site_id INTEGER NOT NULL,
+		path_id INTEGER NOT NULL,
+		hour TEXT NOT NULL,
+		total INTEGER NOT NULL,
+		CONSTRAINT "hit_counts#site_id#path_id#hour" UNIQUE(site_id, path_id, hour) ON CONFLICT REPLACE
+	)`)
+	execMust(t, e, `INSERT INTO hit_counts (site_id, path_id, hour, total) VALUES (1, 1, '2024-01-01 00:00:00', 5)`)
+	execMust(t, e, `INSERT INTO hit_counts (site_id, path_id, hour, total) VALUES (1, 1, '2024-01-01 00:00:00', 9)`)
+	res = execMust(t, e, `SELECT total FROM hit_counts WHERE site_id = 1 AND path_id = 1`)
+	if res.RowCount != 1 || res.Rows[0][0] != int64(9) {
+		t.Fatalf("hit_counts replace = %v rows %v", res.Rows, res.Rows)
+	}
+}
+
+// TestGoatCounterMigrationRebuildShape covers the table-rebuild dance used by
+// 2021-12-09-1-email-reports-sqlite.sql and 2022-01-13-1-unfk-sqlite.sql:
+// create a replacement table, copy rows with INSERT ... SELECT, drop and rename,
+// then build indexes (including composite expression unique indexes).
+func TestGoatCounterMigrationRebuildShape(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, "CREATE TABLE users (user_id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER, email TEXT, seen_updates_at TIMESTAMP)")
+	execMust(t, e, "INSERT INTO users (site_id, email) VALUES (1, 'A@x.com'), (1, 'b@x.com'), (2, 'a@x.com')")
+
+	execMust(t, 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
+	)`)
+	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")
+	execMust(t, e, "ALTER TABLE users2 RENAME TO users")
+	execMust(t, e, `CREATE INDEX "users#site_id" ON users(site_id)`)
+	execMust(t, e, `CREATE UNIQUE INDEX "users#site_id#email" ON users(site_id, lower(email))`)
+
+	res := execMust(t, e, "SELECT count(*) FROM users")
+	if res.Rows[0][0] != int64(3) {
+		t.Fatalf("copied row count = %v, want 3", res.Rows[0][0])
+	}
+	// Case-insensitive uniqueness across the composite expression index.
+	if _, err := execSQL(e, "INSERT INTO users (site_id, email) VALUES (1, 'a@x.com')"); err == nil {
+		t.Fatal("expected composite expression unique index to reject a duplicate")
+	}
+}
+
+func TestGoatCounterInsertWithSelectMigrationShape(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE old_sizes (size TEXT)")
+	execMust(t, e, "CREATE TABLE sizes (width INTEGER, height INTEGER)")
+	execMust(t, e, "INSERT INTO old_sizes VALUES ('10,20'), ('30,40')")
+
+	execMust(t, e, `INSERT INTO sizes (width, height)
+		WITH source AS (
+			SELECT size FROM old_sizes GROUP BY size
+		)
+		SELECT
+			CAST(substr(size, 1, instr(size, ',') - 1) AS INTEGER),
+			CAST(substr(size, instr(size, ',') + 1) AS INTEGER)
+		FROM source`)
+	res := execMust(t, e, "SELECT width, height FROM sizes ORDER BY width")
+	if res.RowCount != 2 || res.Rows[0][0] != int64(10) || res.Rows[1][1] != int64(40) {
+		t.Fatalf("migrated sizes = %v", res.Rows)
+	}
+}
+
+// TestGoatCounterDropSizesShape covers 2025-06-21-2-drop-sizes.sql, which adds a
+// column, backfills it with a correlated scalar subquery, drops a column, and
+// drops the source table.
+func TestGoatCounterDropSizesShape(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE sizes (size_id INTEGER PRIMARY KEY, width INTEGER)")
+	execMust(t, e, "CREATE TABLE hits (id INTEGER PRIMARY KEY, size_id INTEGER)")
+	execMust(t, e, "INSERT INTO sizes VALUES (1, 480), (2, 720)")
+	execMust(t, e, "INSERT INTO hits (id, size_id) VALUES (1, 1), (2, 2)")
+
+	execMust(t, e, "ALTER TABLE hits ADD COLUMN width SMALLINT NULL")
+	execMust(t, e, "UPDATE hits SET width = (SELECT width FROM sizes WHERE size_id = hits.size_id)")
+	execMust(t, e, "ALTER TABLE hits DROP COLUMN size_id")
+	execMust(t, e, "DROP TABLE sizes")
+
+	res := execMust(t, e, "SELECT id, width FROM hits ORDER BY id")
+	if res.Rows[0][1] != int64(480) || res.Rows[1][1] != int64(720) {
+		t.Fatalf("backfilled widths = %v", res.Rows)
+	}
+	if _, err := execSQL(e, "SELECT size_id FROM hits"); err == nil {
+		t.Fatal("size_id should have been dropped")
+	}
+}
+
+func TestGoatCounterDropThenRenameColumnMigration(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE hit_counts (id INTEGER PRIMARY KEY, total INTEGER, total_unique INTEGER)")
+	execMust(t, e, "INSERT INTO hit_counts VALUES (1, 111, 222)")
+
+	execMust(t, e, "ALTER TABLE hit_counts DROP COLUMN total")
+	execMust(t, e, "ALTER TABLE hit_counts RENAME COLUMN total_unique TO total")
+	res := execMust(t, e, "SELECT total FROM hit_counts WHERE id = 1")
+	if res.Rows[0][0] != int64(222) {
+		t.Fatalf("renamed total = %v, want 222", res.Rows[0][0])
+	}
+}

+ 273 - 0
pkg/executor/indexexpr.go

@@ -0,0 +1,273 @@
+package executor
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// statelessEval is a shared, immutable Executor used only to evaluate validated,
+// side-effect-free expressions (expression indexes and generated columns) from
+// the storage layer. It holds no session, catalog, or per-connection state, so
+// concurrent evaluations from different connections share no mutable data. The
+// expression validators below guarantee that only subquery-free, deterministic
+// expressions reach it.
+var statelessEval = &Executor{}
+
+// storedExprCache memoizes parsed stored expressions across all connections. A
+// stored expression is immutable text, so the cache is safe to share.
+var storedExprCache sync.Map // string -> parser.Expr
+
+// parseStoredExpr parses (once) an expression persisted in the durable schema
+// (generated column or expression index) and rejects anything that is not
+// deterministic, subquery-free SQL. The same text always parses to the same
+// expression, so the result is cached.
+func parseStoredExpr(text string) (parser.Expr, error) {
+	if cached, ok := storedExprCache.Load(text); ok {
+		return cached.(parser.Expr), nil
+	}
+	expr, err := parser.ParseExpr(text)
+	if err != nil {
+		return nil, fmt.Errorf("invalid stored expression %q: %w", text, err)
+	}
+	if err := ValidateDeterministicExpr(expr); err != nil {
+		return nil, fmt.Errorf("invalid stored expression %q: %w", text, err)
+	}
+	storedExprCache.Store(text, expr)
+	return expr, nil
+}
+
+// EvalStoredExpression evaluates a persisted, validated expression against a
+// row. It is the storage layer's expression-index evaluator and is stateless and
+// concurrency-safe.
+func EvalStoredExpression(text string, row storage.Row) (interface{}, error) {
+	expr, err := parseStoredExpr(text)
+	if err != nil {
+		return nil, err
+	}
+	return statelessEval.evalExpr(expr, row)
+}
+
+// ValidateDeterministicExpr rejects an expression that SQLite would not allow in
+// an index or generated column: subqueries, window functions, aggregates, and
+// non-deterministic or unknown functions.
+func ValidateDeterministicExpr(expr parser.Expr) error {
+	switch e := expr.(type) {
+	case nil:
+		return nil
+	case *parser.LiteralExpr:
+		return nil
+	case *parser.ColumnRef:
+		return nil
+	case *parser.ParenExpr:
+		return ValidateDeterministicExpr(e.Expr)
+	case *parser.UnaryExpr:
+		return ValidateDeterministicExpr(e.Operand)
+	case *parser.BinaryExpr:
+		if err := ValidateDeterministicExpr(e.Left); err != nil {
+			return err
+		}
+		return ValidateDeterministicExpr(e.Right)
+	case *parser.IsNullExpr:
+		return ValidateDeterministicExpr(e.Left)
+	case *parser.IsDistinctExpr:
+		if err := ValidateDeterministicExpr(e.Left); err != nil {
+			return err
+		}
+		return ValidateDeterministicExpr(e.Right)
+	case *parser.BetweenExpr:
+		if err := ValidateDeterministicExpr(e.Left); err != nil {
+			return err
+		}
+		if err := ValidateDeterministicExpr(e.Low); err != nil {
+			return err
+		}
+		return ValidateDeterministicExpr(e.High)
+	case *parser.LikeExpr:
+		if err := ValidateDeterministicExpr(e.Left); err != nil {
+			return err
+		}
+		if err := ValidateDeterministicExpr(e.Pattern); err != nil {
+			return err
+		}
+		return ValidateDeterministicExpr(e.Escape)
+	case *parser.CaseExpr:
+		if err := ValidateDeterministicExpr(e.Operand); err != nil {
+			return err
+		}
+		for _, w := range e.Whens {
+			if err := ValidateDeterministicExpr(w.Condition); err != nil {
+				return err
+			}
+			if err := ValidateDeterministicExpr(w.Result); err != nil {
+				return err
+			}
+		}
+		return ValidateDeterministicExpr(e.Else)
+	case *parser.CastExpr:
+		return ValidateDeterministicExpr(e.Expr)
+	case *parser.InExpr:
+		if e.Subquery != nil {
+			return fmt.Errorf("subqueries are not allowed in index expressions")
+		}
+		if err := ValidateDeterministicExpr(e.Left); err != nil {
+			return err
+		}
+		for _, v := range e.Values {
+			if err := ValidateDeterministicExpr(v); err != nil {
+				return err
+			}
+		}
+		return nil
+	case *parser.FunctionCall:
+		if e.Star {
+			return fmt.Errorf("function %s(*) is not allowed in index expressions", e.Name)
+		}
+		if isAggregateFunctionName(e.Name, len(e.Args)) {
+			return fmt.Errorf("aggregate function %s() is not allowed in index expressions", e.Name)
+		}
+		if !isDeterministicFunction(e.Name) {
+			return fmt.Errorf("non-deterministic or unsupported function %s() is not allowed in index expressions", e.Name)
+		}
+		for _, a := range e.Args {
+			if err := ValidateDeterministicExpr(a); err != nil {
+				return err
+			}
+		}
+		return nil
+	case *parser.SubqueryExpr:
+		return fmt.Errorf("subqueries are not allowed in index expressions")
+	case *parser.ExistsExpr:
+		return fmt.Errorf("subqueries are not allowed in index expressions")
+	case *parser.WindowExpr:
+		return fmt.Errorf("window functions are not allowed in index expressions")
+	default:
+		return fmt.Errorf("unsupported expression in index definition: %T", expr)
+	}
+}
+
+// isAggregateFunctionName reports whether a function name is an aggregate in the
+// given call shape. MIN/MAX are scalar with two or more arguments.
+func isAggregateFunctionName(name string, argCount int) bool {
+	switch strings.ToUpper(name) {
+	case "COUNT", "SUM", "AVG", "TOTAL", "GROUP_CONCAT",
+		"JSON_GROUP_ARRAY", "JSONB_GROUP_ARRAY", "JSON_GROUP_OBJECT", "JSONB_GROUP_OBJECT":
+		return true
+	case "MIN", "MAX":
+		return argCount < 2
+	}
+	return false
+}
+
+// deterministicFunctions is the allowlist of scalar functions that may appear in
+// a persisted expression. It intentionally excludes date/time functions (which
+// are non-deterministic when using "now") and every random/session function.
+var deterministicFunctions = map[string]bool{
+	"LOWER": true, "UPPER": true, "LENGTH": true, "ABS": true,
+	"COALESCE": true, "NULLIF": true, "IFNULL": true, "NVL": true,
+	"TYPEOF": true, "SUBSTR": true, "SUBSTRING": true, "TRIM": true,
+	"REPLACE": true, "PRINTF": true, "HEX": true, "UNHEX": true,
+	"ZEROBLOB": true, "INSTR": true, "GLOB": true, "ROUND": true,
+	"MAX": true, "MIN": true, "CONCAT": true, "PERCENT_DIFF": true,
+	// JSON1 scalar functions.
+	"JSON": true, "JSONB": true, "JSON_VALID": true, "JSONB_VALID": true,
+	"JSON_TYPE": true, "JSON_EXTRACT": true, "JSONB_EXTRACT": true,
+	"JSON_SET": true, "JSONB_SET": true, "JSON_INSERT": true, "JSONB_INSERT": true,
+	"JSON_REPLACE": true, "JSONB_REPLACE": true, "JSON_REMOVE": true, "JSONB_REMOVE": true,
+	"JSON_ARRAY": true, "JSONB_ARRAY": true, "JSON_OBJECT": true, "JSONB_OBJECT": true,
+	"JSON_QUOTE": true, "JSONB_QUOTE": true,
+}
+
+func isDeterministicFunction(name string) bool {
+	return deterministicFunctions[strings.ToUpper(name)]
+}
+
+// ValidateIndexColumns checks that every column referenced by a stored
+// expression exists on the table (or is a hidden rowid alias). It is applied to
+// expression indexes and generated columns at creation time.
+func ValidateIndexColumns(expr parser.Expr, schema *storage.Schema) error {
+	switch e := expr.(type) {
+	case nil:
+		return nil
+	case *parser.ColumnRef:
+		if e.Column == "*" {
+			return fmt.Errorf("wildcards are not allowed in index expressions")
+		}
+		if storage.IsRowIDColumn(e.Column) {
+			return nil
+		}
+		if _, ok := schema.GetColumn(e.Column); !ok {
+			return fmt.Errorf("column not found in index expression: %s", e.Column)
+		}
+		return nil
+	case *parser.ParenExpr:
+		return ValidateIndexColumns(e.Expr, schema)
+	case *parser.UnaryExpr:
+		return ValidateIndexColumns(e.Operand, schema)
+	case *parser.BinaryExpr:
+		if err := ValidateIndexColumns(e.Left, schema); err != nil {
+			return err
+		}
+		return ValidateIndexColumns(e.Right, schema)
+	case *parser.IsNullExpr:
+		return ValidateIndexColumns(e.Left, schema)
+	case *parser.IsDistinctExpr:
+		if err := ValidateIndexColumns(e.Left, schema); err != nil {
+			return err
+		}
+		return ValidateIndexColumns(e.Right, schema)
+	case *parser.BetweenExpr:
+		if err := ValidateIndexColumns(e.Left, schema); err != nil {
+			return err
+		}
+		if err := ValidateIndexColumns(e.Low, schema); err != nil {
+			return err
+		}
+		return ValidateIndexColumns(e.High, schema)
+	case *parser.LikeExpr:
+		if err := ValidateIndexColumns(e.Left, schema); err != nil {
+			return err
+		}
+		if err := ValidateIndexColumns(e.Pattern, schema); err != nil {
+			return err
+		}
+		return ValidateIndexColumns(e.Escape, schema)
+	case *parser.CaseExpr:
+		if err := ValidateIndexColumns(e.Operand, schema); err != nil {
+			return err
+		}
+		for _, w := range e.Whens {
+			if err := ValidateIndexColumns(w.Condition, schema); err != nil {
+				return err
+			}
+			if err := ValidateIndexColumns(w.Result, schema); err != nil {
+				return err
+			}
+		}
+		return ValidateIndexColumns(e.Else, schema)
+	case *parser.CastExpr:
+		return ValidateIndexColumns(e.Expr, schema)
+	case *parser.InExpr:
+		if err := ValidateIndexColumns(e.Left, schema); err != nil {
+			return err
+		}
+		for _, v := range e.Values {
+			if err := ValidateIndexColumns(v, schema); err != nil {
+				return err
+			}
+		}
+		return nil
+	case *parser.FunctionCall:
+		for _, a := range e.Args {
+			if err := ValidateIndexColumns(a, schema); err != nil {
+				return err
+			}
+		}
+		return nil
+	default:
+		return nil
+	}
+}

+ 639 - 0
pkg/executor/json.go

@@ -0,0 +1,639 @@
+package executor
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// jsonText is a JSON value produced by a JSON1 function. It carries the JSON
+// subtype across nested function calls (so json_insert can embed the result of
+// json() verbatim) and is normalized to a plain string before it is stored.
+type jsonText string
+
+// jsonEncode marshals a decoded JSON tree without HTML escaping.
+func jsonEncode(v interface{}) (string, error) {
+	var buf bytes.Buffer
+	enc := json.NewEncoder(&buf)
+	enc.SetEscapeHTML(false)
+	if err := enc.Encode(v); err != nil {
+		return "", err
+	}
+	return strings.TrimRight(buf.String(), "\n"), nil
+}
+
+// jsonDecode parses JSON text, preserving number precision with json.Number.
+func jsonDecode(s string) (interface{}, error) {
+	dec := json.NewDecoder(strings.NewReader(s))
+	dec.UseNumber()
+	var v interface{}
+	if err := dec.Decode(&v); err != nil {
+		return nil, fmt.Errorf("malformed JSON")
+	}
+	// Reject trailing content, matching JSON1's strict parsing.
+	if dec.More() {
+		return nil, fmt.Errorf("malformed JSON")
+	}
+	return v, nil
+}
+
+// jsonInput resolves a JSON1 input argument to a decoded tree.
+func jsonInput(v interface{}) (interface{}, error) {
+	switch t := v.(type) {
+	case nil:
+		return nil, nil
+	case jsonText:
+		return jsonDecode(string(t))
+	case string:
+		return jsonDecode(t)
+	case []byte:
+		return jsonDecode(string(t))
+	default:
+		return nil, fmt.Errorf("malformed JSON")
+	}
+}
+
+// jsonArgToNode converts a SQL value into a JSON tree node for embedding.
+func jsonArgToNode(v interface{}) interface{} {
+	switch t := v.(type) {
+	case nil:
+		return nil
+	case jsonText:
+		if node, err := jsonDecode(string(t)); err == nil {
+			return node
+		}
+		return string(t)
+	case bool:
+		return t
+	case int:
+		return json.Number(strconv.FormatInt(int64(t), 10))
+	case int64:
+		return json.Number(strconv.FormatInt(t, 10))
+	case uint64:
+		return json.Number(strconv.FormatUint(t, 10))
+	case float64:
+		return t
+	case string:
+		return t
+	case []byte:
+		return string(t)
+	default:
+		return fmt.Sprintf("%v", t)
+	}
+}
+
+// jsonNodeToSQL converts a JSON tree node to the SQL value json_extract returns.
+func jsonNodeToSQL(v interface{}) interface{} {
+	switch t := v.(type) {
+	case nil:
+		return nil
+	case json.Number:
+		if i, err := t.Int64(); err == nil {
+			return i
+		}
+		f, _ := t.Float64()
+		return f
+	case bool:
+		if t {
+			return int64(1)
+		}
+		return int64(0)
+	case string:
+		return t
+	case []interface{}, map[string]interface{}:
+		s, err := jsonEncode(t)
+		if err != nil {
+			return nil
+		}
+		return jsonText(s)
+	default:
+		return t
+	}
+}
+
+// jsonPathSeg is one component of a parsed JSON path.
+type jsonPathSeg struct {
+	key      string
+	isIndex  bool
+	index    int
+	appendOp bool // [#] — append for insert/set
+	fromEnd  bool // negative index
+}
+
+// parseJSONPath parses a JSON1 path expression such as $.a.b[0] or $[#].
+func parseJSONPath(path string) ([]jsonPathSeg, error) {
+	if !strings.HasPrefix(path, "$") {
+		return nil, fmt.Errorf("JSON path error: %s", path)
+	}
+	var segs []jsonPathSeg
+	i := 1
+	for i < len(path) {
+		switch path[i] {
+		case '.':
+			i++
+			if i >= len(path) {
+				return nil, fmt.Errorf("JSON path error: %s", path)
+			}
+			if path[i] == '"' {
+				end := strings.IndexByte(path[i+1:], '"')
+				if end < 0 {
+					return nil, fmt.Errorf("JSON path error: %s", path)
+				}
+				segs = append(segs, jsonPathSeg{key: path[i+1 : i+1+end]})
+				i += end + 2
+				continue
+			}
+			start := i
+			for i < len(path) && path[i] != '.' && path[i] != '[' {
+				i++
+			}
+			segs = append(segs, jsonPathSeg{key: path[start:i]})
+		case '[':
+			end := strings.IndexByte(path[i:], ']')
+			if end < 0 {
+				return nil, fmt.Errorf("JSON path error: %s", path)
+			}
+			inner := path[i+1 : i+end]
+			i += end + 1
+			if inner == "#" {
+				segs = append(segs, jsonPathSeg{isIndex: true, appendOp: true})
+				continue
+			}
+			if strings.HasPrefix(inner, "\"") && strings.HasSuffix(inner, "\"") && len(inner) >= 2 {
+				segs = append(segs, jsonPathSeg{key: inner[1 : len(inner)-1]})
+				continue
+			}
+			n, err := strconv.Atoi(inner)
+			if err != nil {
+				return nil, fmt.Errorf("JSON path error: %s", path)
+			}
+			segs = append(segs, jsonPathSeg{isIndex: true, index: n, fromEnd: n < 0})
+		default:
+			return nil, fmt.Errorf("JSON path error: %s", path)
+		}
+	}
+	return segs, nil
+}
+
+// jsonLookup walks a decoded tree to the node addressed by segs.
+func jsonLookup(root interface{}, segs []jsonPathSeg) (interface{}, bool) {
+	cur := root
+	for _, seg := range segs {
+		if seg.isIndex {
+			arr, ok := cur.([]interface{})
+			if !ok {
+				return nil, false
+			}
+			idx := seg.index
+			if seg.fromEnd {
+				idx = len(arr) + idx
+			}
+			if idx < 0 || idx >= len(arr) {
+				return nil, false
+			}
+			cur = arr[idx]
+			continue
+		}
+		obj, ok := cur.(map[string]interface{})
+		if !ok {
+			return nil, false
+		}
+		val, ok := obj[seg.key]
+		if !ok {
+			return nil, false
+		}
+		cur = val
+	}
+	return cur, true
+}
+
+// jsonApplyPatch applies json_set/insert/replace mutations to a decoded tree.
+// mode is "set", "insert", or "replace".
+func jsonApplyPatch(root interface{}, segs []jsonPathSeg, value interface{}, mode string) (interface{}, error) {
+	if len(segs) == 0 {
+		return value, nil
+	}
+	seg := segs[0]
+	last := len(segs) == 1
+	childSegs := segs[1:]
+
+	if seg.isIndex {
+		arr, ok := root.([]interface{})
+		if !ok {
+			// A missing container is created only by json_set/json_insert.
+			if mode == "replace" {
+				return root, nil
+			}
+			arr = []interface{}{}
+		}
+		idx := seg.index
+		if seg.appendOp {
+			if last {
+				return append(arr, value), nil
+			}
+			child, childOK := interface{}(nil), false
+			_ = child
+			_ = childOK
+			// append a new container for nested paths
+			container := newJSONContainer(childSegs[0])
+			newChild, err := jsonApplyPatch(container, childSegs, value, mode)
+			if err != nil {
+				return nil, err
+			}
+			return append(arr, newChild), nil
+		}
+		if seg.fromEnd {
+			idx = len(arr) + idx
+		}
+		if idx < 0 || idx > len(arr) {
+			return root, nil
+		}
+		if last {
+			if idx == len(arr) {
+				if mode == "replace" {
+					return root, nil
+				}
+				return append(arr, value), nil
+			}
+			replaced := append([]interface{}(nil), arr...)
+			replaced[idx] = value
+			return replaced, nil
+		}
+		if idx == len(arr) {
+			if mode == "replace" {
+				return root, nil
+			}
+			container := newJSONContainer(childSegs[0])
+			newChild, err := jsonApplyPatch(container, childSegs, value, mode)
+			if err != nil {
+				return nil, err
+			}
+			return append(arr, newChild), nil
+		}
+		newChild, err := jsonApplyPatch(arr[idx], childSegs, value, mode)
+		if err != nil {
+			return nil, err
+		}
+		replaced := append([]interface{}(nil), arr...)
+		replaced[idx] = newChild
+		return replaced, nil
+	}
+
+	obj, ok := root.(map[string]interface{})
+	if !ok {
+		if mode == "replace" {
+			return root, nil
+		}
+		obj = map[string]interface{}{}
+	}
+	if last {
+		_, exists := obj[seg.key]
+		if exists && mode == "insert" {
+			return root, nil
+		}
+		if !exists && mode == "replace" {
+			return root, nil
+		}
+		newObj := make(map[string]interface{}, len(obj)+1)
+		for k, v := range obj {
+			newObj[k] = v
+		}
+		newObj[seg.key] = value
+		return newObj, nil
+	}
+	child, exists := obj[seg.key]
+	if !exists {
+		if mode == "replace" {
+			return root, nil
+		}
+		child = newJSONContainer(childSegs[0])
+	}
+	newChild, err := jsonApplyPatch(child, childSegs, value, mode)
+	if err != nil {
+		return nil, err
+	}
+	newObj := make(map[string]interface{}, len(obj)+1)
+	for k, v := range obj {
+		newObj[k] = v
+	}
+	newObj[seg.key] = newChild
+	return newObj, nil
+}
+
+func newJSONContainer(seg jsonPathSeg) interface{} {
+	if seg.isIndex {
+		return []interface{}{}
+	}
+	return map[string]interface{}{}
+}
+
+// jsonTypeName returns the JSON1 type name for a decoded node.
+func jsonTypeName(v interface{}) string {
+	switch v.(type) {
+	case nil:
+		return "null"
+	case bool:
+		return "false" // caller distinguishes true below
+	case json.Number:
+		s := string(v.(json.Number))
+		if !strings.ContainsAny(s, ".eE") {
+			return "integer"
+		}
+		return "real"
+	case string:
+		return "text"
+	case []interface{}:
+		return "array"
+	case map[string]interface{}:
+		return "object"
+	default:
+		return "null"
+	}
+}
+
+// evalJSONFunction evaluates a JSON1 scalar function. It returns handled=false
+// for names it does not own.
+func evalJSONFunction(name string, args []interface{}) (interface{}, bool, error) {
+	upper := strings.ToUpper(name)
+	switch upper {
+	case "JSON", "JSONB":
+		if len(args) != 1 {
+			return nil, true, fmt.Errorf("wrong number of arguments to %s()", name)
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		node, err := jsonInput(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		s, err := jsonEncode(node)
+		if err != nil {
+			return nil, true, err
+		}
+		return jsonText(s), true, nil
+
+	case "JSON_VALID", "JSONB_VALID":
+		if len(args) != 1 {
+			return nil, true, fmt.Errorf("wrong number of arguments to %s()", name)
+		}
+		if _, err := jsonInput(args[0]); err != nil {
+			return int64(0), true, nil
+		}
+		return int64(1), true, nil
+
+	case "JSON_TYPE":
+		if len(args) < 1 || len(args) > 2 {
+			return nil, true, fmt.Errorf("wrong number of arguments to json_type()")
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		node, err := jsonInput(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		if len(args) == 2 {
+			path, ok := args[1].(string)
+			if !ok {
+				if jt, is := args[1].(jsonText); is {
+					path = string(jt)
+				} else {
+					return nil, true, fmt.Errorf("JSON path error")
+				}
+			}
+			segs, perr := parseJSONPath(path)
+			if perr != nil {
+				return nil, true, perr
+			}
+			found, ok := jsonLookup(node, segs)
+			if !ok {
+				return nil, true, nil
+			}
+			node = found
+		}
+		if b, isBool := node.(bool); isBool {
+			if b {
+				return "true", true, nil
+			}
+			return "false", true, nil
+		}
+		return jsonTypeName(node), true, nil
+
+	case "JSON_EXTRACT", "JSONB_EXTRACT":
+		if len(args) < 2 {
+			return nil, true, fmt.Errorf("wrong number of arguments to json_extract()")
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		node, err := jsonInput(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		for _, p := range args[1:] {
+			path, ok := jsonPathArg(p)
+			if !ok {
+				return nil, true, fmt.Errorf("JSON path error")
+			}
+			segs, perr := parseJSONPath(path)
+			if perr != nil {
+				return nil, true, perr
+			}
+			found, ok := jsonLookup(node, segs)
+			if !ok {
+				return nil, true, nil
+			}
+			node = found
+		}
+		return jsonNodeToSQL(node), true, nil
+
+	case "JSON_SET", "JSONB_SET", "JSON_INSERT", "JSONB_INSERT", "JSON_REPLACE", "JSONB_REPLACE":
+		if len(args) < 3 || len(args)%2 == 0 {
+			return nil, true, fmt.Errorf("wrong number of arguments to %s()", name)
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		mode := "set"
+		if strings.Contains(upper, "INSERT") {
+			mode = "insert"
+		} else if strings.Contains(upper, "REPLACE") {
+			mode = "replace"
+		}
+		node, err := jsonInput(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		for i := 1; i+1 < len(args); i += 2 {
+			path, ok := jsonPathArg(args[i])
+			if !ok {
+				return nil, true, fmt.Errorf("JSON path error")
+			}
+			segs, perr := parseJSONPath(path)
+			if perr != nil {
+				return nil, true, perr
+			}
+			node, err = jsonApplyPatch(node, segs, jsonArgToNode(args[i+1]), mode)
+			if err != nil {
+				return nil, true, err
+			}
+		}
+		s, err := jsonEncode(node)
+		if err != nil {
+			return nil, true, err
+		}
+		return jsonText(s), true, nil
+
+	case "JSON_REMOVE", "JSONB_REMOVE":
+		if len(args) < 2 {
+			return nil, true, fmt.Errorf("wrong number of arguments to json_remove()")
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		node, err := jsonInput(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		for _, p := range args[1:] {
+			path, ok := jsonPathArg(p)
+			if !ok {
+				return nil, true, fmt.Errorf("JSON path error")
+			}
+			segs, perr := parseJSONPath(path)
+			if perr != nil {
+				return nil, true, perr
+			}
+			node, err = jsonRemove(node, segs)
+			if err != nil {
+				return nil, true, err
+			}
+		}
+		s, err := jsonEncode(node)
+		if err != nil {
+			return nil, true, err
+		}
+		return jsonText(s), true, nil
+
+	case "JSON_ARRAY", "JSONB_ARRAY":
+		arr := make([]interface{}, len(args))
+		for i, a := range args {
+			arr[i] = jsonArgToNode(a)
+		}
+		s, err := jsonEncode(arr)
+		if err != nil {
+			return nil, true, err
+		}
+		return jsonText(s), true, nil
+
+	case "JSON_OBJECT", "JSONB_OBJECT":
+		if len(args)%2 != 0 {
+			return nil, true, fmt.Errorf("json_object() requires an even number of arguments")
+		}
+		obj := make(map[string]interface{}, len(args)/2)
+		for i := 0; i+1 < len(args); i += 2 {
+			key, ok := args[i].(string)
+			if !ok {
+				if jt, is := args[i].(jsonText); is {
+					key = string(jt)
+				} else {
+					return nil, true, fmt.Errorf("json_object() labels must be TEXT")
+				}
+			}
+			obj[key] = jsonArgToNode(args[i+1])
+		}
+		s, err := jsonEncode(obj)
+		if err != nil {
+			return nil, true, err
+		}
+		return jsonText(s), true, nil
+
+	case "JSON_QUOTE", "JSONB_QUOTE":
+		if len(args) != 1 {
+			return nil, true, fmt.Errorf("json_quote() requires exactly one argument")
+		}
+		s, err := jsonEncode(jsonArgToNode(args[0]))
+		if err != nil {
+			return nil, true, err
+		}
+		return jsonText(s), true, nil
+	}
+	return nil, false, nil
+}
+
+// jsonPathArg extracts a path string from an argument.
+func jsonPathArg(v interface{}) (string, bool) {
+	switch t := v.(type) {
+	case string:
+		return t, true
+	case jsonText:
+		return string(t), true
+	default:
+		return "", false
+	}
+}
+
+// jsonRemove removes the node addressed by segs from a decoded tree.
+func jsonRemove(root interface{}, segs []jsonPathSeg) (interface{}, error) {
+	if len(segs) == 0 {
+		return root, nil
+	}
+	seg := segs[0]
+	last := len(segs) == 1
+	if seg.isIndex {
+		arr, ok := root.([]interface{})
+		if !ok {
+			return root, nil
+		}
+		idx := seg.index
+		if seg.fromEnd {
+			idx = len(arr) + idx
+		}
+		if idx < 0 || idx >= len(arr) {
+			return root, nil
+		}
+		if last {
+			out := make([]interface{}, 0, len(arr)-1)
+			out = append(out, arr[:idx]...)
+			out = append(out, arr[idx+1:]...)
+			return out, nil
+		}
+		child, err := jsonRemove(arr[idx], segs[1:])
+		if err != nil {
+			return nil, err
+		}
+		out := append([]interface{}(nil), arr...)
+		out[idx] = child
+		return out, nil
+	}
+	obj, ok := root.(map[string]interface{})
+	if !ok {
+		return root, nil
+	}
+	if last {
+		out := make(map[string]interface{}, len(obj))
+		for k, v := range obj {
+			if k != seg.key {
+				out[k] = v
+			}
+		}
+		return out, nil
+	}
+	child, exists := obj[seg.key]
+	if !exists {
+		return root, nil
+	}
+	newChild, err := jsonRemove(child, segs[1:])
+	if err != nil {
+		return nil, err
+	}
+	out := make(map[string]interface{}, len(obj))
+	for k, v := range obj {
+		out[k] = v
+	}
+	out[seg.key] = newChild
+	return out, nil
+}

+ 5 - 0
pkg/executor/result.go

@@ -31,6 +31,11 @@ func (r *Result) AddColumn(name string) {
 
 // AddRow adds a row to the result.
 func (r *Result) AddRow(values ...interface{}) {
+	for i, v := range values {
+		if jt, ok := v.(jsonText); ok {
+			values[i] = string(jt)
+		}
+	}
 	r.Rows = append(r.Rows, values)
 	r.RowCount = len(r.Rows)
 }

+ 55 - 0
pkg/executor/result_types.go

@@ -52,3 +52,58 @@ func projectionColumnType(col parser.SelectColumn, schema *storage.Schema) strin
 	}
 	return "TEXT"
 }
+
+// joinedSelectColumnTypes resolves direct projections against every table in a
+// joined or comma-separated FROM clause. Expressions retain TEXT metadata.
+func (e *Executor) joinedSelectColumnTypes(stmt *parser.SelectStmt, refs []parser.TableRef) []string {
+	types := make([]string, 0, len(stmt.Columns))
+	for _, projection := range stmt.Columns {
+		if projection.Star {
+			for _, ref := range refs {
+				if schema, err := e.schema.GetSchema(ref.Name); err == nil {
+					for _, column := range schema.Columns {
+						types = append(types, column.Type)
+					}
+				}
+			}
+			continue
+		}
+		if projection.TableStar != "" {
+			if columns, _, err := e.resolveTableStar(stmt.From, projection.TableStar); err == nil {
+				for _, column := range columns {
+					types = append(types, column.Type)
+				}
+			}
+			continue
+		}
+
+		ref, ok := projection.Expr.(*parser.ColumnRef)
+		if !ok {
+			types = append(types, "TEXT")
+			continue
+		}
+		columnType := "TEXT"
+		found := false
+		for _, table := range refs {
+			if ref.Table != "" && !strings.EqualFold(ref.Table, table.Alias) && !strings.EqualFold(ref.Table, table.Name) {
+				continue
+			}
+			schema, err := e.schema.GetSchema(table.Name)
+			if err != nil {
+				continue
+			}
+			for _, column := range schema.Columns {
+				if strings.EqualFold(column.Name, ref.Column) {
+					columnType = column.Type
+					found = true
+					break
+				}
+			}
+			if found {
+				break
+			}
+		}
+		types = append(types, columnType)
+	}
+	return types
+}

+ 34 - 0
pkg/executor/result_types_test.go

@@ -71,6 +71,40 @@ func TestSelectColumnTypesEmptyResult(t *testing.T) {
 	}
 }
 
+func TestSelectColumnTypesJoin(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE hits (hit_id INTEGER PRIMARY KEY, path_id INTEGER, created_at TIMESTAMP)`)
+	execMust(t, e, `CREATE TABLE paths (path_id INTEGER PRIMARY KEY, path TEXT)`)
+	execMust(t, e, `INSERT INTO paths VALUES (1, '/home')`)
+	execMust(t, e, `INSERT INTO hits VALUES (1, 1, '2024-05-06 07:08:09')`)
+
+	res := execMust(t, e, `SELECT paths.path, hits.created_at FROM hits JOIN paths USING (path_id)`)
+	want := []string{"TEXT", "TIMESTAMP"}
+	if len(res.ColumnTypes) != len(want) {
+		t.Fatalf("column types = %v, want %v", res.ColumnTypes, want)
+	}
+	for i := range want {
+		if res.ColumnTypes[i] != want[i] {
+			t.Fatalf("column types = %v, want %v", res.ColumnTypes, want)
+		}
+	}
+}
+
+func TestSelectColumnTypesJoinUsesFirstUnqualifiedMatch(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE a (id INTEGER PRIMARY KEY, value TEXT)`)
+	execMust(t, e, `CREATE TABLE b (id INTEGER PRIMARY KEY, value INTEGER)`)
+	execMust(t, e, `INSERT INTO a VALUES (1, 'one')`)
+	execMust(t, e, `INSERT INTO b VALUES (1, 2)`)
+
+	res := execMust(t, e, `SELECT value FROM a JOIN b ON a.id = b.id`)
+	if len(res.ColumnTypes) != 1 || res.ColumnTypes[0] != "TEXT" {
+		t.Fatalf("column types = %v, want [TEXT]", res.ColumnTypes)
+	}
+}
+
 func TestUUIDColumnRoundTrip(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)

+ 108 - 0
pkg/executor/returning.go

@@ -0,0 +1,108 @@
+package executor
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// returningProjection expands a RETURNING column list into output column names
+// and types. A wildcard expands the table's schema columns; an aliased
+// expression uses its alias; a bare column reference uses the column name; any
+// other expression falls back to a synthesized name.
+func returningProjection(cols []parser.SelectColumn, schema *storage.Schema) ([]string, []string) {
+	var names, types []string
+	for i, col := range cols {
+		switch {
+		case col.Star:
+			for _, c := range schema.Columns {
+				names = append(names, c.Name)
+				types = append(types, c.Type)
+			}
+		case col.TableStar != "":
+			for _, c := range schema.Columns {
+				names = append(names, c.Name)
+				types = append(types, c.Type)
+			}
+		case col.Alias != "":
+			names = append(names, col.Alias)
+			types = append(types, projectionType(col.Expr, schema))
+		default:
+			if ref, ok := col.Expr.(*parser.ColumnRef); ok {
+				names = append(names, ref.Column)
+			} else {
+				name := parser.FormatExpr(col.Expr)
+				if name == "" {
+					name = fmt.Sprintf("column%d", i+1)
+				}
+				names = append(names, name)
+			}
+			types = append(types, projectionType(col.Expr, schema))
+		}
+	}
+	return names, types
+}
+
+// projectionType resolves the declared type of a direct column reference.
+func projectionType(expr parser.Expr, schema *storage.Schema) string {
+	ref, ok := expr.(*parser.ColumnRef)
+	if !ok {
+		return "TEXT"
+	}
+	for _, c := range schema.Columns {
+		if strings.EqualFold(c.Name, ref.Column) {
+			return c.Type
+		}
+	}
+	return "TEXT"
+}
+
+// returningResult evaluates a RETURNING projection over the affected rows and
+// builds the result set. For INSERT/UPDATE the rows are the post-change rows;
+// for DELETE the caller passes the removed rows.
+func (e *Executor) returningResult(cols []parser.SelectColumn, schema *storage.Schema, rows []storage.Row) (*Result, error) {
+	names, types := returningProjection(cols, schema)
+	result := NewResult("SELECT")
+	for i := range names {
+		result.AddColumnWithType(names[i], types[i])
+	}
+
+	for _, row := range rows {
+		values := make([]interface{}, 0, len(cols))
+		for _, col := range cols {
+			switch {
+			case col.Star:
+				for _, c := range schema.Columns {
+					values = append(values, e.lookupRowColumn(row, c.Name))
+				}
+			case col.TableStar != "":
+				for _, c := range schema.Columns {
+					values = append(values, e.lookupRowColumn(row, c.Name))
+				}
+			default:
+				val, err := e.evalExpr(col.Expr, row)
+				if err != nil {
+					return nil, err
+				}
+				values = append(values, val)
+			}
+		}
+		result.AddRow(values...)
+	}
+	return result, nil
+}
+
+// lookupRowColumn resolves a column from a row case-insensitively.
+func (e *Executor) lookupRowColumn(row storage.Row, name string) interface{} {
+	if v, ok := row[name]; ok {
+		return v
+	}
+	for k, v := range row {
+		if strings.EqualFold(k, name) {
+			return v
+		}
+	}
+	return nil
+}

+ 406 - 0
pkg/executor/review_fixes_test.go

@@ -0,0 +1,406 @@
+package executor
+
+import (
+	"fmt"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// runWithin fails the test if fn does not return before the deadline. It is used
+// to turn a would-be deadlock into a test failure instead of a hung suite.
+func runWithin(t *testing.T, d time.Duration, fn func()) {
+	t.Helper()
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		fn()
+	}()
+	select {
+	case <-done:
+	case <-time.After(d):
+		t.Fatalf("operation did not complete within %s (deadlock?)", d)
+	}
+}
+
+func TestFinishDMLPreservesLastInsertRowID(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)")
+	execMust(t, e, "INSERT INTO t (v) VALUES ('a')")
+	if got := execMust(t, e, "SELECT last_insert_rowid()").Rows[0][0]; got != int64(1) {
+		t.Fatalf("last_insert_rowid after insert = %v, want 1", got)
+	}
+
+	execMust(t, e, "UPDATE t SET v = 'b'")
+	if got := execMust(t, e, "SELECT last_insert_rowid()").Rows[0][0]; got != int64(1) {
+		t.Fatalf("last_insert_rowid after UPDATE = %v, want 1", got)
+	}
+	if res := execMust(t, e, "UPDATE t SET v = 'c' RETURNING id"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("UPDATE RETURNING id = %v", res.Rows[0][0])
+	}
+	if got := execMust(t, e, "SELECT last_insert_rowid()").Rows[0][0]; got != int64(1) {
+		t.Fatalf("last_insert_rowid after UPDATE RETURNING = %v, want 1", got)
+	}
+
+	execMust(t, e, "DELETE FROM t")
+	if got := execMust(t, e, "SELECT last_insert_rowid()").Rows[0][0]; got != int64(1) {
+		t.Fatalf("last_insert_rowid after DELETE = %v, want 1", got)
+	}
+
+	execMust(t, e, "INSERT INTO t (v) VALUES ('d')")
+	if got := execMust(t, e, "SELECT last_insert_rowid()").Rows[0][0]; got != int64(2) {
+		t.Fatalf("last_insert_rowid after second insert = %v, want 2", got)
+	}
+}
+
+func TestUpsertPreservesLastInsertRowID(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE, tag TEXT)")
+	execMust(t, e, "INSERT INTO t (email, tag) VALUES ('a', 'old')")
+	if got := execMust(t, e, "SELECT last_insert_rowid()").Rows[0][0]; got != int64(1) {
+		t.Fatalf("insert last_insert_rowid = %v, want 1", got)
+	}
+	execMust(t, e, "INSERT INTO t (email, tag) VALUES ('a', 'new') ON CONFLICT (email) DO UPDATE SET tag = excluded.tag")
+	if got := execMust(t, e, "SELECT last_insert_rowid()").Rows[0][0]; got != int64(1) {
+		t.Fatalf("upsert-update last_insert_rowid = %v, want 1", got)
+	}
+}
+
+func TestUpsertNonPKUniqueReturningReturnsStoredRow(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE, tag TEXT)")
+
+	res := execMust(t, e, "INSERT INTO t (email, tag) VALUES ('a', 'old') RETURNING id, tag")
+	id := res.Rows[0][0]
+
+	res = execMust(t, e, "INSERT INTO t (email, tag) VALUES ('a', 'new') ON CONFLICT (email) DO UPDATE SET tag = excluded.tag RETURNING id, tag")
+	if res.RowCount != 1 {
+		t.Fatalf("upsert RETURNING rows = %d, want 1", res.RowCount)
+	}
+	if res.Rows[0][0] != id || res.Rows[0][1] != "new" {
+		t.Fatalf("upsert RETURNING = %v, want [%v new]", res.Rows[0], id)
+	}
+	// The candidate's auto-increment id must not have been consumed/returned.
+	if res.LastInsertID != id {
+		t.Fatalf("upsert LastInsertID = %v, want preserved %v", res.LastInsertID, id)
+	}
+}
+
+func TestUpdatePrimaryKeyReturningReturnsChangedRow(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a')")
+
+	res := execMust(t, e, "UPDATE t SET id = 2 WHERE id = 1 RETURNING id, v")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(2) || res.Rows[0][1] != "a" {
+		t.Fatalf("UPDATE pk RETURNING = %v", res.Rows)
+	}
+	if rows := execMust(t, e, "SELECT id, v FROM t"); rows.RowCount != 1 || rows.Rows[0][0] != int64(2) {
+		t.Fatalf("after pk update table = %v (orphan old key?)", rows.Rows)
+	}
+}
+
+func TestUpdatePrimaryKeyInTransaction(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a')")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "UPDATE t SET id = 2 WHERE id = 1")
+	// The old key must be gone within the transaction overlay too.
+	if res := execMust(t, e, "SELECT id FROM t"); res.RowCount != 1 || res.Rows[0][0] != int64(2) {
+		t.Fatalf("in-tx pk update = %v", res.Rows)
+	}
+	execMust(t, e, "COMMIT")
+
+	res := execMust(t, e, "SELECT id, v FROM t")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(2) {
+		t.Fatalf("after commit = %v", res.Rows)
+	}
+}
+
+func TestUpdateDeleteReturningWithSubqueryNoDeadlock(t *testing.T) {
+	runWithin(t, 10*time.Second, func() {
+		_, schema, table := newTestDB(t)
+		e := newExec(schema, table)
+		execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+		execMust(t, e, "INSERT INTO t VALUES (1, 'a'), (2, 'b')")
+
+		res := execMust(t, e, "UPDATE t SET v = 'x' WHERE id IN (SELECT id FROM t WHERE id = 1) RETURNING id, v")
+		if res.RowCount != 1 || res.Rows[0][0] != int64(1) || res.Rows[0][1] != "x" {
+			t.Fatalf("UPDATE ... IN (subquery) RETURNING = %v", res.Rows)
+		}
+		res = execMust(t, e, "DELETE FROM t WHERE id IN (SELECT id FROM t WHERE id = 1) RETURNING id")
+		if res.RowCount != 1 || res.Rows[0][0] != int64(1) {
+			t.Fatalf("DELETE ... IN (subquery) RETURNING = %v", res.Rows)
+		}
+	})
+}
+
+func TestUpdateCorrelatedSubqueryInSet(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE sizes (size_id INTEGER PRIMARY KEY, width INTEGER)")
+	execMust(t, e, "CREATE TABLE hits (id INTEGER PRIMARY KEY, size_id INTEGER, width INTEGER)")
+	execMust(t, e, "INSERT INTO sizes VALUES (1, 480), (2, 720)")
+	execMust(t, e, "INSERT INTO hits (id, size_id) VALUES (1, 1), (2, 2)")
+
+	execMust(t, e, "UPDATE hits SET width = (SELECT width FROM sizes WHERE size_id = hits.size_id)")
+	res := execMust(t, e, "SELECT id, width FROM hits ORDER BY id")
+	if res.Rows[0][1] != int64(480) || res.Rows[1][1] != int64(720) {
+		t.Fatalf("correlated SET update = %v", res.Rows)
+	}
+}
+
+func TestUpdateFromWithCTE(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (user_id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER, access TEXT DEFAULT 'x')")
+	execMust(t, e, "INSERT INTO users (site_id) VALUES (1), (1), (2)")
+
+	// The exact GoatCounter 2021-12-13-2-superuser.sql shape.
+	execMust(t, e, `WITH x AS (
+		SELECT count(*) AS count, site_id FROM users GROUP BY site_id
+	)
+	UPDATE users SET access = '{"all": "*"}' FROM x
+	WHERE x.count = 1 AND users.site_id = x.site_id`)
+
+	res := execMust(t, e, "SELECT site_id, access FROM users ORDER BY user_id")
+	if res.Rows[0][1] != "x" || res.Rows[1][1] != "x" {
+		t.Fatalf("site 1 users should be unchanged, got %v", res.Rows)
+	}
+	if res.Rows[2][1] != `{"all": "*"}` {
+		t.Fatalf("site 2 user should be updated, got %v", res.Rows[2])
+	}
+}
+
+func TestAnalyzeIsSafeNoOp(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	if res := execMust(t, e, "ANALYZE"); res.CommandTag != "ANALYZE" {
+		t.Fatalf("ANALYZE tag = %q", res.CommandTag)
+	}
+	if res := execMust(t, e, "ANALYZE t"); res.CommandTag != "ANALYZE" {
+		t.Fatalf("ANALYZE t tag = %q", res.CommandTag)
+	}
+}
+
+func TestForeignKeysPragmaNoOp(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	if res := execMust(t, e, "PRAGMA foreign_keys = OFF"); res.CommandTag != "PRAGMA" {
+		t.Fatalf("PRAGMA tag = %q", res.CommandTag)
+	}
+	res := execMust(t, e, "PRAGMA foreign_keys")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(0) {
+		t.Fatalf("foreign_keys = %v, want 0", res.Rows)
+	}
+	if _, err := execSQL(e, "PRAGMA foreign_keys = ON"); err == nil {
+		t.Fatal("expected enabling unsupported foreign keys to fail")
+	}
+}
+
+func TestIsDistinctFrom(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	cases := []struct {
+		sql  string
+		want int64
+	}{
+		{"SELECT 1 IS DISTINCT FROM 2", 1},
+		{"SELECT 1 IS DISTINCT FROM 1", 0},
+		{"SELECT NULL IS DISTINCT FROM NULL", 0},
+		{"SELECT NULL IS DISTINCT FROM 1", 1},
+		{"SELECT 1 IS NOT DISTINCT FROM 1", 1},
+		{"SELECT NULL IS NOT DISTINCT FROM NULL", 1},
+		{"SELECT NULL IS NOT DISTINCT FROM 1", 0},
+	}
+	for _, tc := range cases {
+		got := execMust(t, e, tc.sql).Rows[0][0]
+		var b int64
+		if v, ok := got.(bool); ok {
+			if v {
+				b = 1
+			}
+		} else {
+			b = got.(int64)
+		}
+		if b != tc.want {
+			t.Errorf("%s = %v, want %d", tc.sql, got, tc.want)
+		}
+	}
+}
+
+func TestRejectVirtualGeneratedColumn(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	if _, err := execSQL(e, "CREATE TABLE a (x INTEGER, y INTEGER GENERATED ALWAYS AS (x + 1))"); err == nil {
+		t.Fatal("expected implicit VIRTUAL generated column to be rejected")
+	}
+	if _, err := execSQL(e, "CREATE TABLE b (x INTEGER, y INTEGER AS (x + 1) VIRTUAL)"); err == nil {
+		t.Fatal("expected VIRTUAL generated column to be rejected")
+	}
+	if _, err := execSQL(e, "CREATE TABLE c (x INTEGER, y INTEGER GENERATED ALWAYS AS (x + 1) STORED)"); err != nil {
+		t.Fatalf("STORED generated column should be accepted: %v", err)
+	}
+}
+
+func TestRejectUnsupportedIndexExpressions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (email TEXT)")
+	for _, sql := range []string{
+		"CREATE INDEX i1 ON t (random())",
+		"CREATE INDEX i2 ON t (randomblob(4))",
+		"CREATE INDEX i3 ON t ((SELECT 1))",
+		"CREATE INDEX i4 ON t (email || (SELECT 1))",
+		"CREATE INDEX i5 ON t (no_such_function(email))",
+		"CREATE INDEX i6 ON t (datetime('now'))",
+		"CREATE INDEX i7 ON t (lower(missing))",
+	} {
+		if _, err := execSQL(e, sql); err == nil {
+			t.Errorf("%s: expected rejection", sql)
+		}
+	}
+	// Deterministic expressions remain accepted.
+	if _, err := execSQL(e, "CREATE INDEX iok ON t (lower(email))"); err != nil {
+		t.Fatalf("lower(email) index should be accepted: %v", err)
+	}
+}
+
+func TestRejectGeneratedColumnWithNonDeterministicExpr(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	if _, err := execSQL(e, "CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (random()) STORED)"); err == nil {
+		t.Fatal("expected non-deterministic generated column to be rejected")
+	}
+	if _, err := execSQL(e, "CREATE TABLE t2 (a INTEGER, b INTEGER GENERATED ALWAYS AS ((SELECT 1)) STORED)"); err == nil {
+		t.Fatal("expected subquery generated column to be rejected")
+	}
+}
+
+func TestNumericConcatFormatting(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	cases := map[string]string{
+		"SELECT 5 || 'px'":                 "5px",
+		"SELECT 1.5 - 0.5 || 'px'":         "1.0px",
+		"SELECT 2.5 || ''":                 "2.5",
+		"SELECT '↔ ' || 480 || 'px'":       "↔ 480px",
+		"SELECT (SELECT 3.5 - 0.5) || 'x'": "3.0x",
+		"SELECT CAST(7 AS REAL) || 'x'":    "7.0x",
+	}
+	for sql, want := range cases {
+		got := execMust(t, e, sql).Rows[0][0]
+		if got != want {
+			t.Errorf("%s = %v, want %q", sql, got, want)
+		}
+	}
+}
+
+func TestInsertOrReplaceWithExpressionUniqueIndexIsIndexed(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, tag TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX users_email_lower ON users (lower(email))")
+
+	for i := 1; i <= 50; i++ {
+		execMust(t, e, fmt.Sprintf("INSERT INTO users (id, email, tag) VALUES (%d, 'User%d@example.com', 'seed')", i, i))
+	}
+	execMust(t, e, "INSERT OR REPLACE INTO users (id, email, tag) VALUES (999, 'user7@example.com', 'replaced')")
+
+	res := execMust(t, e, "SELECT id, tag FROM users WHERE lower(email) = 'user7@example.com'")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(999) || res.Rows[0][1] != "replaced" {
+		t.Fatalf("replace result = %v", res.Rows)
+	}
+}
+
+func TestCompositeExpressionUniqueReplace(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE users (
+		user_id INTEGER PRIMARY KEY AUTOINCREMENT,
+		site_id INTEGER NOT NULL,
+		email TEXT NOT NULL
+	)`)
+	execMust(t, e, "CREATE UNIQUE INDEX users_site_email ON users(site_id, lower(email))")
+	execMust(t, e, "INSERT INTO users (site_id, email) VALUES (1, 'A@x.com')")
+	execMust(t, e, "INSERT OR REPLACE INTO users (site_id, email) VALUES (1, 'a@x.com')")
+
+	res := execMust(t, e, "SELECT count(*) FROM users WHERE site_id = 1")
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("composite expression replace left %v rows", res.Rows[0][0])
+	}
+}
+
+// TestConcurrentExpressionIndex exercises the stateless evaluator under -race.
+func TestConcurrentExpressionIndex(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)")
+	execMust(t, e, "CREATE UNIQUE INDEX users_email_lower ON users (lower(email))")
+
+	const workers = 8
+	const perWorker = 20
+	var wg sync.WaitGroup
+	errs := make(chan error, workers)
+	for w := 0; w < workers; w++ {
+		wg.Add(1)
+		go func(w int) {
+			defer wg.Done()
+			exec := New(schema, table)
+			exec.SyncCatalog()
+			for i := 0; i < perWorker; i++ {
+				email := fmt.Sprintf("user-%d-%d@example.com", w, i)
+				sql := fmt.Sprintf("INSERT INTO users (id, email) VALUES (%d, '%s')", w*1000+i+1, email)
+				stmt, err := parser.New(lexer.New(sql)).Parse()
+				if err != nil {
+					errs <- err
+					return
+				}
+				if _, err := exec.Execute(stmt); err != nil {
+					errs <- err
+					return
+				}
+			}
+		}(w)
+	}
+	wg.Wait()
+	close(errs)
+	for err := range errs {
+		t.Fatalf("concurrent insert failed: %v", err)
+	}
+
+	res := execMust(t, e, "SELECT count(*) FROM users")
+	if res.Rows[0][0] != int64(workers*perWorker) {
+		t.Fatalf("row count = %v, want %d", res.Rows[0][0], workers*perWorker)
+	}
+}
+
+func TestExpressionIndexEvaluatorErrorIsNotSwallowed(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (email TEXT)")
+	execMust(t, e, "CREATE INDEX i ON t (lower(email))")
+
+	// Break the evaluator after the index cache is built; a subsequent write
+	// must surface the evaluator error instead of silently skipping index
+	// maintenance.
+	table.SetExpressionEvaluator(func(expression string, row storage.Row) (interface{}, error) {
+		return nil, fmt.Errorf("boom: %s", expression)
+	})
+	if _, err := execSQL(e, "INSERT INTO t (email) VALUES ('a')"); err == nil {
+		t.Fatal("expected evaluator error to surface on insert")
+	}
+}

+ 14 - 0
pkg/executor/session_state_test.go

@@ -231,6 +231,20 @@ func TestUniqueIndexMultiRowInsertAtomic(t *testing.T) {
 	}
 }
 
+func TestPrimaryKeyMultiRowInsertAtomic(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
+
+	_, err := execSQL(e, "INSERT INTO t VALUES (1, 'a'), (1, 'b')")
+	if err == nil {
+		t.Fatal("expected primary-key violation on the second row")
+	}
+	if res := execMust(t, e, "SELECT count(*) FROM t"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("partial insert applied: %v rows present, want 0", res.Rows[0][0])
+	}
+}
+
 func TestUpdateWithScalarSubqueryInTransaction(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)

+ 20 - 1
pkg/executor/sqlite_catalog.go

@@ -294,7 +294,16 @@ func (e *Executor) pragmaTableXInfo(table string) (*Result, error) {
 		if col.PrimaryKey {
 			pk = 1
 		}
-		result.AddRow(int64(i), col.Name, col.Type, notnull, col.Default, pk, int64(0))
+		// Hidden flag: 0 normal, 2 generated VIRTUAL, 3 generated STORED.
+		hidden := int64(0)
+		if col.GeneratedExpr != "" {
+			if col.GeneratedStored {
+				hidden = 3
+			} else {
+				hidden = 2
+			}
+		}
+		result.AddRow(int64(i), col.Name, col.Type, notnull, col.Default, pk, hidden)
 	}
 	return result, nil
 }
@@ -333,6 +342,16 @@ func recreateColumnDef(s *storage.Schema, col storage.Column) string {
 		b.WriteString(" DEFAULT ")
 		b.WriteString(sqlLiteral(col.Default))
 	}
+	if col.GeneratedExpr != "" {
+		b.WriteString(" GENERATED ALWAYS AS (")
+		b.WriteString(col.GeneratedExpr)
+		b.WriteString(")")
+		if col.GeneratedStored {
+			b.WriteString(" STORED")
+		} else {
+			b.WriteString(" VIRTUAL")
+		}
+	}
 	return b.String()
 }
 

+ 85 - 0
pkg/httpserver/features_test.go

@@ -0,0 +1,85 @@
+package httpserver
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+)
+
+// setupKVTestServer builds an HTTP server backed by the in-memory testkv so
+// these feature tests never depend on a running PizzaKV process.
+func setupKVTestServer(t *testing.T) *Server {
+	t.Helper()
+	kv := testkv.New(t)
+	pool := kv.Pool(4)
+	t.Cleanup(func() { pool.Close() })
+	schema := storage.NewSchemaManager(pool, "test_http_features")
+	table := storage.NewTableManager(pool, schema, "test_http_features")
+	exec := executor.New(schema, table)
+	config := DefaultConfig()
+	config.EnableAuth = false
+	return New(config, exec, schema)
+}
+
+func queryHTTP(t *testing.T, server *Server, sql string) QueryResponse {
+	t.Helper()
+	body, _ := json.Marshal(QueryRequest{SQL: sql})
+	r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
+	w := httptest.NewRecorder()
+	server.handleQuery(w, r)
+	if w.Code != http.StatusOK {
+		t.Fatalf("query %q: status %d body %s", sql, w.Code, w.Body.String())
+	}
+	var resp QueryResponse
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatalf("decode response: %v", err)
+	}
+	return resp
+}
+
+func TestHTTPInsertReturning(t *testing.T) {
+	server := setupKVTestServer(t)
+	queryHTTP(t, server, "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+
+	resp := queryHTTP(t, server, "INSERT INTO users (name) VALUES ('alice') RETURNING id, name")
+	if len(resp.Columns) != 2 || resp.Columns[0].Name != "id" || resp.Columns[1].Name != "name" {
+		t.Fatalf("unexpected columns %#v", resp.Columns)
+	}
+	if len(resp.Rows) != 1 || resp.Rows[0][0].(float64) != 1 || resp.Rows[0][1] != "alice" {
+		t.Fatalf("unexpected rows %#v", resp.Rows)
+	}
+	if resp.RowsAffected != 1 || resp.LastInsertID != 1 {
+		t.Fatalf("rowsAffected=%d lastInsertId=%d", resp.RowsAffected, resp.LastInsertID)
+	}
+}
+
+func TestHTTPBlobTextWire(t *testing.T) {
+	server := setupKVTestServer(t)
+	queryHTTP(t, server, "CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB)")
+	queryHTTP(t, server, "INSERT INTO blobs (id, data) VALUES (1, X'00FF10')")
+
+	resp := queryHTTP(t, server, "SELECT data FROM blobs WHERE id = 1")
+	if len(resp.Rows) != 1 {
+		t.Fatalf("expected 1 row, got %#v", resp.Rows)
+	}
+	if resp.Rows[0][0] != `\x00ff10` {
+		t.Fatalf("blob HTTP representation = %v, want \\x00ff10", resp.Rows[0][0])
+	}
+}
+
+func TestHTTPSQLiteVersionAndPercentDiff(t *testing.T) {
+	server := setupKVTestServer(t)
+	resp := queryHTTP(t, server, "SELECT sqlite_version(), percent_diff(1, 2)")
+	if resp.Rows[0][0] != executor.SQLiteCompatVersion {
+		t.Fatalf("sqlite_version = %v", resp.Rows[0][0])
+	}
+	if resp.Rows[0][1].(float64) != 100 {
+		t.Fatalf("percent_diff = %v", resp.Rows[0][1])
+	}
+}

+ 1 - 1
pkg/httpserver/handler.go

@@ -153,7 +153,7 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
 		// Build response
 		resp := &QueryResponse{
 			Columns:            make([]ColumnInfo, len(result.Columns)),
-			Rows:               result.Rows,
+			Rows:               sanitizeRows(result.Rows),
 			RowsAffected:       result.RowsAffected,
 			LastInsertID:       result.LastInsertID,
 			ExecutionTimeMicro: duration.Microseconds(),

+ 16 - 0
pkg/httpserver/response.go

@@ -1,12 +1,28 @@
 package httpserver
 
 import (
+	"encoding/hex"
 	"log"
 	"net/http"
 
 	"github.com/goccy/go-json"
 )
 
+// sanitizeRows converts non-JSON scalar values into a lossless textual form.
+// BLOBs are rendered as PostgreSQL-style \x-hex so HTTP clients receive the
+// same bytea text representation as the PostgreSQL wire protocol instead of an
+// encoding-dependent base64 string.
+func sanitizeRows(rows [][]interface{}) [][]interface{} {
+	for _, row := range rows {
+		for i, v := range row {
+			if b, ok := v.([]byte); ok {
+				row[i] = `\x` + hex.EncodeToString(b)
+			}
+		}
+	}
+	return rows
+}
+
 // ColumnInfo represents column metadata.
 type ColumnInfo struct {
 	Name string `json:"name"`

+ 75 - 3
pkg/lexer/lexer.go

@@ -1,6 +1,7 @@
 package lexer
 
 import (
+	"encoding/hex"
 	"strings"
 	"unicode"
 )
@@ -80,6 +81,14 @@ func (l *Lexer) NextToken() Token {
 		tok.Type = TokenPercent
 		tok.Literal = "%"
 		l.readChar()
+	case '&':
+		tok.Type = TokenBitAnd
+		tok.Literal = "&"
+		l.readChar()
+	case '~':
+		tok.Type = TokenBitNot
+		tok.Literal = "~"
+		l.readChar()
 	case '(':
 		tok.Type = TokenLParen
 		tok.Literal = "("
@@ -113,6 +122,10 @@ func (l *Lexer) NextToken() Token {
 			l.readChar()
 			tok.Type = TokenNeq
 			tok.Literal = "<>"
+		} else if l.peekChar() == '<' {
+			l.readChar()
+			tok.Type = TokenShiftLeft
+			tok.Literal = "<<"
 		} else {
 			tok.Type = TokenLt
 			tok.Literal = "<"
@@ -123,6 +136,10 @@ func (l *Lexer) NextToken() Token {
 			l.readChar()
 			tok.Type = TokenGte
 			tok.Literal = ">="
+		} else if l.peekChar() == '>' {
+			l.readChar()
+			tok.Type = TokenShiftRight
+			tok.Literal = ">>"
 		} else {
 			tok.Type = TokenGt
 			tok.Literal = ">"
@@ -146,8 +163,8 @@ func (l *Lexer) NextToken() Token {
 			tok.Literal = "||"
 			l.readChar()
 		} else {
-			tok.Type = TokenError
-			tok.Literal = "unexpected character: |"
+			tok.Type = TokenBitOr
+			tok.Literal = "|"
 			l.readChar()
 		}
 	case '-':
@@ -168,7 +185,9 @@ func (l *Lexer) NextToken() Token {
 	case '[':
 		tok = l.readBracketIdentifier()
 	default:
-		if isLetter(l.ch) || l.ch == '_' {
+		if (l.ch == 'x' || l.ch == 'X') && l.peekChar() == '\'' {
+			tok = l.readBlob()
+		} else if isLetter(l.ch) || l.ch == '_' {
 			tok = l.readIdentifier()
 		} else if isDigit(l.ch) {
 			tok = l.readNumber()
@@ -320,6 +339,55 @@ func (l *Lexer) readBracketIdentifier() Token {
 	return tok
 }
 
+// readBlob reads a X'hex' blob literal. Whitespace between hex digits is
+// ignored (SQLite allows it). The token literal holds the decoded raw bytes so
+// consumers never have to re-parse the hex form.
+func (l *Lexer) readBlob() Token {
+	tok := Token{
+		Type:   TokenBlob,
+		Line:   l.line,
+		Column: l.column,
+	}
+
+	l.readChar() // skip x/X
+	l.readChar() // skip opening quote
+
+	var sb strings.Builder
+	for l.ch != '\'' && l.ch != 0 {
+		if isHexDigit(l.ch) {
+			sb.WriteByte(l.ch)
+		} else if l.ch != ' ' && l.ch != '\t' && l.ch != '\n' && l.ch != '\r' {
+			tok.Type = TokenError
+			tok.Literal = "invalid character in blob literal: " + string(l.ch)
+			return tok
+		}
+		l.readChar()
+	}
+
+	if l.ch == 0 {
+		tok.Type = TokenError
+		tok.Literal = "unterminated blob literal"
+		return tok
+	}
+	l.readChar() // skip closing quote
+
+	if sb.Len()%2 != 0 {
+		tok.Type = TokenError
+		tok.Literal = "blob literal must contain an even number of hex digits"
+		return tok
+	}
+
+	decoded, err := hex.DecodeString(sb.String())
+	if err != nil {
+		tok.Type = TokenError
+		tok.Literal = "invalid blob literal: " + err.Error()
+		return tok
+	}
+
+	tok.Literal = string(decoded)
+	return tok
+}
+
 // readIdentifier reads an identifier or keyword.
 func (l *Lexer) readIdentifier() Token {
 	tok := Token{
@@ -397,3 +465,7 @@ func isLetter(ch byte) bool {
 func isDigit(ch byte) bool {
 	return ch >= '0' && ch <= '9'
 }
+
+func isHexDigit(ch byte) bool {
+	return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')
+}

+ 46 - 5
pkg/lexer/lexer_test.go

@@ -312,11 +312,6 @@ func TestLexerErrors(t *testing.T) {
 			input:  "@",
 			errMsg: "unexpected character: @",
 		},
-		{
-			name:   "single pipe",
-			input:  "|",
-			errMsg: "unexpected character: |",
-		},
 	}
 
 	for _, tt := range tests {
@@ -446,6 +441,52 @@ func TestLexerSubquery(t *testing.T) {
 	}
 }
 
+func TestLexerBitwiseOperators(t *testing.T) {
+	input := "a & b | c << 2 >> 1 ~d"
+	expected := []TokenType{
+		TokenIdent, TokenBitAnd, TokenIdent, TokenBitOr, TokenIdent,
+		TokenShiftLeft, TokenNumber, TokenShiftRight, TokenNumber,
+		TokenBitNot, TokenIdent, TokenEOF,
+	}
+
+	tokens := New(input).Tokenize()
+	if len(tokens) != len(expected) {
+		t.Fatalf("expected %d tokens, got %d: %v", len(expected), len(tokens), tokens)
+	}
+	for i, exp := range expected {
+		if tokens[i].Type != exp {
+			t.Errorf("token[%d]: expected %v, got %v", i, exp, tokens[i].Type)
+		}
+	}
+}
+
+func TestLexerBlobLiteral(t *testing.T) {
+	tokens := New("X'53514C697465'").Tokenize()
+	if len(tokens) != 2 {
+		t.Fatalf("expected 2 tokens, got %d: %v", len(tokens), tokens)
+	}
+	if tokens[0].Type != TokenBlob {
+		t.Fatalf("expected TokenBlob, got %v", tokens[0].Type)
+	}
+	if tokens[0].Literal != "SQLite" {
+		t.Fatalf("blob literal = %q, want %q", tokens[0].Literal, "SQLite")
+	}
+}
+
+func TestLexerBlobLiteralWhitespaceAndErrors(t *testing.T) {
+	tokens := New("x'53 51'").Tokenize()
+	if tokens[0].Type != TokenBlob || tokens[0].Literal != "SQ" {
+		t.Fatalf("blob with whitespace = %v %q", tokens[0].Type, tokens[0].Literal)
+	}
+
+	for _, input := range []string{"X'5'", "X'zz'", "X'5"} {
+		tok := New(input).NextToken()
+		if tok.Type != TokenError {
+			t.Errorf("input %q: expected TokenError, got %v", input, tok.Type)
+		}
+	}
+}
+
 func BenchmarkLexer(b *testing.B) {
 	input := `
 		SELECT u.id, u.name, u.email, COUNT(o.id) as order_count

+ 57 - 42
pkg/lexer/token.go

@@ -14,20 +14,26 @@ const (
 	TokenIdent  // identifiers
 	TokenNumber // integers and floats
 	TokenString // 'string literals'
+	TokenBlob   // X'hex' blob literals
 
 	// Operators
-	TokenPlus    // +
-	TokenMinus   // -
-	TokenStar    // *
-	TokenSlash   // /
-	TokenPercent // %
-	TokenConcat  // ||
-	TokenEq      // =
-	TokenNeq     // <> or !=
-	TokenLt      // <
-	TokenLte     // <=
-	TokenGt      // >
-	TokenGte     // >=
+	TokenPlus       // +
+	TokenMinus      // -
+	TokenStar       // *
+	TokenSlash      // /
+	TokenPercent    // %
+	TokenConcat     // ||
+	TokenEq         // =
+	TokenNeq        // <> or !=
+	TokenLt         // <
+	TokenLte        // <=
+	TokenGt         // >
+	TokenGte        // >=
+	TokenBitAnd     // &
+	TokenBitOr      // |
+	TokenBitNot     // ~
+	TokenShiftLeft  // <<
+	TokenShiftRight // >>
 
 	// Punctuation
 	TokenLParen    // (
@@ -46,6 +52,7 @@ const (
 	TokenAS
 	TokenDISTINCT
 	TokenALL
+	TokenRETURNING
 
 	TokenINSERT
 	TokenINTO
@@ -203,12 +210,15 @@ var keywords = map[string]TokenType{
 	"AS":       TokenAS,
 	"DISTINCT": TokenDISTINCT,
 	"ALL":      TokenALL,
-	"INSERT":   TokenINSERT,
-	"INTO":     TokenINTO,
-	"VALUES":   TokenVALUES,
-	"UPDATE":   TokenUPDATE,
-	"SET":      TokenSET,
-	"DELETE":   TokenDELETE,
+	// RETURNING is a keyword so it is not mistaken for a table/column alias in
+	// INSERT ... SELECT ... RETURNING.
+	"RETURNING": TokenRETURNING,
+	"INSERT":    TokenINSERT,
+	"INTO":      TokenINTO,
+	"VALUES":    TokenVALUES,
+	"UPDATE":    TokenUPDATE,
+	"SET":       TokenSET,
+	"DELETE":    TokenDELETE,
 
 	// DDL
 	"CREATE":   TokenCREATE,
@@ -329,7 +339,6 @@ var keywords = map[string]TokenType{
 	"PRAGMA":  TokenPRAGMA,
 	"EXPLAIN": TokenEXPLAIN,
 	"QUERY":   TokenQUERY,
-	"PLAN":    TokenPLAN,
 	"ATTACH":  TokenATTACH,
 	"DETACH":  TokenDETACH,
 	"VACUUM":  TokenVACUUM,
@@ -380,29 +389,35 @@ func (t Token) IsOperator() bool {
 }
 
 var tokenNames = map[TokenType]string{
-	TokenEOF:       "EOF",
-	TokenError:     "ERROR",
-	TokenComment:   "COMMENT",
-	TokenIdent:     "IDENT",
-	TokenNumber:    "NUMBER",
-	TokenString:    "STRING",
-	TokenPlus:      "+",
-	TokenMinus:     "-",
-	TokenStar:      "*",
-	TokenSlash:     "/",
-	TokenPercent:   "%",
-	TokenConcat:    "||",
-	TokenEq:        "=",
-	TokenNeq:       "<>",
-	TokenLt:        "<",
-	TokenLte:       "<=",
-	TokenGt:        ">",
-	TokenGte:       ">=",
-	TokenLParen:    "(",
-	TokenRParen:    ")",
-	TokenComma:     ",",
-	TokenSemicolon: ";",
-	TokenDot:       ".",
+	TokenEOF:        "EOF",
+	TokenError:      "ERROR",
+	TokenComment:    "COMMENT",
+	TokenIdent:      "IDENT",
+	TokenNumber:     "NUMBER",
+	TokenString:     "STRING",
+	TokenBlob:       "BLOB",
+	TokenPlus:       "+",
+	TokenMinus:      "-",
+	TokenStar:       "*",
+	TokenSlash:      "/",
+	TokenPercent:    "%",
+	TokenConcat:     "||",
+	TokenEq:         "=",
+	TokenNeq:        "<>",
+	TokenLt:         "<",
+	TokenLte:        "<=",
+	TokenGt:         ">",
+	TokenGte:        ">=",
+	TokenBitAnd:     "&",
+	TokenBitOr:      "|",
+	TokenBitNot:     "~",
+	TokenShiftLeft:  "<<",
+	TokenShiftRight: ">>",
+	TokenLParen:     "(",
+	TokenRParen:     ")",
+	TokenComma:      ",",
+	TokenSemicolon:  ";",
+	TokenDot:        ".",
 }
 
 func (t TokenType) String() string {

+ 47 - 6
pkg/parser/ast.go

@@ -145,6 +145,7 @@ type InsertStmt struct {
 	ConflictTarget    []string
 	ConflictUpdate    []Assignment
 	ConflictDoNothing bool
+	Returning         []SelectColumn
 }
 
 func (s *InsertStmt) node()     {}
@@ -152,9 +153,11 @@ func (s *InsertStmt) stmtNode() {}
 
 // UpdateStmt represents an UPDATE statement.
 type UpdateStmt struct {
-	Table *TableRef
-	Set   []Assignment
-	Where Expr
+	Table     *TableRef
+	Set       []Assignment
+	From      []TableRef
+	Where     Expr
+	Returning []SelectColumn
 }
 
 func (s *UpdateStmt) node()     {}
@@ -168,8 +171,9 @@ type Assignment struct {
 
 // DeleteStmt represents a DELETE statement.
 type DeleteStmt struct {
-	Table *TableRef
-	Where Expr
+	Table     *TableRef
+	Where     Expr
+	Returning []SelectColumn
 }
 
 func (s *DeleteStmt) node()     {}
@@ -191,6 +195,10 @@ type ColumnDef struct {
 	Name        string
 	Type        DataType
 	Constraints []ColumnConstraint
+	// GeneratedExpr is non-nil for a GENERATED ALWAYS AS (expr) column. A
+	// generated column's value is computed rather than supplied by the user.
+	GeneratedExpr   Expr
+	GeneratedStored bool // true for STORED, false for VIRTUAL
 }
 
 // DataType represents a SQL data type.
@@ -207,6 +215,11 @@ type ColumnConstraint struct {
 	Default   Expr   // for DEFAULT
 	RefTable  string // for REFERENCES
 	RefColumn string // for REFERENCES
+	Check     Expr   // for CHECK
+	// OnConflict is the conflict resolution algorithm declared with
+	// ON CONFLICT REPLACE/etc. (ConflictAbort is the zero value/default).
+	OnConflict    ConflictAction
+	HasOnConflict bool
 }
 
 // ConstraintType represents the type of constraint.
@@ -230,6 +243,10 @@ type TableConstraint struct {
 	RefTable   string   // for FOREIGN KEY
 	RefColumns []string // for FOREIGN KEY
 	Check      Expr     // for CHECK
+	// OnConflict is the conflict resolution declared with ON CONFLICT
+	// REPLACE/etc.; HasOnConflict distinguishes it from the default ABORT.
+	OnConflict    ConflictAction
+	HasOnConflict bool
 }
 
 // DropTableStmt represents a DROP TABLE statement.
@@ -253,10 +270,13 @@ type CreateIndexStmt struct {
 func (s *CreateIndexStmt) node()     {}
 func (s *CreateIndexStmt) stmtNode() {}
 
-// IndexColumn represents a column in an index.
+// IndexColumn represents a column in an index. Expr is set for expression
+// indexes (e.g. an index on lower(email)); a plain column index leaves it nil
+// and uses Name.
 type IndexColumn struct {
 	Name string
 	Desc bool // true for DESC ordering
+	Expr Expr
 }
 
 // DropIndexStmt represents a DROP INDEX statement.
@@ -363,6 +383,15 @@ type PragmaStmt struct {
 func (s *PragmaStmt) node()     {}
 func (s *PragmaStmt) stmtNode() {}
 
+// AnalyzeStmt represents an ANALYZE statement. PizzaSQL does not maintain
+// optimizer statistics, so it is accepted and executed as a documented no-op.
+type AnalyzeStmt struct {
+	Name string // optional table name
+}
+
+func (s *AnalyzeStmt) node()     {}
+func (s *AnalyzeStmt) stmtNode() {}
+
 // ExplainStmt represents an EXPLAIN statement.
 type ExplainStmt struct {
 	QueryPlan bool      // true for EXPLAIN QUERY PLAN
@@ -539,6 +568,18 @@ type IsNullExpr struct {
 func (e *IsNullExpr) node()     {}
 func (e *IsNullExpr) exprNode() {}
 
+// IsDistinctExpr represents `left IS DISTINCT FROM right` (Not=false) or
+// `left IS NOT DISTINCT FROM right` (Not=true). Unlike `=`, NULLs compare
+// equal to each other and distinct from non-NULLs.
+type IsDistinctExpr struct {
+	Left  Expr
+	Right Expr
+	Not   bool
+}
+
+func (e *IsDistinctExpr) node()     {}
+func (e *IsDistinctExpr) exprNode() {}
+
 // CastExpr represents a CAST expression.
 type CastExpr struct {
 	Expr Expr

+ 67 - 8
pkg/parser/cte.go

@@ -102,14 +102,14 @@ func (p *Parser) parseWithStatement() (Statement, error) {
 		return nil, err
 	}
 
-	sel, ok := stmt.(*SelectStmt)
-	if !ok {
-		return nil, p.curError("WITH is only supported before a SELECT statement")
-	}
-
 	if recursive {
 		// Recursive CTEs are materialized by the executor, which needs the
-		// definitions; desugaring cannot express self-reference.
+		// definitions; desugaring cannot express self-reference. Only SELECT
+		// carries that machinery today.
+		sel, ok := stmt.(*SelectStmt)
+		if !ok {
+			return nil, p.curError("WITH RECURSIVE is only supported before a SELECT statement")
+		}
 		sel.With = make([]*CTE, 0, len(ctes))
 		for _, c := range ctes {
 			sel.With = append(sel.With, &CTE{
@@ -128,10 +128,64 @@ func (p *Parser) parseWithStatement() (Statement, error) {
 			return nil, err
 		}
 	}
-	if err := substituteSelectCTEs(sel, ctes); err != nil {
+	if err := substituteStatementCTEs(stmt, ctes); err != nil {
 		return nil, err
 	}
-	return sel, nil
+	return stmt, nil
+}
+
+// substituteStatementCTEs desugars named CTE references inside any DML
+// statement. UPDATE reads them from its FROM clause (and expressions); DELETE
+// and INSERT read them from subqueries.
+func substituteStatementCTEs(stmt Statement, ctes []*cteDef) error {
+	switch s := stmt.(type) {
+	case *SelectStmt:
+		return substituteSelectCTEs(s, ctes)
+	case *UpdateStmt:
+		for i := range s.From {
+			if err := substituteTableRefCTEs(&s.From[i], ctes); err != nil {
+				return err
+			}
+		}
+		for i := range s.Set {
+			if err := substituteExprCTEs(s.Set[i].Value, ctes); err != nil {
+				return err
+			}
+		}
+		if err := substituteExprCTEs(s.Where, ctes); err != nil {
+			return err
+		}
+		for i := range s.Returning {
+			if err := substituteExprCTEs(s.Returning[i].Expr, ctes); err != nil {
+				return err
+			}
+		}
+		return nil
+	case *DeleteStmt:
+		if err := substituteExprCTEs(s.Where, ctes); err != nil {
+			return err
+		}
+		for i := range s.Returning {
+			if err := substituteExprCTEs(s.Returning[i].Expr, ctes); err != nil {
+				return err
+			}
+		}
+		return nil
+	case *InsertStmt:
+		if s.Select != nil {
+			return substituteSelectCTEs(s.Select, ctes)
+		}
+		for _, row := range s.Values {
+			for _, v := range row {
+				if err := substituteExprCTEs(v, ctes); err != nil {
+					return err
+				}
+			}
+		}
+		return nil
+	default:
+		return fmt.Errorf("WITH is not supported before this statement")
+	}
 }
 
 // applyCTEColumnNames aliases the CTE query's projection columns with the names
@@ -285,6 +339,11 @@ func substituteExprCTEs(expr Expr, ctes []*cteDef) error {
 		return substituteExprCTEs(e.Escape, ctes)
 	case *IsNullExpr:
 		return substituteExprCTEs(e.Left, ctes)
+	case *IsDistinctExpr:
+		if err := substituteExprCTEs(e.Left, ctes); err != nil {
+			return err
+		}
+		return substituteExprCTEs(e.Right, ctes)
 	case *CaseExpr:
 		if err := substituteExprCTEs(e.Operand, ctes); err != nil {
 			return err

+ 192 - 0
pkg/parser/format.go

@@ -0,0 +1,192 @@
+package parser
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+)
+
+// FormatExpr renders an expression as canonical SQL text. It is used to persist
+// expression indexes and generated-column definitions in the durable schema, so
+// the output only needs to be stable and re-parseable, not byte-identical to the
+// original input. A nil expression formats as an empty string.
+func FormatExpr(expr Expr) string {
+	if expr == nil {
+		return ""
+	}
+	switch e := expr.(type) {
+	case *LiteralExpr:
+		switch e.Type {
+		case lexer.TokenString:
+			return "'" + strings.ReplaceAll(e.Value, "'", "''") + "'"
+		case lexer.TokenBlob:
+			return "X'" + fmt.Sprintf("%X", []byte(e.Value)) + "'"
+		case lexer.TokenNULL:
+			return "NULL"
+		case lexer.TokenTRUE:
+			return "TRUE"
+		case lexer.TokenFALSE:
+			return "FALSE"
+		default:
+			return e.Value
+		}
+	case *ColumnRef:
+		if e.Table != "" {
+			return quoteFormatIdent(e.Table) + "." + quoteFormatIdent(e.Column)
+		}
+		return quoteFormatIdent(e.Column)
+	case *BinaryExpr:
+		return fmt.Sprintf("(%s %s %s)", FormatExpr(e.Left), operatorString(e.Op), FormatExpr(e.Right))
+	case *UnaryExpr:
+		if e.Op == lexer.TokenNOT {
+			return fmt.Sprintf("(NOT %s)", FormatExpr(e.Operand))
+		}
+		return fmt.Sprintf("(%s%s)", operatorString(e.Op), FormatExpr(e.Operand))
+	case *ParenExpr:
+		return fmt.Sprintf("(%s)", FormatExpr(e.Expr))
+	case *FunctionCall:
+		if e.Star {
+			return strings.ToLower(e.Name) + "(*)"
+		}
+		args := make([]string, len(e.Args))
+		for i, a := range e.Args {
+			args[i] = FormatExpr(a)
+		}
+		prefix := ""
+		if e.Distinct {
+			prefix = "DISTINCT "
+		}
+		return strings.ToLower(e.Name) + "(" + prefix + strings.Join(args, ", ") + ")"
+	case *CastExpr:
+		return fmt.Sprintf("CAST(%s AS %s)", FormatExpr(e.Expr), e.Type.Name)
+	case *CaseExpr:
+		var b strings.Builder
+		b.WriteString("CASE")
+		if e.Operand != nil {
+			b.WriteString(" ")
+			b.WriteString(FormatExpr(e.Operand))
+		}
+		for _, w := range e.Whens {
+			b.WriteString(" WHEN ")
+			b.WriteString(FormatExpr(w.Condition))
+			b.WriteString(" THEN ")
+			b.WriteString(FormatExpr(w.Result))
+		}
+		if e.Else != nil {
+			b.WriteString(" ELSE ")
+			b.WriteString(FormatExpr(e.Else))
+		}
+		b.WriteString(" END")
+		return b.String()
+	case *InExpr:
+		not := ""
+		if e.Not {
+			not = "NOT "
+		}
+		if e.Subquery != nil {
+			return fmt.Sprintf("(%s %sIN (SELECT ...))", FormatExpr(e.Left), not)
+		}
+		vals := make([]string, len(e.Values))
+		for i, v := range e.Values {
+			vals[i] = FormatExpr(v)
+		}
+		return fmt.Sprintf("(%s %sIN (%s))", FormatExpr(e.Left), not, strings.Join(vals, ", "))
+	case *BetweenExpr:
+		not := ""
+		if e.Not {
+			not = "NOT "
+		}
+		return fmt.Sprintf("(%s %sBETWEEN %s AND %s)", FormatExpr(e.Left), not, FormatExpr(e.Low), FormatExpr(e.High))
+	case *LikeExpr:
+		not := ""
+		if e.Not {
+			not = "NOT "
+		}
+		out := fmt.Sprintf("(%s %sLIKE %s)", FormatExpr(e.Left), not, FormatExpr(e.Pattern))
+		if e.Escape != nil {
+			out = fmt.Sprintf("(%s %sLIKE %s ESCAPE %s)", FormatExpr(e.Left), not, FormatExpr(e.Pattern), FormatExpr(e.Escape))
+		}
+		return out
+	case *IsNullExpr:
+		if e.Not {
+			return fmt.Sprintf("(%s IS NOT NULL)", FormatExpr(e.Left))
+		}
+		return fmt.Sprintf("(%s IS NULL)", FormatExpr(e.Left))
+	case *IsDistinctExpr:
+		op := "IS DISTINCT FROM"
+		if e.Not {
+			op = "IS NOT DISTINCT FROM"
+		}
+		return fmt.Sprintf("(%s %s %s)", FormatExpr(e.Left), op, FormatExpr(e.Right))
+	case *SubqueryExpr:
+		return "(SELECT ...)"
+	default:
+		return ""
+	}
+}
+
+// operatorString renders an operator token back to SQL.
+func operatorString(op lexer.TokenType) string {
+	switch op {
+	case lexer.TokenPlus:
+		return "+"
+	case lexer.TokenMinus:
+		return "-"
+	case lexer.TokenStar:
+		return "*"
+	case lexer.TokenSlash:
+		return "/"
+	case lexer.TokenPercent:
+		return "%"
+	case lexer.TokenConcat:
+		return "||"
+	case lexer.TokenEq:
+		return "="
+	case lexer.TokenNeq:
+		return "<>"
+	case lexer.TokenLt:
+		return "<"
+	case lexer.TokenLte:
+		return "<="
+	case lexer.TokenGt:
+		return ">"
+	case lexer.TokenGte:
+		return ">="
+	case lexer.TokenAND:
+		return "AND"
+	case lexer.TokenOR:
+		return "OR"
+	case lexer.TokenBitAnd:
+		return "&"
+	case lexer.TokenBitOr:
+		return "|"
+	case lexer.TokenBitNot:
+		return "~"
+	case lexer.TokenShiftLeft:
+		return "<<"
+	case lexer.TokenShiftRight:
+		return ">>"
+	default:
+		return op.String()
+	}
+}
+
+// quoteFormatIdent quotes an identifier when it is not a bare word.
+func quoteFormatIdent(name string) string {
+	if name == "" {
+		return name
+	}
+	bare := true
+	for i := 0; i < len(name); i++ {
+		c := name[i]
+		if !(c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (i > 0 && c >= '0' && c <= '9')) {
+			bare = false
+			break
+		}
+	}
+	if bare {
+		return name
+	}
+	return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
+}

+ 358 - 24
pkg/parser/parser.go

@@ -41,6 +41,21 @@ func (p *Parser) Parse() (Statement, error) {
 	return stmt, nil
 }
 
+// ParseExpr parses a standalone SQL expression, used for persisted generated
+// column and expression-index definitions. It rejects trailing tokens so a
+// malformed stored expression surfaces immediately.
+func ParseExpr(input string) (Expr, error) {
+	p := New(lexer.New(input))
+	expr, err := p.parseExpr()
+	if err != nil {
+		return nil, err
+	}
+	if !p.curTokenIs(lexer.TokenEOF) {
+		return nil, p.curError("unexpected trailing token: " + p.curToken.Type.String())
+	}
+	return expr, nil
+}
+
 // ParseMultiple parses multiple SQL statements.
 func (p *Parser) ParseMultiple() ([]Statement, error) {
 	var stmts []Statement
@@ -87,6 +102,14 @@ func (p *Parser) expectPeek(t lexer.TokenType) error {
 	return p.peekError(t)
 }
 
+// curIdentIs reports whether the current token is an identifier spelling word
+// case-insensitively. It lets context-sensitive keywords (GENERATED, ALWAYS,
+// STORED, VIRTUAL) be recognized only in the positions where they are
+// meaningful, so they remain usable as ordinary identifiers elsewhere.
+func (p *Parser) curIdentIs(word string) bool {
+	return p.curToken.Type == lexer.TokenIdent && strings.EqualFold(p.curToken.Literal, word)
+}
+
 func (p *Parser) peekError(t lexer.TokenType) error {
 	return newError(
 		"expected "+t.String()+", got "+p.peekToken.Type.String(),
@@ -130,6 +153,8 @@ func (p *Parser) parseStatement() (Statement, error) {
 		return p.parseDetach()
 	case lexer.TokenPRAGMA:
 		return p.parsePragma()
+	case lexer.TokenANALYZE:
+		return p.parseAnalyze()
 	case lexer.TokenEXPLAIN:
 		return p.parseExplain()
 	case lexer.TokenBEGIN:
@@ -675,8 +700,8 @@ func (p *Parser) parseJoin() (*JoinClause, error) {
 		join.Condition = cond
 	} else if p.curTokenIs(lexer.TokenUSING) {
 		p.nextToken()
-		if err := p.expectPeek(lexer.TokenLParen); err != nil {
-			return nil, err
+		if !p.curTokenIs(lexer.TokenLParen) {
+			return nil, p.curError("expected (")
 		}
 		p.nextToken()
 		cols, err := p.parseIdentList()
@@ -792,7 +817,7 @@ func (p *Parser) parseInsert() (*InsertStmt, error) {
 		p.nextToken()
 	}
 
-	// Parse VALUES or SELECT
+	// Parse VALUES, SELECT, or SQLite's INSERT ... WITH ... SELECT form.
 	if p.curTokenIs(lexer.TokenVALUES) {
 		p.nextToken()
 		values, err := p.parseValuesList()
@@ -806,8 +831,18 @@ func (p *Parser) parseInsert() (*InsertStmt, error) {
 			return nil, err
 		}
 		stmt.Select = sel
+	} else if p.isWithStart() {
+		withStmt, err := p.parseWithStatement()
+		if err != nil {
+			return nil, err
+		}
+		sel, ok := withStmt.(*SelectStmt)
+		if !ok {
+			return nil, p.curError("expected SELECT after WITH")
+		}
+		stmt.Select = sel
 	} else {
-		return nil, p.curError("expected VALUES or SELECT")
+		return nil, p.curError("expected VALUES, SELECT, or WITH")
 	}
 
 	if p.curTokenIs(lexer.TokenON) {
@@ -866,6 +901,14 @@ func (p *Parser) parseInsert() (*InsertStmt, error) {
 		}
 	}
 
+	if p.curTokenIs(lexer.TokenRETURNING) {
+		returning, err := p.parseReturningClause()
+		if err != nil {
+			return nil, err
+		}
+		stmt.Returning = returning
+	}
+
 	return stmt, nil
 }
 
@@ -943,6 +986,16 @@ func (p *Parser) parseUpdate() (*UpdateStmt, error) {
 		p.nextToken()
 	}
 
+	// Parse optional FROM clause (SQLite UPDATE ... FROM).
+	if p.curTokenIs(lexer.TokenFROM) {
+		p.nextToken()
+		from, err := p.parseTableRefs()
+		if err != nil {
+			return nil, err
+		}
+		stmt.From = from
+	}
+
 	// Parse optional WHERE
 	if p.curTokenIs(lexer.TokenWHERE) {
 		p.nextToken()
@@ -953,6 +1006,14 @@ func (p *Parser) parseUpdate() (*UpdateStmt, error) {
 		stmt.Where = where
 	}
 
+	if p.curTokenIs(lexer.TokenRETURNING) {
+		returning, err := p.parseReturningClause()
+		if err != nil {
+			return nil, err
+		}
+		stmt.Returning = returning
+	}
+
 	return stmt, nil
 }
 
@@ -984,9 +1045,31 @@ func (p *Parser) parseDelete() (*DeleteStmt, error) {
 		stmt.Where = where
 	}
 
+	if p.curTokenIs(lexer.TokenRETURNING) {
+		returning, err := p.parseReturningClause()
+		if err != nil {
+			return nil, err
+		}
+		stmt.Returning = returning
+	}
+
 	return stmt, nil
 }
 
+// parseReturningClause parses the projection after a RETURNING keyword. The
+// caller must leave curToken on the RETURNING identifier.
+func (p *Parser) parseReturningClause() ([]SelectColumn, error) {
+	p.nextToken() // consume RETURNING
+	cols, err := p.parseSelectColumns()
+	if err != nil {
+		return nil, err
+	}
+	if len(cols) == 0 {
+		return nil, p.curError("RETURNING requires at least one expression")
+	}
+	return cols, nil
+}
+
 // parseCreate parses CREATE statements.
 func (p *Parser) parseCreate() (Statement, error) {
 	p.nextToken() // consume CREATE
@@ -1089,7 +1172,7 @@ func (p *Parser) isTableConstraintStart() bool {
 func (p *Parser) parseColumnDef() (*ColumnDef, error) {
 	col := &ColumnDef{}
 
-	if !p.curTokenIs(lexer.TokenIdent) {
+	if !p.curIsName() {
 		return nil, p.curError("expected column name")
 	}
 	col.Name = p.curToken.Literal
@@ -1104,6 +1187,11 @@ func (p *Parser) parseColumnDef() (*ColumnDef, error) {
 
 	// Parse column constraints
 	for {
+		if consumed, err := p.tryParseGeneratedColumn(col); err != nil {
+			return nil, err
+		} else if consumed {
+			continue
+		}
 		constraint, ok, err := p.parseColumnConstraint()
 		if err != nil {
 			return nil, err
@@ -1119,6 +1207,50 @@ func (p *Parser) parseColumnDef() (*ColumnDef, error) {
 	return col, nil
 }
 
+// tryParseGeneratedColumn parses a GENERATED ALWAYS AS (expr) [STORED|VIRTUAL]
+// clause, or the shorthand AS (expr) [STORED|VIRTUAL]. It reports whether a
+// clause was consumed. Generated columns cannot be written by the user.
+func (p *Parser) tryParseGeneratedColumn(col *ColumnDef) (bool, error) {
+	started := false
+	if p.curIdentIs("GENERATED") {
+		started = true
+		p.nextToken()
+		if !p.curIdentIs("ALWAYS") {
+			return false, p.curError("expected ALWAYS after GENERATED")
+		}
+		p.nextToken()
+	}
+	if !p.curTokenIs(lexer.TokenAS) {
+		if started {
+			return false, p.curError("expected AS in generated column definition")
+		}
+		return false, nil
+	}
+	p.nextToken() // consume AS
+	if !p.curTokenIs(lexer.TokenLParen) {
+		return false, p.curError("expected ( after AS")
+	}
+	p.nextToken()
+	expr, err := p.parseExpr()
+	if err != nil {
+		return false, err
+	}
+	if !p.curTokenIs(lexer.TokenRParen) {
+		return false, p.curError("expected ) after generated expression")
+	}
+	p.nextToken()
+
+	col.GeneratedExpr = expr
+	if p.curIdentIs("STORED") {
+		col.GeneratedStored = true
+		p.nextToken()
+	} else if p.curIdentIs("VIRTUAL") {
+		col.GeneratedStored = false
+		p.nextToken()
+	}
+	return true, nil
+}
+
 // identTypeAliases maps non-reserved type names (lexed as plain identifiers)
 // to their canonical data type name. These are stored with SQLite text
 // affinity and never gain native PostgreSQL semantics.
@@ -1199,6 +1331,12 @@ func (p *Parser) parseColumnConstraint() (*ColumnConstraint, bool, error) {
 		}
 		constraint.Type = ConstraintPrimaryKey
 		p.nextToken()
+		if action, ok, err := p.parseOnConflictAction(); err != nil {
+			return nil, false, err
+		} else if ok {
+			constraint.OnConflict = action
+			constraint.HasOnConflict = true
+		}
 
 	case lexer.TokenNOT:
 		p.nextToken()
@@ -1211,6 +1349,12 @@ func (p *Parser) parseColumnConstraint() (*ColumnConstraint, bool, error) {
 	case lexer.TokenUNIQUE:
 		constraint.Type = ConstraintUnique
 		p.nextToken()
+		if action, ok, err := p.parseOnConflictAction(); err != nil {
+			return nil, false, err
+		} else if ok {
+			constraint.OnConflict = action
+			constraint.HasOnConflict = true
+		}
 
 	case lexer.TokenDEFAULT:
 		p.nextToken()
@@ -1250,6 +1394,23 @@ func (p *Parser) parseColumnConstraint() (*ColumnConstraint, bool, error) {
 		constraint.Type = ConstraintAutoIncrement
 		p.nextToken()
 
+	case lexer.TokenCHECK:
+		p.nextToken()
+		if !p.curTokenIs(lexer.TokenLParen) {
+			return nil, false, p.curError("expected ( after CHECK")
+		}
+		p.nextToken()
+		check, err := p.parseExpr()
+		if err != nil {
+			return nil, false, err
+		}
+		if !p.curTokenIs(lexer.TokenRParen) {
+			return nil, false, p.curError("expected ) after CHECK expression")
+		}
+		p.nextToken()
+		constraint.Type = ConstraintCheck
+		constraint.Check = check
+
 	case lexer.TokenNULL:
 		// Explicit NULL is a no-op: columns are nullable by default, so an
 		// explicit NULL declaration carries no constraint. Consume it so
@@ -1265,6 +1426,58 @@ func (p *Parser) parseColumnConstraint() (*ColumnConstraint, bool, error) {
 	return constraint, true, nil
 }
 
+// parseOnConflictAction parses an optional constraint clause of the form
+// ON CONFLICT IGNORE|REPLACE|FAIL|ABORT|ROLLBACK. It reports ok=false and leaves
+// the cursor unchanged when no ON CONFLICT follows.
+func (p *Parser) parseOnConflictAction() (ConflictAction, bool, error) {
+	if !p.curTokenIs(lexer.TokenON) {
+		return ConflictAbort, false, nil
+	}
+	p.nextToken()
+	if !p.curTokenIs(lexer.TokenCONFLICT) {
+		return ConflictAbort, false, p.curError("expected CONFLICT after ON")
+	}
+	p.nextToken()
+	switch p.curToken.Type {
+	case lexer.TokenREPLACE:
+		p.nextToken()
+		return ConflictReplace, true, nil
+	case lexer.TokenIGNORE:
+		p.nextToken()
+		return ConflictIgnore, true, nil
+	case lexer.TokenFAIL:
+		p.nextToken()
+		return ConflictFail, true, nil
+	case lexer.TokenABORT:
+		p.nextToken()
+		return ConflictAbort, true, nil
+	case lexer.TokenROLLBACK:
+		p.nextToken()
+		return ConflictRollback, true, nil
+	default:
+		return ConflictAbort, false, p.curError("expected REPLACE, IGNORE, FAIL, ABORT, or ROLLBACK after ON CONFLICT")
+	}
+}
+
+// consumeReferentialAction consumes one foreign-key referential action
+// (CASCADE, RESTRICT, SET NULL, SET DEFAULT, NO ACTION). It is a no-op for the
+// engine, which does not enforce foreign keys.
+func (p *Parser) consumeReferentialAction() {
+	if p.curTokenIs(lexer.TokenSET) {
+		p.nextToken() // NULL or DEFAULT
+		p.nextToken()
+		return
+	}
+	if p.curTokenIs(lexer.TokenIdent) && strings.EqualFold(p.curToken.Literal, "NO") {
+		p.nextToken()
+		if p.curTokenIs(lexer.TokenIdent) {
+			p.nextToken() // ACTION
+		}
+		return
+	}
+	p.nextToken() // CASCADE, RESTRICT, ...
+}
+
 func (p *Parser) parseTableConstraint() (*TableConstraint, error) {
 	constraint := &TableConstraint{}
 
@@ -1291,6 +1504,12 @@ func (p *Parser) parseTableConstraint() (*TableConstraint, error) {
 			return nil, err
 		}
 		constraint.Columns = cols
+		if action, ok, err := p.parseOnConflictAction(); err != nil {
+			return nil, err
+		} else if ok {
+			constraint.OnConflict = action
+			constraint.HasOnConflict = true
+		}
 
 	case lexer.TokenUNIQUE:
 		p.nextToken()
@@ -1300,6 +1519,12 @@ func (p *Parser) parseTableConstraint() (*TableConstraint, error) {
 			return nil, err
 		}
 		constraint.Columns = cols
+		if action, ok, err := p.parseOnConflictAction(); err != nil {
+			return nil, err
+		} else if ok {
+			constraint.OnConflict = action
+			constraint.HasOnConflict = true
+		}
 
 	case lexer.TokenFOREIGN:
 		p.nextToken()
@@ -1329,6 +1554,17 @@ func (p *Parser) parseTableConstraint() (*TableConstraint, error) {
 		}
 		constraint.RefColumns = refCols
 
+		// Consume and ignore referential actions (ON DELETE/UPDATE ...). The
+		// engine does not enforce foreign keys, but the clause must parse.
+		for p.curTokenIs(lexer.TokenON) {
+			p.nextToken()
+			if !p.curTokenIs(lexer.TokenDELETE) && !p.curTokenIs(lexer.TokenUPDATE) {
+				return nil, p.curError("expected DELETE or UPDATE after ON")
+			}
+			p.nextToken()
+			p.consumeReferentialAction()
+		}
+
 	case lexer.TokenCHECK:
 		p.nextToken()
 		constraint.Type = ConstraintCheck
@@ -1415,17 +1651,24 @@ func (p *Parser) parseCreateIndex(unique bool) (*CreateIndexStmt, error) {
 	}
 	p.nextToken()
 
-	// Parse column list
+	// Parse column list. Each element is either a bare column name or an
+	// arbitrary deterministic expression (e.g. lower(email)).
 	for {
 		if p.curTokenIs(lexer.TokenRParen) {
 			break
 		}
 
-		if !p.curTokenIs(lexer.TokenIdent) {
-			return nil, p.curError("expected column name")
+		expr, err := p.parseExpr()
+		if err != nil {
+			return nil, err
+		}
+		col := IndexColumn{}
+		if ref, ok := expr.(*ColumnRef); ok && ref.Table == "" {
+			col.Name = ref.Column
+		} else {
+			col.Expr = expr
+			col.Name = FormatExpr(expr)
 		}
-		col := IndexColumn{Name: p.curToken.Literal}
-		p.nextToken()
 
 		// Check for ASC/DESC
 		if p.curTokenIs(lexer.TokenASC) {
@@ -1675,7 +1918,7 @@ func (p *Parser) parseAlterTableDrop(stmt *AlterTableStmt) (*AlterTableStmt, err
 	}
 
 	// Parse column name
-	if !p.curTokenIs(lexer.TokenIdent) {
+	if !p.curIsName() {
 		return nil, p.curError("expected column name")
 	}
 
@@ -1766,16 +2009,38 @@ func (p *Parser) parsePragma() (*PragmaStmt, error) {
 	return stmt, nil
 }
 
+// parseAnalyze parses ANALYZE [schema.]table. PizzaSQL does not maintain
+// optimizer statistics, so the statement is accepted and ignored at execution.
+func (p *Parser) parseAnalyze() (*AnalyzeStmt, error) {
+	stmt := &AnalyzeStmt{}
+	p.nextToken() // consume ANALYZE
+
+	if p.curTokenIs(lexer.TokenIdent) {
+		stmt.Name = p.curToken.Literal
+		p.nextToken()
+		if p.curTokenIs(lexer.TokenDot) {
+			p.nextToken()
+			if !p.curTokenIs(lexer.TokenIdent) {
+				return nil, p.curError("expected table name after .")
+			}
+			stmt.Name = p.curToken.Literal
+			p.nextToken()
+		}
+	}
+	return stmt, nil
+}
+
 // parseExplain parses an EXPLAIN statement.
 func (p *Parser) parseExplain() (*ExplainStmt, error) {
 	stmt := &ExplainStmt{}
 
 	p.nextToken() // consume EXPLAIN
 
-	// Check for QUERY PLAN
+	// Check for QUERY PLAN. PLAN is not a reserved lexer keyword so it can be a
+	// column name; recognize it contextually here.
 	if p.curTokenIs(lexer.TokenQUERY) {
 		p.nextToken()
-		if !p.curTokenIs(lexer.TokenPLAN) {
+		if !p.curIdentIs("PLAN") {
 			return nil, p.curError("expected PLAN after QUERY")
 		}
 		stmt.QueryPlan = true
@@ -1989,12 +2254,12 @@ func (p *Parser) parseNotExpr() (Expr, error) {
 }
 
 func (p *Parser) parseComparisonExpr() (Expr, error) {
-	left, err := p.parseAddExpr()
+	left, err := p.parseBitwiseExpr()
 	if err != nil {
 		return nil, err
 	}
 
-	// Handle IS NULL / IS NOT NULL
+	// Handle IS NULL / IS NOT NULL and IS [NOT] DISTINCT FROM.
 	if p.curTokenIs(lexer.TokenIS) {
 		p.nextToken()
 		not := false
@@ -2002,8 +2267,20 @@ func (p *Parser) parseComparisonExpr() (Expr, error) {
 			not = true
 			p.nextToken()
 		}
+		if p.curTokenIs(lexer.TokenDISTINCT) {
+			p.nextToken()
+			if !p.curTokenIs(lexer.TokenFROM) {
+				return nil, p.curError("expected FROM after IS [NOT] DISTINCT")
+			}
+			p.nextToken()
+			right, err := p.parseBitwiseExpr()
+			if err != nil {
+				return nil, err
+			}
+			return &IsDistinctExpr{Left: left, Right: right, Not: not}, nil
+		}
 		if !p.curTokenIs(lexer.TokenNULL) {
-			return nil, p.curError("expected NULL after IS")
+			return nil, p.curError("expected NULL or DISTINCT FROM after IS")
 		}
 		p.nextToken()
 		return &IsNullExpr{Left: left, Not: not}, nil
@@ -2042,7 +2319,7 @@ func (p *Parser) parseComparisonExpr() (Expr, error) {
 	if isComparisonOp(p.curToken.Type) {
 		op := p.curToken.Type
 		p.nextToken()
-		right, err := p.parseAddExpr()
+		right, err := p.parseBitwiseExpr()
 		if err != nil {
 			return nil, err
 		}
@@ -2061,6 +2338,30 @@ func isComparisonOp(t lexer.TokenType) bool {
 	return false
 }
 
+// parseBitwiseExpr parses the SQLite bitwise layer: << >> & | bind more tightly
+// than comparisons but more loosely than + and -.
+func (p *Parser) parseBitwiseExpr() (Expr, error) {
+	left, err := p.parseAddExpr()
+	if err != nil {
+		return nil, err
+	}
+
+	for {
+		switch p.curToken.Type {
+		case lexer.TokenShiftLeft, lexer.TokenShiftRight, lexer.TokenBitAnd, lexer.TokenBitOr:
+			op := p.curToken.Type
+			p.nextToken()
+			right, err := p.parseAddExpr()
+			if err != nil {
+				return nil, err
+			}
+			left = &BinaryExpr{Left: left, Op: op, Right: right}
+		default:
+			return left, nil
+		}
+	}
+}
+
 func (p *Parser) parseInExpr(left Expr, not bool) (Expr, error) {
 	expr := &InExpr{Left: left, Not: not}
 
@@ -2094,7 +2395,7 @@ func (p *Parser) parseInExpr(left Expr, not bool) (Expr, error) {
 }
 
 func (p *Parser) parseBetweenExpr(left Expr, not bool) (Expr, error) {
-	low, err := p.parseAddExpr()
+	low, err := p.parseBitwiseExpr()
 	if err != nil {
 		return nil, err
 	}
@@ -2104,7 +2405,7 @@ func (p *Parser) parseBetweenExpr(left Expr, not bool) (Expr, error) {
 	}
 	p.nextToken()
 
-	high, err := p.parseAddExpr()
+	high, err := p.parseBitwiseExpr()
 	if err != nil {
 		return nil, err
 	}
@@ -2113,7 +2414,7 @@ func (p *Parser) parseBetweenExpr(left Expr, not bool) (Expr, error) {
 }
 
 func (p *Parser) parseLikeExpr(left Expr, not bool) (Expr, error) {
-	pattern, err := p.parseAddExpr()
+	pattern, err := p.parseBitwiseExpr()
 	if err != nil {
 		return nil, err
 	}
@@ -2123,7 +2424,7 @@ func (p *Parser) parseLikeExpr(left Expr, not bool) (Expr, error) {
 	// Check for ESCAPE
 	if p.curTokenIs(lexer.TokenESCAPE) {
 		p.nextToken()
-		esc, err := p.parseAddExpr()
+		esc, err := p.parseBitwiseExpr()
 		if err != nil {
 			return nil, err
 		}
@@ -2138,7 +2439,6 @@ func (p *Parser) parseAddExpr() (Expr, error) {
 	if err != nil {
 		return nil, err
 	}
-
 	for p.curTokenIs(lexer.TokenPlus) || p.curTokenIs(lexer.TokenMinus) || p.curTokenIs(lexer.TokenConcat) {
 		op := p.curToken.Type
 		p.nextToken()
@@ -2172,7 +2472,7 @@ func (p *Parser) parseMulExpr() (Expr, error) {
 }
 
 func (p *Parser) parseUnaryExpr() (Expr, error) {
-	if p.curTokenIs(lexer.TokenMinus) || p.curTokenIs(lexer.TokenPlus) {
+	if p.curTokenIs(lexer.TokenMinus) || p.curTokenIs(lexer.TokenPlus) || p.curTokenIs(lexer.TokenBitNot) {
 		op := p.curToken.Type
 		p.nextToken()
 		operand, err := p.parseUnaryExpr()
@@ -2197,6 +2497,11 @@ func (p *Parser) parsePrimaryExpr() (Expr, error) {
 		p.nextToken()
 		return expr, nil
 
+	case lexer.TokenBlob:
+		expr := &LiteralExpr{Type: lexer.TokenBlob, Value: p.curToken.Literal}
+		p.nextToken()
+		return expr, nil
+
 	case lexer.TokenNULL:
 		expr := &LiteralExpr{Type: lexer.TokenNULL, Value: "NULL"}
 		p.nextToken()
@@ -2250,6 +2555,21 @@ func (p *Parser) parsePrimaryExpr() (Expr, error) {
 		// These keywords can be used as function names
 		return p.parseKeywordFunction()
 
+	case lexer.TokenJSON, lexer.TokenJSONB:
+		// json(expr) / jsonb(expr) are JSON1 constructor functions. They lex as
+		// data-type keywords but are also callable.
+		return p.parseIdentOrFunction()
+
+	case lexer.TokenDATE, lexer.TokenTIME, lexer.TokenTIMESTAMP, lexer.TokenDATETIME:
+		// Date/time functions (date(), time(), datetime(), ...) lex as data-type
+		// keywords but are callable.
+		return p.parseIdentOrFunction()
+
+	case lexer.TokenKEY:
+		// KEY is a lexer keyword (PRIMARY KEY) that SQLite also permits as a
+		// column name, e.g. store.key.
+		return p.parseIdentOrFunction()
+
 	case lexer.TokenIdent:
 		return p.parseIdentOrFunction()
 
@@ -2513,7 +2833,7 @@ func (p *Parser) parseIdentList() ([]string, error) {
 	var idents []string
 
 	for {
-		if !p.curTokenIs(lexer.TokenIdent) {
+		if !p.curIsName() {
 			return nil, p.curError("expected identifier")
 		}
 		idents = append(idents, p.curToken.Literal)
@@ -2528,6 +2848,20 @@ func (p *Parser) parseIdentList() ([]string, error) {
 	return idents, nil
 }
 
+// curIsName reports whether the current token can serve as a name (column or
+// table identifier). A few lexer keywords, notably KEY, are commonly used as
+// column names in the GoatCounter schema and SQLite allows them.
+func (p *Parser) curIsName() bool {
+	if p.curTokenIs(lexer.TokenIdent) {
+		return true
+	}
+	switch p.curToken.Type {
+	case lexer.TokenKEY:
+		return true
+	}
+	return false
+}
+
 // Helper functions
 
 func parseInt(s string) int {

+ 195 - 4
pkg/parser/parser_test.go

@@ -284,6 +284,18 @@ func TestParseInsertValues(t *testing.T) {
 	}
 }
 
+func TestParseJoinUsingMultipleColumns(t *testing.T) {
+	stmt := parse(t, "SELECT * FROM hit_counts JOIN paths USING (site_id, path_id)")
+	selectStmt, ok := stmt.(*SelectStmt)
+	if !ok || len(selectStmt.From) != 1 || selectStmt.From[0].Join == nil {
+		t.Fatalf("unexpected statement %#v", stmt)
+	}
+	using := selectStmt.From[0].Join.Using
+	if len(using) != 2 || using[0] != "site_id" || using[1] != "path_id" {
+		t.Fatalf("USING columns = %v", using)
+	}
+}
+
 func TestParseInsertMultipleRows(t *testing.T) {
 	stmt := parse(t, "INSERT INTO users VALUES (1, 'John'), (2, 'Jane')")
 	ins := stmt.(*InsertStmt)
@@ -946,10 +958,189 @@ func TestParseMultiple(t *testing.T) {
 	}
 }
 
-func TestParseRejectsTrailingReturning(t *testing.T) {
-	l := lexer.New("INSERT INTO users (id) VALUES (1) RETURNING id")
-	if _, err := New(l).Parse(); err == nil {
-		t.Fatal("expected RETURNING to be rejected before execution")
+func TestParseInsertReturning(t *testing.T) {
+	stmt := parse(t, "INSERT INTO users (id, name) VALUES (1, 'a') RETURNING id, name AS n, id + 1")
+	ins, ok := stmt.(*InsertStmt)
+	if !ok {
+		t.Fatalf("expected InsertStmt, got %T", stmt)
+	}
+	if len(ins.Returning) != 3 {
+		t.Fatalf("expected 3 returning columns, got %d", len(ins.Returning))
+	}
+	if ref, ok := ins.Returning[0].Expr.(*ColumnRef); !ok || ref.Column != "id" {
+		t.Fatalf("unexpected first returning column: %#v", ins.Returning[0])
+	}
+	if ins.Returning[1].Alias != "n" {
+		t.Fatalf("expected alias n, got %q", ins.Returning[1].Alias)
+	}
+	if _, ok := ins.Returning[2].Expr.(*BinaryExpr); !ok {
+		t.Fatalf("expected expression in third returning column, got %T", ins.Returning[2].Expr)
+	}
+}
+
+func TestParseUpdateAndDeleteReturning(t *testing.T) {
+	upd := parse(t, "UPDATE users SET name = 'b' WHERE id = 1 RETURNING id, name")
+	if len(upd.(*UpdateStmt).Returning) != 2 {
+		t.Fatalf("expected 2 returning columns on UPDATE")
+	}
+
+	del := parse(t, "DELETE FROM users WHERE id = 1 RETURNING *")
+	if len(del.(*DeleteStmt).Returning) != 1 || !del.(*DeleteStmt).Returning[0].Star {
+		t.Fatalf("expected RETURNING * on DELETE")
+	}
+}
+
+func TestParseGeneratedColumn(t *testing.T) {
+	stmt := parse(t, `CREATE TABLE t (
+		a INTEGER,
+		b INTEGER,
+		total INTEGER GENERATED ALWAYS AS (a + b) STORED,
+		vit TEXT AS (a || '-') VIRTUAL
+	)`)
+	ct := stmt.(*CreateTableStmt)
+	if len(ct.Columns) != 4 {
+		t.Fatalf("expected 4 columns, got %d", len(ct.Columns))
+	}
+	if ct.Columns[2].GeneratedExpr == nil || !ct.Columns[2].GeneratedStored {
+		t.Fatalf("expected stored generated column, got %#v", ct.Columns[2])
+	}
+	if ct.Columns[3].GeneratedExpr == nil || ct.Columns[3].GeneratedStored {
+		t.Fatalf("expected virtual generated column, got %#v", ct.Columns[3])
+	}
+}
+
+func TestParseTableConstraintOnConflictReplace(t *testing.T) {
+	stmt := parse(t, `CREATE TABLE t (
+		a INTEGER,
+		b INTEGER,
+		CONSTRAINT "t#a#b" UNIQUE(a, b) ON CONFLICT REPLACE
+	)`)
+	ct := stmt.(*CreateTableStmt)
+	if len(ct.Constraints) != 1 {
+		t.Fatalf("expected 1 constraint, got %d", len(ct.Constraints))
+	}
+	c := ct.Constraints[0]
+	if !c.HasOnConflict || c.OnConflict != ConflictReplace {
+		t.Fatalf("expected ON CONFLICT REPLACE, got %#v", c)
+	}
+}
+
+func TestParseExpressionIndex(t *testing.T) {
+	stmt := parse(t, "CREATE UNIQUE INDEX users_email ON users (lower(email))")
+	ci := stmt.(*CreateIndexStmt)
+	if len(ci.Columns) != 1 {
+		t.Fatalf("expected 1 index column, got %d", len(ci.Columns))
+	}
+	if ci.Columns[0].Expr == nil {
+		t.Fatalf("expected expression index column, got %#v", ci.Columns[0])
+	}
+	if ci.Columns[0].Name != "lower(email)" {
+		t.Fatalf("expected name lower(email), got %q", ci.Columns[0].Name)
+	}
+}
+
+func TestParseBitwisePrecedence(t *testing.T) {
+	// 1 + 2 | 4 must parse as (1 + 2) | 4 because + binds tighter than |.
+	expr := parseExpr(t, "1 + 2 | 4")
+	bin, ok := expr.(*BinaryExpr)
+	if !ok || bin.Op != lexer.TokenBitOr {
+		t.Fatalf("expected top-level bit-or, got %#v", expr)
+	}
+	if left, ok := bin.Left.(*BinaryExpr); !ok || left.Op != lexer.TokenPlus {
+		t.Fatalf("expected (1+2) on the left, got %#v", bin.Left)
+	}
+}
+
+func TestParseBlobLiteral(t *testing.T) {
+	expr := parseExpr(t, "X'00FF'")
+	lit, ok := expr.(*LiteralExpr)
+	if !ok || lit.Type != lexer.TokenBlob {
+		t.Fatalf("expected blob literal, got %#v", expr)
+	}
+	if []byte(lit.Value)[0] != 0x00 || []byte(lit.Value)[1] != 0xFF {
+		t.Fatalf("unexpected blob bytes: %v", []byte(lit.Value))
+	}
+}
+
+func TestParseAnalyze(t *testing.T) {
+	if _, ok := parse(t, "ANALYZE").(*AnalyzeStmt); !ok {
+		t.Fatalf("ANALYZE did not parse as AnalyzeStmt")
+	}
+	stmt := parse(t, "ANALYZE main.users")
+	if got := stmt.(*AnalyzeStmt).Name; got != "users" {
+		t.Fatalf("ANALYZE name = %q, want users", got)
+	}
+}
+
+func TestParseIsDistinctFrom(t *testing.T) {
+	expr := parseExpr(t, "a IS DISTINCT FROM b")
+	d, ok := expr.(*IsDistinctExpr)
+	if !ok {
+		t.Fatalf("expected IsDistinctExpr, got %T", expr)
+	}
+	if d.Not {
+		t.Fatalf("IS DISTINCT FROM should not set Not")
+	}
+	expr = parseExpr(t, "a IS NOT DISTINCT FROM b")
+	if d, ok := expr.(*IsDistinctExpr); !ok || !d.Not {
+		t.Fatalf("expected IsDistinctExpr with Not=true, got %#v", expr)
+	}
+}
+
+func TestParseUpdateFrom(t *testing.T) {
+	stmt := parse(t, "UPDATE users SET access = 'x' FROM other WHERE other.id = users.id RETURNING users.id")
+	upd, ok := stmt.(*UpdateStmt)
+	if !ok {
+		t.Fatalf("expected UpdateStmt, got %T", stmt)
+	}
+	if len(upd.From) != 1 || upd.From[0].Name != "other" {
+		t.Fatalf("unexpected FROM: %#v", upd.From)
+	}
+	if len(upd.Returning) != 1 {
+		t.Fatalf("expected 1 RETURNING column, got %d", len(upd.Returning))
+	}
+}
+
+func TestParseWithUpdate(t *testing.T) {
+	stmt := parse(t, `WITH x AS (SELECT count(*) AS c, site_id FROM users GROUP BY site_id)
+		UPDATE users SET access = 'y' FROM x WHERE x.c = 1 AND users.site_id = x.site_id`)
+	upd, ok := stmt.(*UpdateStmt)
+	if !ok {
+		t.Fatalf("expected UpdateStmt, got %T", stmt)
+	}
+	if len(upd.From) != 1 || upd.From[0].Subquery == nil {
+		t.Fatalf("CTE was not desugared into the FROM clause: %#v", upd.From)
+	}
+}
+
+func TestParseInsertSelectReturning(t *testing.T) {
+	// RETURNING after INSERT ... SELECT must not be swallowed as a table alias.
+	stmt := parse(t, "INSERT INTO dst (a, b) SELECT a, b FROM src RETURNING a")
+	ins, ok := stmt.(*InsertStmt)
+	if !ok {
+		t.Fatalf("expected InsertStmt, got %T", stmt)
+	}
+	if ins.Select == nil || len(ins.Select.From) != 1 {
+		t.Fatalf("unexpected SELECT: %#v", ins.Select)
+	}
+	if ins.Select.From[0].Alias != "" {
+		t.Fatalf("RETURNING was parsed as a table alias: %q", ins.Select.From[0].Alias)
+	}
+	if len(ins.Returning) != 1 {
+		t.Fatalf("expected 1 RETURNING column, got %d", len(ins.Returning))
+	}
+}
+
+func TestParseInsertWithSelect(t *testing.T) {
+	stmt := parse(t, `INSERT INTO dst (id, value)
+		WITH source AS (SELECT id, value FROM src WHERE id > 1)
+		SELECT id, value FROM source`)
+	insert, ok := stmt.(*InsertStmt)
+	if !ok {
+		t.Fatalf("expected InsertStmt, got %T", stmt)
+	}
+	if insert.Select == nil || len(insert.Select.From) != 1 || insert.Select.From[0].Subquery == nil {
+		t.Fatalf("WITH SELECT was not attached to INSERT: %#v", insert.Select)
 	}
 }
 

+ 37 - 10
pkg/pgserver/connection.go

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"bytes"
 	"encoding/binary"
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"io"
@@ -909,23 +910,19 @@ func parameterLiteral(param boundParameter) (string, error) {
 
 // sendResult sends query results
 func (c *Connection) sendResult(result *executor.Result, stmt parser.Statement) error {
-	// For SELECT statements, send row description and data rows
-	if _, isSelect := stmt.(*parser.SelectStmt); isSelect && len(result.Columns) > 0 {
-		// Send row description
-		if err := c.sendRowDescription(result.Columns, result.ColumnTypes); err != nil {
+	// Any statement that produced a row set (SELECT, or INSERT/UPDATE/DELETE
+	// with RETURNING) sends a row description and its data rows. Other
+	// statements only send the command completion tag.
+	if len(result.Columns) > 0 {
+		if err := c.sendRowDescription(result.Columns, wireColumnTypes(result)); err != nil {
 			return err
 		}
-
-		// Send data rows
 		for _, row := range result.Rows {
 			if err := c.sendDataRow(row, result.Columns); err != nil {
 				return err
 			}
 		}
-
-		// Send command complete
-		tag := fmt.Sprintf("SELECT %d", len(result.Rows))
-		return c.sendCommandComplete(tag)
+		return c.sendCommandComplete(c.getCommandTag(stmt, result))
 	}
 
 	// For other statements, just send command complete
@@ -933,9 +930,34 @@ func (c *Connection) sendResult(result *executor.Result, stmt parser.Statement)
 	return c.sendCommandComplete(tag)
 }
 
+// wireColumnTypes corrects declared-affinity metadata when SQLite has stored a
+// value of another type. In particular, binding []byte to a VARCHAR column is
+// still a BLOB in SQLite; advertising TEXT while encoding PostgreSQL bytea text
+// would make clients receive the literal "\\x..." instead of the original
+// bytes.
+func wireColumnTypes(result *executor.Result) []string {
+	types := append([]string(nil), result.ColumnTypes...)
+	if len(types) < len(result.Columns) {
+		types = append(types, make([]string, len(result.Columns)-len(types))...)
+	}
+	for _, row := range result.Rows {
+		for i, value := range row {
+			if i >= len(types) {
+				break
+			}
+			if _, ok := value.([]byte); ok {
+				types[i] = "BLOB"
+			}
+		}
+	}
+	return types
+}
+
 // getCommandTag returns the command completion tag
 func (c *Connection) getCommandTag(stmt parser.Statement, result *executor.Result) string {
 	switch s := stmt.(type) {
+	case *parser.SelectStmt:
+		return fmt.Sprintf("SELECT %d", len(result.Rows))
 	case *parser.CreateTableStmt:
 		return "CREATE TABLE"
 	case *parser.DropTableStmt:
@@ -1151,6 +1173,11 @@ func (c *Connection) valueToString(value interface{}) string {
 	if value == nil {
 		return ""
 	}
+	// PostgreSQL's text format for bytea is \x followed by hex. Emitting that
+	// lets libpq/pgx decode a BLOB column (OID 17) losslessly.
+	if b, ok := value.([]byte); ok {
+		return `\x` + hex.EncodeToString(b)
+	}
 	return fmt.Sprintf("%v", value)
 }
 

+ 158 - 0
pkg/pgserver/features_test.go

@@ -0,0 +1,158 @@
+package pgserver
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/binary"
+	"net"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+)
+
+// newDataConnection builds a connection with a real executor backed by testkv so
+// simple-query behavior can be asserted end to end.
+func newDataConnection(t *testing.T) (*Connection, net.Conn) {
+	t.Helper()
+	server, client := net.Pipe()
+	t.Cleanup(func() {
+		server.Close()
+		client.Close()
+	})
+	kv := testkv.New(t)
+	pool := kv.Pool(4)
+	t.Cleanup(func() { pool.Close() })
+	schema := storage.NewSchemaManager(pool, "pg_features")
+	table := storage.NewTableManager(pool, schema, "pg_features")
+	exec := executor.New(schema, table)
+	exec.SyncCatalog()
+
+	c := &Connection{
+		conn:       server,
+		reader:     bufio.NewReader(server),
+		writer:     bufio.NewWriter(server),
+		params:     map[string]string{"user": "tester"},
+		statements: make(map[string]*preparedStatement),
+		portals:    make(map[string]*portal),
+		txStatus:   TxStatusIdle,
+		quiet:      true,
+		executor:   exec,
+		schema:     schema,
+	}
+	return c, client
+}
+
+// dataRowValues decodes a DataRow message into its text values.
+func dataRowValues(t *testing.T, msg *Message) []string {
+	t.Helper()
+	if msg.Type != MsgDataRow {
+		t.Fatalf("message type = %c, want DataRow", msg.Type)
+	}
+	data := msg.Data
+	if len(data) < 2 {
+		t.Fatal("short DataRow")
+	}
+	count := int(binary.BigEndian.Uint16(data[:2]))
+	pos := 2
+	values := make([]string, 0, count)
+	for i := 0; i < count; i++ {
+		if pos+4 > len(data) {
+			t.Fatal("short DataRow field length")
+		}
+		l := int32(binary.BigEndian.Uint32(data[pos : pos+4]))
+		pos += 4
+		if l == -1 {
+			values = append(values, "<null>")
+			continue
+		}
+		values = append(values, string(data[pos:pos+int(l)]))
+		pos += int(l)
+	}
+	return values
+}
+
+// commandTag extracts the NUL-terminated tag from a CommandComplete message.
+func commandTag(t *testing.T, msg *Message) string {
+	t.Helper()
+	if msg.Type != MsgCommandComplete {
+		t.Fatalf("message type = %c, want CommandComplete", msg.Type)
+	}
+	return string(bytes.TrimRight(msg.Data, "\x00"))
+}
+
+func TestSimpleQueryInsertReturning(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"), 0)})
+
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("INSERT INTO t (name) VALUES ('alice') RETURNING id, name"), 0)})
+	if len(msgs) != 4 {
+		t.Fatalf("got %d messages, want RowDescription+DataRow+CommandComplete+ReadyForQuery: %v", len(msgs), msgs)
+	}
+	if msgs[0].Type != MsgRowDescription {
+		t.Fatalf("first message = %c, want RowDescription", msgs[0].Type)
+	}
+	values := dataRowValues(t, msgs[1])
+	if len(values) != 2 || values[0] != "1" || values[1] != "alice" {
+		t.Fatalf("returning row = %v", values)
+	}
+	if tag := commandTag(t, msgs[2]); tag != "INSERT 0 1" {
+		t.Fatalf("command tag = %q, want INSERT 0 1", tag)
+	}
+}
+
+func TestSimpleQueryByteaTextWire(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE b (id INTEGER PRIMARY KEY, data BLOB)"), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("INSERT INTO b (id, data) VALUES (1, X'00FF10')"), 0)})
+
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("SELECT data FROM b WHERE id = 1"), 0)})
+	values := dataRowValues(t, msgs[1])
+	if len(values) != 1 || values[0] != `\x00ff10` {
+		t.Fatalf("bytea wire value = %v, want \\x00ff10", values)
+	}
+}
+
+func TestBlobValueOverridesTextAffinityWireType(t *testing.T) {
+	result := executor.NewResult("SELECT")
+	result.AddColumnWithType("settings", "VARCHAR")
+	result.AddRow([]byte(`{"collect":1}`))
+
+	types := wireColumnTypes(result)
+	if len(types) != 1 || types[0] != "BLOB" {
+		t.Fatalf("wire types = %v, want [BLOB]", types)
+	}
+	if result.ColumnTypes[0] != "VARCHAR" {
+		t.Fatalf("wire type inference mutated result metadata: %v", result.ColumnTypes)
+	}
+}
+
+func TestCommandTagSelectAndUpdate(t *testing.T) {
+	c, client := newDataConnection(t)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("INSERT INTO t VALUES (1, 'a')"), 0)})
+
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("SELECT * FROM t"), 0)})
+	if tag := commandTag(t, msgs[len(msgs)-2]); tag != "SELECT 1" {
+		t.Fatalf("select tag = %q", tag)
+	}
+
+	msgs = runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("UPDATE t SET v='b' RETURNING id"), 0)})
+	if msgs[0].Type != MsgRowDescription {
+		t.Fatalf("update returning first message = %c", msgs[0].Type)
+	}
+	if tag := commandTag(t, msgs[len(msgs)-2]); tag != "UPDATE 1" {
+		t.Fatalf("update tag = %q", tag)
+	}
+}
+
+func TestGetCommandTagSelectUsesRowCount(t *testing.T) {
+	c := &Connection{txStatus: TxStatusIdle}
+	res := executor.NewResult("SELECT")
+	res.AddRow(1)
+	res.AddRow(2)
+	if tag := c.getCommandTag(parseStmt(t, "SELECT 1"), res); tag != "SELECT 2" {
+		t.Fatalf("tag = %q, want SELECT 2", tag)
+	}
+}

+ 144 - 0
pkg/storage/expression_index_test.go

@@ -0,0 +1,144 @@
+package storage
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+)
+
+// registerLowerEvaluator installs a tiny expression evaluator that understands
+// the lower(col) form, mirroring what the SQL executor registers in production.
+func registerLowerEvaluator(t *testing.T, tables *TableManager) {
+	t.Helper()
+	tables.SetExpressionEvaluator(func(expression string, row Row) (interface{}, error) {
+		if strings.HasPrefix(expression, "lower(") && strings.HasSuffix(expression, ")") {
+			col := expression[len("lower(") : len(expression)-1]
+			if v, ok := row[col]; ok && v != nil {
+				return strings.ToLower(fmt.Sprintf("%v", v)), nil
+			}
+			return nil, nil
+		}
+		return nil, fmt.Errorf("unsupported expression %q", expression)
+	})
+}
+
+func TestExpressionUniqueIndexEnforced(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "users", []Column{
+		{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		{Name: "email", Type: "TEXT"},
+	})
+	registerLowerEvaluator(t, tables)
+
+	if err := schemas.CreateIndex(&Index{
+		Name:   "users_email_lower",
+		Table:  "users",
+		Unique: true,
+		Columns: []IndexColumn{
+			{Name: "lower(email)", Expression: "lower(email)"},
+		},
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := tables.Insert("users", Row{"id": int64(1), "email": "Alice@Example.com"}); err != nil {
+		t.Fatalf("first insert: %v", err)
+	}
+	err := tables.Insert("users", Row{"id": int64(2), "email": "alice@example.com"})
+	if err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("expected case-insensitive uniqueness violation, got %v", err)
+	}
+	if err := tables.Insert("users", Row{"id": int64(3), "email": "bob@example.com"}); err != nil {
+		t.Fatalf("distinct insert: %v", err)
+	}
+}
+
+func TestExpressionUniqueIndexAllowsNull(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "users", []Column{
+		{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		{Name: "email", Type: "TEXT", Nullable: true},
+	})
+	registerLowerEvaluator(t, tables)
+	if err := schemas.CreateIndex(&Index{
+		Name: "users_email_lower", Table: "users", Unique: true,
+		Columns: []IndexColumn{{Name: "lower(email)", Expression: "lower(email)"}},
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("users", Row{"id": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("users", Row{"id": int64(2)}); err != nil {
+		t.Fatalf("NULL expression values must be exempt from uniqueness: %v", err)
+	}
+}
+
+func TestGeneratedColumnMetadataRoundTrip(t *testing.T) {
+	_, _, schemas, _ := newTestSession(t)
+	schema := &Schema{
+		Name: "t",
+		Columns: []Column{
+			{Name: "a", Type: "INTEGER", Nullable: true},
+			{Name: "b", Type: "INTEGER", Nullable: true, GeneratedExpr: "a + 1", GeneratedStored: true},
+		},
+	}
+	if err := schemas.CreateTable(schema); err != nil {
+		t.Fatal(err)
+	}
+	got, err := schemas.GetSchema("t")
+	if err != nil {
+		t.Fatal(err)
+	}
+	b, ok := got.GetColumn("b")
+	if !ok {
+		t.Fatal("column b missing")
+	}
+	if b.GeneratedExpr != "a + 1" || !b.GeneratedStored {
+		t.Fatalf("generated metadata not persisted: %#v", b)
+	}
+}
+
+func TestIndexExpressionMetadataRoundTrip(t *testing.T) {
+	_, _, schemas, _ := newTestSession(t)
+	createTestTable(t, schemas, "users", []Column{{Name: "email", Type: "TEXT"}})
+	if err := schemas.CreateIndex(&Index{
+		Name: "users_email_lower", Table: "users", Unique: true,
+		Columns: []IndexColumn{{Name: "lower(email)", Expression: "lower(email)"}},
+	}); err != nil {
+		t.Fatal(err)
+	}
+	idx, err := schemas.GetIndex("users_email_lower")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(idx.Columns) != 1 || idx.Columns[0].Expression != "lower(email)" {
+		t.Fatalf("expression metadata not persisted: %#v", idx.Columns)
+	}
+}
+
+func TestExpressionIndexEvaluatorErrorPropagates(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "users", []Column{
+		{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		{Name: "email", Type: "TEXT", Nullable: true},
+	})
+	registerLowerEvaluator(t, tables)
+	if err := schemas.CreateIndex(&Index{
+		Name: "users_email_lower", Table: "users",
+		Columns: []IndexColumn{{Name: "lower(email)", Expression: "lower(email)"}},
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.BuildIndex("users_email_lower", "users", []string{"lower(email)"}); err != nil {
+		t.Fatal(err)
+	}
+
+	// A broken evaluator must surface, not be skipped during index maintenance.
+	tables.SetExpressionEvaluator(func(expression string, row Row) (interface{}, error) {
+		return nil, fmt.Errorf("evaluator boom")
+	})
+	if err := tables.Insert("users", Row{"id": int64(1), "email": "a@x"}); err == nil {
+		t.Fatal("expected evaluator error to propagate from Insert")
+	}
+}

+ 273 - 27
pkg/storage/schema.go

@@ -34,6 +34,11 @@ type Column struct {
 	Nullable   bool        `json:"nullable"`
 	Default    interface{} `json:"default,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.
+	GeneratedExpr   string `json:"generated_expr,omitempty"`
+	GeneratedStored bool   `json:"generated_stored,omitempty"`
 }
 
 // Index represents an index definition.
@@ -43,12 +48,18 @@ type Index struct {
 	Columns   []IndexColumn `json:"columns"`
 	Unique    bool          `json:"unique"`
 	CreatedAt time.Time     `json:"created_at"`
+	// OnConflict is the default conflict resolution declared for this index via
+	// a UNIQUE(...) ON CONFLICT clause. Empty means the SQLite default (ABORT).
+	OnConflict string `json:"on_conflict,omitempty"`
 }
 
-// IndexColumn represents a column in an index.
+// IndexColumn represents a column in an index. Expression is set for expression
+// indexes (e.g. lower(email)); a plain column index leaves it empty and uses
+// Name.
 type IndexColumn struct {
-	Name string `json:"name"`
-	Desc bool   `json:"desc"`
+	Name       string `json:"name"`
+	Desc       bool   `json:"desc"`
+	Expression string `json:"expression,omitempty"`
 }
 
 // rowIDAllocator owns the next-ROWID state for a single table. It is a separate
@@ -454,6 +465,7 @@ func (s *Schema) ToAnalyzerTableInfo() *analyzer.TableInfo {
 			Nullable:   col.Nullable,
 			PrimaryKey: col.PrimaryKey,
 			TableName:  s.Name,
+			Generated:  col.GeneratedExpr != "",
 		})
 	}
 
@@ -945,6 +957,10 @@ func (m *SchemaManager) AddColumn(table string, column Column) error {
 
 // DropColumn removes a column from a table.
 func (m *SchemaManager) DropColumn(table, columnName string) error {
+	tableLock := m.tableLock(table)
+	tableLock.Lock()
+	defer tableLock.Unlock()
+
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -975,6 +991,17 @@ func (m *SchemaManager) DropColumn(table, columnName string) error {
 
 	schema = cloneSchema(schema)
 	schema.Columns = newColumns
+	if err := m.rewriteRows(table, func(row Row) bool {
+		for key := range row {
+			if strings.EqualFold(key, columnName) {
+				delete(row, key)
+				return true
+			}
+		}
+		return false
+	}); err != nil {
+		return err
+	}
 
 	// Update schema
 	return m.updateSchemaUnsafe(schema)
@@ -1001,25 +1028,59 @@ func (m *SchemaManager) RenameTable(oldName, newName string) error {
 	// Update schema name
 	schema.Name = newName
 
-	rowIDKey := m.rowIDKey(oldName)
+	// Move durable rows from the old table's data prefix to the new one. Without
+	// this, ALTER TABLE ... RENAME leaves every row under the old key and the
+	// renamed table appears empty.
+	if err := m.renameDataKeys(oldName, newName); err != nil {
+		return err
+	}
 
-	// Delete old schema
+	// Repoint indexes that belonged to the old table.
+	if err := m.repointIndexesLocked(oldName, newName); err != nil {
+		return err
+	}
+
+	// Build the replacement catalog before switching schema names. The schema,
+	// catalog, and stale ROWID state change in one batch, so a crash cannot
+	// leave neither table name addressable after all row chunks have moved.
+	tables, err := m.ListTables()
+	if err != nil {
+		return err
+	}
+	foundOld := false
+	for i, name := range tables {
+		if strings.EqualFold(name, oldName) {
+			tables[i] = newName
+			foundOld = true
+			break
+		}
+	}
+	if !foundOld {
+		return fmt.Errorf("table not found in catalog: %s", oldName)
+	}
+	catalogData, err := json.Marshal(tables)
+	if err != nil {
+		return err
+	}
+	schemaData, err := json.Marshal(schema)
+	if err != nil {
+		return err
+	}
 	oldKey := m.schemaKey(oldName)
+	newKey := m.schemaKey(newName)
 	err = m.pool.WithClient(func(c *KVClient) error {
-		return c.Delete(oldKey)
+		_, err := c.BatchWrite([]BatchOp{
+			{Op: batchPut, Key: []byte(newKey), Value: schemaData},
+			{Op: batchDelete, Key: []byte(oldKey)},
+			{Op: batchDelete, Key: []byte(m.rowIDKey(oldName))},
+			{Op: batchPut, Key: []byte(m.catalogKey()), Value: catalogData},
+		}, nil)
+		return err
 	})
 	if err != nil {
 		return err
 	}
 
-	// Remove from catalog
-	m.removeFromCatalog(oldName)
-
-	// Move ROWID state.
-	m.pool.WithClient(func(c *KVClient) error {
-		return c.Delete(rowIDKey)
-	})
-
 	// Update cache
 	oldLower := strings.ToLower(oldName)
 	newLower := strings.ToLower(newName)
@@ -1028,19 +1089,6 @@ func (m *SchemaManager) RenameTable(oldName, newName string) error {
 	delete(m.rowIDAlloc, oldLower)
 	m.rowIDMu.Unlock()
 
-	// Write new schema
-	newKey := m.schemaKey(newName)
-	data, _ := json.Marshal(schema)
-	err = m.pool.WithClient(func(c *KVClient) error {
-		return c.Write(newKey, string(data))
-	})
-	if err != nil {
-		return err
-	}
-
-	// Add to catalog
-	m.addToCatalog(newName)
-
 	// Update cache
 	m.cache[newLower] = schema
 	m.bumpVersionLocked()
@@ -1048,8 +1096,131 @@ func (m *SchemaManager) RenameTable(oldName, newName string) error {
 	return nil
 }
 
+// renameDataKeys moves every durable row of a table from the old name's data
+// prefix to the new name's prefix in a single atomic batch per page.
+func (m *SchemaManager) renameDataKeys(oldName, newName string) error {
+	oldPrefix := []byte(fmt.Sprintf("%s:_data:%s:", m.database, strings.ToLower(oldName)))
+	newPrefix := fmt.Sprintf("%s:_data:%s:", m.database, strings.ToLower(newName))
+	return m.pool.WithClient(func(client *KVClient) (retErr error) {
+		cursor, err := client.Scan(oldPrefix)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if cerr := cursor.Close(); retErr == nil {
+				retErr = cerr
+			}
+		}()
+
+		ops := make([]BatchOp, 0, scanPageSize*2)
+		batchBytes := 8
+		flush := func() error {
+			if len(ops) == 0 {
+				return nil
+			}
+			if _, err := client.BatchWrite(ops, nil); err != nil {
+				return err
+			}
+			ops = ops[:0]
+			batchBytes = 8
+			return nil
+		}
+		for {
+			entries, done, err := cursor.Next()
+			if err != nil {
+				return err
+			}
+			for _, e := range entries {
+				suffix := string(e.Key[len(oldPrefix):])
+				newKey := []byte(newPrefix + suffix)
+				opBytes := 24 + len(newKey) + len(e.Value) + len(e.Key)
+				if len(ops)+2 > maxOperations || batchBytes+opBytes > bulkBatchByteBudget {
+					if err := flush(); err != nil {
+						return err
+					}
+				}
+				ops = append(ops,
+					BatchOp{Op: batchPut, Key: newKey, Value: e.Value},
+					BatchOp{Op: batchDelete, Key: append([]byte(nil), e.Key...)},
+				)
+				batchBytes += opBytes
+			}
+			if done {
+				return flush()
+			}
+		}
+	})
+}
+
+// repointIndexesLocked updates indexes whose table was renamed. The caller holds
+// m.mu; it reads the durable index list directly rather than calling the
+// self-locking ListIndexes helper.
+func (m *SchemaManager) repointIndexesLocked(oldName, newName string) error {
+	var names []string
+	if m.indexListCached {
+		names = append([]string(nil), m.indexListCache...)
+	} else {
+		var data string
+		err := m.pool.WithClient(func(c *KVClient) error {
+			var rerr error
+			data, rerr = c.Read(m.indexListKey())
+			return rerr
+		})
+		if err == ErrKeyNotFound {
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+		if err := json.Unmarshal([]byte(data), &names); err != nil {
+			return err
+		}
+	}
+
+	for _, name := range names {
+		key := m.indexKey(name)
+		var data string
+		err := m.pool.WithClient(func(c *KVClient) error {
+			var rerr error
+			data, rerr = c.Read(key)
+			return rerr
+		})
+		if err != nil {
+			if err == ErrKeyNotFound {
+				continue
+			}
+			return err
+		}
+		var idx Index
+		if err := json.Unmarshal([]byte(data), &idx); err != nil {
+			return err
+		}
+		if !strings.EqualFold(idx.Table, oldName) {
+			continue
+		}
+		idx.Table = newName
+		updated, err := json.Marshal(&idx)
+		if err != nil {
+			return err
+		}
+		if err := m.pool.WithClient(func(c *KVClient) error {
+			return c.Write(key, string(updated))
+		}); err != nil {
+			return err
+		}
+		if cached, ok := m.indexCache[strings.ToLower(name)]; ok {
+			cached.Table = newName
+		}
+	}
+	return nil
+}
+
 // RenameColumn renames a column in a table.
 func (m *SchemaManager) RenameColumn(table, oldName, newName string) error {
+	tableLock := m.tableLock(table)
+	tableLock.Lock()
+	defer tableLock.Unlock()
+
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -1084,11 +1255,86 @@ func (m *SchemaManager) RenameColumn(table, oldName, newName string) error {
 	if !found {
 		return fmt.Errorf("column not found: %s", oldName)
 	}
+	if err := m.rewriteRows(table, func(row Row) bool {
+		for key, value := range row {
+			if strings.EqualFold(key, oldName) {
+				delete(row, key)
+				row[newName] = value
+				return true
+			}
+		}
+		return false
+	}); err != nil {
+		return err
+	}
 
 	// Update schema
 	return m.updateSchemaUnsafe(schema)
 }
 
+// rewriteRows applies an idempotent name-keyed row transformation in bounded
+// batches. Column DDL updates the schema only after all rows are rewritten, so
+// an interrupted operation can safely resume against the old schema.
+func (m *SchemaManager) rewriteRows(table string, transform func(Row) bool) error {
+	prefix := []byte(fmt.Sprintf("%s:_data:%s:", m.database, strings.ToLower(table)))
+	return m.pool.WithClient(func(client *KVClient) (retErr error) {
+		cursor, err := client.Scan(prefix)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if cerr := cursor.Close(); retErr == nil {
+				retErr = cerr
+			}
+		}()
+
+		ops := make([]BatchOp, 0, scanPageSize)
+		batchBytes := 8
+		flush := func() error {
+			if len(ops) == 0 {
+				return nil
+			}
+			if _, err := client.BatchWrite(ops, nil); err != nil {
+				return err
+			}
+			ops = ops[:0]
+			batchBytes = 8
+			return nil
+		}
+
+		for {
+			entries, done, err := cursor.Next()
+			if err != nil {
+				return err
+			}
+			for _, entry := range entries {
+				row, err := decodeRow(entry.Value)
+				if err != nil {
+					return err
+				}
+				if !transform(row) {
+					continue
+				}
+				value, err := encodeRow(row)
+				if err != nil {
+					return err
+				}
+				opBytes := 12 + len(entry.Key) + len(value)
+				if len(ops) == maxOperations || batchBytes+opBytes > bulkBatchByteBudget {
+					if err := flush(); err != nil {
+						return err
+					}
+				}
+				ops = append(ops, BatchOp{Op: batchPut, Key: append([]byte(nil), entry.Key...), Value: value})
+				batchBytes += opBytes
+			}
+			if done {
+				return flush()
+			}
+		}
+	})
+}
+
 // getSchemaUnsafe gets a schema without locking (internal use).
 func (m *SchemaManager) getSchemaUnsafe(table string) (*Schema, error) {
 	tableLower := strings.ToLower(table)

+ 217 - 174
pkg/storage/table.go

@@ -46,6 +46,31 @@ type TableManager struct {
 	genMu                sync.Mutex
 	generations          map[string]uint64
 	predicateGenerations map[string]uint64
+
+	// exprMu guards exprEval, the pluggable evaluator for expression-index
+	// columns. It is set by the SQL executor so storage stays independent of
+	// the SQL front end.
+	exprMu   sync.RWMutex
+	exprEval ExpressionEvaluator
+}
+
+// ExpressionEvaluator computes the value of a persisted index expression for a
+// row. The SQL executor registers one so the storage layer can enforce
+// expression indexes without importing the parser/executor.
+type ExpressionEvaluator func(expression string, row Row) (interface{}, error)
+
+// SetExpressionEvaluator installs the evaluator used to compute expression-index
+// values during uniqueness validation and index maintenance.
+func (m *TableManager) SetExpressionEvaluator(eval ExpressionEvaluator) {
+	m.exprMu.Lock()
+	m.exprEval = eval
+	m.exprMu.Unlock()
+}
+
+func (m *TableManager) expressionEvaluator() ExpressionEvaluator {
+	m.exprMu.RLock()
+	defer m.exprMu.RUnlock()
+	return m.exprEval
 }
 
 // NewTableManager creates a new table manager.
@@ -123,10 +148,10 @@ func (m *TableManager) predicateGeneration(key string) uint64 {
 	return gen
 }
 
-func (m *TableManager) bumpIndexPredicates(table string, rows ...Row) {
+func (m *TableManager) bumpIndexPredicates(table string, rows ...Row) error {
 	indexes, err := m.schema.ListTableIndexes(table)
 	if err != nil || len(indexes) == 0 {
-		return
+		return err
 	}
 	m.genMu.Lock()
 	defer m.genMu.Unlock()
@@ -135,14 +160,14 @@ func (m *TableManager) bumpIndexPredicates(table string, rows ...Row) {
 			continue
 		}
 		for _, index := range indexes {
-			columns := make([]string, len(index.Columns))
-			for i, column := range index.Columns {
-				columns[i] = column.Name
+			value, err := m.buildIndexValue(row, index.Columns)
+			if err != nil {
+				return err
 			}
-			value := formatIndexValue(m.buildIndexValue(row, columns))
 			m.predicateGenerations[indexPredicateKey(table, index.Name, value)]++
 		}
 	}
+	return nil
 }
 
 func (m *TableManager) bumpIndexPredicateWildcard(table string) {
@@ -573,11 +598,15 @@ func (m *TableManager) InsertWithRowID(table string, row Row) (int64, error) {
 		return 0, fmt.Errorf("duplicate primary key: %v", row[schemaPrimaryKey(m.schema, table)])
 	}
 
-	m.updateIndexesForRow(table, nr, true)
+	if err := m.updateIndexesForRow(table, nr, true); err != nil {
+		return 0, err
+	}
 	// Publish derived index state before its generations. A reader that races
 	// with publication either sees the old generation and aborts or sees the
 	// complete new state.
-	m.bumpIndexPredicates(table, nr)
+	if err := m.bumpIndexPredicates(table, nr); err != nil {
+		return 0, err
+	}
 	m.bumpGeneration(table)
 	if schema, serr := m.schema.GetSchema(table); serr == nil {
 		m.incrCount(table, schema.CreatedAt, 1, wasInit)
@@ -796,11 +825,15 @@ func (m *TableManager) InsertBulkWithLastRowID(table string, rows []Row) (int, i
 	}
 	// Maintain indexes for the rows that persisted (the first numOK ops).
 	for i := 0; i < numOK; i++ {
-		m.updateIndexesForRow(table, encoded[i], true)
+		if err := m.updateIndexesForRow(table, encoded[i], true); err != nil {
+			return numOK, 0, err
+		}
 	}
 	if numOK > 0 {
 		m.bumpGeneration(table)
-		m.bumpIndexPredicates(table, encoded[:numOK]...)
+		if err := m.bumpIndexPredicates(table, encoded[:numOK]...); err != nil {
+			return numOK, 0, err
+		}
 	}
 	m.incrCount(table, schema.CreatedAt, numOK, wasInit)
 
@@ -814,15 +847,15 @@ func (m *TableManager) InsertBulkWithLastRowID(table string, rows []Row) (int, i
 // updateIndexesForRow adds or removes entries from already-built in-memory
 // indexes. Index entries are rebuildable from durable row data, so this method
 // intentionally does not write idx:* keys to KV.
-func (m *TableManager) updateIndexesForRow(table string, row Row, add bool) {
+func (m *TableManager) updateIndexesForRow(table string, row Row, add bool) error {
 	indexes, err := m.schema.ListTableIndexes(table)
 	if err != nil || len(indexes) == 0 {
-		return
+		return err
 	}
 
 	rowid, ok := rowIDFromRow(row)
 	if !ok {
-		return
+		return nil
 	}
 	tableKey := strings.ToLower(table)
 	tableSchema, schemaErr := m.schema.GetSchema(table)
@@ -847,11 +880,10 @@ func (m *TableManager) updateIndexesForRow(table string, row Row, add bool) {
 			continue
 		}
 
-		columns := make([]string, len(idx.Columns))
-		for i, col := range idx.Columns {
-			columns[i] = col.Name
+		colValue, err := m.buildIndexValue(row, idx.Columns)
+		if err != nil {
+			return err
 		}
-		colValue := m.buildIndexValue(row, columns)
 
 		if add {
 			m.AddIndexEntry(idx.Name, colValue, rowid)
@@ -859,6 +891,7 @@ func (m *TableManager) updateIndexesForRow(table string, row Row, add bool) {
 			m.RemoveIndexEntry(idx.Name, colValue, rowid)
 		}
 	}
+	return nil
 }
 
 // Select retrieves rows from a table by scanning durable rows and collecting
@@ -949,77 +982,20 @@ func (m *TableManager) SelectWithLimit(table string, filter func(Row) bool, limi
 
 // Update updates rows matching the filter.
 func (m *TableManager) Update(table string, updates Row, filter func(Row) bool) (int, error) {
-	tl := m.tableLock(strings.ToLower(table))
-	tl.Lock()
-	defer tl.Unlock()
-	rows, err := m.selectRows(table, filter)
-	if err != nil {
-		return 0, err
-	}
-	schema, err := m.schema.GetSchema(table)
-	if err != nil {
-		return 0, err
-	}
-
-	count := 0
-	for _, row := range rows {
-		// Snapshot the pre-update row so removed index entries can be restored
-		// if persistence fails.
-		oldRow := cloneRow(row)
-		m.updateIndexesForRow(table, row, false)
-
-		// Apply updates
-		for k, v := range updates {
-			// Normalize column name
-			for _, col := range schema.Columns {
-				if strings.EqualFold(k, col.Name) {
-					row[col.Name] = v
-					break
-				}
-			}
-		}
-
-		// Get primary key
-		pkValue := row[schema.PrimaryKey]
-		pk := fmt.Sprintf("%v", pkValue)
-
-		// Serialize row
-		data, err := encodeRow(row)
-		if err != nil {
-			m.updateIndexesForRow(table, oldRow, true)
-			continue
-		}
-
-		if err := m.validateUniqueRows(table, []Row{row}, excludedKey(m.dataKey(table, fmt.Sprintf("%v", oldRow[schema.PrimaryKey])))); err != nil {
-			m.updateIndexesForRow(table, oldRow, true)
-			return count, err
-		}
-
-		// Write back
-		key := m.dataKey(table, pk)
-		err = m.pool.WithClient(func(c *KVClient) error {
-			_, err := c.Put([]byte(key), data)
-			return err
-		})
-		if err == nil {
-			// Add new index entries after update
-			m.updateIndexesForRow(table, row, true)
-			m.bumpIndexPredicates(table, oldRow, row)
-			count++
-		} else {
-			m.updateIndexesForRow(table, oldRow, true)
-		}
-	}
-
-	if count > 0 {
-		m.bumpGeneration(table)
-	}
-	return count, nil
+	return m.updateLocked(table, func(Row) (Row, error) { return updates, nil }, filter)
 }
 
 // UpdateFunc updates rows matching the filter using a function to compute new values.
 // The updateFn receives the current row and returns the updates to apply.
 func (m *TableManager) UpdateFunc(table string, updateFn func(Row) (Row, error), filter func(Row) bool) (int, error) {
+	return m.updateLocked(table, updateFn, filter)
+}
+
+// updateLocked is the shared scan-based update implementation. It applies
+// per-row updates, revalidates unique indexes, and moves a row when its primary
+// key changes (deleting the old key and refusing to overwrite an occupied new
+// key) so an UPDATE of the primary key does not orphan or clobber rows.
+func (m *TableManager) updateLocked(table string, updateFn func(Row) (Row, error), filter func(Row) bool) (int, error) {
 	tl := m.tableLock(strings.ToLower(table))
 	tl.Lock()
 	defer tl.Unlock()
@@ -1035,18 +1011,17 @@ func (m *TableManager) UpdateFunc(table string, updateFn func(Row) (Row, error),
 	count := 0
 	for _, row := range rows {
 		oldRow := cloneRow(row)
-		m.updateIndexesForRow(table, row, false)
+		oldKey := m.dataKey(table, fmt.Sprintf("%v", oldRow[schema.PrimaryKey]))
+		if err := m.updateIndexesForRow(table, row, false); err != nil {
+			return count, err
+		}
 
-		// Compute updates using the provided function
 		updates, err := updateFn(row)
 		if err != nil {
-			m.updateIndexesForRow(table, oldRow, true)
+			_ = m.updateIndexesForRow(table, oldRow, true)
 			return count, err
 		}
-
-		// Apply updates
 		for k, v := range updates {
-			// Normalize column name
 			for _, col := range schema.Columns {
 				if strings.EqualFold(k, col.Name) {
 					row[col.Name] = v
@@ -1055,36 +1030,51 @@ func (m *TableManager) UpdateFunc(table string, updateFn func(Row) (Row, error),
 			}
 		}
 
-		// Get primary key
-		pkValue := row[schema.PrimaryKey]
-		pk := fmt.Sprintf("%v", pkValue)
-
-		// Serialize row
+		newKey := m.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
 		data, err := encodeRow(row)
 		if err != nil {
-			m.updateIndexesForRow(table, oldRow, true)
+			_ = m.updateIndexesForRow(table, oldRow, true)
 			continue
 		}
 
-		if err := m.validateUniqueRows(table, []Row{row}, excludedKey(m.dataKey(table, fmt.Sprintf("%v", oldRow[schema.PrimaryKey])))); err != nil {
-			m.updateIndexesForRow(table, oldRow, true)
+		excluded := map[string]bool{oldKey: true}
+		if err := m.validateUniqueRows(table, []Row{row}, excluded); err != nil {
+			_ = m.updateIndexesForRow(table, oldRow, true)
 			return count, err
 		}
 
-		// Write back
-		key := m.dataKey(table, pk)
-		err = m.pool.WithClient(func(c *KVClient) error {
-			_, err := c.Put([]byte(key), data)
-			return err
-		})
-		if err == nil {
-			// Add new index entries after update
-			m.updateIndexesForRow(table, row, true)
-			m.bumpIndexPredicates(table, oldRow, row)
-			count++
-		} else {
-			m.updateIndexesForRow(table, oldRow, true)
+		if newKey != oldKey {
+			// Refuse to move onto an existing primary key.
+			if _, _, gerr := m.getByPKWithLSN(table, fmt.Sprintf("%v", row[schema.PrimaryKey])); gerr == nil {
+				_ = m.updateIndexesForRow(table, oldRow, true)
+				return count, fmt.Errorf("duplicate primary key: %v", row[schema.PrimaryKey])
+			} else if gerr != ErrKeyNotFound {
+				_ = m.updateIndexesForRow(table, oldRow, true)
+				return count, gerr
+			}
+			if err := m.pool.WithClient(func(c *KVClient) error {
+				_, derr := c.Del([]byte(oldKey))
+				return derr
+			}); err != nil {
+				_ = m.updateIndexesForRow(table, oldRow, true)
+				continue
+			}
+		}
+
+		if err := m.pool.WithClient(func(c *KVClient) error {
+			_, perr := c.Put([]byte(newKey), data)
+			return perr
+		}); err != nil {
+			_ = m.updateIndexesForRow(table, oldRow, true)
+			continue
+		}
+		if err := m.updateIndexesForRow(table, row, true); err != nil {
+			return count, err
+		}
+		if err := m.bumpIndexPredicates(table, oldRow, row); err != nil {
+			return count, err
 		}
+		count++
 	}
 
 	if count > 0 {
@@ -1154,9 +1144,15 @@ func (m *TableManager) UpdateByPK(table, pk string, updateFn func(Row) (Row, err
 		return nil, false, ErrSerialization
 	}
 
-	m.updateIndexesForRow(table, oldRow, false)
-	m.updateIndexesForRow(table, row, true)
-	m.bumpIndexPredicates(table, oldRow, row)
+	if err := m.updateIndexesForRow(table, oldRow, false); err != nil {
+		return nil, false, err
+	}
+	if err := m.updateIndexesForRow(table, row, true); err != nil {
+		return nil, false, err
+	}
+	if err := m.bumpIndexPredicates(table, oldRow, row); err != nil {
+		return nil, false, err
+	}
 	m.bumpGeneration(table)
 	return oldRow, true, nil
 }
@@ -1179,7 +1175,9 @@ func (m *TableManager) Delete(table string, filter func(Row) bool) (int, error)
 	count := 0
 	for _, row := range rows {
 		// Remove index entries before deleting row
-		m.updateIndexesForRow(table, row, false)
+		if err := m.updateIndexesForRow(table, row, false); err != nil {
+			return count, err
+		}
 
 		pkValue := row[schema.PrimaryKey]
 		pk := fmt.Sprintf("%v", pkValue)
@@ -1190,11 +1188,13 @@ func (m *TableManager) Delete(table string, filter func(Row) bool) (int, error)
 			return err
 		})
 		if err == nil {
-			m.bumpIndexPredicates(table, row)
+			if err := m.bumpIndexPredicates(table, row); err != nil {
+				return count, err
+			}
 			count++
 		} else {
 			// Restore the index entries removed above.
-			m.updateIndexesForRow(table, row, true)
+			_ = m.updateIndexesForRow(table, row, true)
 		}
 	}
 
@@ -1241,8 +1241,12 @@ func (m *TableManager) DeleteByPK(table, pk string) (Row, bool, error) {
 		return nil, false, ErrSerialization
 	}
 
-	m.updateIndexesForRow(table, row, false)
-	m.bumpIndexPredicates(table, row)
+	if err := m.updateIndexesForRow(table, row, false); err != nil {
+		return nil, false, err
+	}
+	if err := m.bumpIndexPredicates(table, row); err != nil {
+		return nil, false, err
+	}
 	m.bumpGeneration(table)
 	m.incrCount(table, schema.CreatedAt, -1, wasInit)
 	return row, true, nil
@@ -1413,10 +1417,6 @@ func (m *TableManager) ensureIndex(index *Index) error {
 		return nil
 	}
 
-	columns := make([]string, len(index.Columns))
-	for i, col := range index.Columns {
-		columns[i] = col.Name
-	}
 	tableSchema, err := m.schema.GetSchema(table)
 	if err != nil {
 		return err
@@ -1429,7 +1429,10 @@ func (m *TableManager) ensureIndex(index *Index) error {
 		if !ok {
 			return false, nil
 		}
-		colValue := m.buildIndexValue(row, columns)
+		colValue, err := m.buildIndexValue(row, index.Columns)
+		if err != nil {
+			return true, err
+		}
 		valueKey := formatIndexValue(colValue)
 		values[valueKey] = append(values[valueKey], rowid)
 		rowKeys[rowid] = fmt.Sprintf("%v", row[tableSchema.PrimaryKey])
@@ -1566,6 +1569,10 @@ func (m *TableManager) BuildIndex(indexName, tableName string, columns []string)
 	if schemaErr != nil {
 		return schemaErr
 	}
+	indexCols := make([]IndexColumn, len(columns))
+	for i, name := range columns {
+		indexCols[i] = IndexColumn{Name: name}
+	}
 	values := make(map[string][]int64)
 	rowKeys := make(map[int64]string)
 	if err := m.scanRows(tableName, func(row Row) (bool, error) {
@@ -1573,7 +1580,10 @@ func (m *TableManager) BuildIndex(indexName, tableName string, columns []string)
 		if !ok {
 			return false, nil
 		}
-		colValue := m.buildIndexValue(row, columns)
+		colValue, err := m.buildIndexValue(row, indexCols)
+		if err != nil {
+			return true, err
+		}
 		values[formatIndexValue(colValue)] = append(values[formatIndexValue(colValue)], rowid)
 		rowKeys[rowid] = fmt.Sprintf("%v", row[tableSchema.PrimaryKey])
 		return false, nil
@@ -1590,35 +1600,49 @@ func (m *TableManager) BuildIndex(indexName, tableName string, columns []string)
 	return nil
 }
 
-// buildIndexValue creates the index key value from row columns.
-func (m *TableManager) buildIndexValue(row Row, columns []string) string {
-	formatValue := func(v interface{}) string {
-		switch val := v.(type) {
-		case float64:
-			// Check if it's actually an integer value
-			if val == float64(int64(val)) {
-				return fmt.Sprintf("%d", int64(val))
-			}
-			return fmt.Sprintf("%f", val)
-		case int64:
-			return fmt.Sprintf("%d", val)
-		case int:
-			return fmt.Sprintf("%d", val)
-		default:
-			return fmt.Sprintf("%v", val)
+// indexColumnValue resolves a single index column's value for a row. A plain
+// column is looked up case-insensitively; an expression column is evaluated
+// through the registered expression evaluator.
+func (m *TableManager) indexColumnValue(row Row, col IndexColumn) (interface{}, error) {
+	if col.Expression != "" {
+		eval := m.expressionEvaluator()
+		if eval == nil {
+			return nil, fmt.Errorf("expression index column %q requires an expression evaluator", col.Expression)
+		}
+		return eval(col.Expression, row)
+	}
+	if v, ok := row[col.Name]; ok {
+		return v, nil
+	}
+	for k, v := range row {
+		if strings.EqualFold(k, col.Name) {
+			return v, nil
 		}
 	}
+	return nil, nil
+}
 
+// buildIndexValue creates the index key value from an index's columns. Single
+// column values are formatted directly; composite values are joined with a NUL
+// separator.
+func (m *TableManager) buildIndexValue(row Row, columns []IndexColumn) (string, error) {
 	if len(columns) == 1 {
-		return formatValue(row[columns[0]])
+		v, err := m.indexColumnValue(row, columns[0])
+		if err != nil {
+			return "", err
+		}
+		return formatIndexValue(v), nil
 	}
 
-	// Multi-column index: concatenate values with separator
-	var parts []string
+	parts := make([]string, 0, len(columns))
 	for _, col := range columns {
-		parts = append(parts, formatValue(row[col]))
+		v, err := m.indexColumnValue(row, col)
+		if err != nil {
+			return "", err
+		}
+		parts = append(parts, formatIndexValue(v))
 	}
-	return strings.Join(parts, "\x00")
+	return strings.Join(parts, "\x00"), nil
 }
 
 // ── UNIQUE index enforcement ────────────────────────────────────────────────
@@ -1666,29 +1690,24 @@ func (m *TableManager) HasUniqueIndex(table string) (bool, error) {
 // encodeUniqueValue encodes the indexed value of a row with per-column type tags
 // and length prefixes. It returns isNull=true when any indexed column is NULL, in
 // which case the row is exempt from uniqueness. The length prefix makes composite
-// encodings collision-free.
-func encodeUniqueValue(row Row, columns []string) (string, bool) {
+// encodings collision-free. Expression columns are evaluated through the
+// registered evaluator.
+func (m *TableManager) encodeUniqueValue(row Row, columns []IndexColumn) (string, bool, error) {
 	var sb strings.Builder
 	for _, col := range columns {
-		v, ok := row[col]
-		if !ok {
-			for k, val := range row {
-				if strings.EqualFold(k, col) {
-					v = val
-					ok = true
-					break
-				}
-			}
+		v, err := m.indexColumnValue(row, col)
+		if err != nil {
+			return "", false, err
 		}
-		if !ok || v == nil {
-			return "", true
+		if v == nil {
+			return "", true, nil
 		}
 		s := encodeUniqueScalar(v)
 		sb.WriteString(strconv.Itoa(len(s)))
 		sb.WriteByte(':')
 		sb.WriteString(s)
 	}
-	return sb.String(), false
+	return sb.String(), false, nil
 }
 
 // encodeUniqueScalar encodes a scalar for uniqueness comparison. Integral
@@ -1772,8 +1791,10 @@ func (m *TableManager) validateUniqueRows(table string, pending []Row, excludedK
 	seen := make(map[string]map[string]bool, len(indexes))
 	for _, row := range pending {
 		for _, idx := range indexes {
-			columns := indexColumnNames(idx)
-			encoded, isNull := encodeUniqueValue(row, columns)
+			encoded, isNull, err := m.encodeUniqueValue(row, idx.Columns)
+			if err != nil {
+				return err
+			}
 			if isNull {
 				continue
 			}
@@ -1813,8 +1834,10 @@ func (m *TableManager) validateUniqueRows(table string, pending []Row, excludedK
 					return err
 				}
 				for _, idx := range indexes {
-					columns := indexColumnNames(idx)
-					encoded, isNull := encodeUniqueValue(row, columns)
+					encoded, isNull, encErr := m.encodeUniqueValue(row, idx.Columns)
+					if encErr != nil {
+						return encErr
+					}
 					if isNull {
 						continue
 					}
@@ -1879,21 +1902,15 @@ func (m *TableManager) lockForWrite(table string) (unlock func(), unique bool, e
 	}
 }
 
-func indexColumnNames(idx *Index) []string {
-	names := make([]string, len(idx.Columns))
-	for i, c := range idx.Columns {
-		names[i] = c.Name
-	}
-	return names
-}
-
 // ValidateUniqueIndex rejects a UNIQUE index definition if existing rows already
 // contain duplicate non-NULL values for the indexed columns.
 func (m *TableManager) ValidateUniqueIndex(index *Index) error {
-	columns := indexColumnNames(index)
 	seen := make(map[string]bool)
 	return m.scanRows(index.Table, func(row Row) (bool, error) {
-		encoded, isNull := encodeUniqueValue(row, columns)
+		encoded, isNull, err := m.encodeUniqueValue(row, index.Columns)
+		if err != nil {
+			return true, err
+		}
 		if isNull {
 			return false, nil
 		}
@@ -1934,6 +1951,33 @@ type indexPredicateSnapshot struct {
 // selectByIndexWithLSN retrieves indexed rows and their durable versions. The
 // transaction layer uses the versions for optimistic commit validation.
 func (m *TableManager) selectByIndexWithLSN(table, indexName string, colValue interface{}) ([]indexedRowVersion, indexPredicateSnapshot, error) {
+	return m.selectByIndexKeyWithLSN(table, indexName, formatIndexValue(colValue))
+}
+
+// IndexRowKey computes the formatted in-memory index key a row maps to for an
+// index. Expression columns are evaluated through the same registered evaluator
+// used by uniqueness enforcement, so the key matches both durable index entries
+// and staged overlay rows.
+func (m *TableManager) IndexRowKey(idx *Index, row Row) (string, error) {
+	value, err := m.buildIndexValue(row, idx.Columns)
+	if err != nil {
+		return "", err
+	}
+	return formatIndexValue(value), nil
+}
+
+// IndexValueContainsNull reports whether any column contributing to an index
+// key evaluates to NULL. UNIQUE indexes exempt such rows from conflicts under
+// SQLite semantics.
+func (m *TableManager) IndexValueContainsNull(idx *Index, row Row) (bool, error) {
+	_, isNull, err := m.encodeUniqueValue(row, idx.Columns)
+	return isNull, err
+}
+
+// selectByIndexKeyWithLSN is selectByIndexWithLSN with an already-computed
+// formatted index key, so callers can look up composite and expression indexes
+// without re-deriving the key from a single column value.
+func (m *TableManager) selectByIndexKeyWithLSN(table, indexName, valueKey string) ([]indexedRowVersion, indexPredicateSnapshot, error) {
 	index, err := m.schema.GetIndex(indexName)
 	if err != nil {
 		return nil, indexPredicateSnapshot{}, err
@@ -1951,7 +1995,6 @@ func (m *TableManager) selectByIndexWithLSN(table, indexName string, colValue in
 	}
 
 	indexKey := strings.ToLower(indexName)
-	valueKey := formatIndexValue(colValue)
 	predicateKey, predicateGen, wildcardKey, wildcardGen := m.predicateSnapshot(table, indexName, valueKey)
 	snapshot := indexPredicateSnapshot{
 		valueKey: predicateKey, valueGen: predicateGen,

+ 68 - 2
pkg/storage/tx.go

@@ -523,6 +523,60 @@ func (s *Session) SelectByIndex(table, indexName string, colValue interface{}) (
 	return rows, nil
 }
 
+// SelectByIndexKey retrieves the rows whose index key equals valueKey, merging
+// the staged overlay so a transaction sees its own writes. valueKey is the
+// formatted key from TableManager.IndexRowKey, which supports plain, composite,
+// and expression indexes uniformly.
+func (s *Session) SelectByIndexKey(table string, idx *Index, valueKey string) ([]Row, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if !s.inTx {
+		versions, _, err := s.table.selectByIndexKeyWithLSN(table, idx.Name, valueKey)
+		if err != nil {
+			return nil, err
+		}
+		rows := make([]Row, len(versions))
+		for i := range versions {
+			rows[i] = versions[i].row
+		}
+		return rows, nil
+	}
+
+	tableKey := strings.ToLower(table)
+	versions, predicate, err := s.table.selectByIndexKeyWithLSN(table, idx.Name, valueKey)
+	if err != nil {
+		return nil, err
+	}
+	if _, ok := s.predicateGens[predicate.valueKey]; !ok {
+		s.predicateGens[predicate.valueKey] = predicateRead{table: tableKey, gen: predicate.valueGen}
+	}
+	if _, ok := s.predicateGens[predicate.wildcardKey]; !ok {
+		s.predicateGens[predicate.wildcardKey] = predicateRead{table: tableKey, gen: predicate.wildcardGen}
+	}
+	overlay := s.overlay[tableKey]
+	rows := make([]Row, 0, len(versions)+len(overlay))
+	for _, version := range versions {
+		if _, staged := overlay[version.key]; staged {
+			continue
+		}
+		s.reads[version.key] = version.lsn
+		rows = append(rows, version.row)
+	}
+	for _, entry := range overlay {
+		if entry.absent {
+			continue
+		}
+		key, kerr := s.table.IndexRowKey(idx, entry.row)
+		if kerr != nil {
+			return nil, kerr
+		}
+		if key == valueKey {
+			rows = append(rows, cloneRow(entry.row))
+		}
+	}
+	return rows, nil
+}
+
 // CountFast returns the exact row count, observing the staged overlay in a
 // transaction.
 func (s *Session) CountFast(table string) (int, error) {
@@ -730,6 +784,8 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 	}
 	count := 0
 	for _, row := range rows {
+		oldRow := cloneRow(row)
+		oldKey := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
 		updates, err := s.runUpdateFn(updateFn, row)
 		if err != nil {
 			return count, err
@@ -742,8 +798,18 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 				}
 			}
 		}
-		key := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
-		s.stagePut(table, key, row)
+		newKey := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
+		if newKey != oldKey {
+			// The primary key changed: move the staged row instead of leaving a
+			// stale copy under the old key, and refuse to overwrite another row.
+			if _, gerr := s.getByPKLocked(table, fmt.Sprintf("%v", row[schema.PrimaryKey])); gerr == nil {
+				return count, fmt.Errorf("duplicate primary key: %v", row[schema.PrimaryKey])
+			} else if gerr != ErrKeyNotFound {
+				return count, gerr
+			}
+			s.stageDelete(table, oldKey, oldRow)
+		}
+		s.stagePut(table, newKey, row)
 		count++
 	}
 	return count, nil

Some files were not shown because too many files changed in this diff