فهرست منبع

add SQLite compatibility surface for the Gogs backend

Support running Gogs (GORM + xorm, SQLite dialect) over the PostgreSQL wire protocol:

- emulate sqlite_master/sqlite_schema and index_list/index_info/table_xinfo
- session-local last_insert_rowid(), changes(), and total_changes()
- return the generated row ID as Result.LastInsertID
- enforce unique indexes and constraints (scan-based, atomic), including composite and constraint-declared uniqueness
- parse explicit NULL, signed numeric defaults, and a UUID text alias
- report result column types for direct projections
- support qualified wildcard projections (table.*)
- give real oid/rowid columns precedence over hidden rowid aliases
- defer WHERE until after a JOIN
- prime non-correlated subqueries before the UPDATE/DELETE session lock to avoid a deadlock on WHERE id IN (SELECT ...)
Danilo Fragoso 1 روز پیش
والد
کامیت
55793fb734

BIN
bin/pizzasql


+ 51 - 0
pkg/analyzer/analyzer.go

@@ -145,6 +145,14 @@ func (a *Analyzer) analyzeSelect(stmt *parser.SelectStmt) error {
 			// SELECT * - all columns from all tables
 			// SELECT * - all columns from all tables
 			continue
 			continue
 		}
 		}
+		if col.TableStar != "" {
+			// Qualified wildcard (table.*) - validate the qualifier names a
+			// table or alias in scope; expansion happens at execution time.
+			if err := a.validateTableStar(col.TableStar); err != nil {
+				return err
+			}
+			continue
+		}
 
 
 		info, err := a.analyzeExpr(col.Expr)
 		info, err := a.analyzeExpr(col.Expr)
 		if err != nil {
 		if err != nil {
@@ -176,6 +184,12 @@ func (a *Analyzer) analyzeSelect(stmt *parser.SelectStmt) error {
 					Message: "SELECT * not allowed with aggregate functions without GROUP BY",
 					Message: "SELECT * not allowed with aggregate functions without GROUP BY",
 				}
 				}
 			}
 			}
+			if col.TableStar != "" {
+				return &AnalysisError{
+					Type:    ErrNonAggregateInSelect,
+					Message: fmt.Sprintf("SELECT %s.* not allowed with aggregate functions without GROUP BY", col.TableStar),
+				}
+			}
 			info, exprErr := a.analyzeExpr(col.Expr)
 			info, exprErr := a.analyzeExpr(col.Expr)
 			if exprErr != nil || info == nil {
 			if exprErr != nil || info == nil {
 				continue
 				continue
@@ -312,6 +326,19 @@ func (a *Analyzer) resolveFromClause(tables []parser.TableRef) error {
 	return nil
 	return nil
 }
 }
 
 
+// validateTableStar checks that a qualified wildcard qualifier names a table or
+// alias present in scope, returning an error for unknown qualifiers instead of
+// silently falling back to a plain column expansion.
+func (a *Analyzer) validateTableStar(qualifier string) error {
+	if _, ok := a.scope.LookupTable(qualifier); !ok {
+		return &AnalysisError{
+			Type:    ErrTableNotFound,
+			Message: fmt.Sprintf("no such table or alias: %s", qualifier),
+		}
+	}
+	return nil
+}
+
 // resolveJoin resolves a JOIN clause.
 // resolveJoin resolves a JOIN clause.
 func (a *Analyzer) resolveJoin(join *parser.JoinClause) error {
 func (a *Analyzer) resolveJoin(join *parser.JoinClause) error {
 	if join.Table == nil {
 	if join.Table == nil {
@@ -669,6 +696,20 @@ func (a *Analyzer) analyzeColumnRef(e *parser.ColumnRef) (*ExprInfo, error) {
 		if len(a.scope.GetTables()) == 0 {
 		if len(a.scope.GetTables()) == 0 {
 			return &ExprInfo{Type: TypeUnknown}, nil
 			return &ExprInfo{Type: TypeUnknown}, nil
 		}
 		}
+		// Hidden rowid alias (rowid/oid/_rowid_) when the table has no real column
+		// of that name. LookupColumn already resolved a real column, so this only
+		// fires for the alias (SQLite gives explicit columns precedence).
+		if isRowIDAlias(e.Column) {
+			if e.Table != "" {
+				if _, found := a.scope.LookupTable(e.Table); !found {
+					return nil, &AnalysisError{
+						Type:    ErrColumnNotFound,
+						Message: fmt.Sprintf("column not found: %s", formatColumnRef(e)),
+					}
+				}
+			}
+			return &ExprInfo{Type: TypeInteger}, nil
+		}
 		return nil, &AnalysisError{
 		return nil, &AnalysisError{
 			Type:    ErrColumnNotFound,
 			Type:    ErrColumnNotFound,
 			Message: fmt.Sprintf("column not found: %s", formatColumnRef(e)),
 			Message: fmt.Sprintf("column not found: %s", formatColumnRef(e)),
@@ -681,6 +722,16 @@ func (a *Analyzer) analyzeColumnRef(e *parser.ColumnRef) (*ExprInfo, error) {
 	}, nil
 	}, nil
 }
 }
 
 
+// isRowIDAlias reports whether a column name is a hidden rowid alias (rowid, oid,
+// or _rowid_), matching the storage layer's IsRowIDColumn.
+func isRowIDAlias(name string) bool {
+	switch strings.ToLower(name) {
+	case "rowid", "oid", "_rowid_":
+		return true
+	}
+	return false
+}
+
 func formatColumnRef(e *parser.ColumnRef) string {
 func formatColumnRef(e *parser.ColumnRef) string {
 	if e.Table != "" {
 	if e.Table != "" {
 		return e.Table + "." + e.Column
 		return e.Table + "." + e.Column

+ 48 - 0
pkg/analyzer/analyzer_test.go

@@ -84,6 +84,7 @@ func TestTypeFromName(t *testing.T) {
 		{"BOOLEAN", TypeBoolean},
 		{"BOOLEAN", TypeBoolean},
 		{"NUMERIC", TypeNumeric},
 		{"NUMERIC", TypeNumeric},
 		{"DECIMAL", TypeNumeric},
 		{"DECIMAL", TypeNumeric},
+		{"UUID", TypeText},
 		{"", TypeBlob}, // Empty type -> BLOB (SQLite rule)
 		{"", TypeBlob}, // Empty type -> BLOB (SQLite rule)
 	}
 	}
 
 
@@ -239,6 +240,53 @@ func TestAnalyzeSelectJoin(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestAnalyzeSelectQualifiedWildcard(t *testing.T) {
+	catalog := setupCatalog()
+	analyzer := New(catalog)
+
+	valid := []struct {
+		name string
+		sql  string
+	}{
+		{"alias wildcard", "SELECT u.* FROM users u"},
+		{"table name wildcard", "SELECT users.* FROM users"},
+		{"join alias wildcard", "SELECT u.* FROM users u JOIN orders o ON u.id = o.user_id"},
+		{"mixed wildcard and column", "SELECT u.*, o.amount FROM users u JOIN orders o ON u.id = o.user_id"},
+	}
+	for _, tt := range valid {
+		t.Run(tt.name, func(t *testing.T) {
+			err := analyzer.Analyze(parse(t, tt.sql))
+			if err != nil {
+				t.Errorf("Analyze(%q) error: %v", tt.sql, err)
+			}
+		})
+	}
+
+	invalid := []struct {
+		name    string
+		sql     string
+		errType ErrorType
+	}{
+		{"unknown qualifier", "SELECT nope.* FROM users u", ErrTableNotFound},
+		{"aggregate without group by", "SELECT u.*, COUNT(*) FROM users u", ErrNonAggregateInSelect},
+	}
+	for _, tt := range invalid {
+		t.Run(tt.name, func(t *testing.T) {
+			err := analyzer.Analyze(parse(t, tt.sql))
+			if err == nil {
+				t.Fatalf("Analyze(%q) expected error, got nil", tt.sql)
+			}
+			analysisErr, ok := err.(*AnalysisError)
+			if !ok {
+				t.Fatalf("expected AnalysisError, got %T", err)
+			}
+			if analysisErr.Type != tt.errType {
+				t.Errorf("expected error type %v, got %v (%s)", tt.errType, analysisErr.Type, analysisErr.Message)
+			}
+		})
+	}
+}
+
 func TestAnalyzeSelectAggregate(t *testing.T) {
 func TestAnalyzeSelectAggregate(t *testing.T) {
 	catalog := setupCatalog()
 	catalog := setupCatalog()
 	analyzer := New(catalog)
 	analyzer := New(catalog)

+ 24 - 19
pkg/analyzer/types.go

@@ -62,6 +62,11 @@ func TypeFromName(name string) Type {
 		return TypeText
 		return TypeText
 	}
 	}
 
 
+	// UUID is stored as text (SQLite-style), not as a native PostgreSQL UUID.
+	if upper == "UUID" {
+		return TypeText
+	}
+
 	// Rule 3: If the type contains "BLOB" or is empty -> BLOB
 	// Rule 3: If the type contains "BLOB" or is empty -> BLOB
 	if strings.Contains(upper, "BLOB") || upper == "" {
 	if strings.Contains(upper, "BLOB") || upper == "" {
 		return TypeBlob
 		return TypeBlob
@@ -171,23 +176,23 @@ func CommonType(a, b Type) Type {
 
 
 // FunctionSignature describes a SQL function.
 // FunctionSignature describes a SQL function.
 type FunctionSignature struct {
 type FunctionSignature struct {
-	Name         string
-	MinArgs      int
-	MaxArgs      int   // -1 for variadic
-	ArgTypes     []Type // Expected argument types (TypeAny for flexible)
-	ReturnType   Type
-	IsAggregate  bool
+	Name        string
+	MinArgs     int
+	MaxArgs     int    // -1 for variadic
+	ArgTypes    []Type // Expected argument types (TypeAny for flexible)
+	ReturnType  Type
+	IsAggregate bool
 }
 }
 
 
 // builtinFunctions contains all built-in SQL functions.
 // builtinFunctions contains all built-in SQL functions.
 var builtinFunctions = map[string]FunctionSignature{
 var builtinFunctions = map[string]FunctionSignature{
 	// Aggregate functions
 	// Aggregate functions
-	"COUNT": {Name: "COUNT", MinArgs: 0, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeInteger, IsAggregate: true},
-	"SUM":   {Name: "SUM", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeNumeric}, ReturnType: TypeNumeric, IsAggregate: true},
-	"AVG":   {Name: "AVG", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeNumeric}, ReturnType: TypeReal, IsAggregate: true},
-	"MIN":   {Name: "MIN", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: true},
-	"MAX":   {Name: "MAX", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: true},
-	"TOTAL": {Name: "TOTAL", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeNumeric}, ReturnType: TypeReal, IsAggregate: true},
+	"COUNT":        {Name: "COUNT", MinArgs: 0, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeInteger, IsAggregate: true},
+	"SUM":          {Name: "SUM", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeNumeric}, ReturnType: TypeNumeric, IsAggregate: true},
+	"AVG":          {Name: "AVG", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeNumeric}, ReturnType: TypeReal, IsAggregate: true},
+	"MIN":          {Name: "MIN", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: true},
+	"MAX":          {Name: "MAX", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: true},
+	"TOTAL":        {Name: "TOTAL", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeNumeric}, ReturnType: TypeReal, IsAggregate: true},
 	"GROUP_CONCAT": {Name: "GROUP_CONCAT", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: true},
 	"GROUP_CONCAT": {Name: "GROUP_CONCAT", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: true},
 
 
 	// String functions
 	// String functions
@@ -231,17 +236,17 @@ var builtinFunctions = map[string]FunctionSignature{
 	"TIMEDIFF":  {Name: "TIMEDIFF", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeAny}, ReturnType: TypeText, IsAggregate: false},
 	"TIMEDIFF":  {Name: "TIMEDIFF", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeAny}, ReturnType: TypeText, IsAggregate: false},
 
 
 	// SQLite specific
 	// SQLite specific
-	"SQLITE_VERSION": {Name: "SQLITE_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
-	"PIZZASQL_VERSION": {Name: "PIZZASQL_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+	"SQLITE_VERSION":    {Name: "SQLITE_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
+	"PIZZASQL_VERSION":  {Name: "PIZZASQL_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},
 	"LAST_INSERT_ROWID": {Name: "LAST_INSERT_ROWID", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
 	"LAST_INSERT_ROWID": {Name: "LAST_INSERT_ROWID", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
-	"CHANGES": {Name: "CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
-	"TOTAL_CHANGES": {Name: "TOTAL_CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
+	"CHANGES":           {Name: "CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
+	"TOTAL_CHANGES":     {Name: "TOTAL_CHANGES", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeInteger, IsAggregate: false},
 
 
 	// Other
 	// Other
-	"HEX":    {Name: "HEX", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeBlob}, ReturnType: TypeText, IsAggregate: false},
-	"UNHEX":  {Name: "UNHEX", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeText}, ReturnType: TypeBlob, IsAggregate: false},
+	"HEX":      {Name: "HEX", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeBlob}, ReturnType: TypeText, IsAggregate: false},
+	"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},
 	"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},
+	"QUOTE":    {Name: "QUOTE", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
 }
 }
 
 
 // LookupFunction returns the function signature for a function name.
 // LookupFunction returns the function signature for a function name.

+ 4 - 5
pkg/executor/datetime.go

@@ -283,11 +283,10 @@ func applyTimeDiffMod(t time.Time, mod string) (time.Time, bool) {
 	}
 	}
 
 
 	t = t.AddDate(sign*years, sign*months, sign*days)
 	t = t.AddDate(sign*years, sign*months, sign*days)
-	dur := time.Duration(sign) * (
-		time.Duration(hours)*time.Hour +
-			time.Duration(minutes)*time.Minute +
-			time.Duration(secs)*time.Second +
-			time.Duration(millis)*time.Millisecond)
+	dur := time.Duration(sign) * (time.Duration(hours)*time.Hour +
+		time.Duration(minutes)*time.Minute +
+		time.Duration(secs)*time.Second +
+		time.Duration(millis)*time.Millisecond)
 	return t.Add(dur), true
 	return t.Add(dur), true
 }
 }
 
 

+ 451 - 103
pkg/executor/executor.go

@@ -48,6 +48,12 @@ type Executor struct {
 
 
 	// In-memory view registry: view name (lowercase) → SELECT AST.
 	// In-memory view registry: view name (lowercase) → SELECT AST.
 	views map[string]*parser.SelectStmt
 	views map[string]*parser.SelectStmt
+
+	// Session-local SQLite compatibility state, tracked per connection so
+	// last_insert_rowid()/changes()/total_changes() reflect this session only.
+	lastInsertRowID int64 // rowid of the most recent successful INSERT
+	changes         int64 // rows changed by the most recent INSERT/UPDATE/DELETE
+	totalChanges    int64 // rows changed since this connection opened (monotonic)
 }
 }
 
 
 type correlatedAggCache struct {
 type correlatedAggCache struct {
@@ -148,8 +154,13 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 		e.correlatedAggCache = nil
 		e.correlatedAggCache = nil
 	}()
 	}()
 
 
-	// PRAGMA doesn't need analysis
+	// PRAGMA doesn't need analysis. SQLite catalog-introspection pragmas
+	// (index_list, index_info, table_xinfo) are answered by the catalog agent
+	// first; everything else falls through to the built-in handler.
 	if pragma, ok := stmt.(*parser.PragmaStmt); ok {
 	if pragma, ok := stmt.(*parser.PragmaStmt); ok {
+		if res, handled, err := e.sqliteCatalogPragma(pragma); handled {
+			return res, err
+		}
 		return e.executePragma(pragma)
 		return e.executePragma(pragma)
 	}
 	}
 
 
@@ -184,6 +195,15 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 		return e.executeDetach(s)
 		return e.executeDetach(s)
 	}
 	}
 
 
+	// SQLite catalog/metadata dispatch. SELECTs against sqlite_master /
+	// sqlite_schema must be answered before the analyzer, which would otherwise
+	// reject those virtual tables as unknown. Falls through when unhandled.
+	if sel, ok := stmt.(*parser.SelectStmt); ok {
+		if res, handled, err := e.sqliteCatalogSelect(sel); handled {
+			return res, err
+		}
+	}
+
 	// Analyze first. If the cached analyzer catalog is stale because schema was
 	// Analyze first. If the cached analyzer catalog is stale because schema was
 	// changed through another executor/API path, resync from storage and retry
 	// changed through another executor/API path, resync from storage and retry
 	// once before returning table/column-not-found errors.
 	// once before returning table/column-not-found errors.
@@ -439,7 +459,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 	if !usedIndex {
 	if !usedIndex {
 		var filterErr error
 		var filterErr error
 		var filter func(storage.Row) bool
 		var filter func(storage.Row) bool
-		if effectiveWhere != nil && stmt.From[0].Alias == "" && !isMultiTable {
+		if effectiveWhere != nil && stmt.From[0].Alias == "" && !isMultiTable && stmt.From[0].Join == nil {
 			filter = func(row storage.Row) bool {
 			filter = func(row storage.Row) bool {
 				val, ferr := e.evalExpr(effectiveWhere, row)
 				val, ferr := e.evalExpr(effectiveWhere, row)
 				if ferr != nil {
 				if ferr != nil {
@@ -474,8 +494,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		}
 		}
 	}
 	}
 
 
-	// Apply WHERE for single-table with alias (after alias mapping so alias.col refs work)
-	if effectiveWhere != nil && stmt.From[0].Alias != "" && !isMultiTable {
+	// Apply WHERE for single-table with alias (after alias mapping so alias.col refs work).
+	// A query with a JOIN defers WHERE until after the join, because the WHERE may
+	// reference columns from the joined table.
+	if effectiveWhere != nil && stmt.From[0].Alias != "" && !isMultiTable && stmt.From[0].Join == nil {
 		var filterErr error
 		var filterErr error
 		var filtered []storage.Row
 		var filtered []storage.Row
 		for _, row := range rows {
 		for _, row := range rows {
@@ -535,11 +557,12 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 				}
 				}
 			}
 			}
 		}
 		}
-		// Apply WHERE after all cross-joins
-		if stmt.Where != nil && len(stmt.From) > 1 {
+		// Apply WHERE after all joins. It is intentionally deferred past the base
+		// scan for JOIN queries because the WHERE may reference joined-table columns.
+		if effectiveWhere != nil {
 			var filtered []storage.Row
 			var filtered []storage.Row
 			for _, row := range rows {
 			for _, row := range rows {
-				val, _ := e.evalExpr(stmt.Where, row)
+				val, _ := e.evalExpr(effectiveWhere, row)
 				if toBool(val) {
 				if toBool(val) {
 					filtered = append(filtered, row)
 					filtered = append(filtered, row)
 				}
 				}
@@ -1081,6 +1104,27 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 	hasJoin := len(stmt.From) > 0 && stmt.From[0].Join != nil
 	hasJoin := len(stmt.From) > 0 && stmt.From[0].Join != nil
 	allTableRefs := collectAllTableRefs(stmt.From)
 	allTableRefs := collectAllTableRefs(stmt.From)
 
 
+	// Resolve qualified wildcard (table.*) projections once, keyed by qualifier.
+	type starProjection struct {
+		cols   []storage.Column
+		prefix string
+	}
+	tableStars := make(map[string]starProjection)
+	for _, col := range stmt.Columns {
+		if col.TableStar == "" {
+			continue
+		}
+		key := strings.ToUpper(col.TableStar)
+		if _, ok := tableStars[key]; ok {
+			continue
+		}
+		cols, prefix, err := e.resolveTableStar(stmt.From, col.TableStar)
+		if err != nil {
+			return nil, err
+		}
+		tableStars[key] = starProjection{cols: cols, prefix: prefix}
+	}
+
 	// Determine columns
 	// Determine columns
 	for i, col := range stmt.Columns {
 	for i, col := range stmt.Columns {
 		if col.Alias != "" {
 		if col.Alias != "" {
@@ -1104,11 +1148,23 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 					result.AddColumn(c.Name)
 					result.AddColumn(c.Name)
 				}
 				}
 			}
 			}
+		} else if col.TableStar != "" {
+			proj := tableStars[strings.ToUpper(col.TableStar)]
+			for _, c := range proj.cols {
+				result.AddColumn(c.Name)
+			}
 		} else {
 		} else {
 			result.AddColumn(fmt.Sprintf("column%d", i+1))
 			result.AddColumn(fmt.Sprintf("column%d", i+1))
 		}
 		}
 	}
 	}
 
 
+	// Populate column types from schema metadata for single-table projections so
+	// protocol consumers can decode typed values (e.g. timestamps) even when the
+	// result set is empty. Join and multi-table projections defer type metadata.
+	if !isMultiTable && !hasJoin {
+		result.ColumnTypes = selectColumnTypes(stmt, schema)
+	}
+
 	// Add rows - evaluate each select expression
 	// Add rows - evaluate each select expression
 	for _, row := range rows {
 	for _, row := range rows {
 		values := make([]interface{}, 0)
 		values := make([]interface{}, 0)
@@ -1130,15 +1186,25 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 						}
 						}
 					}
 					}
 				} else {
 				} else {
-					// For SELECT *, add all columns in order
+					// For SELECT *, add all columns in order. A real column named
+					// oid/rowid/_rowid_ is an ordinary column here, not the hidden
+					// rowid alias, so use its own value key.
 					for _, c := range schema.Columns {
 					for _, c := range schema.Columns {
-						if storage.IsRowIDColumn(c.Name) {
-							values = append(values, row["_rowid_"])
-						} else {
-							values = append(values, row[c.Name])
-						}
+						values = append(values, row[c.Name])
 					}
 					}
 				}
 				}
+			} else if col.TableStar != "" {
+				// Qualified wildcard (table.*): emit only that table's columns,
+				// resolving values via the effective alias, falling back to the
+				// unqualified key for single-table queries without an alias.
+				proj := tableStars[strings.ToUpper(col.TableStar)]
+				for _, c := range proj.cols {
+					val, ok := row[proj.prefix+"."+c.Name]
+					if !ok {
+						val = row[c.Name]
+					}
+					values = append(values, val)
+				}
 			} else {
 			} else {
 				// Evaluate the expression
 				// Evaluate the expression
 				val, err := e.evalExpr(col.Expr, row)
 				val, err := e.evalExpr(col.Expr, row)
@@ -1154,6 +1220,7 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 	// Apply DISTINCT if specified
 	// Apply DISTINCT if specified
 	if stmt.Distinct {
 	if stmt.Distinct {
 		result.Rows = e.applyDistinct(result.Rows)
 		result.Rows = e.applyDistinct(result.Rows)
+		result.RowCount = len(result.Rows)
 	}
 	}
 
 
 	return result, nil
 	return result, nil
@@ -1465,6 +1532,17 @@ func (e *Executor) executeGroupBy(stmt *parser.SelectStmt, rows []storage.Row, s
 					Expr: &parser.ColumnRef{Column: c.Name},
 					Expr: &parser.ColumnRef{Column: c.Name},
 				})
 				})
 			}
 			}
+		} else if col.TableStar != "" {
+			// Qualified wildcard: expand only the named table's columns.
+			cols, prefix, err := e.resolveTableStar(stmt.From, col.TableStar)
+			if err != nil {
+				return nil, err
+			}
+			for _, c := range cols {
+				expandedColumns = append(expandedColumns, parser.SelectColumn{
+					Expr: &parser.ColumnRef{Table: prefix, Column: c.Name},
+				})
+			}
 		} else {
 		} else {
 			expandedColumns = append(expandedColumns, col)
 			expandedColumns = append(expandedColumns, col)
 		}
 		}
@@ -2129,6 +2207,34 @@ func collectAllTableRefs(from []parser.TableRef) []parser.TableRef {
 	return refs
 	return refs
 }
 }
 
 
+// resolveTableStar resolves a qualified wildcard (table.*) qualifier to the
+// matching table ref in a FROM clause. It returns the table's schema columns
+// and the key prefix used to look values up in a joined/aliased row (the
+// effective table alias). An unknown qualifier is an error, never a silent
+// fallback to plain column expansion.
+func (e *Executor) resolveTableStar(from []parser.TableRef, qualifier string) ([]storage.Column, string, error) {
+	refs := collectAllTableRefs(from)
+	for _, ref := range refs {
+		if strings.EqualFold(ref.Alias, qualifier) {
+			sch, err := e.schema.GetSchema(ref.Name)
+			if err != nil {
+				return nil, "", err
+			}
+			return sch.Columns, ref.Alias, nil
+		}
+	}
+	for _, ref := range refs {
+		if strings.EqualFold(ref.Name, qualifier) {
+			sch, err := e.schema.GetSchema(ref.Name)
+			if err != nil {
+				return nil, "", err
+			}
+			return sch.Columns, ref.Alias, nil
+		}
+	}
+	return nil, "", fmt.Errorf("no such table or alias: %s", qualifier)
+}
+
 // addTableAlias adds table-qualified names to a row.
 // addTableAlias adds table-qualified names to a row.
 // normalizeRowBySchema converts float64 values in integer-affinity columns to int64.
 // normalizeRowBySchema converts float64 values in integer-affinity columns to int64.
 // This is needed because JSON deserialization always produces float64 for numbers.
 // This is needed because JSON deserialization always produces float64 for numbers.
@@ -2193,123 +2299,184 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 			rows = append(rows, row)
 			rows = append(rows, row)
 		}
 		}
 		var count int
 		var count int
+		var lastRowID int64
 		if e.inTransaction {
 		if e.inTransaction {
 			for _, row := range rows {
 			for _, row := range rows {
-				if err := e.session.Insert(tableName, row); err != nil {
+				rid, err := e.session.InsertWithRowID(tableName, row)
+				if err != nil {
 					return nil, err
 					return nil, err
 				}
 				}
+				lastRowID = rid
 				count++
 				count++
 			}
 			}
 		} else {
 		} else {
-			count, err = e.session.InsertBulk(tableName, rows)
+			count, lastRowID, err = e.session.InsertBulkWithLastRowID(tableName, rows)
 		}
 		}
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
 		result := NewResult("INSERT")
 		result := NewResult("INSERT")
 		result.SetRowCount(count)
 		result.SetRowCount(count)
+		result.SetLastInsertID(lastRowID)
+		if count > 0 {
+			e.lastInsertRowID = lastRowID
+		}
+		e.recordChanges(int64(count))
 		return result, nil
 		return result, nil
 	}
 	}
 
 
 	count := 0
 	count := 0
-	for _, values := range stmt.Values {
-		row := make(storage.Row)
+	var lastRowID int64
+	err = e.runDMLAtomic(tableName, func() error {
+		for _, values := range stmt.Values {
+			row := make(storage.Row)
 
 
-		if len(stmt.Columns) > 0 {
-			// Named columns
-			for i, col := range stmt.Columns {
-				if i < len(values) {
-					val, err := e.evalExpr(values[i], nil)
-					if err != nil {
-						return nil, err
+			if len(stmt.Columns) > 0 {
+				// Named columns
+				for i, col := range stmt.Columns {
+					if i < len(values) {
+						val, err := e.evalExpr(values[i], nil)
+						if err != nil {
+							return err
+						}
+						row[col] = val
 					}
 					}
-					row[col] = val
 				}
 				}
-			}
-		} else {
-			// All columns in order
-			for i, col := range schema.Columns {
-				if i < len(values) {
-					val, err := e.evalExpr(values[i], nil)
-					if err != nil {
-						return nil, err
+			} else {
+				// All columns in order
+				for i, col := range schema.Columns {
+					if i < len(values) {
+						val, err := e.evalExpr(values[i], nil)
+						if err != nil {
+							return err
+						}
+						row[col.Name] = val
 					}
 					}
-					row[col.Name] = val
 				}
 				}
 			}
 			}
-		}
 
 
-		err := e.session.Insert(tableName, row)
-		if err != nil {
-			if strings.Contains(err.Error(), "duplicate") && (stmt.ConflictDoNothing || len(stmt.ConflictUpdate) > 0) {
-				if stmt.ConflictDoNothing {
-					continue
-				}
-				if len(stmt.ConflictTarget) > 0 && !containsFold(stmt.ConflictTarget, schema.PrimaryKey) {
-					return nil, fmt.Errorf("ON CONFLICT target must include primary key %s", schema.PrimaryKey)
-				}
-				pkValue := row[schema.PrimaryKey]
-				updated, updateErr := e.session.UpdateFunc(tableName, func(existing storage.Row) (storage.Row, error) {
-					context := e.addTableAlias(existing, tableName)
-					updates := make(storage.Row)
-					for _, assignment := range stmt.ConflictUpdate {
-						value, evalErr := e.evalExpr(assignment.Value, context)
-						if evalErr != nil {
-							return nil, evalErr
+			rowID, err := e.session.InsertWithRowID(tableName, row)
+			if err != nil {
+				if strings.Contains(err.Error(), "duplicate") && (stmt.ConflictDoNothing || len(stmt.ConflictUpdate) > 0) {
+					if stmt.ConflictDoNothing {
+						continue
+					}
+					if len(stmt.ConflictTarget) > 0 && !containsFold(stmt.ConflictTarget, schema.PrimaryKey) {
+						return fmt.Errorf("ON CONFLICT target must include primary key %s", schema.PrimaryKey)
+					}
+					pkValue := row[schema.PrimaryKey]
+					updated, updateErr := e.session.UpdateFunc(tableName, func(existing storage.Row) (storage.Row, error) {
+						context := e.addTableAlias(existing, tableName)
+						updates := make(storage.Row)
+						for _, assignment := range stmt.ConflictUpdate {
+							value, evalErr := e.evalExpr(assignment.Value, context)
+							if evalErr != nil {
+								return nil, evalErr
+							}
+							updates[assignment.Column] = value
 						}
 						}
-						updates[assignment.Column] = value
+						return updates, nil
+					}, func(existing storage.Row) bool {
+						return fmt.Sprintf("%v", existing[schema.PrimaryKey]) == fmt.Sprintf("%v", pkValue)
+					})
+					if updateErr != nil {
+						return updateErr
 					}
 					}
-					return updates, nil
-				}, func(existing storage.Row) bool {
-					return fmt.Sprintf("%v", existing[schema.PrimaryKey]) == fmt.Sprintf("%v", pkValue)
-				})
-				if updateErr != nil {
-					return nil, updateErr
-				}
-				if updated != 1 {
-					return nil, fmt.Errorf("ON CONFLICT row disappeared during update")
-				}
-				count++
-				continue
-			}
-			// Handle conflict based on OnConflict action
-			if strings.Contains(err.Error(), "duplicate") {
-				switch stmt.OnConflict {
-				case parser.ConflictIgnore:
-					// Silently ignore the duplicate
+					if updated != 1 {
+						return fmt.Errorf("ON CONFLICT row disappeared during update")
+					}
+					count++
 					continue
 					continue
-				case parser.ConflictReplace:
-					// Delete existing row and insert new one
-					pkValue := row[schema.PrimaryKey]
-					if pkValue != nil {
-						e.session.Delete(tableName, func(r storage.Row) bool {
-							return fmt.Sprintf("%v", r[schema.PrimaryKey]) == fmt.Sprintf("%v", pkValue)
-						})
-						// Try insert again
-						if err := e.session.Insert(tableName, row); err != nil {
-							return nil, err
+				}
+				// Handle conflict based on OnConflict action
+				if strings.Contains(err.Error(), "duplicate") {
+					switch stmt.OnConflict {
+					case parser.ConflictIgnore:
+						// Silently ignore the duplicate
+						continue
+					case parser.ConflictReplace:
+						// Delete existing row and insert new one
+						pkValue := row[schema.PrimaryKey]
+						if pkValue != nil {
+							e.session.Delete(tableName, func(r storage.Row) bool {
+								return fmt.Sprintf("%v", r[schema.PrimaryKey]) == fmt.Sprintf("%v", pkValue)
+							})
+							// Try insert again
+							replacedID, replaceErr := e.session.InsertWithRowID(tableName, row)
+							if replaceErr != nil {
+								return replaceErr
+							}
+							lastRowID = replacedID
 						}
 						}
+					case parser.ConflictAbort, parser.ConflictFail:
+						return err
+					case parser.ConflictRollback:
+						// In a real implementation, this would rollback the transaction
+						return err
+					default:
+						return err
 					}
 					}
-				case parser.ConflictAbort, parser.ConflictFail:
-					return nil, err
-				case parser.ConflictRollback:
-					// In a real implementation, this would rollback the transaction
-					return nil, err
-				default:
-					return nil, err
+				} else {
+					return err
 				}
 				}
 			} else {
 			} else {
-				return nil, err
+				lastRowID = rowID
 			}
 			}
+			count++
 		}
 		}
-		count++
+		return nil
+	})
+	if err != nil {
+		return nil, err
 	}
 	}
 
 
 	result := NewResult("INSERT")
 	result := NewResult("INSERT")
 	result.SetRowCount(count)
 	result.SetRowCount(count)
+	result.SetLastInsertID(lastRowID)
+	if count > 0 {
+		e.lastInsertRowID = lastRowID
+	}
+	e.recordChanges(int64(count))
 	return result, nil
 	return result, nil
 }
 }
 
 
+// recordChanges updates the session-local changes()/total_changes() state after
+// a successful INSERT/UPDATE/DELETE. total_changes() is a monotonic counter of
+// every completed DML statement (SQLite semantics): it is incremented even when
+// the change is later undone by ROLLBACK or ROLLBACK TO, and is never decremented.
+func (e *Executor) recordChanges(affected int64) {
+	e.changes = affected
+	e.totalChanges += affected
+}
+
+// runDMLAtomic runs apply, staging the DML in an implicit transaction when the
+// target table carries a UNIQUE index and no explicit transaction is open, so a
+// statement that fails partway (e.g. a multi-row UPDATE/INSERT that hits a unique
+// violation after earlier rows staged) leaves no durable effects — matching
+// SQLite's statement-level atomicity. It commits on success and rolls back on any
+// error. When already in an explicit transaction, apply runs directly and the
+// surrounding COMMIT/ROLLBACK governs durability.
+func (e *Executor) runDMLAtomic(tableName string, apply func() error) error {
+	if e.inTransaction {
+		return apply()
+	}
+	uniq, err := e.table.HasUniqueIndex(tableName)
+	if err != nil {
+		return err
+	}
+	if !uniq {
+		return apply()
+	}
+	if err := e.session.Begin(); err != nil {
+		return err
+	}
+	if err := apply(); err != nil {
+		_ = e.session.Rollback()
+		return err
+	}
+	return e.session.Commit()
+}
+
 func containsFold(values []string, target string) bool {
 func containsFold(values []string, target string) bool {
 	for _, value := range values {
 	for _, value := range values {
 		if strings.EqualFold(value, target) {
 		if strings.EqualFold(value, target) {
@@ -2319,6 +2486,80 @@ func containsFold(values []string, target string) bool {
 	return false
 	return false
 }
 }
 
 
+// primeSubqueries pre-executes non-correlated subqueries referenced by a
+// statement expression and stores their results in the per-query subquery cache.
+// It runs before the storage session acquires its lock for a scan-based
+// UPDATE/DELETE. Without this, evaluating a predicate such as
+// "WHERE id IN (SELECT ...)" invokes the session recursively while the session
+// lock is held, which deadlocks. The cache is only consulted when there is no
+// outer row (see evalInExpr), so priming is limited to that same non-correlated
+// case.
+func (e *Executor) primeSubqueries(expr parser.Expr) {
+	if expr == nil || e.subqueryCache == nil || e.outerRow != nil {
+		return
+	}
+	prime := func(sub *parser.SelectStmt) {
+		if sub == nil {
+			return
+		}
+		if _, ok := e.subqueryCache[sub]; ok {
+			return
+		}
+		if result, err := e.executeSelect(sub); err == nil {
+			e.subqueryCache[sub] = result
+		}
+	}
+	var walk func(parser.Expr)
+	walk = func(x parser.Expr) {
+		if x == nil {
+			return
+		}
+		switch n := x.(type) {
+		case *parser.InExpr:
+			prime(n.Subquery)
+			walk(n.Left)
+			for _, v := range n.Values {
+				walk(v)
+			}
+		case *parser.SubqueryExpr:
+			prime(n.Query)
+		case *parser.ExistsExpr:
+			prime(n.Subquery)
+		case *parser.BinaryExpr:
+			walk(n.Left)
+			walk(n.Right)
+		case *parser.UnaryExpr:
+			walk(n.Operand)
+		case *parser.BetweenExpr:
+			walk(n.Left)
+			walk(n.Low)
+			walk(n.High)
+		case *parser.LikeExpr:
+			walk(n.Left)
+			walk(n.Pattern)
+			walk(n.Escape)
+		case *parser.IsNullExpr:
+			walk(n.Left)
+		case *parser.CaseExpr:
+			walk(n.Operand)
+			for _, w := range n.Whens {
+				walk(w.Condition)
+				walk(w.Result)
+			}
+			walk(n.Else)
+		case *parser.FunctionCall:
+			for _, a := range n.Args {
+				walk(a)
+			}
+		case *parser.ParenExpr:
+			walk(n.Expr)
+		case *parser.CastExpr:
+			walk(n.Expr)
+		}
+	}
+	walk(expr)
+}
+
 // executeUpdate executes an UPDATE statement.
 // executeUpdate executes an UPDATE statement.
 func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 	tableName := stmt.Table.Name
 	tableName := stmt.Table.Name
@@ -2371,17 +2612,34 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 			}
 			}
 			result := NewResult("UPDATE")
 			result := NewResult("UPDATE")
 			result.SetRowCount(count)
 			result.SetRowCount(count)
+			e.recordChanges(int64(count))
 			return result, nil
 			return result, nil
 		}
 		}
 	}
 	}
 
 
-	count, err := e.session.UpdateFunc(tableName, updateFn, filter)
+	// Pre-evaluate subqueries in the predicate and assignments before the
+	// scan-based update takes the session lock.
+	e.primeSubqueries(stmt.Where)
+	for _, assign := range stmt.Set {
+		e.primeSubqueries(assign.Value)
+	}
+
+	count := 0
+	err = e.runDMLAtomic(tableName, func() error {
+		n, err := e.session.UpdateFunc(tableName, updateFn, filter)
+		if err != nil {
+			return err
+		}
+		count = n
+		return nil
+	})
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
 	result := NewResult("UPDATE")
 	result := NewResult("UPDATE")
 	result.SetRowCount(count)
 	result.SetRowCount(count)
+	e.recordChanges(int64(count))
 	return result, nil
 	return result, nil
 }
 }
 
 
@@ -2417,10 +2675,15 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 			}
 			}
 			result := NewResult("DELETE")
 			result := NewResult("DELETE")
 			result.SetRowCount(count)
 			result.SetRowCount(count)
+			e.recordChanges(int64(count))
 			return result, nil
 			return result, nil
 		}
 		}
 	}
 	}
 
 
+	// Pre-evaluate subqueries in the predicate before the scan-based delete
+	// takes the session lock.
+	e.primeSubqueries(stmt.Where)
+
 	count, err := e.session.Delete(tableName, filter)
 	count, err := e.session.Delete(tableName, filter)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -2428,6 +2691,7 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 
 
 	result := NewResult("DELETE")
 	result := NewResult("DELETE")
 	result.SetRowCount(count)
 	result.SetRowCount(count)
+	e.recordChanges(int64(count))
 	return result, nil
 	return result, nil
 }
 }
 
 
@@ -2488,6 +2752,11 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 		}
 		}
 	}
 	}
 
 
+	// Translate inline/table UNIQUE constraints (Gogs/GORM style) into the same
+	// scan-validated unique Index metadata that explicit CREATE UNIQUE INDEX
+	// produces, so both enforcement and the catalog observe them.
+	uniqIndexes := e.uniqueConstraintIndexes(stmt)
+
 	if err := e.schema.CreateTable(schema); err != nil {
 	if err := e.schema.CreateTable(schema); err != nil {
 		if stmt.IfNotExists && strings.Contains(err.Error(), "table already exists") {
 		if stmt.IfNotExists && strings.Contains(err.Error(), "table already exists") {
 			return NewResult("CREATE TABLE"), nil
 			return NewResult("CREATE TABLE"), nil
@@ -2495,6 +2764,20 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	// Register each materialized unique index. On failure, best-effort cleanup of
+	// the table and any indexes already created for it (DDL is not transactional).
+	var created []*storage.Index
+	for _, idx := range uniqIndexes {
+		if err := e.schema.CreateIndex(idx); err != nil {
+			for _, c := range created {
+				e.schema.DropIndex(c.Name)
+			}
+			e.schema.DropTable(stmt.Table.Name)
+			return nil, err
+		}
+		created = append(created, idx)
+	}
+
 	if err := e.SyncCatalog(); err != nil {
 	if err := e.SyncCatalog(); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -2503,6 +2786,49 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 	return result, nil
 	return result, nil
 }
 }
 
 
+// uniqueConstraintIndexes translates a CREATE TABLE's inline/table UNIQUE
+// constraints into unique Index definitions. Named constraints keep their name;
+// unnamed constraints (column-level UNIQUE, or bare UNIQUE(col,...)) receive a
+// deterministic internal name derived from the table and columns.
+func (e *Executor) uniqueConstraintIndexes(stmt *parser.CreateTableStmt) []*storage.Index {
+	var indexes []*storage.Index
+	used := make(map[string]bool)
+
+	uniqueName := func(base string, columns []string) string {
+		if base == "" {
+			base = "uniq_" + strings.ToLower(stmt.Table.Name) + "_" + strings.ToLower(strings.Join(columns, "_"))
+		}
+		name := base
+		for n := 2; used[strings.ToLower(name)]; n++ {
+			name = fmt.Sprintf("%s_%d", base, n)
+		}
+		used[strings.ToLower(name)] = true
+		return name
+	}
+
+	add := func(name string, columns []string) {
+		idx := &storage.Index{Name: uniqueName(name, columns), Table: stmt.Table.Name, Unique: true}
+		for _, c := range columns {
+			idx.Columns = append(idx.Columns, storage.IndexColumn{Name: c})
+		}
+		indexes = append(indexes, idx)
+	}
+
+	for _, constraint := range stmt.Constraints {
+		if constraint.Type == parser.ConstraintUnique && len(constraint.Columns) > 0 {
+			add(constraint.Name, constraint.Columns)
+		}
+	}
+	for _, colDef := range stmt.Columns {
+		for _, constraint := range colDef.Constraints {
+			if constraint.Type == parser.ConstraintUnique {
+				add("", []string{colDef.Name})
+			}
+		}
+	}
+	return indexes
+}
+
 // executeDropTable executes a DROP TABLE statement.
 // executeDropTable executes a DROP TABLE statement.
 func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error) {
 func (e *Executor) executeDropTable(stmt *parser.DropTableStmt) (*Result, error) {
 	for _, tableRef := range stmt.Tables {
 	for _, tableRef := range stmt.Tables {
@@ -2583,11 +2909,23 @@ func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, er
 		})
 		})
 	}
 	}
 
 
-	if err := e.schema.CreateIndex(index); err != nil {
-		if stmt.IfNotExists && strings.Contains(err.Error(), "index already exists") {
-			return NewResult("CREATE INDEX"), nil
+	if stmt.Unique {
+		// Validate existing rows and register the index under the table's
+		// exclusive gate so no concurrent writer can insert a conflicting value
+		// between validation and registration.
+		if err := e.table.CreateUniqueIndex(index); err != nil {
+			if stmt.IfNotExists && strings.Contains(err.Error(), "index already exists") {
+				return NewResult("CREATE INDEX"), nil
+			}
+			return nil, err
+		}
+	} else {
+		if err := e.schema.CreateIndex(index); err != nil {
+			if stmt.IfNotExists && strings.Contains(err.Error(), "index already exists") {
+				return NewResult("CREATE INDEX"), nil
+			}
+			return nil, err
 		}
 		}
-		return nil, err
 	}
 	}
 
 
 	// Build index entries for existing rows
 	// Build index entries for existing rows
@@ -2869,6 +3207,8 @@ func (e *Executor) executeRollback(stmt *parser.RollbackStmt) (*Result, error) {
 	e.inTransaction = false
 	e.inTransaction = false
 	e.savepoints = nil
 	e.savepoints = nil
 	e.savepointPositions = nil
 	e.savepointPositions = nil
+	// total_changes() is monotonic (SQLite semantics): it is NOT decremented on
+	// rollback. last_insert_rowid() and changes() also intentionally hold.
 
 
 	result := NewResult("ROLLBACK")
 	result := NewResult("ROLLBACK")
 	return result, nil
 	return result, nil
@@ -3340,14 +3680,6 @@ func (e *Executor) evalColumnRef(ref *parser.ColumnRef, row storage.Row) (interf
 		return nil, fmt.Errorf("no row context for column: %s", ref.Column)
 		return nil, fmt.Errorf("no row context for column: %s", ref.Column)
 	}
 	}
 
 
-	// Check for ROWID aliases (rowid, oid, _rowid_)
-	if storage.IsRowIDColumn(ref.Column) {
-		if val, ok := row["_rowid_"]; ok {
-			return val, nil
-		}
-		return nil, nil
-	}
-
 	// For qualified column references (table.column):
 	// For qualified column references (table.column):
 	//
 	//
 	// Resolution order:
 	// Resolution order:
@@ -3421,6 +3753,16 @@ func (e *Executor) evalColumnRef(ref *parser.ColumnRef, row storage.Row) (interf
 		}
 		}
 	}
 	}
 
 
+	// Hidden rowid alias (rowid/oid/_rowid_): only used when the row carries no
+	// real column of that name. SQLite semantics give an explicit column named
+	// oid/rowid/_rowid_ precedence over the hidden rowid alias, so this fallback
+	// runs last.
+	if storage.IsRowIDColumn(ref.Column) {
+		if val, ok := row["_rowid_"]; ok {
+			return val, nil
+		}
+	}
+
 	return nil, nil // Column not found, return NULL
 	return nil, nil // Column not found, return NULL
 }
 }
 
 
@@ -3990,6 +4332,12 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 		return evalTimediffFunc(args)
 		return evalTimediffFunc(args)
 	case "PIZZASQL_VERSION", "SQLITE_VERSION":
 	case "PIZZASQL_VERSION", "SQLITE_VERSION":
 		return version.String(), nil
 		return version.String(), nil
+	case "LAST_INSERT_ROWID":
+		return e.lastInsertRowID, nil
+	case "CHANGES":
+		return e.changes, nil
+	case "TOTAL_CHANGES":
+		return e.totalChanges, nil
 	}
 	}
 
 
 	return nil, nil
 	return nil, nil

+ 60 - 0
pkg/executor/org_dashboard_test.go

@@ -0,0 +1,60 @@
+package executor
+
+import "testing"
+
+// TestGogsOrganizationDashboardJoin reproduces the exact SQL Gogs emits for the
+// dashboard "switch context" organization list.
+func TestGogsOrganizationDashboardJoin(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, "CREATE TABLE `user` (id INTEGER PRIMARY KEY, lower_name TEXT, name TEXT, type INTEGER)")
+	execMust(t, e, "CREATE TABLE org_user (id INTEGER PRIMARY KEY, uid INTEGER, org_id INTEGER, is_public INTEGER, is_owner INTEGER, num_teams INTEGER)")
+	execMust(t, e, "INSERT INTO `user` VALUES (1, 'danfragoso', 'danfragoso', 0)")
+	execMust(t, e, "INSERT INTO `user` VALUES (2, 'database.pizza', 'database.pizza', 1)")
+	execMust(t, e, "INSERT INTO org_user VALUES (1, 1, 2, 0, 1, 1)")
+
+	t.Run("gorm_raw_select_star", func(t *testing.T) {
+		res := execMust(t, e, "SELECT * FROM `user` JOIN org_user ON org_user.org_id = user.id WHERE org_user.uid = 1 ORDER BY user.id ASC")
+		t.Logf("columns=%v rows=%d", res.Columns, res.RowCount)
+		if res.RowCount != 1 {
+			t.Fatalf("expected 1 row, got %d", res.RowCount)
+		}
+	})
+
+	t.Run("qualified_user_star", func(t *testing.T) {
+		res := execMust(t, e, "SELECT user.* FROM user JOIN org_user ON org_user.org_id = user.id WHERE org_user.uid = 1 ORDER BY user.id ASC")
+		t.Logf("columns=%v rows=%d", res.Columns, res.RowCount)
+		if res.RowCount != 1 {
+			t.Fatalf("expected 1 row, got %d", res.RowCount)
+		}
+	})
+
+	t.Run("quoted_user_join", func(t *testing.T) {
+		res := execMust(t, e, "SELECT * FROM `user` JOIN org_user ON org_user.org_id = `user`.id WHERE org_user.uid = 1 ORDER BY `user`.id ASC")
+		t.Logf("columns=%v rows=%d", res.Columns, res.RowCount)
+		if res.RowCount != 1 {
+			t.Fatalf("expected 1 row, got %d", res.RowCount)
+		}
+	})
+
+	t.Run("left_join_where_on_joined_table", func(t *testing.T) {
+		res := execMust(t, e, "SELECT user.* FROM user LEFT JOIN org_user ON org_user.org_id = user.id WHERE org_user.uid = 1")
+		if res.RowCount != 1 {
+			t.Fatalf("expected 1 row, got %d", res.RowCount)
+		}
+		if res.Rows[0][1] != "database.pizza" {
+			t.Fatalf("expected organization row, got %v", res.Rows[0])
+		}
+	})
+
+	t.Run("left_join_where_on_left_table_still_works", func(t *testing.T) {
+		res := execMust(t, e, "SELECT user.* FROM user LEFT JOIN org_user ON org_user.org_id = user.id WHERE user.id = 1")
+		if res.RowCount != 1 {
+			t.Fatalf("expected 1 row, got %d", res.RowCount)
+		}
+		if res.Rows[0][1] != "danfragoso" {
+			t.Fatalf("expected individual row, got %v", res.Rows[0])
+		}
+	})
+}

+ 153 - 0
pkg/executor/qualified_wildcard_test.go

@@ -0,0 +1,153 @@
+package executor
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"reflect"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// startPizzaKV launches a local PizzaKV for a test and returns a pool connected
+// to it plus a cleanup func. Skips when PIZZAKV_BIN is not set.
+func startPizzaKV(t *testing.T) (*storage.KVPool, func()) {
+	t.Helper()
+	binary := os.Getenv("PIZZAKV_BIN")
+	if binary == "" {
+		t.Skip("PIZZAKV_BIN is not set")
+	}
+
+	dir := t.TempDir()
+	socket := filepath.Join(dir, "kv.sock")
+	database := filepath.Join(dir, "test.pkvdb")
+
+	cmd := exec.Command(binary, "-unix="+socket, "-path="+database)
+	cmd.Stdout = os.Stderr
+	cmd.Stderr = os.Stderr
+	if err := cmd.Start(); err != nil {
+		t.Fatalf("start PizzaKV: %v", err)
+	}
+	var once sync.Once
+	stop := func() {
+		once.Do(func() {
+			_ = cmd.Process.Kill()
+			_ = cmd.Wait()
+		})
+	}
+	t.Cleanup(stop)
+
+	addr := "unix:" + socket
+	deadline := time.Now().Add(10 * time.Second)
+	for time.Now().Before(deadline) {
+		pool, err := storage.NewKVPool(addr, 2, 5*time.Second)
+		if err == nil {
+			return pool, stop
+		}
+		time.Sleep(20 * time.Millisecond)
+	}
+	t.Fatal("PizzaKV did not become ready")
+	return nil, nil
+}
+
+func TestQualifiedWildcardProjection(t *testing.T) {
+	pool, stop := startPizzaKV(t)
+	if pool == nil {
+		return
+	}
+	defer stop()
+	defer pool.Close()
+
+	schema := storage.NewSchemaManager(pool, "gogs")
+	table := storage.NewTableManager(pool, schema, "gogs")
+	exec := New(schema, table)
+
+	mustExec := func(sql string) *Result {
+		t.Helper()
+		res, err := execSQL(exec, sql)
+		if err != nil {
+			t.Fatalf("exec %q: %v", sql, err)
+		}
+		return res
+	}
+
+	mustExec(`CREATE TABLE repository (id INTEGER PRIMARY KEY, owner_id INTEGER, name TEXT)`)
+	mustExec(`CREATE TABLE access (id INTEGER PRIMARY KEY, user_id INTEGER, repo_id INTEGER, mode INTEGER)`)
+
+	mustExec(`INSERT INTO repository VALUES (1, 100, 'gogs')`)
+	mustExec(`INSERT INTO repository VALUES (2, 100, 'pizza')`)
+	mustExec(`INSERT INTO repository VALUES (3, 200, 'shared')`)
+
+	mustExec(`INSERT INTO access VALUES (1, 1, 1, 2)`)
+	mustExec(`INSERT INTO access VALUES (2, 1, 2, 1)`)
+	mustExec(`INSERT INTO access VALUES (3, 2, 1, 1)`)
+
+	t.Run("single_table_alias", func(t *testing.T) {
+		res := mustExec(`SELECT repo.* FROM repository AS repo ORDER BY repo.id`)
+		wantCols := []string{"id", "owner_id", "name"}
+		if !reflect.DeepEqual(res.Columns, wantCols) {
+			t.Fatalf("columns = %v, want %v", res.Columns, wantCols)
+		}
+		if res.RowCount != 3 {
+			t.Fatalf("row count = %d, want 3", res.RowCount)
+		}
+		if res.Rows[0][0] != int64(1) || res.Rows[0][1] != int64(100) || res.Rows[0][2] != "gogs" {
+			t.Fatalf("row 0 = %v", res.Rows[0])
+		}
+	})
+
+	t.Run("distinct_left_join_only_repo", func(t *testing.T) {
+		// The Gogs SearchRepositoryByName shape: qualified wildcard on the left
+		// table of a LEFT JOIN must not leak joined-table columns.
+		res := mustExec(`SELECT DISTINCT repo.* FROM repository AS repo LEFT JOIN access ON access.repo_id = repo.id WHERE repo.owner_id = 100 ORDER BY repo.id`)
+		wantCols := []string{"id", "owner_id", "name"}
+		if !reflect.DeepEqual(res.Columns, wantCols) {
+			t.Fatalf("columns = %v, want %v", res.Columns, wantCols)
+		}
+		if res.RowCount != 2 {
+			t.Fatalf("row count = %d, want 2 (DISTINCT dedupe)", res.RowCount)
+		}
+		if res.Rows[0][0] != int64(1) || res.Rows[0][2] != "gogs" {
+			t.Fatalf("row 0 = %v", res.Rows[0])
+		}
+		if res.Rows[1][0] != int64(2) || res.Rows[1][2] != "pizza" {
+			t.Fatalf("row 1 = %v", res.Rows[1])
+		}
+	})
+
+	t.Run("mixed_wildcard_and_qualified_column", func(t *testing.T) {
+		res := mustExec(`SELECT repo.*, access.mode FROM repository AS repo LEFT JOIN access ON access.repo_id = repo.id WHERE repo.id = 1 ORDER BY access.mode`)
+		wantCols := []string{"id", "owner_id", "name", "mode"}
+		if !reflect.DeepEqual(res.Columns, wantCols) {
+			t.Fatalf("columns = %v, want %v", res.Columns, wantCols)
+		}
+		if res.RowCount != 2 {
+			t.Fatalf("row count = %d, want 2", res.RowCount)
+		}
+		if res.Rows[0][3] != int64(1) || res.Rows[1][3] != int64(2) {
+			t.Fatalf("modes = %v, want [1 2]", [][]interface{}{res.Rows[0], res.Rows[1]})
+		}
+	})
+
+	t.Run("unknown_qualifier_errors", func(t *testing.T) {
+		_, err := execSQL(exec, `SELECT nope.* FROM repository AS repo`)
+		if err == nil {
+			t.Fatal("expected error for unknown wildcard qualifier, got nil")
+		}
+	})
+
+	t.Run("unaliased_table_wildcard", func(t *testing.T) {
+		res := mustExec(`SELECT repository.* FROM repository ORDER BY id`)
+		wantCols := []string{"id", "owner_id", "name"}
+		if !reflect.DeepEqual(res.Columns, wantCols) {
+			t.Fatalf("columns = %v, want %v", res.Columns, wantCols)
+		}
+		wantTypes := []string{"INTEGER", "INTEGER", "TEXT"}
+		if !reflect.DeepEqual(res.ColumnTypes, wantTypes) {
+			t.Fatalf("column types = %v, want %v", res.ColumnTypes, wantTypes)
+		}
+	})
+}

+ 54 - 0
pkg/executor/result_types.go

@@ -0,0 +1,54 @@
+package executor
+
+import (
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// selectColumnTypes computes the result column types for a single-table SELECT
+// projection, parallel to the column names produced by the normal projection
+// expansion. Direct column references (including aliases) and SELECT * resolve
+// to the declared schema type; any expression whose type the engine does not
+// know is reported as TEXT. Types are derived from schema metadata rather than
+// row values so they remain correct even when the result set is empty.
+func selectColumnTypes(stmt *parser.SelectStmt, schema *storage.Schema) []string {
+	types := make([]string, 0, len(stmt.Columns))
+	for _, col := range stmt.Columns {
+		if col.Star {
+			for _, c := range schema.Columns {
+				types = append(types, c.Type)
+			}
+			continue
+		}
+		if col.TableStar != "" {
+			// Single-table qualified wildcard: expand the table's columns. The
+			// analyzer already validated the qualifier, and for this path the
+			// FROM clause holds exactly one table.
+			for _, c := range schema.Columns {
+				types = append(types, c.Type)
+			}
+			continue
+		}
+		types = append(types, projectionColumnType(col, schema))
+	}
+	return types
+}
+
+// projectionColumnType resolves the type of a single projected expression. A
+// direct column reference resolves to the declared schema type (matched
+// case-insensitively and ignoring any table qualifier); anything else is
+// unknown and reported as TEXT.
+func projectionColumnType(col parser.SelectColumn, schema *storage.Schema) string {
+	ref, ok := col.Expr.(*parser.ColumnRef)
+	if !ok {
+		return "TEXT"
+	}
+	for _, c := range schema.Columns {
+		if strings.EqualFold(c.Name, ref.Column) {
+			return c.Type
+		}
+	}
+	return "TEXT"
+}

+ 90 - 0
pkg/executor/result_types_test.go

@@ -0,0 +1,90 @@
+package executor
+
+import "testing"
+
+func TestSelectColumnTypesFromSchema(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE users (
+		id BIGINT PRIMARY KEY,
+		name TEXT NOT NULL,
+		created_at TIMESTAMP NULL,
+		score DOUBLE
+	)`)
+
+	res := execMust(t, e, "SELECT id, name, created_at, score FROM users")
+	if len(res.ColumnTypes) != 4 {
+		t.Fatalf("got %d column types, want 4", len(res.ColumnTypes))
+	}
+	want := []string{"BIGINT", "TEXT", "TIMESTAMP", "DOUBLE"}
+	for i, w := range want {
+		if res.ColumnTypes[i] != w {
+			t.Errorf("column %d type = %q, want %q", i, res.ColumnTypes[i], w)
+		}
+	}
+}
+
+func TestSelectColumnTypesStar(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)")
+	res := execMust(t, e, "SELECT * FROM t")
+	if len(res.ColumnTypes) != 2 {
+		t.Fatalf("got %d column types, want 2", len(res.ColumnTypes))
+	}
+	if res.ColumnTypes[0] != "INTEGER" || res.ColumnTypes[1] != "TEXT" {
+		t.Fatalf("unexpected column types %v", res.ColumnTypes)
+	}
+}
+
+func TestSelectColumnTypesAlias(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id BIGINT PRIMARY KEY)")
+	res := execMust(t, e, "SELECT id AS ident FROM t")
+	if len(res.ColumnTypes) != 1 || res.ColumnTypes[0] != "BIGINT" {
+		t.Fatalf("unexpected column types %v", res.ColumnTypes)
+	}
+}
+
+func TestSelectColumnTypesUnknownExpression(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
+	res := execMust(t, e, "SELECT id + 1, id * 2 FROM t")
+	if len(res.ColumnTypes) != 2 || res.ColumnTypes[0] != "TEXT" || res.ColumnTypes[1] != "TEXT" {
+		t.Fatalf("unexpected column types %v", res.ColumnTypes)
+	}
+}
+
+func TestSelectColumnTypesEmptyResult(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id BIGINT PRIMARY KEY, created_at DATETIME)")
+	execMust(t, e, "INSERT INTO t VALUES (1, '2024-01-01T00:00:00Z')")
+	res := execMust(t, e, "SELECT id, created_at FROM t WHERE id = 999")
+	if res.RowCount != 0 {
+		t.Fatalf("expected empty result, got %d rows", res.RowCount)
+	}
+	if len(res.ColumnTypes) != 2 || res.ColumnTypes[0] != "BIGINT" || res.ColumnTypes[1] != "DATETIME" {
+		t.Fatalf("empty result types = %v, want [BIGINT DATETIME]", res.ColumnTypes)
+	}
+}
+
+func TestUUIDColumnRoundTrip(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE upload (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, uuid UUID NULL, name TEXT NULL)")
+	execMust(t, e, "INSERT INTO upload (uuid, name) VALUES ('abc-123', 'x')")
+
+	res := execMust(t, e, "SELECT uuid, name FROM upload")
+	if res.RowCount != 1 {
+		t.Fatalf("expected 1 row, got %d", res.RowCount)
+	}
+	if res.Rows[0][0] != "abc-123" {
+		t.Fatalf("uuid value = %v, want %q", res.Rows[0][0], "abc-123")
+	}
+	if len(res.ColumnTypes) != 2 || res.ColumnTypes[0] != "UUID" || res.ColumnTypes[1] != "TEXT" {
+		t.Fatalf("column types = %v, want [UUID TEXT]", res.ColumnTypes)
+	}
+}

+ 59 - 0
pkg/executor/rowid_precedence_test.go

@@ -0,0 +1,59 @@
+package executor
+
+import "testing"
+
+func TestRowIDAliasPrecedenceRealOidColumn(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	// id is the implicit integer PK (the rowid); oid is a real TEXT column.
+	execMust(t, e, "CREATE TABLE lfs_object (id INTEGER PRIMARY KEY, oid TEXT, size INTEGER)")
+	execMust(t, e, "INSERT INTO lfs_object VALUES (1, 'ef79c8f0', 1234)")
+
+	// Projection: oid must be the real string, not the hidden rowid.
+	res := execMust(t, e, "SELECT oid FROM lfs_object")
+	if res.Rows[0][0] != "ef79c8f0" {
+		t.Fatalf("SELECT oid = %v (%T), want the real string", res.Rows[0][0], res.Rows[0][0])
+	}
+
+	// Predicate: WHERE oid = '...' must match the real string column.
+	res = execMust(t, e, "SELECT id FROM lfs_object WHERE oid = 'ef79c8f0'")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) {
+		t.Fatalf("WHERE oid filter wrong: %v", res.Rows)
+	}
+
+	// SELECT * returns the real oid value, not the rowid in its place.
+	res = execMust(t, e, "SELECT * FROM lfs_object")
+	if len(res.Rows[0]) != 3 || res.Rows[0][1] != "ef79c8f0" {
+		t.Fatalf("SELECT * row = %v, want [1 ef79c8f0 1234]", res.Rows[0])
+	}
+}
+
+func TestRowIDAliasPrecedenceRealRowidColumn(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, rowid TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (5, 'custom-rowid')")
+
+	res := execMust(t, e, "SELECT rowid FROM t WHERE id = 5")
+	if res.Rows[0][0] != "custom-rowid" {
+		t.Fatalf("SELECT rowid = %v, want 'custom-rowid' (real column)", res.Rows[0][0])
+	}
+}
+
+func TestRowIDAliasWhenNoRealColumn(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a')")
+	execMust(t, e, "INSERT INTO t VALUES (2, 'b')")
+
+	// Without a real oid/rowid column, the aliases fall back to the hidden rowid.
+	res := execMust(t, e, "SELECT rowid FROM t WHERE id = 2")
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("SELECT rowid (hidden alias) = %v, want 2", res.Rows[0][0])
+	}
+	res = execMust(t, e, "SELECT oid FROM t WHERE id = 1")
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("SELECT oid (hidden alias) = %v, want 1", res.Rows[0][0])
+	}
+}

+ 263 - 0
pkg/executor/session_state_test.go

@@ -0,0 +1,263 @@
+package executor
+
+import (
+	"strings"
+	"sync"
+	"testing"
+	"time"
+)
+
+func TestResultLastInsertIDActualRowID(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+
+	res := execMust(t, e, "INSERT INTO t (name) VALUES ('a')")
+	if res.LastInsertID != 1 {
+		t.Fatalf("first LastInsertID = %d, want 1", res.LastInsertID)
+	}
+	res = execMust(t, e, "INSERT INTO t (name) VALUES ('b')")
+	if res.LastInsertID != 2 {
+		t.Fatalf("second LastInsertID = %d, want 2", res.LastInsertID)
+	}
+
+	// Never MAX: an explicit high id advances the counter, not a table scan MAX.
+	execMust(t, e, "INSERT INTO t (id, name) VALUES (100, 'high')")
+	res = execMust(t, e, "INSERT INTO t (name) VALUES ('c')")
+	if res.LastInsertID != 101 {
+		t.Fatalf("LastInsertID after explicit 100 = %d, want 101", res.LastInsertID)
+	}
+}
+
+func TestLastInsertRowIDFunction(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+
+	if res := execMust(t, e, "SELECT last_insert_rowid()"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("initial last_insert_rowid = %v, want 0", res.Rows[0][0])
+	}
+	execMust(t, e, "INSERT INTO t (name) VALUES ('a')")
+	if res := execMust(t, e, "SELECT last_insert_rowid()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("last_insert_rowid after insert = %v, want 1", res.Rows[0][0])
+	}
+}
+
+func TestChangesAndTotalChangesFunctions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+
+	execMust(t, e, "INSERT INTO t VALUES (1)")
+	execMust(t, e, "INSERT INTO t VALUES (2)")
+	if res := execMust(t, e, "SELECT changes()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("changes() after single insert = %v, want 1", res.Rows[0][0])
+	}
+	if res := execMust(t, e, "SELECT total_changes()"); res.Rows[0][0] != int64(2) {
+		t.Fatalf("total_changes() = %v, want 2", res.Rows[0][0])
+	}
+
+	execMust(t, e, "UPDATE t SET id = id WHERE id = 1")
+	if res := execMust(t, e, "SELECT changes()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("changes() after update = %v, want 1", res.Rows[0][0])
+	}
+	execMust(t, e, "DELETE FROM t")
+	if res := execMust(t, e, "SELECT changes()"); res.Rows[0][0] != int64(2) {
+		t.Fatalf("changes() after delete = %v, want 2", res.Rows[0][0])
+	}
+	if res := execMust(t, e, "SELECT total_changes()"); res.Rows[0][0] != int64(5) {
+		t.Fatalf("total_changes() = %v, want 5", res.Rows[0][0])
+	}
+}
+
+func TestLastInsertRowIDHoldsAcrossRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO t (name) VALUES ('x')")
+	execMust(t, e, "ROLLBACK")
+
+	if res := execMust(t, e, "SELECT last_insert_rowid()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("last_insert_rowid() after rollback = %v, want 1 (SQLite holds it)", res.Rows[0][0])
+	}
+	// total_changes() is monotonic: the rolled-back insert still counts.
+	if res := execMust(t, e, "SELECT total_changes()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("total_changes() after rollback = %v, want 1 (not decremented)", res.Rows[0][0])
+	}
+	if res := execMust(t, e, "SELECT changes()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("changes() after rollback = %v, want 1 (reflects last DML)", res.Rows[0][0])
+	}
+}
+
+func TestTotalChangesMonotonicAcrossSavepointRollback(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO t (name) VALUES ('a')") // total=1
+	execMust(t, e, "SAVEPOINT sp1")
+	execMust(t, e, "INSERT INTO t (name) VALUES ('b')") // total=2
+	execMust(t, e, "INSERT INTO t (name) VALUES ('c')") // total=3
+	execMust(t, e, "ROLLBACK TO sp1")
+	if res := execMust(t, e, "SELECT total_changes()"); res.Rows[0][0] != int64(3) {
+		t.Fatalf("total_changes() after ROLLBACK TO = %v, want 3 (monotonic)", res.Rows[0][0])
+	}
+	execMust(t, e, "INSERT INTO t (name) VALUES ('d')") // total=4
+	execMust(t, e, "ROLLBACK")
+	if res := execMust(t, e, "SELECT total_changes()"); res.Rows[0][0] != int64(4) {
+		t.Fatalf("total_changes() after full rollback = %v, want 4 (monotonic)", res.Rows[0][0])
+	}
+	// changes() reflects the most recent DML statement (INSERT 'd' = 1 row).
+	if res := execMust(t, e, "SELECT changes()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("changes() after rollback = %v, want 1", res.Rows[0][0])
+	}
+	// The entire transaction was rolled back, so no rows survive; total_changes
+	// nevertheless still counts every completed DML statement.
+	if res := execMust(t, e, "SELECT count(*) FROM t"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("surviving rows = %v, want 0 (whole transaction rolled back)", res.Rows[0][0])
+	}
+}
+
+func TestLastInsertRowIDMultiRowAndInsertSelect(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+	execMust(t, e, "CREATE TABLE src (name TEXT)")
+
+	res := execMust(t, e, "INSERT INTO t (name) VALUES ('a'), ('b')")
+	if res.LastInsertID != 2 {
+		t.Fatalf("multi-row LastInsertID = %d, want 2 (last row)", res.LastInsertID)
+	}
+
+	execMust(t, e, "INSERT INTO src VALUES ('c'), ('d')")
+	res = execMust(t, e, "INSERT INTO t (name) SELECT name FROM src")
+	if res.LastInsertID != 4 {
+		t.Fatalf("insert-select LastInsertID = %d, want 4 (last row)", res.LastInsertID)
+	}
+}
+
+func TestSessionLocalStateIsolation(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e1 := newExec(schema, table)
+	e2 := newExec(schema, table)
+
+	execMust(t, e1, "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+
+	execMust(t, e1, "INSERT INTO t (name) VALUES ('a')")
+	execMust(t, e2, "INSERT INTO t (name) VALUES ('b')")
+
+	if res := execMust(t, e1, "SELECT last_insert_rowid()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("e1 last_insert_rowid = %v, want 1", res.Rows[0][0])
+	}
+	if res := execMust(t, e2, "SELECT last_insert_rowid()"); res.Rows[0][0] != int64(2) {
+		t.Fatalf("e2 last_insert_rowid = %v, want 2", res.Rows[0][0])
+	}
+	if res := execMust(t, e2, "SELECT total_changes()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("e2 total_changes = %v, want 1 (session-local)", res.Rows[0][0])
+	}
+	if res := execMust(t, e1, "SELECT total_changes()"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("e1 total_changes = %v, want 1 (INSERT only; DDL does not count)", res.Rows[0][0])
+	}
+}
+
+func TestConcurrentSessionRowIDs(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	execMust(t, newExec(schema, table), "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
+
+	const n = 32
+	rowIDs := make([]int64, n)
+	var wg sync.WaitGroup
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			e := newExec(schema, table)
+			res := execMust(t, e, "INSERT INTO t (name) VALUES ('x')")
+			rowIDs[i] = res.LastInsertID
+		}(i)
+	}
+	wg.Wait()
+
+	seen := make(map[int64]bool, n)
+	for _, id := range rowIDs {
+		if id == 0 {
+			t.Fatal("an insert returned LastInsertID 0")
+		}
+		if seen[id] {
+			t.Fatalf("duplicate generated rowid %d across concurrent sessions", id)
+		}
+		seen[id] = true
+	}
+}
+
+func TestUniqueIndexMultiRowUpdateAtomic(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, "CREATE UNIQUE INDEX uq_v ON t (v)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c')")
+
+	_, err := execSQL(e, "UPDATE t SET v = 'x'")
+	if err == nil {
+		t.Fatal("expected unique violation")
+	}
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	// No partial writes: the first row must not have been changed to 'x'.
+	if res := execMust(t, e, "SELECT v FROM t WHERE id = 1"); res.Rows[0][0] != "a" {
+		t.Fatalf("partial update applied: row 1 v = %v, want 'a'", res.Rows[0][0])
+	}
+}
+
+func TestUniqueIndexMultiRowInsertAtomic(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, "CREATE UNIQUE INDEX uq_v ON t (v)")
+
+	_, err := execSQL(e, "INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'a')")
+	if err == nil {
+		t.Fatal("expected unique violation on the third row")
+	}
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	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)
+	execMust(t, e, "CREATE TABLE watch (user_id INTEGER, repo_id INTEGER, PRIMARY KEY(user_id, repo_id))")
+	execMust(t, e, "CREATE TABLE repository (id INTEGER PRIMARY KEY, num_watches INTEGER)")
+	execMust(t, e, "INSERT INTO repository VALUES (1, 0)")
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "INSERT INTO watch VALUES (1, 1)")
+
+	// A scalar subquery in SET must not deadlock on the session lock while the
+	// update callback runs inside an explicit transaction.
+	done := make(chan error, 1)
+	go func() {
+		_, err := execSQL(e, "UPDATE repository SET num_watches = (SELECT COUNT(*) FROM watch WHERE repo_id = 1) WHERE id = 1")
+		done <- err
+	}()
+	select {
+	case err := <-done:
+		if err != nil {
+			t.Fatalf("update with scalar subquery: %v", err)
+		}
+	case <-time.After(10 * time.Second):
+		t.Fatal("UPDATE with scalar subquery deadlocked inside a transaction")
+	}
+
+	execMust(t, e, "COMMIT")
+	if res := execMust(t, e, "SELECT num_watches FROM repository WHERE id = 1"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("num_watches = %v, want 1", res.Rows[0][0])
+	}
+}

+ 380 - 0
pkg/executor/sqlite_catalog.go

@@ -0,0 +1,380 @@
+package executor
+
+import (
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// SQLite catalog-introspection support for xorm.io/xorm v0.8.0 and
+// github.com/glebarez/sqlite v1.11.0. These drivers read the virtual
+// sqlite_master / sqlite_schema table and the index_list / index_info /
+// table_xinfo pragmas. Rows are synthesized from the durable PizzaSQL schema;
+// nothing is persisted.
+var sqliteCatalogTables = map[string]bool{
+	"sqlite_master": true,
+	"sqlite_schema": true,
+}
+
+func sqliteCatalogSchema(tableName string) *storage.Schema {
+	return &storage.Schema{
+		Name: tableName,
+		Columns: []storage.Column{
+			{Name: "type", Type: "TEXT", Nullable: true},
+			{Name: "name", Type: "TEXT", Nullable: true},
+			{Name: "tbl_name", Type: "TEXT", Nullable: true},
+			{Name: "rootpage", Type: "INTEGER", Nullable: true},
+			{Name: "sql", Type: "TEXT", Nullable: true},
+		},
+	}
+}
+
+func isSQLiteCatalogTable(name string) bool {
+	return sqliteCatalogTables[strings.ToLower(name)]
+}
+
+// sqliteCatalogSelect answers a single-table SELECT against sqlite_master /
+// sqlite_schema. handled is false for any other shape so the caller falls
+// through to the normal SELECT path.
+func (e *Executor) sqliteCatalogSelect(stmt *parser.SelectStmt) (*Result, bool, error) {
+	if stmt == nil || stmt.Compound != nil || len(stmt.From) != 1 {
+		return nil, false, nil
+	}
+	ref := stmt.From[0]
+	if ref.Subquery != nil || ref.Join != nil || !isSQLiteCatalogTable(ref.Name) {
+		return nil, false, nil
+	}
+
+	rows, err := e.sqliteCatalogRows()
+	if err != nil {
+		return nil, true, err
+	}
+	if ref.Alias != "" {
+		for i := range rows {
+			rows[i] = e.addTableAlias(rows[i], ref.Alias)
+		}
+	}
+
+	result, err := e.executeSelectOnRows(stmt, rows, sqliteCatalogSchema(ref.Name))
+	if err != nil {
+		return nil, true, err
+	}
+	return result, true, nil
+}
+
+// sqliteCatalogRows materializes the sqlite_master row set: one "table" row per
+// user table (with recreated CREATE TABLE SQL) and one "index" row per index
+// (with recreated CREATE INDEX SQL), deterministically ordered.
+func (e *Executor) sqliteCatalogRows() ([]storage.Row, error) {
+	var rows []storage.Row
+
+	tables, err := e.schema.ListTables()
+	if err != nil {
+		return nil, err
+	}
+	tableNames := append([]string(nil), tables...)
+	sort.Strings(tableNames)
+	for _, name := range tableNames {
+		schema, err := e.schema.GetSchema(name)
+		if err != nil {
+			return nil, fmt.Errorf("sqlite_catalog: resolve table %q: %w", name, err)
+		}
+		rows = append(rows, storage.Row{
+			"type":     "table",
+			"name":     schema.Name,
+			"tbl_name": schema.Name,
+			"rootpage": int64(0),
+			"sql":      recreateCreateTableSQL(schema),
+		})
+	}
+
+	indexes, err := e.schema.ListIndexes()
+	if err != nil {
+		return nil, err
+	}
+	indexNames := append([]string(nil), indexes...)
+	sort.Strings(indexNames)
+	for _, name := range indexNames {
+		idx, err := e.schema.GetIndex(name)
+		if err != nil {
+			return nil, fmt.Errorf("sqlite_catalog: resolve index %q: %w", name, err)
+		}
+		rows = append(rows, storage.Row{
+			"type":     "index",
+			"name":     idx.Name,
+			"tbl_name": idx.Table,
+			"rootpage": int64(0),
+			"sql":      recreateCreateIndexSQL(idx),
+		})
+	}
+
+	return rows, nil
+}
+
+// executeSelectOnRows runs the shared SELECT tail (WHERE, GROUP BY, aggregates,
+// ORDER BY/LIMIT/OFFSET, projection, DISTINCT) over an in-memory row set.
+func (e *Executor) executeSelectOnRows(stmt *parser.SelectStmt, rows []storage.Row, schema *storage.Schema) (*Result, error) {
+	if stmt.Where != nil {
+		filtered := make([]storage.Row, 0, len(rows))
+		for _, row := range rows {
+			val, err := e.evalExpr(stmt.Where, row)
+			if err != nil {
+				return nil, err
+			}
+			if toBool(val) {
+				filtered = append(filtered, row)
+			}
+		}
+		rows = filtered
+	}
+
+	if len(stmt.GroupBy) > 0 {
+		return e.executeGroupBy(stmt, rows, schema)
+	}
+	if e.hasAggregates(stmt.Columns) {
+		return e.executeAggregateSelect(stmt, rows, schema)
+	}
+
+	rows = e.orderAndLimitRows(rows, stmt.OrderBy, stmt.Limit, stmt.Offset, stmt.Columns)
+
+	result := NewResult("SELECT")
+
+	for i, col := range stmt.Columns {
+		switch {
+		case col.Alias != "":
+			result.AddColumn(col.Alias)
+		case col.Star:
+			for _, c := range schema.Columns {
+				result.AddColumn(c.Name)
+			}
+		default:
+			if ref, ok := col.Expr.(*parser.ColumnRef); ok {
+				result.AddColumn(ref.Column)
+			} else {
+				result.AddColumn(fmt.Sprintf("column%d", i+1))
+			}
+		}
+	}
+
+	for _, row := range rows {
+		values := make([]interface{}, 0, len(stmt.Columns))
+		for _, col := range stmt.Columns {
+			if col.Star {
+				for _, c := range schema.Columns {
+					values = append(values, row[c.Name])
+				}
+			} else {
+				val, err := e.evalExpr(col.Expr, row)
+				if err != nil {
+					return nil, err
+				}
+				values = append(values, val)
+			}
+		}
+		result.AddRow(values...)
+	}
+
+	if stmt.Distinct {
+		result.Rows = e.applyDistinct(result.Rows)
+	}
+
+	return result, nil
+}
+
+// sqliteCatalogPragma answers the introspection pragmas (index_list, index_info,
+// table_xinfo). handled is false for everything else so the built-in handler runs.
+func (e *Executor) sqliteCatalogPragma(stmt *parser.PragmaStmt) (*Result, bool, error) {
+	if stmt == nil {
+		return nil, false, nil
+	}
+	switch strings.ToLower(stmt.Name) {
+	case "index_list", "index_info", "table_xinfo":
+		res, err := e.executeCatalogPragma(stmt)
+		return res, true, err
+	default:
+		return nil, false, nil
+	}
+}
+
+func (e *Executor) executeCatalogPragma(stmt *parser.PragmaStmt) (*Result, error) {
+	switch strings.ToLower(stmt.Name) {
+	case "index_list":
+		return e.pragmaIndexList(stmt.Arg)
+	case "index_info":
+		return e.pragmaIndexInfo(stmt.Arg)
+	case "table_xinfo":
+		return e.pragmaTableXInfo(stmt.Arg)
+	default:
+		return nil, fmt.Errorf("unknown pragma: %s", stmt.Name)
+	}
+}
+
+// pragmaIndexList returns PRAGMA index_list(table): seq, name, unique, origin,
+// partial. PizzaSQL only creates indexes via CREATE INDEX, so origin is "c".
+func (e *Executor) pragmaIndexList(table string) (*Result, error) {
+	if table == "" {
+		return nil, fmt.Errorf("index_list requires a table name")
+	}
+	indexes, err := e.schema.ListTableIndexes(table)
+	if err != nil {
+		return nil, err
+	}
+	sort.Slice(indexes, func(i, j int) bool { return indexes[i].Name < indexes[j].Name })
+
+	result := NewResult("PRAGMA")
+	for _, c := range []string{"seq", "name", "unique", "origin", "partial"} {
+		result.AddColumn(c)
+	}
+	for i, idx := range indexes {
+		unique := int64(0)
+		if idx.Unique {
+			unique = 1
+		}
+		result.AddRow(int64(i), idx.Name, unique, "c", int64(0))
+	}
+	return result, nil
+}
+
+// pragmaIndexInfo returns PRAGMA index_info(index): seqno, cid, name.
+func (e *Executor) pragmaIndexInfo(name string) (*Result, error) {
+	if name == "" {
+		return nil, fmt.Errorf("index_info requires an index name")
+	}
+	idx, err := e.schema.GetIndex(name)
+	if err != nil {
+		return nil, err
+	}
+	schema, err := e.schema.GetSchema(idx.Table)
+	if err != nil {
+		return nil, err
+	}
+
+	result := NewResult("PRAGMA")
+	for _, c := range []string{"seqno", "cid", "name"} {
+		result.AddColumn(c)
+	}
+	for seqno, ic := range idx.Columns {
+		cid := int64(-1)
+		for i, c := range schema.Columns {
+			if strings.EqualFold(c.Name, ic.Name) {
+				cid = int64(i)
+				break
+			}
+		}
+		result.AddRow(int64(seqno), cid, ic.Name)
+	}
+	return result, nil
+}
+
+// pragmaTableXInfo returns PRAGMA table_xinfo(table): table_info columns plus a
+// trailing hidden flag (always 0).
+func (e *Executor) pragmaTableXInfo(table string) (*Result, error) {
+	if table == "" {
+		return nil, fmt.Errorf("table_xinfo requires a table name")
+	}
+	schema, err := e.schema.GetSchema(table)
+	if err != nil {
+		return nil, err
+	}
+
+	result := NewResult("PRAGMA")
+	for _, c := range []string{"cid", "name", "type", "notnull", "dflt_value", "pk", "hidden"} {
+		result.AddColumn(c)
+	}
+	for i, col := range schema.Columns {
+		notnull := int64(0)
+		if !col.Nullable {
+			notnull = 1
+		}
+		pk := int64(0)
+		if col.PrimaryKey {
+			pk = 1
+		}
+		result.AddRow(int64(i), col.Name, col.Type, notnull, col.Default, pk, int64(0))
+	}
+	return result, nil
+}
+
+// recreateCreateTableSQL rebuilds CREATE TABLE from the durable schema. The
+// implicit _rowid_ (and its aliases) are stripped, and identifiers are quoted so
+// xorm's IsColumnExist / GORM's HasColumn LIKE patterns match.
+func recreateCreateTableSQL(s *storage.Schema) string {
+	cols := make([]string, 0, len(s.Columns))
+	for _, col := range s.Columns {
+		// Strip only the engine-injected hidden rowid column, never user
+		// columns that happen to be named oid/rowid/_rowid_.
+		if s.PrimaryKey == "_rowid_" && col.Name == "_rowid_" {
+			continue
+		}
+		cols = append(cols, recreateColumnDef(s, col))
+	}
+	return fmt.Sprintf("CREATE TABLE %s (%s)", quoteIdent(s.Name), strings.Join(cols, ", "))
+}
+
+func recreateColumnDef(s *storage.Schema, col storage.Column) string {
+	var b strings.Builder
+	b.WriteString(quoteIdent(col.Name))
+	b.WriteString(" ")
+	b.WriteString(col.Type)
+	if col.PrimaryKey {
+		b.WriteString(" PRIMARY KEY")
+	}
+	if col.PrimaryKey && s.AutoIncrement {
+		b.WriteString(" AUTOINCREMENT")
+	}
+	if !col.Nullable {
+		b.WriteString(" NOT NULL")
+	}
+	if col.Default != nil {
+		b.WriteString(" DEFAULT ")
+		b.WriteString(sqlLiteral(col.Default))
+	}
+	return b.String()
+}
+
+func recreateCreateIndexSQL(idx *storage.Index) string {
+	unique := ""
+	if idx.Unique {
+		unique = "UNIQUE "
+	}
+	cols := make([]string, 0, len(idx.Columns))
+	for _, c := range idx.Columns {
+		col := quoteIdent(c.Name)
+		if c.Desc {
+			col += " DESC"
+		}
+		cols = append(cols, col)
+	}
+	return fmt.Sprintf("CREATE %sINDEX %s ON %s (%s)", unique, quoteIdent(idx.Name), quoteIdent(idx.Table), strings.Join(cols, ", "))
+}
+
+// quoteIdent backtick-quotes an identifier, doubling embedded backticks.
+func quoteIdent(name string) string {
+	return "`" + strings.ReplaceAll(name, "`", "``") + "`"
+}
+
+func sqlLiteral(v interface{}) string {
+	switch t := v.(type) {
+	case nil:
+		return "NULL"
+	case string:
+		return "'" + strings.ReplaceAll(t, "'", "''") + "'"
+	case bool:
+		if t {
+			return "1"
+		}
+		return "0"
+	case int:
+		return strconv.Itoa(t)
+	case int64:
+		return strconv.FormatInt(t, 10)
+	case float64:
+		return strconv.FormatFloat(t, 'g', -1, 64)
+	default:
+		return "'" + strings.ReplaceAll(fmt.Sprintf("%v", v), "'", "''") + "'"
+	}
+}

+ 283 - 0
pkg/executor/sqlite_catalog_test.go

@@ -0,0 +1,283 @@
+package executor
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+func setupCatalogFixture(t *testing.T) *Executor {
+	t.Helper()
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT, age INTEGER DEFAULT 0)")
+	execMust(t, e, "CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
+	execMust(t, e, "CREATE UNIQUE INDEX uq_users_email ON users (email)")
+	execMust(t, e, "CREATE INDEX idx_users_name ON users (name)")
+	return e
+}
+
+func TestSQLiteCatalogListTables(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+	if len(res.Rows) != 2 {
+		t.Fatalf("expected 2 table rows, got %d: %v", len(res.Rows), res.Rows)
+	}
+	if res.Rows[0][0] != "posts" || res.Rows[1][0] != "users" {
+		t.Fatalf("unexpected table order: %v", res.Rows)
+	}
+}
+
+func TestSQLiteCatalogCountStar(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='users'")
+	if len(res.Rows) != 1 || res.Rows[0][0] != int64(1) {
+		t.Fatalf("expected count 1, got %v", res.Rows)
+	}
+	res = execMust(t, e, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='missing'")
+	if res.Rows[0][0] != int64(0) {
+		t.Fatalf("expected count 0, got %v", res.Rows)
+	}
+}
+
+func TestSQLiteCatalogTableSQL(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT sql FROM sqlite_master WHERE type='table' AND name='users'")
+	if len(res.Rows) != 1 {
+		t.Fatalf("expected 1 row, got %d", len(res.Rows))
+	}
+	sql, _ := res.Rows[0][0].(string)
+	for _, want := range []string{
+		"CREATE TABLE",
+		"`users`",
+		"`id` INTEGER PRIMARY KEY AUTOINCREMENT",
+		"`name` TEXT NOT NULL",
+		"`email` TEXT",
+		"`age` INTEGER DEFAULT 0",
+	} {
+		if !strings.Contains(sql, want) {
+			t.Fatalf("CREATE TABLE sql %q missing %q", sql, want)
+		}
+	}
+}
+
+func TestSQLiteCatalogIndexSQL(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='users' ORDER BY sql")
+	if len(res.Rows) != 2 {
+		t.Fatalf("expected 2 index rows, got %d: %v", len(res.Rows), res.Rows)
+	}
+	var hasUnique, hasPlain bool
+	for _, r := range res.Rows {
+		sql, _ := r[0].(string)
+		if strings.Contains(sql, "CREATE UNIQUE INDEX `uq_users_email` ON `users` (`email`)") {
+			hasUnique = true
+		}
+		if strings.Contains(sql, "CREATE INDEX `idx_users_name` ON `users` (`name`)") {
+			hasPlain = true
+		}
+	}
+	if !hasUnique || !hasPlain {
+		t.Fatalf("missing expected index SQL: %v", res.Rows)
+	}
+}
+
+func TestSQLiteCatalogSchemaAlias(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT count(*) FROM sqlite_schema WHERE type='table'")
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("sqlite_schema expected 2 tables, got %v", res.Rows)
+	}
+}
+
+func TestSQLiteCatalogSelectStar(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT * FROM sqlite_master WHERE name='users' AND type='table'")
+	if len(res.Columns) != 5 {
+		t.Fatalf("expected 5 columns, got %v", res.Columns)
+	}
+	if len(res.Rows) != 1 {
+		t.Fatalf("expected 1 row, got %d", len(res.Rows))
+	}
+	if res.Rows[0][0] != "table" || res.Rows[0][1] != "users" || res.Rows[0][2] != "users" {
+		t.Fatalf("unexpected row: %v", res.Rows)
+	}
+}
+
+func TestSQLiteCatalogInAndIsNotNull(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT sql FROM sqlite_master WHERE type IN ('table','index') AND tbl_name='users' AND sql IS NOT NULL")
+	if len(res.Rows) != 3 {
+		t.Fatalf("expected 3 rows, got %d", len(res.Rows))
+	}
+}
+
+func TestSQLiteCatalogLikeColumnExist(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "SELECT name FROM sqlite_master WHERE type='table' AND name='users' AND sql LIKE '%`email`%'")
+	if len(res.Rows) != 1 || res.Rows[0][0] != "users" {
+		t.Fatalf("expected users row, got %v", res.Rows)
+	}
+	res = execMust(t, e, "SELECT name FROM sqlite_master WHERE type='table' AND name='users' AND sql LIKE '%`nope`%'")
+	if len(res.Rows) != 0 {
+		t.Fatalf("expected no rows, got %v", res.Rows)
+	}
+}
+
+func TestSQLiteCatalogPragmaIndexList(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "PRAGMA index_list('users')")
+	if len(res.Rows) != 2 {
+		t.Fatalf("expected 2 indexes, got %d", len(res.Rows))
+	}
+	for _, row := range res.Rows {
+		if row[3] != "c" {
+			t.Fatalf("expected origin 'c', got %v", row[3])
+		}
+	}
+}
+
+func TestSQLiteCatalogPragmaIndexInfo(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "PRAGMA index_info('uq_users_email')")
+	if len(res.Rows) != 1 {
+		t.Fatalf("expected 1 column, got %d", len(res.Rows))
+	}
+	if res.Rows[0][2] != "email" {
+		t.Fatalf("expected email, got %v", res.Rows[0][2])
+	}
+}
+
+func TestSQLiteCatalogPragmaTableXInfo(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "PRAGMA table_xinfo('users')")
+	if len(res.Columns) != 7 {
+		t.Fatalf("expected 7 columns, got %v", res.Columns)
+	}
+	if len(res.Rows) != 4 {
+		t.Fatalf("expected 4 columns, got %d", len(res.Rows))
+	}
+	if res.Rows[0][1] != "id" || res.Rows[0][5] != int64(1) {
+		t.Fatalf("unexpected id row: %v", res.Rows[0])
+	}
+	if res.Rows[1][3] != int64(1) {
+		t.Fatalf("expected name notnull=1, got %v", res.Rows[1])
+	}
+}
+
+func TestSQLiteCatalogPragmaTableInfoStillDelegated(t *testing.T) {
+	e := setupCatalogFixture(t)
+	res := execMust(t, e, "PRAGMA table_info('users')")
+	if len(res.Columns) != 6 {
+		t.Fatalf("expected built-in table_info with 6 columns, got %v", res.Columns)
+	}
+}
+
+func TestRecreateCreateTableSQLQuotesIdentifiers(t *testing.T) {
+	s := &storage.Schema{
+		Name: "we`ird table",
+		Columns: []storage.Column{
+			{Name: "col`umn", Type: "TEXT", Nullable: true},
+			{Name: "has space", Type: "INTEGER", Nullable: false, PrimaryKey: true},
+		},
+	}
+	got := recreateCreateTableSQL(s)
+	want := "CREATE TABLE `we``ird table` (`col``umn` TEXT, `has space` INTEGER PRIMARY KEY NOT NULL)"
+	if got != want {
+		t.Fatalf("got  %q\nwant %q", got, want)
+	}
+}
+
+func TestRecreateCreateTableSQLStripsRowID(t *testing.T) {
+	s := &storage.Schema{
+		Name:       "t",
+		PrimaryKey: "_rowid_",
+		Columns: []storage.Column{
+			{Name: "_rowid_", Type: "INTEGER", PrimaryKey: true},
+			{Name: "v", Type: "TEXT", Nullable: true},
+		},
+	}
+	got := recreateCreateTableSQL(s)
+	if strings.Contains(got, "_rowid_") {
+		t.Fatalf("implicit _rowid_ must be stripped: %q", got)
+	}
+	if got != "CREATE TABLE `t` (`v` TEXT)" {
+		t.Fatalf("unexpected SQL: %q", got)
+	}
+}
+
+func TestRecreateCreateTableSQLKeepsUserOidRowid(t *testing.T) {
+	// A real Gogs LFS table has an oid TEXT column; a user table may also have
+	// an explicit rowid column. Neither may be dropped from the catalog.
+	s := &storage.Schema{
+		Name:       "lfs_object",
+		PrimaryKey: "_rowid_",
+		Columns: []storage.Column{
+			{Name: "oid", Type: "TEXT", Nullable: false},
+			{Name: "rowid", Type: "INTEGER", Nullable: true},
+		},
+	}
+	got := recreateCreateTableSQL(s)
+	want := "CREATE TABLE `lfs_object` (`oid` TEXT NOT NULL, `rowid` INTEGER)"
+	if got != want {
+		t.Fatalf("got  %q\nwant %q", got, want)
+	}
+}
+
+func TestRecreateCreateTableSQLDefaults(t *testing.T) {
+	s := &storage.Schema{
+		Name: "t",
+		Columns: []storage.Column{
+			{Name: "a", Type: "TEXT", Default: "it's", Nullable: true},
+			{Name: "b", Type: "INTEGER", Default: int64(7), Nullable: true},
+			{Name: "c", Type: "INTEGER", Default: true, Nullable: true},
+			{Name: "d", Type: "REAL", Default: 3.5, Nullable: true},
+			{Name: "e", Type: "TEXT", Nullable: true},
+		},
+	}
+	got := recreateCreateTableSQL(s)
+	for _, want := range []string{
+		"`a` TEXT DEFAULT 'it''s'",
+		"`b` INTEGER DEFAULT 7",
+		"`c` INTEGER DEFAULT 1",
+		"`d` REAL DEFAULT 3.5",
+		"`e` TEXT",
+	} {
+		if !strings.Contains(got, want) {
+			t.Fatalf("missing %q in %q", want, got)
+		}
+	}
+}
+
+func TestRecreateCreateIndexSQL(t *testing.T) {
+	idx := &storage.Index{
+		Name:   "ix`a",
+		Table:  "ta`ble",
+		Unique: true,
+		Columns: []storage.IndexColumn{
+			{Name: "co`l1"},
+			{Name: "col2", Desc: true},
+		},
+	}
+	got := recreateCreateIndexSQL(idx)
+	want := "CREATE UNIQUE INDEX `ix``a` ON `ta``ble` (`co``l1`, `col2` DESC)"
+	if got != want {
+		t.Fatalf("got  %q\nwant %q", got, want)
+	}
+}
+
+func TestQuoteIdent(t *testing.T) {
+	cases := map[string]string{
+		"a":     "`a`",
+		"a`b":   "`a``b`",
+		"plain": "`plain`",
+		"x y z": "`x y z`",
+	}
+	for in, want := range cases {
+		if got := quoteIdent(in); got != want {
+			t.Errorf("quoteIdent(%q) = %q, want %q", in, got, want)
+		}
+	}
+}

+ 81 - 0
pkg/executor/unique_constraint_test.go

@@ -0,0 +1,81 @@
+package executor
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestCreateTableInlineUniqueConstraint(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE login_source (id INTEGER PRIMARY KEY, name TEXT UNIQUE)")
+
+	execMust(t, e, "INSERT INTO login_source VALUES (1, 'github')")
+	_, err := execSQL(e, "INSERT INTO login_source VALUES (2, 'github')")
+	if err == nil {
+		t.Fatal("expected unique violation for inline column UNIQUE")
+	}
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestCreateTableCompositeUniqueConstraint(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE access_token (id INTEGER PRIMARY KEY, sha1 TEXT, sha256 TEXT, CONSTRAINT uni_access_token_sha1 UNIQUE(sha1), CONSTRAINT uni_access_token_sha256 UNIQUE(sha256))")
+
+	execMust(t, e, "INSERT INTO access_token (sha1, sha256) VALUES ('s1', 'h1')")
+	if _, err := execSQL(e, "INSERT INTO access_token (sha1, sha256) VALUES ('s1', 'h2')"); err == nil {
+		t.Fatal("expected unique violation on sha1")
+	}
+	if _, err := execSQL(e, "INSERT INTO access_token (sha1, sha256) VALUES ('s2', 'h1')"); err == nil {
+		t.Fatal("expected unique violation on sha256")
+	}
+	execMust(t, e, "INSERT INTO access_token (sha1, sha256) VALUES ('s2', 'h2')")
+}
+
+func TestCreateTableMultiColumnUniqueConstraint(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT, CONSTRAINT uq_ab UNIQUE(a, b))")
+
+	execMust(t, e, "INSERT INTO t VALUES (1, 'x', 'y')")
+	execMust(t, e, "INSERT INTO t VALUES (2, 'x', 'z')")
+	if _, err := execSQL(e, "INSERT INTO t VALUES (3, 'x', 'y')"); err == nil {
+		t.Fatal("expected unique violation on composite (x,y)")
+	}
+}
+
+func TestCreateTableUniqueConstraintReportedInCatalog(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, CONSTRAINT uq_t_name UNIQUE(name))")
+
+	res := execMust(t, e, "PRAGMA index_list('t')")
+	found := false
+	for _, row := range res.Rows {
+		// columns: seq, name, unique, origin, partial
+		if row[1] == "uq_t_name" {
+			found = true
+			if row[2] != int64(1) {
+				t.Fatalf("index uq_t_name unique flag = %v, want 1", row[2])
+			}
+		}
+	}
+	if !found {
+		t.Fatalf("named unique constraint not reported in index_list: %v", res.Rows)
+	}
+}
+
+func TestCreateTableIfNotExistsWithUniqueConstraint(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, name TEXT UNIQUE)")
+	execMust(t, e, "INSERT INTO t VALUES (1, 'a')")
+	// Second CREATE IF NOT EXISTS must be a no-op and not fail on the duplicate index.
+	execMust(t, e, "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, name TEXT UNIQUE)")
+	if _, err := execSQL(e, "INSERT INTO t VALUES (2, 'a')"); err == nil {
+		t.Fatal("expected unique violation to still be enforced after no-op recreate")
+	}
+}

+ 66 - 0
pkg/executor/update_subquery_deadlock_test.go

@@ -0,0 +1,66 @@
+package executor
+
+import (
+	"testing"
+	"time"
+)
+
+// runWithTimeout runs fn and fails if it does not finish, which detects the
+// organization-delete deadlock (UPDATE ... WHERE id IN (SELECT ...)).
+func runWithTimeout(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 finish within %s (deadlock)", d)
+	}
+}
+
+func TestUpdateWhereInSubqueryInTransaction(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, "CREATE TABLE repository (id INTEGER PRIMARY KEY, num_watches INTEGER)")
+	execMust(t, e, "CREATE TABLE watch (id INTEGER PRIMARY KEY, user_id INTEGER, repo_id INTEGER)")
+	execMust(t, e, "INSERT INTO repository VALUES (1, 5)")
+	execMust(t, e, "INSERT INTO repository VALUES (2, 7)")
+	execMust(t, e, "INSERT INTO watch VALUES (1, 1, 1)")
+
+	execMust(t, e, "BEGIN")
+	runWithTimeout(t, 5*time.Second, func() {
+		execMust(t, e, "UPDATE repository SET num_watches = num_watches - 1 WHERE id IN (SELECT repo_id FROM watch WHERE user_id = 1)")
+	})
+	execMust(t, e, "COMMIT")
+
+	res := execMust(t, e, "SELECT num_watches FROM repository WHERE id = 1")
+	if res.Rows[0][0] != int64(4) {
+		t.Fatalf("num_watches = %v, want 4", res.Rows[0][0])
+	}
+}
+
+func TestDeleteWhereInSubqueryInTransaction(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	execMust(t, e, "CREATE TABLE repository (id INTEGER PRIMARY KEY, num_watches INTEGER)")
+	execMust(t, e, "CREATE TABLE watch (id INTEGER PRIMARY KEY, user_id INTEGER, repo_id INTEGER)")
+	execMust(t, e, "INSERT INTO repository VALUES (1, 5)")
+	execMust(t, e, "INSERT INTO repository VALUES (2, 7)")
+	execMust(t, e, "INSERT INTO watch VALUES (1, 1, 1)")
+
+	execMust(t, e, "BEGIN")
+	runWithTimeout(t, 5*time.Second, func() {
+		execMust(t, e, "DELETE FROM repository WHERE id IN (SELECT repo_id FROM watch WHERE user_id = 1)")
+	})
+	execMust(t, e, "COMMIT")
+
+	res := execMust(t, e, "SELECT id FROM repository")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(2) {
+		t.Fatalf("remaining rows = %v, want [2]", res.Rows)
+	}
+}

+ 3 - 3
pkg/lexer/lexer_test.go

@@ -293,9 +293,9 @@ func TestLexerLineTracking(t *testing.T) {
 
 
 func TestLexerErrors(t *testing.T) {
 func TestLexerErrors(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
-		name    string
-		input   string
-		errMsg  string
+		name   string
+		input  string
+		errMsg string
 	}{
 	}{
 		{
 		{
 			name:   "unterminated string",
 			name:   "unterminated string",

+ 4 - 3
pkg/parser/ast.go

@@ -62,9 +62,10 @@ func (s *SelectStmt) stmtNode() {}
 
 
 // SelectColumn represents a column in SELECT.
 // SelectColumn represents a column in SELECT.
 type SelectColumn struct {
 type SelectColumn struct {
-	Expr  Expr
-	Alias string
-	Star  bool // true if this is *
+	Expr      Expr
+	Alias     string
+	Star      bool   // true if this is *
+	TableStar string // table name/alias if this is a qualified wildcard (table.*)
 }
 }
 
 
 // TableRef represents a table reference.
 // TableRef represents a table reference.

+ 59 - 19
pkg/parser/parser.go

@@ -411,20 +411,28 @@ func (p *Parser) parseSelectColumns() ([]SelectColumn, error) {
 			if err != nil {
 			if err != nil {
 				return nil, err
 				return nil, err
 			}
 			}
-			col.Expr = expr
 
 
-			// Check for AS alias
-			if p.curTokenIs(lexer.TokenAS) {
-				p.nextToken()
-				if !p.curTokenIs(lexer.TokenIdent) {
-					return nil, p.curError("expected identifier after AS")
+			// Qualified wildcard (table.*): the expression grammar parses this as
+			// ColumnRef{Table: table, Column: "*"}. Lift it into a scoped wildcard
+			// projection instead of a column reference literally named "*".
+			if ref, ok := expr.(*ColumnRef); ok && ref.Table != "" && ref.Column == "*" {
+				col.TableStar = ref.Table
+			} else {
+				col.Expr = expr
+
+				// Check for AS alias
+				if p.curTokenIs(lexer.TokenAS) {
+					p.nextToken()
+					if !p.curTokenIs(lexer.TokenIdent) {
+						return nil, p.curError("expected identifier after AS")
+					}
+					col.Alias = p.curToken.Literal
+					p.nextToken()
+				} else if p.curTokenIs(lexer.TokenIdent) {
+					// Alias without AS
+					col.Alias = p.curToken.Literal
+					p.nextToken()
 				}
 				}
-				col.Alias = p.curToken.Literal
-				p.nextToken()
-			} else if p.curTokenIs(lexer.TokenIdent) {
-				// Alias without AS
-				col.Alias = p.curToken.Literal
-				p.nextToken()
 			}
 			}
 		}
 		}
 
 
@@ -1083,22 +1091,42 @@ func (p *Parser) parseColumnDef() (*ColumnDef, error) {
 		if !ok {
 		if !ok {
 			break
 			break
 		}
 		}
-		col.Constraints = append(col.Constraints, *constraint)
+		if constraint != nil {
+			col.Constraints = append(col.Constraints, *constraint)
+		}
 	}
 	}
 
 
 	return col, nil
 	return col, 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.
+var identTypeAliases = map[string]string{
+	"UUID": "UUID",
+}
+
 func (p *Parser) parseDataType() (*DataType, error) {
 func (p *Parser) parseDataType() (*DataType, error) {
 	dt := &DataType{}
 	dt := &DataType{}
 
 
-	if !p.isDataTypeKeyword() {
-		return nil, p.curError("expected data type")
+	if p.curTokenIs(lexer.TokenIdent) {
+		// A few well-known type names (e.g. UUID) are not reserved keywords and
+		// lex as identifiers. Recognize them as type aliases so they can be used
+		// in column definitions.
+		name, ok := identTypeAliases[strings.ToUpper(p.curToken.Literal)]
+		if !ok {
+			return nil, p.curError("expected data type")
+		}
+		dt.Name = name
+		p.nextToken()
+	} else {
+		if !p.isDataTypeKeyword() {
+			return nil, p.curError("expected data type")
+		}
+		dt.Name = strings.ToUpper(p.curToken.Literal)
+		p.nextToken()
 	}
 	}
 
 
-	dt.Name = strings.ToUpper(p.curToken.Literal)
-	p.nextToken()
-
 	// Check for precision/scale
 	// Check for precision/scale
 	if p.curTokenIs(lexer.TokenLParen) {
 	if p.curTokenIs(lexer.TokenLParen) {
 		p.nextToken()
 		p.nextToken()
@@ -1166,7 +1194,11 @@ func (p *Parser) parseColumnConstraint() (*ColumnConstraint, bool, error) {
 
 
 	case lexer.TokenDEFAULT:
 	case lexer.TokenDEFAULT:
 		p.nextToken()
 		p.nextToken()
-		expr, err := p.parsePrimaryExpr()
+		// parseUnaryExpr accepts a signed numeric literal (-1, +5) and falls
+		// through to primary expressions, including parenthesized expressions.
+		// It stops before NOT/NULL, so a following NOT NULL constraint is left
+		// for the enclosing constraint loop.
+		expr, err := p.parseUnaryExpr()
 		if err != nil {
 		if err != nil {
 			return nil, false, err
 			return nil, false, err
 		}
 		}
@@ -1198,6 +1230,14 @@ func (p *Parser) parseColumnConstraint() (*ColumnConstraint, bool, error) {
 		constraint.Type = ConstraintAutoIncrement
 		constraint.Type = ConstraintAutoIncrement
 		p.nextToken()
 		p.nextToken()
 
 
+	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
+		// "col TYPE NULL" parses without inventing a fake nullable constraint.
+		// NOT NULL is applied whenever it appears, regardless of ordering.
+		p.nextToken()
+		return nil, true, nil
+
 	default:
 	default:
 		return nil, false, nil
 		return nil, false, nil
 	}
 	}

+ 178 - 0
pkg/parser/parser_test.go

@@ -69,6 +69,58 @@ func TestParseSelectColumns(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestParseSelectQualifiedWildcard(t *testing.T) {
+	stmt := parse(t, "SELECT DISTINCT repo.* FROM repository AS repo LEFT JOIN access ON access.repo_id = repo.id")
+	sel := stmt.(*SelectStmt)
+
+	if !sel.Distinct {
+		t.Error("expected DISTINCT")
+	}
+	if len(sel.Columns) != 1 {
+		t.Fatalf("expected 1 column, got %d", len(sel.Columns))
+	}
+	col := sel.Columns[0]
+	if col.TableStar != "repo" {
+		t.Errorf("expected TableStar=repo, got %q", col.TableStar)
+	}
+	if col.Star || col.Expr != nil || col.Alias != "" {
+		t.Errorf("qualified wildcard should not set Star/Expr/Alias: %+v", col)
+	}
+}
+
+func TestParseSelectQualifiedWildcardMixed(t *testing.T) {
+	stmt := parse(t, "SELECT repo.*, access.mode FROM repository AS repo LEFT JOIN access ON access.repo_id = repo.id")
+	sel := stmt.(*SelectStmt)
+
+	if len(sel.Columns) != 2 {
+		t.Fatalf("expected 2 columns, got %d", len(sel.Columns))
+	}
+	if sel.Columns[0].TableStar != "repo" {
+		t.Errorf("column 0 TableStar = %q, want repo", sel.Columns[0].TableStar)
+	}
+	ref, ok := sel.Columns[1].Expr.(*ColumnRef)
+	if !ok || ref.Table != "access" || ref.Column != "mode" {
+		t.Errorf("column 1 = %+v, want access.mode ColumnRef", sel.Columns[1].Expr)
+	}
+}
+
+func TestParseSelectCountStarStillWorks(t *testing.T) {
+	stmt := parse(t, "SELECT COUNT(*) FROM users")
+	sel := stmt.(*SelectStmt)
+
+	if len(sel.Columns) != 1 {
+		t.Fatalf("expected 1 column, got %d", len(sel.Columns))
+	}
+	col := sel.Columns[0]
+	if col.TableStar != "" || col.Star {
+		t.Errorf("COUNT(*) should not be a wildcard: %+v", col)
+	}
+	fn, ok := col.Expr.(*FunctionCall)
+	if !ok || !fn.Star || fn.Name != "COUNT" {
+		t.Errorf("expected COUNT(*) FunctionCall, got %+v", col.Expr)
+	}
+}
+
 func TestParseSelectWithAlias(t *testing.T) {
 func TestParseSelectWithAlias(t *testing.T) {
 	stmt := parse(t, "SELECT id AS user_id, name AS full_name FROM users u")
 	stmt := parse(t, "SELECT id AS user_id, name AS full_name FROM users u")
 	sel := stmt.(*SelectStmt)
 	sel := stmt.(*SelectStmt)
@@ -414,6 +466,45 @@ func TestParseCreateTableWithConstraints(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestParseCreateTableExplicitNullable(t *testing.T) {
+	stmt := parse(t, `CREATE TABLE users (
+		id INTEGER,
+		full_name TEXT NULL,
+		nickname TEXT NOT NULL,
+		created_at TIMESTAMP NULL NOT NULL
+	)`)
+
+	create, ok := stmt.(*CreateTableStmt)
+	if !ok {
+		t.Fatalf("expected CreateTableStmt, got %T", stmt)
+	}
+
+	if len(create.Columns) != 4 {
+		t.Fatalf("expected 4 columns, got %d", len(create.Columns))
+	}
+
+	// full_name TEXT NULL: explicit NULL is a no-op, so no constraint is added.
+	fullName := create.Columns[1]
+	if fullName.Name != "full_name" || fullName.Type.Name != "TEXT" {
+		t.Fatalf("unexpected full_name column: %+v", fullName)
+	}
+	if len(fullName.Constraints) != 0 {
+		t.Errorf("explicit NULL should not produce a constraint, got %d", len(fullName.Constraints))
+	}
+
+	// nickname TEXT NOT NULL still records NOT NULL.
+	nickname := create.Columns[2]
+	if len(nickname.Constraints) != 1 || nickname.Constraints[0].Type != ConstraintNotNull {
+		t.Errorf("expected NOT NULL on nickname, got %+v", nickname.Constraints)
+	}
+
+	// created_at TIMESTAMP NULL NOT NULL: NOT NULL wins regardless of ordering.
+	created := create.Columns[3]
+	if len(created.Constraints) != 1 || created.Constraints[0].Type != ConstraintNotNull {
+		t.Errorf("expected NOT NULL on created_at, got %+v", created.Constraints)
+	}
+}
+
 // DROP TABLE tests
 // DROP TABLE tests
 
 
 func TestParseDropTable(t *testing.T) {
 func TestParseDropTable(t *testing.T) {
@@ -428,6 +519,93 @@ func TestParseDropTable(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestParseCreateTableDefaultSignedNumeric(t *testing.T) {
+	stmt := parse(t, `CREATE TABLE repo (
+		id INTEGER PRIMARY KEY,
+		max_repo_creation INTEGER DEFAULT -1 NOT NULL,
+		delta INTEGER DEFAULT +5,
+		tally INTEGER DEFAULT (-7)
+	)`)
+
+	create, ok := stmt.(*CreateTableStmt)
+	if !ok {
+		t.Fatalf("expected CreateTableStmt, got %T", stmt)
+	}
+	if len(create.Columns) != 4 {
+		t.Fatalf("expected 4 columns, got %d", len(create.Columns))
+	}
+
+	// xorm real shape: DEFAULT -1 followed by NOT NULL.
+	col := create.Columns[1]
+	if len(col.Constraints) != 2 {
+		t.Fatalf("expected DEFAULT + NOT NULL, got %d constraints", len(col.Constraints))
+	}
+	var defaultExpr Expr
+	var notNull bool
+	for _, c := range col.Constraints {
+		switch c.Type {
+		case ConstraintDefault:
+			defaultExpr = c.Default
+		case ConstraintNotNull:
+			notNull = true
+		}
+	}
+	if !notNull {
+		t.Error("expected NOT NULL constraint on max_repo_creation")
+	}
+	unary, ok := defaultExpr.(*UnaryExpr)
+	if !ok || unary.Op != lexer.TokenMinus {
+		t.Fatalf("expected unary minus default, got %T %+v", defaultExpr, defaultExpr)
+	}
+	lit, ok := unary.Operand.(*LiteralExpr)
+	if !ok || lit.Value != "1" {
+		t.Fatalf("expected -1 literal, got %+v", unary.Operand)
+	}
+
+	// Positive signed default: DEFAULT +5.
+	plus, ok := create.Columns[2].Constraints[0].Default.(*UnaryExpr)
+	if !ok || plus.Op != lexer.TokenPlus {
+		t.Fatalf("expected unary plus default, got %+v", create.Columns[2].Constraints[0].Default)
+	}
+
+	// Parenthesized signed default: DEFAULT (-7).
+	paren, ok := create.Columns[3].Constraints[0].Default.(*ParenExpr)
+	if !ok {
+		t.Fatalf("expected parenthesized default, got %T", create.Columns[3].Constraints[0].Default)
+	}
+	inner, ok := paren.Expr.(*UnaryExpr)
+	if !ok || inner.Op != lexer.TokenMinus {
+		t.Fatalf("expected unary minus inside parens, got %T %+v", paren.Expr, paren.Expr)
+	}
+}
+
+func TestParseCreateTableUUIDType(t *testing.T) {
+	stmt := parse(t, `CREATE TABLE upload (
+		id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+		uuid UUID NULL,
+		name TEXT NULL
+	)`)
+
+	create, ok := stmt.(*CreateTableStmt)
+	if !ok {
+		t.Fatalf("expected CreateTableStmt, got %T", stmt)
+	}
+	if len(create.Columns) != 3 {
+		t.Fatalf("expected 3 columns, got %d", len(create.Columns))
+	}
+
+	uuidCol := create.Columns[1]
+	if uuidCol.Name != "uuid" {
+		t.Errorf("column name = %q, want %q", uuidCol.Name, "uuid")
+	}
+	if uuidCol.Type.Name != "UUID" {
+		t.Errorf("column type = %q, want %q", uuidCol.Type.Name, "UUID")
+	}
+	if len(uuidCol.Constraints) != 0 {
+		t.Errorf("explicit NULL should produce no constraints, got %d", len(uuidCol.Constraints))
+	}
+}
+
 func TestParseDropTableIfExists(t *testing.T) {
 func TestParseDropTableIfExists(t *testing.T) {
 	stmt := parse(t, "DROP TABLE IF EXISTS users")
 	stmt := parse(t, "DROP TABLE IF EXISTS users")
 	drop := stmt.(*DropTableStmt)
 	drop := stmt.(*DropTableStmt)

+ 13 - 1
pkg/pgserver/connection.go

@@ -1103,7 +1103,11 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 	switch strings.ToUpper(typeName) {
 	switch strings.ToUpper(typeName) {
 	case "INTEGER", "INT":
 	case "INTEGER", "INT":
 		return 23 // INT4OID
 		return 23 // INT4OID
-	case "TEXT", "VARCHAR", "CHAR":
+	case "BIGINT":
+		return 20 // INT8OID
+	case "TEXT", "VARCHAR", "CHAR", "UUID":
+		// UUID is stored as text (SQLite-style), so it reports TEXTOID rather
+		// than the native PG UUID OID.
 		return 25 // TEXTOID
 		return 25 // TEXTOID
 	case "REAL", "FLOAT":
 	case "REAL", "FLOAT":
 		return 700 // FLOAT4OID
 		return 700 // FLOAT4OID
@@ -1113,6 +1117,10 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 		return 16 // BOOLOID
 		return 16 // BOOLOID
 	case "BLOB":
 	case "BLOB":
 		return 17 // BYTEAOID
 		return 17 // BYTEAOID
+	case "DATETIME", "TIMESTAMP":
+		// Datetime values are exchanged as UTC RFC3339 (ISO8601 with a timezone
+		// offset), which timestamptz decodes directly.
+		return 1184 // TIMESTAMPTZOID
 	default:
 	default:
 		return 25 // Default to TEXT
 		return 25 // Default to TEXT
 	}
 	}
@@ -1123,12 +1131,16 @@ func (c *Connection) getTypeSizeForType(typeName string) int16 {
 	switch strings.ToUpper(typeName) {
 	switch strings.ToUpper(typeName) {
 	case "INTEGER", "INT":
 	case "INTEGER", "INT":
 		return 4
 		return 4
+	case "BIGINT":
+		return 8
 	case "REAL", "FLOAT":
 	case "REAL", "FLOAT":
 		return 4
 		return 4
 	case "DOUBLE":
 	case "DOUBLE":
 		return 8
 		return 8
 	case "BOOLEAN", "BOOL":
 	case "BOOLEAN", "BOOL":
 		return 1
 		return 1
+	case "DATETIME", "TIMESTAMP":
+		return 8
 	default:
 	default:
 		return -1 // Variable length
 		return -1 // Variable length
 	}
 	}

+ 109 - 0
pkg/pgserver/oid_test.go

@@ -0,0 +1,109 @@
+package pgserver
+
+import (
+	"bytes"
+	"encoding/binary"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+)
+
+func TestGetOIDForTypeDatetime(t *testing.T) {
+	c := &Connection{}
+	cases := map[string]int32{
+		"DATETIME":  1184,
+		"TIMESTAMP": 1184,
+		"BIGINT":    20,
+		"INTEGER":   23,
+		"INT":       23,
+		"TEXT":      25,
+		"UUID":      25,
+		"BOOLEAN":   16,
+	}
+	for name, want := range cases {
+		if got := c.getOIDForType(name); got != want {
+			t.Errorf("getOIDForType(%q) = %d, want %d", name, got, want)
+		}
+	}
+}
+
+func TestSendRowDescriptionTimestampOID(t *testing.T) {
+	c, cc := newCountingTestConnection(t)
+	if err := c.sendRowDescription([]string{"created_at", "id"}, []string{"TIMESTAMP", "BIGINT"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := c.sendReadyForQuery(); err != nil {
+		t.Fatal(err)
+	}
+	msgs := readAllMessages(t, cc.bytes())
+	rd := findRowDescription(t, msgs)
+	oids := rowDescriptionOIDs(t, rd.Data)
+	if len(oids) != 2 {
+		t.Fatalf("got %d columns, want 2", len(oids))
+	}
+	if oids[0] != 1184 {
+		t.Errorf("timestamp OID = %d, want 1184", oids[0])
+	}
+	if oids[1] != 20 {
+		t.Errorf("bigint OID = %d, want 20", oids[1])
+	}
+}
+
+func TestSendResultEmptyProjectionSendsRowDescription(t *testing.T) {
+	c, cc := newCountingTestConnection(t)
+	result := executor.NewResult("SELECT")
+	result.AddColumnWithType("id", "BIGINT")
+	result.AddColumnWithType("created_at", "DATETIME")
+	if err := c.sendResult(result, &parser.SelectStmt{}); err != nil {
+		t.Fatal(err)
+	}
+	if err := c.sendReadyForQuery(); err != nil {
+		t.Fatal(err)
+	}
+	msgs := readAllMessages(t, cc.bytes())
+	rd := findRowDescription(t, msgs)
+	oids := rowDescriptionOIDs(t, rd.Data)
+	if len(oids) != 2 || oids[0] != 20 || oids[1] != 1184 {
+		t.Fatalf("oids = %v, want [20 1184]", oids)
+	}
+}
+
+func findRowDescription(t *testing.T, msgs []*Message) *Message {
+	t.Helper()
+	for _, m := range msgs {
+		if m.Type == MsgRowDescription {
+			return m
+		}
+	}
+	t.Fatal("no RowDescription message found")
+	return nil
+}
+
+func rowDescriptionOIDs(t *testing.T, data []byte) []int32 {
+	t.Helper()
+	if len(data) < 2 {
+		t.Fatalf("row description too short")
+	}
+	count := int(binary.BigEndian.Uint16(data[:2]))
+	pos := 2
+	oids := make([]int32, 0, count)
+	for i := 0; i < count; i++ {
+		end := bytes.IndexByte(data[pos:], 0)
+		if end < 0 {
+			t.Fatalf("unterminated column name")
+		}
+		pos += end + 1 // name
+		pos += 4       // table OID
+		pos += 2       // column attribute number
+		if pos+4 > len(data) {
+			t.Fatalf("truncated row description")
+		}
+		oids = append(oids, int32(binary.BigEndian.Uint32(data[pos:pos+4])))
+		pos += 4 // type OID
+		pos += 2 // type size
+		pos += 4 // type modifier
+		pos += 2 // format code
+	}
+	return oids
+}

+ 10 - 10
pkg/sqliteimport/import.go

@@ -313,25 +313,25 @@ var pizzasqlReservedKeywords = map[string]bool{
 }
 }
 
 
 var (
 var (
-	reAutoincrement  = regexp.MustCompile(`(?i)\bAUTOINCREMENT\b`)
-	reWithoutRowid   = regexp.MustCompile(`(?i)\bWITHOUT\s+ROWID\b`)
-	reStrict         = regexp.MustCompile(`(?i),?\s*\bSTRICT\b`)
+	reAutoincrement = regexp.MustCompile(`(?i)\bAUTOINCREMENT\b`)
+	reWithoutRowid  = regexp.MustCompile(`(?i)\bWITHOUT\s+ROWID\b`)
+	reStrict        = regexp.MustCompile(`(?i),?\s*\bSTRICT\b`)
 	// REFERENCES x(y) ON DELETE/UPDATE action — strip whole inline FK clause.
 	// REFERENCES x(y) ON DELETE/UPDATE action — strip whole inline FK clause.
 	// Use \w+ (not \S+) so the trailing comma of the column is preserved.
 	// Use \w+ (not \S+) so the trailing comma of the column is preserved.
-	reInlineRefs     = regexp.MustCompile(`(?i)\bREFERENCES\s+\w+\s*(?:\([^)]*\))?\s*(?:(?:ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))\s*)*`)
+	reInlineRefs = regexp.MustCompile(`(?i)\bREFERENCES\s+\w+\s*(?:\([^)]*\))?\s*(?:(?:ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))\s*)*`)
 	// Table-level FOREIGN KEY constraint lines
 	// Table-level FOREIGN KEY constraint lines
-	reTableFK        = regexp.MustCompile(`(?i),?\s*FOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+\w+\s*(?:\([^)]*\))?\s*(?:(?:ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))\s*)*`)
+	reTableFK = regexp.MustCompile(`(?i),?\s*FOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+\w+\s*(?:\([^)]*\))?\s*(?:(?:ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION))\s*)*`)
 	// Table-level CHECK constraints
 	// Table-level CHECK constraints
-	reTableCheck     = regexp.MustCompile(`(?i),?\s*CHECK\s*\([^)]*\)`)
-	reOnConflict     = regexp.MustCompile(`(?i)\bON\s+CONFLICT\s+\w+`)
+	reTableCheck = regexp.MustCompile(`(?i),?\s*CHECK\s*\([^)]*\)`)
+	reOnConflict = regexp.MustCompile(`(?i)\bON\s+CONFLICT\s+\w+`)
 	// Complex DEFAULT expressions: DEFAULT (...) — strip entirely, keep no default
 	// Complex DEFAULT expressions: DEFAULT (...) — strip entirely, keep no default
 	reComplexDefault = regexp.MustCompile(`(?i)\bDEFAULT\s*\([^)]*\)`)
 	reComplexDefault = regexp.MustCompile(`(?i)\bDEFAULT\s*\([^)]*\)`)
 	// Trailing comma before closing paren
 	// Trailing comma before closing paren
-	reTableTrailing  = regexp.MustCompile(`(?m),\s*\)`)
+	reTableTrailing = regexp.MustCompile(`(?m),\s*\)`)
 	// DESC/ASC in index column lists
 	// DESC/ASC in index column lists
-	reIndexColOrder  = regexp.MustCompile(`(?i)\b(ASC|DESC)\b`)
+	reIndexColOrder = regexp.MustCompile(`(?i)\b(ASC|DESC)\b`)
 	// Column name (first word) followed by a type keyword on each column line
 	// Column name (first word) followed by a type keyword on each column line
-	reColumnName     = regexp.MustCompile(`(?m)^\s{1,}(\w+)(\s+)`)
+	reColumnName = regexp.MustCompile(`(?m)^\s{1,}(\w+)(\s+)`)
 )
 )
 
 
 // sanitizeDDL strips SQLite-specific clauses that PizzaSQL doesn't support.
 // sanitizeDDL strips SQLite-specific clauses that PizzaSQL doesn't support.

+ 17 - 3
pkg/storage/schema.go

@@ -1,6 +1,7 @@
 package storage
 package storage
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
@@ -11,6 +12,11 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 )
 )
 
 
+// ErrIndexNotFound is returned by GetIndex when an index is absent. It is
+// distinct from a storage/IO error so callers like ListTableIndexes can skip a
+// concurrently-dropped index without swallowing real read failures.
+var ErrIndexNotFound = errors.New("index not found")
+
 // Schema represents a table schema.
 // Schema represents a table schema.
 type Schema struct {
 type Schema struct {
 	Name          string    `json:"name"`
 	Name          string    `json:"name"`
@@ -775,7 +781,10 @@ func (m *SchemaManager) GetIndex(name string) (*Index, error) {
 		return err
 		return err
 	})
 	})
 	if err != nil {
 	if err != nil {
-		return nil, fmt.Errorf("index not found: %s", name)
+		if err == ErrKeyNotFound {
+			return nil, fmt.Errorf("%w: %s", ErrIndexNotFound, name)
+		}
+		return nil, err
 	}
 	}
 
 
 	var index Index
 	var index Index
@@ -813,7 +822,7 @@ func (m *SchemaManager) ListIndexes() ([]string, error) {
 
 
 	var indexes []string
 	var indexes []string
 	if err := json.Unmarshal([]byte(data), &indexes); err != nil {
 	if err := json.Unmarshal([]byte(data), &indexes); err != nil {
-		return []string{}, nil
+		return nil, fmt.Errorf("failed to parse index list: %w", err)
 	}
 	}
 
 
 	m.indexListCache = append([]string(nil), indexes...)
 	m.indexListCache = append([]string(nil), indexes...)
@@ -832,7 +841,12 @@ func (m *SchemaManager) ListTableIndexes(table string) ([]*Index, error) {
 	for _, name := range indexes {
 	for _, name := range indexes {
 		idx, err := m.GetIndex(name)
 		idx, err := m.GetIndex(name)
 		if err != nil {
 		if err != nil {
-			continue
+			// An index dropped concurrently is simply absent; any other error
+			// (storage/IO or corrupt data) must not be silently swallowed.
+			if errors.Is(err, ErrIndexNotFound) {
+				continue
+			}
+			return nil, err
 		}
 		}
 		if strings.EqualFold(idx.Table, table) {
 		if strings.EqualFold(idx.Table, table) {
 			result = append(result, idx)
 			result = append(result, idx)

+ 63 - 0
pkg/storage/schema_test.go

@@ -2,6 +2,7 @@ package storage
 
 
 import (
 import (
 	"bufio"
 	"bufio"
+	"errors"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"sort"
 	"sort"
@@ -807,3 +808,65 @@ func TestListTableIndexesCachesMetadata(t *testing.T) {
 		t.Fatalf("cached index metadata issued %d extra reads", getsAfterCached-getsAfterFirst)
 		t.Fatalf("cached index metadata issued %d extra reads", getsAfterCached-getsAfterFirst)
 	}
 	}
 }
 }
+
+func TestGetIndexDistinguishesNotFound(t *testing.T) {
+	kv := newTestKVServer(t)
+	defer kv.close()
+	pool := newTestKVPool(kv, 4, 5*time.Second)
+	defer pool.Close()
+	schemas := NewSchemaManager(pool, "testdb")
+
+	_, err := schemas.GetIndex("missing")
+	if err == nil {
+		t.Fatal("expected an error for a missing index")
+	}
+	if !errors.Is(err, ErrIndexNotFound) {
+		t.Fatalf("expected ErrIndexNotFound, got %v", err)
+	}
+}
+
+func TestListIndexesPropagatesCorruptJSON(t *testing.T) {
+	kv := newTestKVServer(t)
+	defer kv.close()
+	pool := newTestKVPool(kv, 4, 5*time.Second)
+	defer pool.Close()
+	schemas := NewSchemaManager(pool, "testdb")
+
+	if err := pool.WithClient(func(c *KVClient) error {
+		return c.Write("testdb:indexes", "{not json")
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := schemas.ListIndexes(); err == nil {
+		t.Fatal("expected an error for corrupt index list JSON")
+	}
+}
+
+func TestListTableIndexesSkipsOnlyNotFound(t *testing.T) {
+	kv := newTestKVServer(t)
+	defer kv.close()
+	pool := newTestKVPool(kv, 4, 5*time.Second)
+	defer pool.Close()
+	schemas := NewSchemaManager(pool, "testdb")
+
+	// Create a table and one index on it, then a dangling name in the list that
+	// no longer has a definition (a concurrent drop), which must be skipped, not
+	// surfaced.
+	if err := schemas.CreateTable(&Schema{Name: "t", Columns: []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}, {Name: "v", Type: "TEXT"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := schemas.CreateIndex(&Index{Name: "uq_v", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "v"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := schemas.addToIndexList("ghost_idx"); err != nil {
+		t.Fatal(err)
+	}
+
+	indexes, err := schemas.ListTableIndexes("t")
+	if err != nil {
+		t.Fatalf("ListTableIndexes should skip the dangling index, got: %v", err)
+	}
+	if len(indexes) != 1 || indexes[0].Name != "uq_v" {
+		t.Fatalf("expected only uq_v, got %v", indexes)
+	}
+}

+ 388 - 28
pkg/storage/table.go

@@ -1,9 +1,11 @@
 package storage
 package storage
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 	"hash/fnv"
 	"hash/fnv"
 	"math"
 	"math"
+	"strconv"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
@@ -525,22 +527,38 @@ func (m *TableManager) prepareInsert(table string, row Row) (Row, string, error)
 // persist. The shared table gate is acquired before the key's striped lock so
 // persist. The shared table gate is acquired before the key's striped lock so
 // a queued scan writer cannot invert the lock order with point operations.
 // a queued scan writer cannot invert the lock order with point operations.
 func (m *TableManager) Insert(table string, row Row) error {
 func (m *TableManager) Insert(table string, row Row) error {
+	_, err := m.InsertWithRowID(table, row)
+	return err
+}
+
+// InsertWithRowID returns the actual row identifier, never a concurrent counter.
+func (m *TableManager) InsertWithRowID(table string, row Row) (int64, error) {
 	nr, key, err := m.prepareInsert(table, row)
 	nr, key, err := m.prepareInsert(table, row)
 	if err != nil {
 	if err != nil {
-		return err
+		return 0, err
 	}
 	}
+	rowid, _ := rowIDFromRow(nr)
 	data, err := encodeRow(nr)
 	data, err := encodeRow(nr)
 	if err != nil {
 	if err != nil {
-		return fmt.Errorf("failed to serialize row: %w", err)
+		return 0, fmt.Errorf("failed to serialize row: %w", err)
 	}
 	}
 	wasInit := m.countInitialized(table)
 	wasInit := m.countInitialized(table)
 
 
 	// Point writers share this gate with each other. Transaction commits and
 	// Point writers share this gate with each other. Transaction commits and
 	// scan-based writes take it exclusively, so generation validation and cache
 	// scan-based writes take it exclusively, so generation validation and cache
 	// publication are ordered without serializing writes to different keys.
 	// publication are ordered without serializing writes to different keys.
-	tl := m.tableLock(table)
-	tl.RLock()
-	defer tl.RUnlock()
+	// A UNIQUE index forces the exclusive gate so the validating scan below
+	// cannot race another writer (or a concurrent CREATE UNIQUE INDEX).
+	unlock, uniq, err := m.lockForWrite(table)
+	if err != nil {
+		return 0, err
+	}
+	defer unlock()
+	if uniq {
+		if err := m.validateUniqueRows(table, []Row{nr}, nil); err != nil {
+			return 0, err
+		}
+	}
 	st := m.stripeKey(key)
 	st := m.stripeKey(key)
 	st.Lock()
 	st.Lock()
 	defer st.Unlock()
 	defer st.Unlock()
@@ -549,10 +567,10 @@ func (m *TableManager) Insert(table string, row Row) error {
 		[]BatchOp{{Op: batchPut, Key: []byte(key), Value: data}},
 		[]BatchOp{{Op: batchPut, Key: []byte(key), Value: data}},
 	)
 	)
 	if err != nil {
 	if err != nil {
-		return err
+		return 0, err
 	}
 	}
 	if !committed {
 	if !committed {
-		return fmt.Errorf("duplicate primary key: %v", row[schemaPrimaryKey(m.schema, table)])
+		return 0, fmt.Errorf("duplicate primary key: %v", row[schemaPrimaryKey(m.schema, table)])
 	}
 	}
 
 
 	m.updateIndexesForRow(table, nr, true)
 	m.updateIndexesForRow(table, nr, true)
@@ -564,7 +582,7 @@ func (m *TableManager) Insert(table string, row Row) error {
 	if schema, serr := m.schema.GetSchema(table); serr == nil {
 	if schema, serr := m.schema.GetSchema(table); serr == nil {
 		m.incrCount(table, schema.CreatedAt, 1, wasInit)
 		m.incrCount(table, schema.CreatedAt, 1, wasInit)
 	}
 	}
-	return nil
+	return rowid, nil
 }
 }
 
 
 func schemaPrimaryKey(s *SchemaManager, table string) string {
 func schemaPrimaryKey(s *SchemaManager, table string) string {
@@ -613,8 +631,15 @@ func chunkBatchOps(ops []BatchOp) [][]BatchOp {
 // bounded by the PKBFI operation-count and frame-size limits. Skips per-row
 // bounded by the PKBFI operation-count and frame-size limits. Skips per-row
 // duplicate checks (caller must ensure uniqueness). Used by INSERT ... SELECT.
 // duplicate checks (caller must ensure uniqueness). Used by INSERT ... SELECT.
 func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
+	count, _, err := m.InsertBulkWithLastRowID(table, rows)
+	return count, err
+}
+
+// InsertBulkWithLastRowID is InsertBulk, additionally returning the ROWID of the
+// last persisted row (0 when nothing persisted).
+func (m *TableManager) InsertBulkWithLastRowID(table string, rows []Row) (int, int64, error) {
 	if len(rows) == 0 {
 	if len(rows) == 0 {
-		return 0, nil
+		return 0, 0, nil
 	}
 	}
 	tl := m.tableLock(table)
 	tl := m.tableLock(table)
 	tl.Lock()
 	tl.Lock()
@@ -622,7 +647,7 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 
 
 	schema, err := m.schema.GetSchema(table)
 	schema, err := m.schema.GetSchema(table)
 	if err != nil {
 	if err != nil {
-		return 0, err
+		return 0, 0, err
 	}
 	}
 	wasInit := m.countInitialized(table)
 	wasInit := m.countInitialized(table)
 
 
@@ -653,7 +678,7 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 			switch v := nr[schema.PrimaryKey].(type) {
 			switch v := nr[schema.PrimaryKey].(type) {
 			case float64:
 			case float64:
 				if math.Trunc(v) != v {
 				if math.Trunc(v) != v {
-					return 0, fmt.Errorf("invalid integer primary key: %v", v)
+					return 0, 0, fmt.Errorf("invalid integer primary key: %v", v)
 				}
 				}
 				rowid = int64(v)
 				rowid = int64(v)
 				hasRowid = true
 				hasRowid = true
@@ -666,18 +691,28 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 			}
 			}
 		}
 		}
 		if !hasRowid {
 		if !hasRowid {
-			if schema.PrimaryKey != "_rowid_" {
+			// Auto-generate for an INTEGER primary key or the synthetic _rowid_,
+			// mirroring prepareInsert so INSERT ... SELECT works on AUTOINCREMENT
+			// tables.
+			if isIntegerPK || schema.PrimaryKey == "_rowid_" {
+				rowid, err = m.schema.GetNextRowID(table)
+				if err != nil {
+					return 0, 0, err
+				}
+				if isIntegerPK {
+					nr[schema.PrimaryKey] = rowid
+				} else {
+					nr["_rowid_"] = rowid
+				}
+			} else {
 				pk, ok := nr[schema.PrimaryKey]
 				pk, ok := nr[schema.PrimaryKey]
 				if !ok || pk == nil {
 				if !ok || pk == nil {
-					return 0, fmt.Errorf("missing primary key: %s", schema.PrimaryKey)
+					return 0, 0, fmt.Errorf("missing primary key: %s", schema.PrimaryKey)
+				}
+				rowid, err = m.schema.GetNextRowID(table)
+				if err != nil {
+					return 0, 0, err
 				}
 				}
-			}
-			rowid, err = m.schema.GetNextRowID(table)
-			if err != nil {
-				return 0, err
-			}
-			if schema.PrimaryKey == "_rowid_" {
-				nr[schema.PrimaryKey] = rowid
 			}
 			}
 		}
 		}
 		nr["_rowid_"] = rowid
 		nr["_rowid_"] = rowid
@@ -699,7 +734,7 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 		pk := fmt.Sprintf("%v", nr[schema.PrimaryKey])
 		pk := fmt.Sprintf("%v", nr[schema.PrimaryKey])
 		data, err := encodeRow(nr)
 		data, err := encodeRow(nr)
 		if err != nil {
 		if err != nil {
-			return 0, err
+			return 0, 0, err
 		}
 		}
 		ops = append(ops, BatchOp{Op: batchPut, Key: []byte(m.dataKey(table, pk)), Value: data})
 		ops = append(ops, BatchOp{Op: batchPut, Key: []byte(m.dataKey(table, pk)), Value: data})
 		encoded = append(encoded, nr)
 		encoded = append(encoded, nr)
@@ -709,7 +744,7 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 	for i, op := range ops {
 	for i, op := range ops {
 		key := string(op.Key)
 		key := string(op.Key)
 		if _, duplicate := seen[key]; duplicate {
 		if _, duplicate := seen[key]; duplicate {
-			return 0, fmt.Errorf("duplicate primary key: %s", key)
+			return 0, 0, fmt.Errorf("duplicate primary key: %s", key)
 		}
 		}
 		seen[key] = struct{}{}
 		seen[key] = struct{}{}
 		keys[i] = op.Key
 		keys[i] = op.Key
@@ -729,14 +764,18 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 		}
 		}
 		return nil
 		return nil
 	}); err != nil {
 	}); err != nil {
-		return 0, err
+		return 0, 0, err
 	}
 	}
 	for i, found := range existing {
 	for i, found := range existing {
 		if found {
 		if found {
-			return 0, fmt.Errorf("duplicate primary key: %s", ops[i].Key)
+			return 0, 0, fmt.Errorf("duplicate primary key: %s", ops[i].Key)
 		}
 		}
 	}
 	}
 
 
+	if err := m.validateUniqueRows(table, encoded, nil); err != nil {
+		return 0, 0, err
+	}
+
 	// Write rows in atomic BATCH_WRITE chunks. Maintain in-memory indexes only
 	// Write rows in atomic BATCH_WRITE chunks. Maintain in-memory indexes only
 	// for rows that actually persisted, so a partial failure cannot leave an
 	// for rows that actually persisted, so a partial failure cannot leave an
 	// already-built index stale.
 	// already-built index stale.
@@ -765,7 +804,11 @@ func (m *TableManager) InsertBulk(table string, rows []Row) (int, error) {
 	}
 	}
 	m.incrCount(table, schema.CreatedAt, numOK, wasInit)
 	m.incrCount(table, schema.CreatedAt, numOK, wasInit)
 
 
-	return numOK, firstErr
+	var lastRowID int64
+	if numOK > 0 {
+		lastRowID, _ = rowIDFromRow(encoded[numOK-1])
+	}
+	return numOK, lastRowID, firstErr
 }
 }
 
 
 // updateIndexesForRow adds or removes entries from already-built in-memory
 // updateIndexesForRow adds or removes entries from already-built in-memory
@@ -947,6 +990,11 @@ func (m *TableManager) Update(table string, updates Row, filter func(Row) bool)
 			continue
 			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
 		// Write back
 		key := m.dataKey(table, pk)
 		key := m.dataKey(table, pk)
 		err = m.pool.WithClient(func(c *KVClient) error {
 		err = m.pool.WithClient(func(c *KVClient) error {
@@ -1018,6 +1066,11 @@ func (m *TableManager) UpdateFunc(table string, updateFn func(Row) (Row, error),
 			continue
 			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
 		// Write back
 		key := m.dataKey(table, pk)
 		key := m.dataKey(table, pk)
 		err = m.pool.WithClient(func(c *KVClient) error {
 		err = m.pool.WithClient(func(c *KVClient) error {
@@ -1050,9 +1103,11 @@ func (m *TableManager) UpdateByPK(table, pk string, updateFn func(Row) (Row, err
 	}
 	}
 
 
 	key := m.dataKey(table, pk)
 	key := m.dataKey(table, pk)
-	tl := m.tableLock(table)
-	tl.RLock()
-	defer tl.RUnlock()
+	unlock, uniq, err := m.lockForWrite(table)
+	if err != nil {
+		return nil, false, err
+	}
+	defer unlock()
 	st := m.stripeKey(key)
 	st := m.stripeKey(key)
 	st.Lock()
 	st.Lock()
 	defer st.Unlock()
 	defer st.Unlock()
@@ -1083,6 +1138,11 @@ func (m *TableManager) UpdateByPK(table, pk string, updateFn func(Row) (Row, err
 	if err != nil {
 	if err != nil {
 		return nil, false, err
 		return nil, false, err
 	}
 	}
+	if uniq {
+		if err := m.validateUniqueRows(table, []Row{row}, excludedKey(key)); err != nil {
+			return nil, false, err
+		}
+	}
 	committed, err := m.compareWritePoint(
 	committed, err := m.compareWritePoint(
 		[]CompareCheck{{Key: []byte(key), LSN: lsn}},
 		[]CompareCheck{{Key: []byte(key), LSN: lsn}},
 		[]BatchOp{{Op: batchPut, Key: []byte(key), Value: data}},
 		[]BatchOp{{Op: batchPut, Key: []byte(key), Value: data}},
@@ -1498,6 +1558,9 @@ func (m *TableManager) BuildIndex(indexName, tableName string, columns []string)
 	if err == nil {
 	if err == nil {
 		return m.ensureIndex(index)
 		return m.ensureIndex(index)
 	}
 	}
+	if !errors.Is(err, ErrIndexNotFound) {
+		return err
+	}
 
 
 	tableSchema, schemaErr := m.schema.GetSchema(tableName)
 	tableSchema, schemaErr := m.schema.GetSchema(tableName)
 	if schemaErr != nil {
 	if schemaErr != nil {
@@ -1558,6 +1621,303 @@ func (m *TableManager) buildIndexValue(row Row, columns []string) string {
 	return strings.Join(parts, "\x00")
 	return strings.Join(parts, "\x00")
 }
 }
 
 
+// ── UNIQUE index enforcement ────────────────────────────────────────────────
+//
+// Uniqueness is enforced by scanning durable rows rather than by persisting a
+// separate claim key. There is therefore no storage migration and no new durable
+// state: a table with a UNIQUE index takes its exclusive table gate for writes so
+// a validating scan can never race a concurrent writer. NULL values are exempt
+// (SQLite permits multiple NULLs in a unique index), and composite values are
+// encoded with length prefixes so "a\x00b" in one column can never collide with
+// "a", "b" across two columns.
+
+// uniqueIndexes returns the UNIQUE indexes defined on a table.
+func (m *TableManager) uniqueIndexes(table string) ([]*Index, error) {
+	indexes, err := m.schema.ListTableIndexes(table)
+	if err != nil {
+		return nil, err
+	}
+	var uniq []*Index
+	for _, idx := range indexes {
+		if idx.Unique {
+			uniq = append(uniq, idx)
+		}
+	}
+	return uniq, nil
+}
+
+// hasUniqueIndex reports whether the table has any UNIQUE index. An IO error is
+// surfaced so callers never silently skip uniqueness enforcement.
+func (m *TableManager) hasUniqueIndex(table string) (bool, error) {
+	indexes, err := m.uniqueIndexes(table)
+	if err != nil {
+		return false, err
+	}
+	return len(indexes) > 0, nil
+}
+
+// HasUniqueIndex reports whether the table has any UNIQUE index, surfacing IO
+// errors. It is the exported form used by the executor to decide whether to run a
+// DML statement inside an implicit transaction for statement-level atomicity.
+func (m *TableManager) HasUniqueIndex(table string) (bool, error) {
+	return m.hasUniqueIndex(table)
+}
+
+// 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) {
+	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
+				}
+			}
+		}
+		if !ok || v == nil {
+			return "", true
+		}
+		s := encodeUniqueScalar(v)
+		sb.WriteString(strconv.Itoa(len(s)))
+		sb.WriteByte(':')
+		sb.WriteString(s)
+	}
+	return sb.String(), false
+}
+
+// encodeUniqueScalar encodes a scalar for uniqueness comparison. Integral
+// numerics are canonicalized across their integer/float/unsigned Go
+// representations so a computed value such as 1.5-0.5 (float64) collides with
+// the literal 1 (int64) the same way SQLite's numeric affinity does. Non-integral
+// reals keep a full-precision tag so no distinct value is lost. TEXT and BLOB
+// remain distinct from numbers.
+func encodeUniqueScalar(v interface{}) string {
+	switch t := v.(type) {
+	case bool:
+		if t {
+			return "b1"
+		}
+		return "b0"
+	case int:
+		return "i" + strconv.FormatInt(int64(t), 10)
+	case int64:
+		return "i" + strconv.FormatInt(t, 10)
+	case uint:
+		return "i" + strconv.FormatUint(uint64(t), 10)
+	case uint8:
+		return "i" + strconv.FormatUint(uint64(t), 10)
+	case uint16:
+		return "i" + strconv.FormatUint(uint64(t), 10)
+	case uint32:
+		return "i" + strconv.FormatUint(uint64(t), 10)
+	case uint64:
+		return "i" + strconv.FormatUint(t, 10)
+	case uintptr:
+		return "i" + strconv.FormatUint(uint64(t), 10)
+	case float64:
+		if s, ok := encodeIntegralFloat(t); ok {
+			return s
+		}
+		return "r" + strconv.FormatFloat(t, 'g', -1, 64)
+	case string:
+		return "s" + t
+	case []byte:
+		return "x" + string(t)
+	default:
+		return "s" + fmt.Sprintf("%v", t)
+	}
+}
+
+// encodeIntegralFloat canonicalizes a whole float64 to the same "i" encoding used
+// by integer values, without precision loss, so integral reals and integers are
+// not treated as distinct under a unique index. It reports false (and returns
+// nothing) for non-integral values or values outside the int64 range.
+func encodeIntegralFloat(v float64) (string, bool) {
+	if v != math.Trunc(v) {
+		return "", false
+	}
+	if v < -9223372036854775808.0 || v >= 9223372036854775808.0 {
+		return "", false
+	}
+	return "i" + strconv.FormatInt(int64(v), 10), true
+}
+
+// validateUniqueRows checks that pending rows do not conflict with one another or
+// with durable rows on any UNIQUE index. excludedKeys lists the actual durable
+// data keys whose rows are being replaced in this operation (delete, or an update
+// of a non-indexed column), so those rows' own unique values do not self-conflict
+// and swapping two unique values remains possible. Matching is done against the
+// raw scanned KV key rather than a re-stringified primary key, so numeric and
+// composite keys are unambiguous. The caller must hold the table's exclusive gate.
+func (m *TableManager) validateUniqueRows(table string, pending []Row, excludedKeys map[string]bool) error {
+	indexes, err := m.uniqueIndexes(table)
+	if err != nil {
+		return err
+	}
+	if len(indexes) == 0 {
+		return nil
+	}
+
+	// seen tracks, per unique index, the encoded values already claimed by the
+	// pending rows. Keying by index name (not just the encoded value) is essential:
+	// a single row can legitimately carry the same value in two different unique
+	// indexes (e.g. name and lower_name both "alice"), which must not collide with
+	// itself. Two rows only conflict when they share a value within the SAME index.
+	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)
+			if isNull {
+				continue
+			}
+			perIndex := seen[idx.Name]
+			if perIndex == nil {
+				perIndex = make(map[string]bool)
+				seen[idx.Name] = perIndex
+			}
+			if perIndex[encoded] {
+				return fmt.Errorf("UNIQUE constraint failed: %s", idx.Name)
+			}
+			perIndex[encoded] = true
+		}
+	}
+
+	return m.pool.WithClient(func(client *KVClient) (retErr error) {
+		cursor, err := client.ScanWithLimit([]byte(m.dataPrefix(table)), scanPageSize)
+		if err != nil {
+			return err
+		}
+		defer func() {
+			if err := cursor.Close(); retErr == nil {
+				retErr = err
+			}
+		}()
+		for {
+			entries, done, err := cursor.Next()
+			if err != nil {
+				return err
+			}
+			for _, e := range entries {
+				if excludedKeys != nil && excludedKeys[string(e.Key)] {
+					continue
+				}
+				row, err := decodeRow(e.Value)
+				if err != nil {
+					return err
+				}
+				for _, idx := range indexes {
+					columns := indexColumnNames(idx)
+					encoded, isNull := encodeUniqueValue(row, columns)
+					if isNull {
+						continue
+					}
+					if perIndex := seen[idx.Name]; perIndex != nil && perIndex[encoded] {
+						return fmt.Errorf("UNIQUE constraint failed: %s", idx.Name)
+					}
+				}
+			}
+			if done {
+				return nil
+			}
+		}
+	})
+}
+
+// excludedKey returns the exclusion set that exempts a single row's durable value
+// from the uniqueness scan, keyed by its actual durable data key.
+func excludedKey(dataKey string) map[string]bool {
+	return map[string]bool{dataKey: true}
+}
+
+// lockForWrite acquires the table gate appropriate for a point write: exclusive
+// when the table has a UNIQUE index (so the validating scan cannot race another
+// writer), shared otherwise. The uniqueness decision is re-checked under the lock
+// so a concurrent CREATE UNIQUE INDEX cannot slip in between the unlocked probe
+// and the lock acquisition; the returned unlock closes the gate.
+func (m *TableManager) lockForWrite(table string) (unlock func(), unique bool, err error) {
+	tl := m.tableLock(table)
+	for {
+		uniq, err := m.hasUniqueIndex(table)
+		if err != nil {
+			return nil, false, err
+		}
+		if uniq {
+			tl.Lock()
+			// Re-check under the exclusive gate (authoritative): CreateUniqueIndex
+			// also takes the exclusive gate, so it cannot run while we hold it.
+			uniq, err = m.hasUniqueIndex(table)
+			if err != nil {
+				tl.Unlock()
+				return nil, false, err
+			}
+			if uniq {
+				return tl.Unlock, true, nil
+			}
+			tl.Unlock()
+			continue
+		}
+		tl.RLock()
+		// A unique index could have appeared before we acquired the shared gate;
+		// re-check and escalate if so.
+		uniq, err = m.hasUniqueIndex(table)
+		if err != nil {
+			tl.RUnlock()
+			return nil, false, err
+		}
+		if uniq {
+			tl.RUnlock()
+			continue
+		}
+		return tl.RUnlock, false, nil
+	}
+}
+
+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)
+		if isNull {
+			return false, nil
+		}
+		if seen[encoded] {
+			return true, fmt.Errorf("UNIQUE constraint failed: %s", index.Name)
+		}
+		seen[encoded] = true
+		return false, nil
+	})
+}
+
+// CreateUniqueIndex validates existing rows and registers the index while
+// holding the table's exclusive gate, so a concurrent writer cannot insert a
+// conflicting value between validation and registration.
+func (m *TableManager) CreateUniqueIndex(index *Index) error {
+	tl := m.tableLock(index.Table)
+	tl.Lock()
+	defer tl.Unlock()
+	if err := m.ValidateUniqueIndex(index); err != nil {
+		return err
+	}
+	return m.schema.CreateIndex(index)
+}
+
 type indexedRowVersion struct {
 type indexedRowVersion struct {
 	row Row
 	row Row
 	key string
 	key string

+ 184 - 90
pkg/storage/tx.go

@@ -160,48 +160,14 @@ func (s *Session) Commit() error {
 		return fmt.Errorf("current transaction is aborted")
 		return fmt.Errorf("current transaction is aborted")
 	}
 	}
 
 
-	// Collect affected tables and lock them in sorted order. Tables read through
-	// scans or predicates need an exclusive validation gate. Tables that are
-	// only written use the shared publication gate, allowing disjoint optimistic
-	// commits to proceed concurrently while still excluding scanner commits.
-	affected := make(map[string]bool)
-	for t := range s.overlay {
-		affected[t] = false
-	}
-	for t := range s.scanGens {
-		affected[t] = true
-	}
-	for _, predicate := range s.predicateGens {
-		affected[predicate.table] = true
-	}
-	tables := make([]string, 0, len(affected))
-	for t := range affected {
-		tables = append(tables, t)
-	}
-	sort.Strings(tables)
-
-	locks := make([]*sync.RWMutex, len(tables))
-	exclusive := make([]bool, len(tables))
-	for i, t := range tables {
-		locks[i] = s.table.tableLock(t)
-		exclusive[i] = affected[t]
-	}
-	for i, lock := range locks {
-		if exclusive[i] {
-			lock.Lock()
-		} else {
-			lock.RLock()
-		}
+	// Acquire the per-table gates, escalating any table whose UNIQUE index
+	// appeared after the initial unlocked probe. See acquireCommitLocks.
+	_, _, unlockAll, uniqueTables, err := s.acquireCommitLocks()
+	if err != nil {
+		s.resetLocked()
+		return err
 	}
 	}
-	defer func() {
-		for i := len(locks) - 1; i >= 0; i-- {
-			if exclusive[i] {
-				locks[i].Unlock()
-			} else {
-				locks[i].RUnlock()
-			}
-		}
-	}()
+	defer unlockAll()
 
 
 	// Validate scan generations for phantom detection.
 	// Validate scan generations for phantom detection.
 	for t, gen := range s.scanGens {
 	for t, gen := range s.scanGens {
@@ -217,6 +183,30 @@ func (s *Session) Commit() error {
 		}
 		}
 	}
 	}
 
 
+	// Validate the final overlay of every unique-indexed written table against
+	// durable rows. This runs under the exclusive gate acquired above, so it
+	// cannot race a concurrent writer. Duplicate unique values fail the commit
+	// with a "UNIQUE constraint failed" error rather than ErrSerialization.
+	for t := range uniqueTables {
+		entries := s.overlay[t]
+		pending := make([]Row, 0, len(entries))
+		// Exclude every overlay data key: rows being written, updated, or deleted
+		// are all replaced by this transaction, so their durable unique values
+		// must not self-conflict with the staged state (e.g. delete a row and
+		// re-insert the same primary key with a different rowid).
+		excluded := make(map[string]bool, len(entries))
+		for key, e := range entries {
+			excluded[key] = true
+			if !e.absent {
+				pending = append(pending, e.row)
+			}
+		}
+		if err := s.table.validateUniqueRows(t, pending, excluded); err != nil {
+			s.resetLocked()
+			return err
+		}
+	}
+
 	// Build the compare set from every observed key.
 	// Build the compare set from every observed key.
 	checks := make([]CompareCheck, 0, len(s.reads))
 	checks := make([]CompareCheck, 0, len(s.reads))
 	for key, lsn := range s.reads {
 	for key, lsn := range s.reads {
@@ -233,6 +223,7 @@ func (s *Session) Commit() error {
 			} else {
 			} else {
 				data, err := encodeRow(e.row)
 				data, err := encodeRow(e.row)
 				if err != nil {
 				if err != nil {
+					s.resetLocked()
 					return err
 					return err
 				}
 				}
 				ops = append(ops, BatchOp{Op: batchPut, Key: []byte(key), Value: data})
 				ops = append(ops, BatchOp{Op: batchPut, Key: []byte(key), Value: data})
@@ -248,12 +239,13 @@ func (s *Session) Commit() error {
 	}
 	}
 
 
 	var committed bool
 	var committed bool
-	err := s.table.pool.WithClient(func(c *KVClient) error {
+	err = s.table.pool.WithClient(func(c *KVClient) error {
 		_, ok, err := c.CompareBatchWrite(checks, ops, nil)
 		_, ok, err := c.CompareBatchWrite(checks, ops, nil)
 		committed = ok
 		committed = ok
 		return err
 		return err
 	})
 	})
 	if err != nil {
 	if err != nil {
+		s.resetLocked()
 		return err
 		return err
 	}
 	}
 	if !committed {
 	if !committed {
@@ -272,6 +264,106 @@ func (s *Session) Commit() error {
 	return nil
 	return nil
 }
 }
 
 
+// acquireCommitLocks acquires the per-table gates needed to commit the staged
+// transaction, returning the held locks, whether each is exclusive, an unlock
+// function, and the set of written tables that carry a UNIQUE index. A written
+// table with a UNIQUE index is locked exclusively so its final overlay can be
+// validated against durable rows. The uniqueness probe is re-checked under the
+// held gates so a concurrent CREATE UNIQUE INDEX cannot slip in after the probe
+// and leave a duplicate unvalidated.
+func (s *Session) acquireCommitLocks() (locks []*sync.RWMutex, exclusive []bool, unlockAll func(), uniqueTables map[string]bool, err error) {
+	unlock := func(ls []*sync.RWMutex, ex []bool) {
+		for i := len(ls) - 1; i >= 0; i-- {
+			if ex[i] {
+				ls[i].Unlock()
+			} else {
+				ls[i].RUnlock()
+			}
+		}
+	}
+	for {
+		// Tables read through scans or predicates need an exclusive validation
+		// gate. Tables that are only written use the shared publication gate,
+		// allowing disjoint optimistic commits to proceed concurrently while
+		// still excluding scanner commits.
+		affected := make(map[string]bool)
+		for t := range s.overlay {
+			affected[t] = false
+		}
+		for t := range s.scanGens {
+			affected[t] = true
+		}
+		for _, predicate := range s.predicateGens {
+			affected[predicate.table] = true
+		}
+
+		// Tables written in this transaction that carry a UNIQUE index need the
+		// exclusive gate so their final overlay can be validated against durable
+		// rows without racing another writer.
+		uniqueTables = make(map[string]bool)
+		for t := range s.overlay {
+			uniq, err := s.table.hasUniqueIndex(t)
+			if err != nil {
+				return nil, nil, nil, nil, err
+			}
+			if uniq {
+				uniqueTables[t] = true
+				affected[t] = true
+			}
+		}
+
+		tables := make([]string, 0, len(affected))
+		for t := range affected {
+			tables = append(tables, t)
+		}
+		sort.Strings(tables)
+
+		locks = make([]*sync.RWMutex, len(tables))
+		exclusive = make([]bool, len(tables))
+		for i, t := range tables {
+			locks[i] = s.table.tableLock(t)
+			exclusive[i] = affected[t]
+		}
+		for i, lock := range locks {
+			if exclusive[i] {
+				lock.Lock()
+			} else {
+				lock.RLock()
+			}
+		}
+
+		// Re-check under the held gates. A concurrent CREATE UNIQUE INDEX can
+		// only have completed before we acquired the gate (it needs the exclusive
+		// gate), so this probe is authoritative; escalate and retry if a written
+		// table gained a unique index.
+		retry := false
+		for i, t := range tables {
+			if exclusive[i] {
+				continue
+			}
+			if _, written := s.overlay[t]; !written {
+				continue
+			}
+			uniq, err := s.table.hasUniqueIndex(t)
+			if err != nil {
+				unlock(locks, exclusive)
+				return nil, nil, nil, nil, err
+			}
+			if uniq {
+				retry = true
+				break
+			}
+		}
+		if retry {
+			unlock(locks, exclusive)
+			continue
+		}
+
+		unlockAll = func() { unlock(locks, exclusive) }
+		return locks, exclusive, unlockAll, uniqueTables, nil
+	}
+}
+
 func (s *Session) resetLocked() {
 func (s *Session) resetLocked() {
 	s.inTx = false
 	s.inTx = false
 	s.aborted = false
 	s.aborted = false
@@ -294,13 +386,15 @@ func (s *Session) stagePut(table, key string, row Row) {
 	}
 	}
 }
 }
 
 
-func (s *Session) stageDelete(table, key string) {
+func (s *Session) stageDelete(table, key string, row Row) {
 	tl := strings.ToLower(table)
 	tl := strings.ToLower(table)
 	if s.overlay[tl] == nil {
 	if s.overlay[tl] == nil {
 		s.overlay[tl] = make(map[string]*overlayEntry)
 		s.overlay[tl] = make(map[string]*overlayEntry)
 	}
 	}
 	s.log = append(s.log, txMutation{table: tl, key: key, prev: s.overlay[tl][key]})
 	s.log = append(s.log, txMutation{table: tl, key: key, prev: s.overlay[tl][key]})
-	s.overlay[tl][key] = &overlayEntry{absent: true}
+	// Keep the deleted row so COMMIT can exempt its rowid from the unique-index
+	// scan and release its unique value.
+	s.overlay[tl][key] = &overlayEntry{row: cloneRow(row), absent: true}
 	if _, ok := s.reads[key]; !ok {
 	if _, ok := s.reads[key]; !ok {
 		s.reads[key] = 0
 		s.reads[key] = 0
 	}
 	}
@@ -447,91 +541,80 @@ func (s *Session) CountFast(table string) (int, error) {
 // Insert stages an insert in a transaction, or performs a durable autocommit
 // Insert stages an insert in a transaction, or performs a durable autocommit
 // insert otherwise.
 // insert otherwise.
 func (s *Session) Insert(table string, row Row) error {
 func (s *Session) Insert(table string, row Row) error {
+	_, err := s.InsertWithRowID(table, row)
+	return err
+}
+
+// InsertWithRowID stages an insert in a transaction, or performs a durable
+// autocommit insert otherwise, returning the actual generated ROWID.
+func (s *Session) InsertWithRowID(table string, row Row) (int64, error) {
 	s.mu.Lock()
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	defer s.mu.Unlock()
 	if !s.inTx {
 	if !s.inTx {
-		return s.table.Insert(table, row)
-	}
-
-	nr, key, err := s.table.prepareInsert(table, row)
-	if err != nil {
-		return err
-	}
-	schema, err := s.schema.GetSchema(table)
-	if err != nil {
-		return err
-	}
-	pk := fmt.Sprintf("%v", nr[schema.PrimaryKey])
-	tl := strings.ToLower(table)
-
-	if e, ok := s.overlay[tl][key]; ok {
-		if !e.absent {
-			return fmt.Errorf("duplicate primary key: %s", pk)
-		}
-	} else {
-		_, lsn, err := s.table.getByPKWithLSN(table, pk)
-		if err == nil {
-			s.reads[key] = lsn
-			return fmt.Errorf("duplicate primary key: %s", pk)
-		}
-		if err != ErrKeyNotFound {
-			return err
-		}
-		s.reads[key] = 0
+		return s.table.InsertWithRowID(table, row)
 	}
 	}
-
-	s.stagePut(table, key, nr)
-	return nil
+	return s.insertLocked(table, row)
 }
 }
 
 
 // InsertBulk stages or durably bulk-inserts multiple rows.
 // InsertBulk stages or durably bulk-inserts multiple rows.
 func (s *Session) InsertBulk(table string, rows []Row) (int, error) {
 func (s *Session) InsertBulk(table string, rows []Row) (int, error) {
+	count, _, err := s.InsertBulkWithLastRowID(table, rows)
+	return count, err
+}
+
+// InsertBulkWithLastRowID is InsertBulk, additionally returning the ROWID of the
+// last staged/persisted row (0 when nothing was inserted).
+func (s *Session) InsertBulkWithLastRowID(table string, rows []Row) (int, int64, error) {
 	s.mu.Lock()
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	defer s.mu.Unlock()
 	if !s.inTx {
 	if !s.inTx {
-		return s.table.InsertBulk(table, rows)
+		return s.table.InsertBulkWithLastRowID(table, rows)
 	}
 	}
+	var lastRowID int64
 	count := 0
 	count := 0
 	for _, row := range rows {
 	for _, row := range rows {
-		if err := s.insertLocked(table, row); err != nil {
-			return count, err
+		rid, err := s.insertLocked(table, row)
+		if err != nil {
+			return count, lastRowID, err
 		}
 		}
+		lastRowID = rid
 		count++
 		count++
 	}
 	}
-	return count, nil
+	return count, lastRowID, nil
 }
 }
 
 
 // insertLocked is the transaction insert helper (caller holds s.mu).
 // insertLocked is the transaction insert helper (caller holds s.mu).
-func (s *Session) insertLocked(table string, row Row) error {
+func (s *Session) insertLocked(table string, row Row) (int64, error) {
 	nr, key, err := s.table.prepareInsert(table, row)
 	nr, key, err := s.table.prepareInsert(table, row)
 	if err != nil {
 	if err != nil {
-		return err
+		return 0, err
 	}
 	}
+	rowid, _ := rowIDFromRow(nr)
 	schema, err := s.schema.GetSchema(table)
 	schema, err := s.schema.GetSchema(table)
 	if err != nil {
 	if err != nil {
-		return err
+		return 0, err
 	}
 	}
 	pk := fmt.Sprintf("%v", nr[schema.PrimaryKey])
 	pk := fmt.Sprintf("%v", nr[schema.PrimaryKey])
 	tl := strings.ToLower(table)
 	tl := strings.ToLower(table)
 
 
 	if e, ok := s.overlay[tl][key]; ok {
 	if e, ok := s.overlay[tl][key]; ok {
 		if !e.absent {
 		if !e.absent {
-			return fmt.Errorf("duplicate primary key: %s", pk)
+			return 0, fmt.Errorf("duplicate primary key: %s", pk)
 		}
 		}
 	} else {
 	} else {
 		_, lsn, err := s.table.getByPKWithLSN(table, pk)
 		_, lsn, err := s.table.getByPKWithLSN(table, pk)
 		if err == nil {
 		if err == nil {
 			s.reads[key] = lsn
 			s.reads[key] = lsn
-			return fmt.Errorf("duplicate primary key: %s", pk)
+			return 0, fmt.Errorf("duplicate primary key: %s", pk)
 		}
 		}
 		if err != ErrKeyNotFound {
 		if err != ErrKeyNotFound {
-			return err
+			return 0, err
 		}
 		}
 		s.reads[key] = 0
 		s.reads[key] = 0
 	}
 	}
 
 
 	s.stagePut(table, key, nr)
 	s.stagePut(table, key, nr)
-	return nil
+	return rowid, nil
 }
 }
 
 
 // UpdateByPK stages or durably applies a single-row update.
 // UpdateByPK stages or durably applies a single-row update.
@@ -558,7 +641,7 @@ func (s *Session) UpdateByPK(table, pk string, updateFn func(Row) (Row, error))
 	}
 	}
 
 
 	oldRow := cloneRow(row)
 	oldRow := cloneRow(row)
-	updates, err := updateFn(row)
+	updates, err := s.runUpdateFn(updateFn, row)
 	if err != nil {
 	if err != nil {
 		return nil, false, err
 		return nil, false, err
 	}
 	}
@@ -575,6 +658,17 @@ func (s *Session) UpdateByPK(table, pk string, updateFn func(Row) (Row, error))
 	return oldRow, true, nil
 	return oldRow, true, nil
 }
 }
 
 
+// runUpdateFn releases the session lock while invoking the update callback so the
+// callback can run nested reads (e.g. a scalar subquery in SET) through the same
+// session without deadlocking on s.mu. A connection executes one statement at a
+// time, so the staged overlay cannot change while the callback runs. The lock is
+// re-acquired before returning.
+func (s *Session) runUpdateFn(updateFn func(Row) (Row, error), row Row) (Row, error) {
+	s.mu.Unlock()
+	defer s.mu.Lock()
+	return updateFn(row)
+}
+
 // DeleteByPK stages or durably applies a single-row delete.
 // DeleteByPK stages or durably applies a single-row delete.
 func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
 func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
 	s.mu.Lock()
 	s.mu.Lock()
@@ -591,7 +685,7 @@ func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
 	if err != nil {
 	if err != nil {
 		return nil, false, err
 		return nil, false, err
 	}
 	}
-	s.stageDelete(table, key)
+	s.stageDelete(table, key, row)
 	return row, true, nil
 	return row, true, nil
 }
 }
 
 
@@ -636,7 +730,7 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 	}
 	}
 	count := 0
 	count := 0
 	for _, row := range rows {
 	for _, row := range rows {
-		updates, err := updateFn(row)
+		updates, err := s.runUpdateFn(updateFn, row)
 		if err != nil {
 		if err != nil {
 			return count, err
 			return count, err
 		}
 		}
@@ -675,7 +769,7 @@ func (s *Session) Delete(table string, filter func(Row) bool) (int, error) {
 	count := 0
 	count := 0
 	for _, row := range rows {
 	for _, row := range rows {
 		key := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
 		key := s.table.dataKey(table, fmt.Sprintf("%v", row[schema.PrimaryKey]))
-		s.stageDelete(table, key)
+		s.stageDelete(table, key, row)
 		count++
 		count++
 	}
 	}
 	return count, nil
 	return count, nil

+ 568 - 0
pkg/storage/unique_test.go

@@ -0,0 +1,568 @@
+package storage
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+	"testing"
+)
+
+func uniqueTable(t *testing.T, cols ...Column) (*SchemaManager, *TableManager) {
+	t.Helper()
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", cols)
+	return schemas, tables
+}
+
+func TestInsertWithRowIDReturnsActualRowID(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}})
+
+	rid, err := tables.InsertWithRowID("t", Row{"id": int64(41)})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if rid != 41 {
+		t.Fatalf("explicit rowid = %d, want 41", rid)
+	}
+	// Auto-generated rowid is max+1, never a naive counter or MAX of a scan.
+	rid, err = tables.InsertWithRowID("t", Row{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if rid != 42 {
+		t.Fatalf("generated rowid = %d, want 42", rid)
+	}
+}
+
+func TestInsertWithRowIDSkipsGaps(t *testing.T) {
+	_, _, schemas, tables := newTestSession(t)
+	createTestTable(t, schemas, "t", []Column{{Name: "id", Type: "INTEGER", PrimaryKey: true}})
+
+	for _, id := range []int64{100, 5, 7} {
+		if _, err := tables.InsertWithRowID("t", Row{"id": id}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	rid, err := tables.InsertWithRowID("t", Row{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if rid != 101 {
+		t.Fatalf("generated rowid = %d, want 101 (max+1, not a re-used gap)", rid)
+	}
+}
+
+func TestUniqueIndexEnforcesOnInsert(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+	err := tables.Insert("t", Row{"id": int64(2), "email": "a@x"})
+	if err == nil {
+		t.Fatal("expected unique violation on duplicate email")
+	}
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	// A distinct value still works.
+	if err := tables.Insert("t", Row{"id": int64(2), "email": "b@x"}); err != nil {
+		t.Fatalf("distinct email should succeed: %v", err)
+	}
+}
+
+func TestUniqueIndexAllowsMultipleNulls(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT", Nullable: true},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	for i := int64(1); i <= 3; i++ {
+		if err := tables.Insert("t", Row{"id": i, "email": nil}); err != nil {
+			t.Fatalf("NULL insert %d should succeed: %v", i, err)
+		}
+	}
+	if err := tables.Insert("t", Row{"id": int64(4), "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(5), "email": "a@x"}); err == nil {
+		t.Fatal("expected unique violation for non-NULL duplicate")
+	}
+}
+
+func TestUniqueCompositeIndex(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "a", Type: "TEXT"},
+		Column{Name: "b", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_ab", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "a"}, {Name: "b"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "a": "x", "b": "y"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(2), "a": "x", "b": "z"}); err != nil {
+		t.Fatalf("distinct composite should succeed: %v", err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(3), "a": "x", "b": "y"}); err == nil {
+		t.Fatal("expected unique violation for duplicate composite (x,y)")
+	}
+}
+
+func TestCreateUniqueIndexRejectsExistingDuplicates(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	for i := int64(1); i <= 2; i++ {
+		if err := tables.Insert("t", Row{"id": i, "email": "dup@x"}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	err := tables.CreateUniqueIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}})
+	if err == nil {
+		t.Fatal("expected CreateUniqueIndex to reject existing duplicates")
+	}
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	// The index must not be registered after the failed create.
+	if schemas.IndexExists("uq_email") {
+		t.Fatal("index should not exist after validation failure")
+	}
+}
+
+func TestUniqueIndexEnforcesOnUpdate(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(2), "email": "b@x"}); err != nil {
+		t.Fatal(err)
+	}
+	_, _, err := tables.UpdateByPK("t", "2", func(Row) (Row, error) { return Row{"email": "a@x"}, nil })
+	if err == nil {
+		t.Fatal("expected unique violation when updating email to existing value")
+	}
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestUniqueIndexFreesOnDelete(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+	if _, deleted, err := tables.DeleteByPK("t", "1"); err != nil || !deleted {
+		t.Fatalf("delete: deleted=%v err=%v", deleted, err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(2), "email": "a@x"}); err != nil {
+		t.Fatalf("re-insert after delete should succeed: %v", err)
+	}
+}
+
+func TestUniqueIndexTransactionCommitRejectsDuplicate(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+
+	s := NewSession(schemas, tables)
+	if err := s.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Insert("t", Row{"id": int64(2), "email": "a@x"}); err != nil {
+		t.Fatalf("staged duplicate insert should not fail until commit: %v", err)
+	}
+	if err := s.Commit(); err == nil {
+		t.Fatal("expected commit to reject unique violation")
+	} else if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected commit error: %v", err)
+	}
+	if _, err := tables.GetByPK("t", "2"); err != ErrKeyNotFound {
+		t.Fatalf("conflicting row should not be durable: %v", err)
+	}
+}
+
+func TestUniqueIndexTransactionSwap(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(2), "email": "b@x"}); err != nil {
+		t.Fatal(err)
+	}
+
+	s := NewSession(schemas, tables)
+	if err := s.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if _, _, err := s.UpdateByPK("t", "1", func(Row) (Row, error) { return Row{"email": "b@x"}, nil }); err != nil {
+		t.Fatal(err)
+	}
+	if _, _, err := s.UpdateByPK("t", "2", func(Row) (Row, error) { return Row{"email": "a@x"}, nil }); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Commit(); err != nil {
+		t.Fatalf("swap of unique values should commit: %v", err)
+	}
+}
+
+func TestUniqueIndexConcurrentInserts(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+
+	const n = 32
+	errs := make([]error, n)
+	var wg sync.WaitGroup
+	for i := 0; i < n; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			errs[i] = tables.Insert("t", Row{"id": int64(i + 1), "email": "same@x"})
+		}(i)
+	}
+	wg.Wait()
+
+	ok := 0
+	for _, e := range errs {
+		if e == nil {
+			ok++
+		} else if !strings.Contains(e.Error(), "UNIQUE constraint failed") {
+			t.Fatalf("unexpected insert error: %v", e)
+		}
+	}
+	if ok != 1 {
+		t.Fatalf("expected exactly one successful insert, got %d", ok)
+	}
+	if got := kvCount(t, tables, "t"); got != 1 {
+		t.Fatalf("expected 1 row, got %d", got)
+	}
+}
+
+func TestUniqueIndexConcurrentTransactions(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+
+	s1 := NewSession(schemas, tables)
+	s2 := NewSession(schemas, tables)
+	if err := s1.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if err := s1.Insert("t", Row{"id": int64(1), "email": "x@y"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := s2.Insert("t", Row{"id": int64(2), "email": "x@y"}); err != nil {
+		t.Fatal(err)
+	}
+
+	errs := make([]error, 2)
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() { defer wg.Done(); errs[0] = s1.Commit() }()
+	go func() { defer wg.Done(); errs[1] = s2.Commit() }()
+	wg.Wait()
+
+	ok, conflict := 0, 0
+	for _, e := range errs {
+		if e == nil {
+			ok++
+		} else if strings.Contains(e.Error(), "UNIQUE constraint failed") {
+			conflict++
+		} else {
+			t.Fatalf("unexpected commit error: %v", e)
+		}
+	}
+	if ok != 1 || conflict != 1 {
+		t.Fatalf("expected one commit and one unique conflict, got ok=%d conflict=%d", ok, conflict)
+	}
+	if got := kvCount(t, tables, "t"); got != 1 {
+		t.Fatalf("expected 1 durable row, got %d", got)
+	}
+}
+
+func TestUniqueIndexUpdateNoChangeDoesNotSelfConflict(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+		Column{Name: "name", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "email": "a@x", "name": "old"}); err != nil {
+		t.Fatal(err)
+	}
+	// Update a non-indexed column; the unchanged unique value must not conflict.
+	if _, updated, err := tables.UpdateByPK("t", "1", func(Row) (Row, error) { return Row{"name": "new"}, nil }); err != nil || !updated {
+		t.Fatalf("no-op unique update: updated=%v err=%v", updated, err)
+	}
+}
+
+func TestUniqueIndexValueEncodingDistinguishesTypes(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "v", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_v", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "v"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "v": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	// INTEGER 1 and TEXT "1" are distinct under a unique index.
+	if err := tables.Insert("t", Row{"id": int64(2), "v": "1"}); err != nil {
+		t.Fatalf("TEXT '1' should be distinct from INTEGER 1: %v", err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(3), "v": int64(1)}); err == nil {
+		t.Fatal("expected duplicate INTEGER 1 to be rejected")
+	}
+}
+
+// TestConcurrentCreateUniqueIndexAndInsert races a duplicate insert against a
+// CREATE UNIQUE INDEX. The invariant is that the unique index can never end up
+// present while two rows share a value: either the index creation wins and the
+// insert fails the uniqueness scan, or the insert wins and the index creation
+// fails validating the existing duplicate.
+func TestConcurrentCreateUniqueIndexAndInsert(t *testing.T) {
+	for iter := 0; iter < 200; iter++ {
+		_, _, schemas, tables := newTestSession(t)
+		createTestTable(t, schemas, "t", []Column{
+			{Name: "id", Type: "INTEGER", PrimaryKey: true},
+			{Name: "v", Type: "TEXT"},
+		})
+		if err := tables.Insert("t", Row{"id": int64(1), "v": "x"}); err != nil {
+			t.Fatal(err)
+		}
+
+		var insertErr, indexErr error
+		start := make(chan struct{})
+		var wg sync.WaitGroup
+		wg.Add(2)
+		go func() {
+			defer wg.Done()
+			<-start
+			insertErr = tables.Insert("t", Row{"id": int64(2), "v": "x"})
+		}()
+		go func() {
+			defer wg.Done()
+			<-start
+			indexErr = tables.CreateUniqueIndex(&Index{Name: "uq_v", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "v"}}})
+		}()
+		close(start)
+		wg.Wait()
+
+		if schemas.IndexExists("uq_v") {
+			rows, err := tables.Select("t", func(r Row) bool { return fmt.Sprintf("%v", r["v"]) == "x" })
+			if err != nil {
+				t.Fatal(err)
+			}
+			if len(rows) != 1 {
+				t.Fatalf("iteration %d: unique index present but %d rows with v='x' (insertErr=%v indexErr=%v)", iter, len(rows), insertErr, indexErr)
+			}
+		}
+	}
+}
+
+// TestConcurrentCreateUniqueIndexAndTransactionCommit exercises the same race
+// against a buffered transaction whose commit validates the final overlay.
+func TestConcurrentCreateUniqueIndexAndTransactionCommit(t *testing.T) {
+	for iter := 0; iter < 100; iter++ {
+		_, _, schemas, tables := newTestSession(t)
+		createTestTable(t, schemas, "t", []Column{
+			{Name: "id", Type: "INTEGER", PrimaryKey: true},
+			{Name: "v", Type: "TEXT"},
+		})
+		if err := tables.Insert("t", Row{"id": int64(1), "v": "x"}); err != nil {
+			t.Fatal(err)
+		}
+
+		s := NewSession(schemas, tables)
+		if err := s.Begin(); err != nil {
+			t.Fatal(err)
+		}
+		if err := s.Insert("t", Row{"id": int64(2), "v": "x"}); err != nil {
+			t.Fatal(err)
+		}
+
+		var commitErr, indexErr error
+		start := make(chan struct{})
+		var wg sync.WaitGroup
+		wg.Add(2)
+		go func() {
+			defer wg.Done()
+			<-start
+			commitErr = s.Commit()
+		}()
+		go func() {
+			defer wg.Done()
+			<-start
+			indexErr = tables.CreateUniqueIndex(&Index{Name: "uq_v", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "v"}}})
+		}()
+		close(start)
+		wg.Wait()
+
+		if schemas.IndexExists("uq_v") {
+			rows, err := tables.Select("t", func(r Row) bool { return fmt.Sprintf("%v", r["v"]) == "x" })
+			if err != nil {
+				t.Fatal(err)
+			}
+			if len(rows) != 1 {
+				t.Fatalf("iteration %d: unique index present but %d rows with v='x' (commitErr=%v indexErr=%v)", iter, len(rows), commitErr, indexErr)
+			}
+		}
+	}
+}
+
+func TestUniqueIndexIntegralNumericCanonicalization(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "v", Type: "REAL"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_v", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "v"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "v": int64(1)}); err != nil {
+		t.Fatal(err)
+	}
+	// A computed integral real (1.5-0.5) must collide with the integer 1.
+	err := tables.Insert("t", Row{"id": int64(2), "v": 1.5 - 0.5})
+	if err == nil {
+		t.Fatal("expected float64(1.0) to collide with int64(1)")
+	}
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	// A non-integral real remains distinct.
+	if err := tables.Insert("t", Row{"id": int64(3), "v": 1.5}); err != nil {
+		t.Fatalf("distinct non-integral real should succeed: %v", err)
+	}
+}
+
+func TestUniqueIndexUnsignedCanonicalization(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "v", Type: "INTEGER"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_v", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "v"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": int64(1), "v": uint64(7)}); err != nil {
+		t.Fatal(err)
+	}
+	// unsigned 7 and signed 7 are the same integer value.
+	if err := tables.Insert("t", Row{"id": int64(2), "v": int64(7)}); err == nil {
+		t.Fatal("expected uint64(7) to collide with int64(7)")
+	}
+}
+
+func TestUniqueIndexDeleteReinsertTextPK(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "TEXT", PrimaryKey: true},
+		Column{Name: "email", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "uq_email", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "email"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := tables.Insert("t", Row{"id": "alice", "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+
+	// Deleting and re-inserting the same TEXT primary key with the same unique
+	// value must not self-conflict: the re-insert is a new rowid but replaces the
+	// same durable data key.
+	s := NewSession(schemas, tables)
+	if err := s.Begin(); err != nil {
+		t.Fatal(err)
+	}
+	if _, deleted, err := s.DeleteByPK("t", "alice"); err != nil || !deleted {
+		t.Fatalf("delete: deleted=%v err=%v", deleted, err)
+	}
+	if err := s.Insert("t", Row{"id": "alice", "email": "a@x"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.Commit(); err != nil {
+		t.Fatalf("delete+reinsert same PK should commit, got: %v", err)
+	}
+	rows, err := tables.Select("t", nil)
+	if err != nil || len(rows) != 1 || rows[0]["email"] != "a@x" {
+		t.Fatalf("expected exactly one row with email a@x, got %v (err=%v)", rows, err)
+	}
+}
+
+func TestUniqueIndexesSameValueDifferentIndexes(t *testing.T) {
+	schemas, tables := uniqueTable(t,
+		Column{Name: "id", Type: "INTEGER", PrimaryKey: true},
+		Column{Name: "name", Type: "TEXT"},
+		Column{Name: "lower_name", Type: "TEXT"},
+	)
+	if err := schemas.CreateIndex(&Index{Name: "UQE_user_name", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "name"}}}); err != nil {
+		t.Fatal(err)
+	}
+	if err := schemas.CreateIndex(&Index{Name: "UQE_user_lower_name", Table: "t", Unique: true, Columns: []IndexColumn{{Name: "lower_name"}}}); err != nil {
+		t.Fatal(err)
+	}
+
+	// A single row carrying the same value in two different unique indexes must
+	// not self-collide (the seen set is per-index, not per-value).
+	if err := tables.Insert("t", Row{"id": int64(1), "name": "alice", "lower_name": "alice"}); err != nil {
+		t.Fatalf("same value across two unique indexes should not self-collide: %v", err)
+	}
+	// A second row with the same value in ONE index must still collide.
+	if err := tables.Insert("t", Row{"id": int64(2), "name": "bob", "lower_name": "alice"}); err == nil {
+		t.Fatal("expected unique violation on lower_name")
+	}
+	if err := tables.Insert("t", Row{"id": int64(2), "name": "alice", "lower_name": "bob"}); err == nil {
+		t.Fatal("expected unique violation on name")
+	}
+	if err := tables.Insert("t", Row{"id": int64(2), "name": "bob", "lower_name": "bob"}); err != nil {
+		t.Fatalf("distinct values should insert: %v", err)
+	}
+}