Kaynağa Gözat

sqlite import

Danilo Fragoso 3 ay önce
ebeveyn
işleme
e74354b715

+ 76 - 3
README.md

@@ -377,12 +377,12 @@ graph TD
 
 # Export / Import
 -o string            Output file (export)
--i string            Input file (import)
+-i string            Input file (import; .db/.sqlite/.sqlite3 auto-imports from SQLite)
 -table string        Table name (required for CSV)
--format string       Format: sql, csv (auto-detected from extension)
+-format string       Format: sql, csv, sqlite (auto-detected from extension)
 -drop                Include DROP TABLE in SQL export
 -create-table        Create table from CSV schema on import
--ignore-errors       Continue import on row errors
+-ignore-errors       Continue import on row/table errors
 
 # Misc
 -quiet               Suppress request/query logging
@@ -407,6 +407,8 @@ graph TD
 
 ### Database Export / Import
 
+#### SQL export/import
+
 ```bash
 # Export full database
 pizzasql -db mydb -o backup.sql
@@ -427,6 +429,77 @@ pizzasql -db mydb -i backup.sql
 pizzasql -db mydb -table users -i users.csv -create-table
 ```
 
+#### SQLite `.db` import
+
+PizzaSQL can import a SQLite database file directly. Tables, indexes, and row data are all imported. Pragmas, views, and triggers are skipped.
+
+**CLI — auto-detected from `.db` / `.sqlite` / `.sqlite3` extension:**
+```bash
+pizzasql -kv -db mydb -i source.db
+```
+
+**Keep going on errors** (e.g. duplicate rows or unsupported DDL):
+```bash
+pizzasql -kv -db mydb -i source.db -ignore-errors
+```
+
+**Insert into an existing database** (skip `CREATE TABLE`, only insert rows):
+```bash
+# Not yet exposed as a CLI flag — use the HTTP API's create_tables=false parameter
+```
+
+**HTTP API — multipart upload:**
+```bash
+curl -X POST http://localhost:8080/import \
+  -H "X-Database: mydb" \
+  -F "file=@source.db"
+```
+
+**HTTP API — raw body** with explicit format:
+```bash
+curl -X POST "http://localhost:8080/import?format=sqlite" \
+  -H "X-Database: mydb" \
+  -H "Content-Type: application/octet-stream" \
+  --data-binary @source.db
+```
+
+**HTTP API options:**
+
+| Query param | Default | Description |
+|---|---|---|
+| `format` | auto | `sqlite` forces binary SQLite mode |
+| `create_tables` | `true` | `false` skips `CREATE TABLE`, only inserts rows |
+| `ignore_errors` | `false` | Continue past individual row/table errors |
+
+**Response:**
+```json
+{
+  "tablesCreated":  ["users", "albums", "tracks"],
+  "tablesImported": ["users", "albums", "tracks"],
+  "rowsInserted":   27754,
+  "indexesCreated": 61,
+  "errors": []
+}
+```
+
+**What gets imported:**
+- All tables (schema + data)
+- Regular indexes (`CREATE INDEX`)
+
+**What is silently skipped:**
+- Pragmas
+- Views
+- Triggers
+- Expression indexes (e.g. `CREATE INDEX ON t(COALESCE(a, b))`)
+- `FOREIGN KEY` / `CHECK` constraints (schema is imported without them)
+- `AUTOINCREMENT` keyword (not needed — PizzaSQL handles PK generation)
+
+**Supported file detection** (format auto-selection in order):
+1. `?format=sqlite` query param
+2. Filename extension: `.db`, `.sqlite`, `.sqlite3`
+3. Content-Type: `application/x-sqlite3` or `application/octet-stream`
+4. Magic bytes: file starts with `SQLite format 3`
+
 ### SQL Support
 
 **Data Types:** `INTEGER` (INT, BIGINT, BOOLEAN) · `REAL` (FLOAT, DOUBLE, DECIMAL) · `TEXT` (VARCHAR, CHAR) · `BLOB` · `NUMERIC`

BIN
bin/pizzasql


+ 11 - 1
go.mod

@@ -1,8 +1,18 @@
 module github.com/danfragoso/pizzasql-next
 
-go 1.24
+go 1.25.0
 
 require (
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/goccy/go-json v0.10.6 // indirect
+	github.com/google/uuid v1.6.0 // indirect
 	github.com/lib/pq v1.12.3 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/ncruces/go-strftime v1.0.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	golang.org/x/sys v0.42.0 // indirect
+	modernc.org/libc v1.72.0 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.11.0 // indirect
+	modernc.org/sqlite v1.50.0 // indirect
 )

+ 39 - 2
main.go

@@ -23,6 +23,7 @@ import (
 	pizzaruntime "github.com/danfragoso/pizzasql-next/pkg/runtime"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlexport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
+	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
 
@@ -545,7 +546,8 @@ func printHelp() {
 	fmt.Println("  pizzasql -db mydb -table users -o t.sql   Export single table")
 	fmt.Println("  pizzasql -db mydb -o backup.sql -drop     Include DROP TABLE statements")
 	fmt.Println("  pizzasql -db mydb -i backup.sql           Import SQL file")
-	fmt.Println("  pizzasql -db mydb -i backup.sql -ignore-errors  Continue on errors")
+	fmt.Println("  pizzasql -db mydb -i source.db            Import SQLite .db file (auto-detected)")
+	fmt.Println("  pizzasql -db mydb -i source.db -ignore-errors  Import, skip errors")
 	fmt.Println()
 	fmt.Println("CSV Format:")
 	fmt.Println("  pizzasql -db mydb -table users -o users.csv         Export table to CSV")
@@ -709,7 +711,42 @@ func runImport() {
 			}
 		}
 
-	default: // sql, sqlite
+	case "sqlite":
+		// Binary SQLite .db import
+		opts := sqliteimport.DefaultImportOptions()
+		opts.IgnoreErrors = *ignoreErrors
+
+		result, err := sqliteimport.ImportSQLiteFile(*importFile, exec, opts)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Import failed: %v\n", err)
+			if len(result.Errors) > 0 {
+				fmt.Fprintf(os.Stderr, "Errors:\n")
+				for _, e := range result.Errors {
+					fmt.Fprintf(os.Stderr, "  - %s\n", e)
+				}
+			}
+			os.Exit(1)
+		}
+
+		fmt.Printf("SQLite import completed successfully\n")
+		if len(result.TablesCreated) > 0 {
+			fmt.Printf("  Tables created: %s\n", strings.Join(result.TablesCreated, ", "))
+		}
+		if len(result.TablesImported) > 0 {
+			fmt.Printf("  Tables imported: %s\n", strings.Join(result.TablesImported, ", "))
+		}
+		fmt.Printf("  Rows inserted: %d\n", result.RowsInserted)
+		if result.IndexesCreated > 0 {
+			fmt.Printf("  Indexes created: %d\n", result.IndexesCreated)
+		}
+		if len(result.Errors) > 0 {
+			fmt.Printf("  Warnings/Errors: %d\n", len(result.Errors))
+			for _, e := range result.Errors {
+				fmt.Printf("    - %s\n", e)
+			}
+		}
+
+	default: // sql
 		// Configure import options
 		opts := sqlimport.ImportOptions{
 			IgnoreErrors: *ignoreErrors,

+ 14 - 5
pkg/analyzer/types.go

@@ -113,6 +113,13 @@ func (t Type) IsComparable(other Type) bool {
 	if (t == TypeText || t == TypeBlob) && (other == TypeText || other == TypeBlob) {
 		return true
 	}
+	// NUMERIC/BOOLEAN accepts TEXT (SQLite-compatible: dates stored as text in numeric columns)
+	if (t == TypeNumeric || t == TypeBoolean) && (other == TypeText || other == TypeBlob) {
+		return true
+	}
+	if (other == TypeNumeric || other == TypeBoolean) && (t == TypeText || t == TypeBlob) {
+		return true
+	}
 	return false
 }
 
@@ -212,11 +219,13 @@ var builtinFunctions = map[string]FunctionSignature{
 	"CAST":   {Name: "CAST", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
 
 	// Date/Time functions
-	"DATE":      {Name: "DATE", MinArgs: 1, MaxArgs: -1, ArgTypes: []Type{TypeText}, ReturnType: TypeText, IsAggregate: false},
-	"TIME":      {Name: "TIME", MinArgs: 1, MaxArgs: -1, ArgTypes: []Type{TypeText}, ReturnType: TypeText, IsAggregate: false},
-	"DATETIME":  {Name: "DATETIME", MinArgs: 1, MaxArgs: -1, ArgTypes: []Type{TypeText}, ReturnType: TypeText, IsAggregate: false},
-	"JULIANDAY": {Name: "JULIANDAY", MinArgs: 1, MaxArgs: -1, ArgTypes: []Type{TypeText}, ReturnType: TypeReal, IsAggregate: false},
-	"STRFTIME":  {Name: "STRFTIME", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeText, TypeText}, ReturnType: TypeText, IsAggregate: false},
+	"DATE":      {Name: "DATE", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"TIME":      {Name: "TIME", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"DATETIME":  {Name: "DATETIME", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JULIANDAY": {Name: "JULIANDAY", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeReal, IsAggregate: false},
+	"UNIXEPOCH": {Name: "UNIXEPOCH", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeInteger, IsAggregate: false},
+	"STRFTIME":  {Name: "STRFTIME", MinArgs: 1, MaxArgs: -1, ArgTypes: []Type{TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"TIMEDIFF":  {Name: "TIMEDIFF", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeAny}, ReturnType: TypeText, IsAggregate: false},
 
 	// SQLite specific
 	"SQLITE_VERSION": {Name: "SQLITE_VERSION", MinArgs: 0, MaxArgs: 0, ArgTypes: []Type{}, ReturnType: TypeText, IsAggregate: false},

+ 551 - 0
pkg/executor/datetime.go

@@ -0,0 +1,551 @@
+package executor
+
+import (
+	"fmt"
+	"math"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// julianDayToTime converts a Julian Day Number to time.Time (UTC).
+// Julian day 2440587.5 = 1970-01-01 00:00:00 UTC.
+func julianDayToTime(jd float64) time.Time {
+	unixSec := (jd - 2440587.5) * 86400.0
+	sec := int64(unixSec)
+	nsec := int64(math.Round((unixSec - float64(sec)) * 1e9))
+	if nsec < 0 {
+		sec--
+		nsec += 1e9
+	}
+	return time.Unix(sec, nsec).UTC()
+}
+
+func timeToJulianDay(t time.Time) float64 {
+	return float64(t.UnixNano())/86400e9 + 2440587.5
+}
+
+// parseISO8601 parses the ISO-8601 subsets that SQLite supports.
+func parseISO8601(s string) (time.Time, error) {
+	loc := time.UTC
+	core := s
+
+	// Strip trailing Z
+	if len(core) > 0 && (core[len(core)-1] == 'Z' || core[len(core)-1] == 'z') {
+		core = core[:len(core)-1]
+	} else {
+		// Strip ±HH:MM timezone suffix (only if after at least YYYY-MM-DD)
+		if len(core) >= 16 {
+			for i := len(core) - 6; i >= 10; i-- {
+				if core[i] == '+' || core[i] == '-' {
+					possible := core[i:]
+					if len(possible) == 6 && possible[3] == ':' {
+						var hh, mm int
+						fmt.Sscanf(possible[1:], "%d:%d", &hh, &mm)
+						sign := 1
+						if possible[0] == '-' {
+							sign = -1
+						}
+						offset := sign * (hh*3600 + mm*60)
+						loc = time.FixedZone("", offset)
+						core = core[:i]
+						break
+					}
+				}
+			}
+		}
+	}
+
+	// Normalize T separator to space
+	core = strings.Replace(core, "T", " ", 1)
+	timeOnly := !strings.Contains(core, "-")
+
+	layouts := []string{
+		"2006-01-02 15:04:05.999999999",
+		"2006-01-02 15:04:05",
+		"2006-01-02 15:04",
+		"2006-01-02",
+		"15:04:05.999999999",
+		"15:04:05",
+		"15:04",
+	}
+
+	for _, layout := range layouts {
+		t, err := time.ParseInLocation(layout, core, loc)
+		if err == nil {
+			if timeOnly {
+				t = time.Date(2000, 1, 1, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc)
+			}
+			return t.UTC(), nil
+		}
+	}
+
+	return time.Time{}, fmt.Errorf("cannot parse time value: %q", s)
+}
+
+// parseDateArgs extracts time.Time and modifier strings from evaluated function args.
+// Handles 'now' default, numeric (Julian/Unix with modifier), and ISO-8601 text.
+func parseDateArgs(args []interface{}) (time.Time, []string, error) {
+	if len(args) == 0 {
+		return time.Now().UTC(), nil, nil
+	}
+
+	mods := make([]string, 0, len(args)-1)
+	for _, a := range args[1:] {
+		mods = append(mods, toString(a))
+	}
+
+	firstStr := strings.TrimSpace(toString(args[0]))
+	firstLower := strings.ToLower(firstStr)
+
+	// 'subsec'/'subsecond' as first arg means time-value defaults to 'now'
+	if firstLower == "subsec" || firstLower == "subsecond" {
+		return time.Now().UTC(), append([]string{firstStr}, mods...), nil
+	}
+
+	if firstLower == "now" {
+		return time.Now().UTC(), mods, nil
+	}
+
+	// Numeric: Julian day by default; first modifier may change interpretation
+	if f, err := strconv.ParseFloat(firstStr, 64); err == nil {
+		if len(mods) > 0 {
+			switch strings.ToLower(strings.TrimSpace(mods[0])) {
+			case "unixepoch":
+				sec := int64(f)
+				nsec := int64(math.Round((f - float64(sec)) * 1e9))
+				return time.Unix(sec, nsec).UTC(), mods[1:], nil
+			case "auto":
+				if f >= 0.0 && f <= 5373484.499999 {
+					return julianDayToTime(f), mods[1:], nil
+				}
+				if f >= -210866760000 && f <= 253402300799 {
+					return time.Unix(int64(f), 0).UTC(), mods[1:], nil
+				}
+				return time.Time{}, nil, fmt.Errorf("time value out of range for auto")
+			case "julianday":
+				return julianDayToTime(f), mods[1:], nil
+			}
+		}
+		return julianDayToTime(f), mods, nil
+	}
+
+	// ISO-8601 text
+	t, err := parseISO8601(firstStr)
+	if err != nil {
+		return time.Time{}, nil, err
+	}
+	return t, mods, nil
+}
+
+// applyModifiers applies SQLite date/time modifiers sequentially.
+// Returns modified time, subsec flag, and error.
+func applyModifiers(t time.Time, mods []string) (time.Time, bool, error) {
+	subsec := false
+
+	for _, mod := range mods {
+		mod = strings.TrimSpace(mod)
+		modLower := strings.ToLower(mod)
+
+		switch modLower {
+		case "subsec", "subsecond":
+			subsec = true
+		case "utc":
+			t = t.UTC()
+		case "localtime":
+			t = t.Local()
+		case "ceiling", "floor":
+			// Affects ambiguous month-shift results; treated as no-op here
+		case "unixepoch", "julianday", "auto":
+			// Only valid as first modifier after numeric time-value; consumed by parseDateArgs
+		case "start of month":
+			t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+		case "start of year":
+			t = time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location())
+		case "start of day":
+			t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+		default:
+			if strings.HasPrefix(modLower, "weekday ") {
+				nStr := strings.TrimPrefix(modLower, "weekday ")
+				if n, err := strconv.Atoi(nStr); err == nil {
+					target := time.Weekday(n % 7)
+					for t.Weekday() != target {
+						t = t.AddDate(0, 0, 1)
+					}
+				}
+				continue
+			}
+			if t2, ok := applyRelativeMod(t, mod); ok {
+				t = t2
+				continue
+			}
+			if t2, ok := applyTimeDiffMod(t, mod); ok {
+				t = t2
+			}
+			// Unknown modifiers are silently ignored (SQLite returns NULL; we're lenient)
+		}
+	}
+
+	return t, subsec, nil
+}
+
+// applyRelativeMod handles "NNN days", "NNN hours", "NNN minutes", "NNN seconds",
+// "NNN months", "NNN years" (trailing 's' optional, sign prefix allowed).
+func applyRelativeMod(t time.Time, mod string) (time.Time, bool) {
+	parts := strings.Fields(mod)
+	if len(parts) != 2 {
+		return t, false
+	}
+	f, err := strconv.ParseFloat(parts[0], 64)
+	if err != nil {
+		return t, false
+	}
+	unit := strings.ToLower(strings.TrimSuffix(parts[1], "s"))
+
+	switch unit {
+	case "day":
+		return t.Add(time.Duration(f * float64(24*time.Hour))), true
+	case "hour":
+		return t.Add(time.Duration(f * float64(time.Hour))), true
+	case "minute":
+		return t.Add(time.Duration(f * float64(time.Minute))), true
+	case "second":
+		return t.Add(time.Duration(f * float64(time.Second))), true
+	case "month":
+		whole := int(f)
+		frac := f - float64(whole)
+		t = t.AddDate(0, whole, 0)
+		if frac != 0 {
+			t = t.Add(time.Duration(frac * float64(30*24*time.Hour)))
+		}
+		return t, true
+	case "year":
+		whole := int(f)
+		frac := f - float64(whole)
+		t = t.AddDate(whole, 0, 0)
+		if frac != 0 {
+			t = t.Add(time.Duration(frac * float64(365*24*time.Hour)))
+		}
+		return t, true
+	}
+	return t, false
+}
+
+// applyTimeDiffMod handles timediff-output style modifiers: ±YYYY-MM-DD HH:MM:SS.SSS
+func applyTimeDiffMod(t time.Time, mod string) (time.Time, bool) {
+	if len(mod) == 0 {
+		return t, false
+	}
+	sign := 1
+	s := mod
+	switch s[0] {
+	case '+':
+		s = s[1:]
+	case '-':
+		sign = -1
+		s = s[1:]
+	default:
+		return t, false
+	}
+
+	parts := strings.SplitN(s, " ", 2)
+	dateSubs := strings.Split(parts[0], "-")
+	if len(dateSubs) != 3 {
+		return t, false
+	}
+	years, e1 := strconv.Atoi(dateSubs[0])
+	months, e2 := strconv.Atoi(dateSubs[1])
+	days, e3 := strconv.Atoi(dateSubs[2])
+	if e1 != nil || e2 != nil || e3 != nil {
+		return t, false
+	}
+
+	hours, minutes, secs, millis := 0, 0, 0, 0
+	if len(parts) == 2 {
+		tp := strings.SplitN(parts[1], ":", 3)
+		if len(tp) >= 1 {
+			hours, _ = strconv.Atoi(tp[0])
+		}
+		if len(tp) >= 2 {
+			minutes, _ = strconv.Atoi(tp[1])
+		}
+		if len(tp) >= 3 {
+			sp := strings.SplitN(tp[2], ".", 2)
+			secs, _ = strconv.Atoi(sp[0])
+			if len(sp) > 1 {
+				ms := sp[1]
+				for len(ms) < 3 {
+					ms += "0"
+				}
+				millis, _ = strconv.Atoi(ms[:3])
+			}
+		}
+	}
+
+	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)
+	return t.Add(dur), true
+}
+
+// sqliteStrftime formats t using SQLite strftime codes.
+func sqliteStrftime(format string, t time.Time, subsec bool) string {
+	var b strings.Builder
+	jd := timeToJulianDay(t)
+
+	for i := 0; i < len(format); i++ {
+		if format[i] != '%' || i+1 >= len(format) {
+			b.WriteByte(format[i])
+			continue
+		}
+		i++
+		switch format[i] {
+		case 'd':
+			fmt.Fprintf(&b, "%02d", t.Day())
+		case 'e':
+			fmt.Fprintf(&b, "%d", t.Day())
+		case 'f':
+			sec := float64(t.Second()) + float64(t.Nanosecond())/1e9
+			fmt.Fprintf(&b, "%06.3f", sec)
+		case 'F':
+			fmt.Fprintf(&b, "%04d-%02d-%02d", t.Year(), int(t.Month()), t.Day())
+		case 'G':
+			y, _ := t.ISOWeek()
+			fmt.Fprintf(&b, "%04d", y)
+		case 'g':
+			y, _ := t.ISOWeek()
+			fmt.Fprintf(&b, "%02d", y%100)
+		case 'H':
+			fmt.Fprintf(&b, "%02d", t.Hour())
+		case 'I':
+			h := t.Hour() % 12
+			if h == 0 {
+				h = 12
+			}
+			fmt.Fprintf(&b, "%02d", h)
+		case 'j':
+			fmt.Fprintf(&b, "%03d", t.YearDay())
+		case 'J':
+			fmt.Fprintf(&b, "%.10f", jd)
+		case 'k':
+			fmt.Fprintf(&b, "%d", t.Hour())
+		case 'l':
+			h := t.Hour() % 12
+			if h == 0 {
+				h = 12
+			}
+			fmt.Fprintf(&b, "%d", h)
+		case 'm':
+			fmt.Fprintf(&b, "%02d", int(t.Month()))
+		case 'M':
+			fmt.Fprintf(&b, "%02d", t.Minute())
+		case 'p':
+			if t.Hour() < 12 {
+				b.WriteString("AM")
+			} else {
+				b.WriteString("PM")
+			}
+		case 'P':
+			if t.Hour() < 12 {
+				b.WriteString("am")
+			} else {
+				b.WriteString("pm")
+			}
+		case 'R':
+			fmt.Fprintf(&b, "%02d:%02d", t.Hour(), t.Minute())
+		case 's':
+			if subsec {
+				fmt.Fprintf(&b, "%.3f", float64(t.Unix())+float64(t.Nanosecond())/1e9)
+			} else {
+				fmt.Fprintf(&b, "%d", t.Unix())
+			}
+		case 'S':
+			fmt.Fprintf(&b, "%02d", t.Second())
+		case 'T':
+			fmt.Fprintf(&b, "%02d:%02d:%02d", t.Hour(), t.Minute(), t.Second())
+		case 'U':
+			fmt.Fprintf(&b, "%02d", sundayWeek(t))
+		case 'u':
+			w := int(t.Weekday())
+			if w == 0 {
+				w = 7
+			}
+			fmt.Fprintf(&b, "%d", w)
+		case 'V':
+			_, week := t.ISOWeek()
+			fmt.Fprintf(&b, "%02d", week)
+		case 'w':
+			fmt.Fprintf(&b, "%d", int(t.Weekday()))
+		case 'W':
+			fmt.Fprintf(&b, "%02d", mondayWeek(t))
+		case 'Y':
+			fmt.Fprintf(&b, "%04d", t.Year())
+		case '%':
+			b.WriteByte('%')
+		default:
+			b.WriteByte('%')
+			b.WriteByte(format[i])
+		}
+	}
+	return b.String()
+}
+
+func sundayWeek(t time.Time) int {
+	yd := t.YearDay()
+	dow := int(t.Weekday()) // 0=Sunday
+	return (yd - dow + 6) / 7
+}
+
+func mondayWeek(t time.Time) int {
+	yd := t.YearDay()
+	dow := int(t.Weekday())
+	if dow == 0 {
+		dow = 7
+	}
+	return (yd - dow + 7) / 7
+}
+
+func evalDateFunc(args []interface{}) (interface{}, error) {
+	t, mods, err := parseDateArgs(args)
+	if err != nil {
+		return nil, nil
+	}
+	t, _, err = applyModifiers(t, mods)
+	if err != nil {
+		return nil, nil
+	}
+	return fmt.Sprintf("%04d-%02d-%02d", t.Year(), int(t.Month()), t.Day()), nil
+}
+
+func evalTimeFunc(args []interface{}) (interface{}, error) {
+	t, mods, err := parseDateArgs(args)
+	if err != nil {
+		return nil, nil
+	}
+	t, subsec, err := applyModifiers(t, mods)
+	if err != nil {
+		return nil, nil
+	}
+	if subsec {
+		return fmt.Sprintf("%02d:%02d:%02d.%03d", t.Hour(), t.Minute(), t.Second(), t.Nanosecond()/1e6), nil
+	}
+	return fmt.Sprintf("%02d:%02d:%02d", t.Hour(), t.Minute(), t.Second()), nil
+}
+
+func evalDatetimeFunc(args []interface{}) (interface{}, error) {
+	t, mods, err := parseDateArgs(args)
+	if err != nil {
+		return nil, nil
+	}
+	t, subsec, err := applyModifiers(t, mods)
+	if err != nil {
+		return nil, nil
+	}
+	if subsec {
+		return fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d.%03d",
+			t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond()/1e6), nil
+	}
+	return fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d",
+		t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()), nil
+}
+
+func evalJuliandayFunc(args []interface{}) (interface{}, error) {
+	t, mods, err := parseDateArgs(args)
+	if err != nil {
+		return nil, nil
+	}
+	t, _, err = applyModifiers(t, mods)
+	if err != nil {
+		return nil, nil
+	}
+	return timeToJulianDay(t), nil
+}
+
+func evalUnixepochFunc(args []interface{}) (interface{}, error) {
+	t, mods, err := parseDateArgs(args)
+	if err != nil {
+		return nil, nil
+	}
+	t, subsec, err := applyModifiers(t, mods)
+	if err != nil {
+		return nil, nil
+	}
+	if subsec {
+		return float64(t.Unix()) + float64(t.Nanosecond())/1e9, nil
+	}
+	return t.Unix(), nil
+}
+
+func evalStrftimeFunc(args []interface{}) (interface{}, error) {
+	if len(args) == 0 {
+		return nil, nil
+	}
+	format := toString(args[0])
+	t, mods, err := parseDateArgs(args[1:])
+	if err != nil {
+		return nil, nil
+	}
+	t, subsec, err := applyModifiers(t, mods)
+	if err != nil {
+		return nil, nil
+	}
+	return sqliteStrftime(format, t, subsec), nil
+}
+
+// evalTimediffFunc implements timediff(A, B): returns ±YYYY-MM-DD HH:MM:SS.SSS
+// representing the amount of time to add to B to reach A.
+func evalTimediffFunc(args []interface{}) (interface{}, error) {
+	if len(args) < 2 {
+		return nil, nil
+	}
+	tA, _, err := parseDateArgs(args[0:1])
+	if err != nil {
+		return nil, nil
+	}
+	tB, _, err := parseDateArgs(args[1:2])
+	if err != nil {
+		return nil, nil
+	}
+
+	sign := "+"
+	a, b := tA, tB
+	if a.Before(b) {
+		sign = "-"
+		a, b = b, a
+	}
+
+	// Greedy calendar subtraction: find years, months, days, then sub-day duration.
+	years := a.Year() - b.Year()
+	cursor := b.AddDate(years, 0, 0)
+	if cursor.After(a) {
+		years--
+		cursor = b.AddDate(years, 0, 0)
+	}
+
+	months := 0
+	for cursor.AddDate(0, 1, 0).Before(a) || cursor.AddDate(0, 1, 0).Equal(a) {
+		months++
+		cursor = cursor.AddDate(0, 1, 0)
+	}
+
+	days := 0
+	for cursor.AddDate(0, 0, 1).Before(a) || cursor.AddDate(0, 0, 1).Equal(a) {
+		days++
+		cursor = cursor.AddDate(0, 0, 1)
+	}
+
+	remaining := a.Sub(cursor)
+	h := int(remaining.Hours())
+	remaining -= time.Duration(h) * time.Hour
+	m := int(remaining.Minutes())
+	remaining -= time.Duration(m) * time.Minute
+	s := int(remaining.Seconds())
+	remaining -= time.Duration(s) * time.Second
+	ms := int(remaining.Milliseconds())
+
+	return fmt.Sprintf("%s%04d-%02d-%02d %02d:%02d:%02d.%03d",
+		sign, years, months, days, h, m, s, ms), nil
+}

+ 16 - 0
pkg/executor/executor.go

@@ -3842,6 +3842,22 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 			result.WriteString(toString(arg))
 		}
 		return result.String(), nil
+
+	// Date/Time functions
+	case "DATE":
+		return evalDateFunc(args)
+	case "TIME":
+		return evalTimeFunc(args)
+	case "DATETIME":
+		return evalDatetimeFunc(args)
+	case "JULIANDAY":
+		return evalJuliandayFunc(args)
+	case "UNIXEPOCH":
+		return evalUnixepochFunc(args)
+	case "STRFTIME":
+		return evalStrftimeFunc(args)
+	case "TIMEDIFF":
+		return evalTimediffFunc(args)
 	}
 
 	return nil, nil

+ 37 - 3
pkg/httpserver/handler.go

@@ -16,6 +16,7 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlexport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
+	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
 )
 
 // QueryRequest represents a single query request.
@@ -828,10 +829,24 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
 
 	// Auto-detect format from filename extension if not specified
 	if format == "" && filename != "" {
-		if strings.HasSuffix(strings.ToLower(filename), ".csv") {
+		lower := strings.ToLower(filename)
+		if strings.HasSuffix(lower, ".csv") {
 			format = "csv"
+		} else if strings.HasSuffix(lower, ".db") || strings.HasSuffix(lower, ".sqlite") || strings.HasSuffix(lower, ".sqlite3") {
+			format = "sqlite"
 		}
 	}
+	// Detect binary SQLite from content-type
+	if format == "" {
+		ct := strings.ToLower(contentType)
+		if strings.Contains(ct, "application/x-sqlite3") || strings.Contains(ct, "application/octet-stream") {
+			format = "sqlite"
+		}
+	}
+	// Check magic bytes: SQLite files start with "SQLite format 3\000"
+	if format == "" && len(fileContent) >= 16 && string(fileContent[:15]) == "SQLite format 3" {
+		format = "sqlite"
+	}
 	if format == "" {
 		format = "sql"
 	}
@@ -865,8 +880,27 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
 
 		writeJSON(w, http.StatusOK, result, pretty)
 
-	case "sql", "sqlite":
-		// SQL import
+	case "sqlite":
+		// Binary SQLite .db import
+		opts := sqliteimport.DefaultImportOptions()
+		opts.CreateTables = r.URL.Query().Get("create_tables") != "false"
+		opts.IgnoreErrors = ignoreErrors
+
+		result, err := sqliteimport.ImportSQLiteBytes(fileContent, exec, opts)
+		if err != nil && !ignoreErrors {
+			writeError(w, http.StatusBadRequest, "IMPORT_ERROR", err.Error(), map[string]interface{}{
+				"tablesCreated":  result.TablesCreated,
+				"rowsInserted":   result.RowsInserted,
+				"errors":         result.Errors,
+			})
+			return
+		}
+
+		exec.SyncCatalog()
+		writeJSON(w, http.StatusOK, result, pretty)
+
+	case "sql":
+		// SQL text import
 		opts := sqlimport.DefaultImportOptions()
 		opts.IgnoreErrors = ignoreErrors
 

+ 10 - 0
pkg/lexer/token.go

@@ -138,7 +138,9 @@ const (
 	// Data types
 	TokenINTEGER
 	TokenINT
+	TokenTINYINT
 	TokenSMALLINT
+	TokenMEDIUMINT
 	TokenBIGINT
 	TokenREAL
 	TokenFLOAT
@@ -149,6 +151,9 @@ const (
 	TokenVARCHAR
 	TokenCHAR
 	TokenCHARACTER
+	TokenCLOB
+	TokenNCHAR
+	TokenNVARCHAR
 	TokenBLOB
 	TokenBOOLEAN
 	TokenDATE
@@ -282,7 +287,9 @@ var keywords = map[string]TokenType{
 	// Data types
 	"INTEGER":   TokenINTEGER,
 	"INT":       TokenINT,
+	"TINYINT":   TokenTINYINT,
 	"SMALLINT":  TokenSMALLINT,
+	"MEDIUMINT": TokenMEDIUMINT,
 	"BIGINT":    TokenBIGINT,
 	"REAL":      TokenREAL,
 	"FLOAT":     TokenFLOAT,
@@ -293,6 +300,9 @@ var keywords = map[string]TokenType{
 	"VARCHAR":   TokenVARCHAR,
 	"CHAR":      TokenCHAR,
 	"CHARACTER": TokenCHARACTER,
+	"CLOB":      TokenCLOB,
+	"NCHAR":     TokenNCHAR,
+	"NVARCHAR":  TokenNVARCHAR,
 	"BLOB":      TokenBLOB,
 	"BOOLEAN":   TokenBOOLEAN,
 	"DATE":      TokenDATE,

+ 2 - 2
pkg/parser/parser.go

@@ -1070,10 +1070,10 @@ func (p *Parser) parseDataType() (*DataType, error) {
 
 func (p *Parser) isDataTypeKeyword() bool {
 	switch p.curToken.Type {
-	case lexer.TokenINTEGER, lexer.TokenINT, lexer.TokenSMALLINT, lexer.TokenBIGINT,
+	case lexer.TokenINTEGER, lexer.TokenINT, lexer.TokenTINYINT, lexer.TokenSMALLINT, lexer.TokenMEDIUMINT, lexer.TokenBIGINT,
 		lexer.TokenREAL, lexer.TokenFLOAT, lexer.TokenDOUBLE,
 		lexer.TokenNUMERIC, lexer.TokenDECIMAL,
-		lexer.TokenTEXT, lexer.TokenVARCHAR, lexer.TokenCHAR, lexer.TokenCHARACTER,
+		lexer.TokenTEXT, lexer.TokenVARCHAR, lexer.TokenCHAR, lexer.TokenCHARACTER, lexer.TokenCLOB, lexer.TokenNCHAR, lexer.TokenNVARCHAR,
 		lexer.TokenBLOB, lexer.TokenBOOLEAN,
 		lexer.TokenDATE, lexer.TokenTIME, lexer.TokenTIMESTAMP, lexer.TokenDATETIME:
 		return true

+ 374 - 0
pkg/sqliteimport/import.go

@@ -0,0 +1,374 @@
+package sqliteimport
+
+import (
+	"database/sql"
+	"encoding/hex"
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	_ "modernc.org/sqlite"
+)
+
+const insertBatchSize = 500
+
+// ImportOptions configures SQLite import behavior.
+type ImportOptions struct {
+	CreateTables bool     // Create tables from the source schema (default true)
+	IgnoreErrors bool     // Continue on individual row/statement errors
+	TableFilter  []string // If non-empty, only import these tables
+}
+
+// DefaultImportOptions returns sensible defaults.
+func DefaultImportOptions() ImportOptions {
+	return ImportOptions{
+		CreateTables: true,
+		IgnoreErrors: false,
+	}
+}
+
+// ImportResult contains the results of an import operation.
+type ImportResult struct {
+	TablesCreated  []string `json:"tablesCreated"`
+	TablesImported []string `json:"tablesImported"`
+	RowsInserted   int64    `json:"rowsInserted"`
+	IndexesCreated int      `json:"indexesCreated"`
+	Errors         []string `json:"errors,omitempty"`
+}
+
+// ImportSQLiteFile imports a SQLite .db file into a PizzaSQL executor.
+func ImportSQLiteFile(path string, exec *executor.Executor, opts ImportOptions) (*ImportResult, error) {
+	db, err := sql.Open("sqlite", path+"?mode=ro")
+	if err != nil {
+		return nil, fmt.Errorf("open sqlite file: %w", err)
+	}
+	defer db.Close()
+
+	if err := db.Ping(); err != nil {
+		return nil, fmt.Errorf("cannot read sqlite file: %w", err)
+	}
+
+	return importFromDB(db, exec, opts)
+}
+
+// ImportSQLiteBytes imports a SQLite database from raw bytes (e.g. from an HTTP upload).
+// It writes to a temporary file, imports, then removes the file.
+func ImportSQLiteBytes(data []byte, exec *executor.Executor, opts ImportOptions) (*ImportResult, error) {
+	tmp, err := os.CreateTemp("", "pizzasql-sqlite-*.db")
+	if err != nil {
+		return nil, fmt.Errorf("create temp file: %w", err)
+	}
+	tmpPath := tmp.Name()
+	defer os.Remove(tmpPath)
+
+	if _, err := tmp.Write(data); err != nil {
+		tmp.Close()
+		return nil, fmt.Errorf("write temp file: %w", err)
+	}
+	tmp.Close()
+
+	return ImportSQLiteFile(tmpPath, exec, opts)
+}
+
+func importFromDB(db *sql.DB, exec *executor.Executor, opts ImportOptions) (*ImportResult, error) {
+	result := &ImportResult{
+		TablesCreated:  []string{},
+		TablesImported: []string{},
+		Errors:         []string{},
+	}
+
+	// Load table list and DDL from sqlite_master
+	type tableEntry struct {
+		name string
+		ddl  string
+	}
+	var tables []tableEntry
+
+	rows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY rowid`)
+	if err != nil {
+		return nil, fmt.Errorf("query sqlite_master: %w", err)
+	}
+	defer rows.Close()
+	for rows.Next() {
+		var name string
+		var ddlNull sql.NullString
+		if err := rows.Scan(&name, &ddlNull); err != nil {
+			continue
+		}
+		if !ddlNull.Valid || ddlNull.String == "" {
+			continue
+		}
+		tables = append(tables, tableEntry{name: name, ddl: ddlNull.String})
+	}
+	rows.Close()
+
+	// Apply table filter
+	if len(opts.TableFilter) > 0 {
+		filter := make(map[string]bool, len(opts.TableFilter))
+		for _, t := range opts.TableFilter {
+			filter[strings.ToLower(t)] = true
+		}
+		filtered := tables[:0]
+		for _, t := range tables {
+			if filter[strings.ToLower(t.name)] {
+				filtered = append(filtered, t)
+			}
+		}
+		tables = filtered
+	}
+
+	// Create tables
+	if opts.CreateTables {
+		for _, t := range tables {
+			ddl := sanitizeDDL(t.ddl)
+			if err := execStatement(exec, ddl); err != nil {
+				msg := fmt.Sprintf("create table %s: %v", t.name, err)
+				result.Errors = append(result.Errors, msg)
+				if !opts.IgnoreErrors {
+					return result, fmt.Errorf("%s", msg)
+				}
+				continue
+			}
+			result.TablesCreated = append(result.TablesCreated, t.name)
+		}
+	}
+
+	// Import indexes
+	idxRows, err := db.Query(`SELECT sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`)
+	if err == nil {
+		defer idxRows.Close()
+		for idxRows.Next() {
+			var idxSQL string
+			if err := idxRows.Scan(&idxSQL); err != nil {
+				continue
+			}
+			idxSQL = sanitizeDDL(idxSQL)
+			if err := execStatement(exec, idxSQL); err != nil {
+				result.Errors = append(result.Errors, fmt.Sprintf("create index: %v", err))
+			} else {
+				result.IndexesCreated++
+			}
+		}
+		idxRows.Close()
+	}
+
+	// Import rows per table
+	for _, t := range tables {
+		n, err := importTableRows(db, exec, t.name, opts.IgnoreErrors)
+		if err != nil {
+			msg := fmt.Sprintf("import rows for %s: %v", t.name, err)
+			result.Errors = append(result.Errors, msg)
+			if !opts.IgnoreErrors {
+				return result, fmt.Errorf("%s", msg)
+			}
+			continue
+		}
+		result.TablesImported = append(result.TablesImported, t.name)
+		result.RowsInserted += n
+	}
+
+	return result, nil
+}
+
+func importTableRows(db *sql.DB, exec *executor.Executor, table string, ignoreErrors bool) (int64, error) {
+	rows, err := db.Query(fmt.Sprintf(`SELECT * FROM %q`, table))
+	if err != nil {
+		return 0, err
+	}
+	defer rows.Close()
+
+	cols, err := rows.Columns()
+	if err != nil {
+		return 0, err
+	}
+	if len(cols) == 0 {
+		return 0, nil
+	}
+
+	var total int64
+	var batch []string
+
+	flush := func() error {
+		if len(batch) == 0 {
+			return nil
+		}
+		// Build multi-row INSERT
+		colList := quoteIdentList(cols)
+		sql := fmt.Sprintf("INSERT INTO %s (%s) VALUES %s",
+			quoteIdent(table), colList, strings.Join(batch, ", "))
+		if err := execStatement(exec, sql); err != nil {
+			return err
+		}
+		total += int64(len(batch))
+		batch = batch[:0]
+		return nil
+	}
+
+	vals := make([]interface{}, len(cols))
+	ptrs := make([]interface{}, len(cols))
+	for i := range vals {
+		ptrs[i] = &vals[i]
+	}
+
+	for rows.Next() {
+		if err := rows.Scan(ptrs...); err != nil {
+			if ignoreErrors {
+				continue
+			}
+			return total, err
+		}
+
+		batch = append(batch, rowToValueList(vals))
+
+		if len(batch) >= insertBatchSize {
+			if err := flush(); err != nil {
+				if ignoreErrors {
+					batch = batch[:0]
+					continue
+				}
+				return total, err
+			}
+		}
+	}
+
+	if err := rows.Err(); err != nil {
+		return total, err
+	}
+
+	if err := flush(); err != nil {
+		return total, err
+	}
+
+	return total, nil
+}
+
+// rowToValueList converts a row of Go values into a SQL VALUES tuple string.
+func rowToValueList(vals []interface{}) string {
+	parts := make([]string, len(vals))
+	for i, v := range vals {
+		parts[i] = sqlLiteral(v)
+	}
+	return "(" + strings.Join(parts, ", ") + ")"
+}
+
+// sqlLiteral converts a Go value (from the sqlite driver) to a SQL literal string.
+func sqlLiteral(v interface{}) string {
+	if v == nil {
+		return "NULL"
+	}
+	switch val := v.(type) {
+	case int64:
+		return fmt.Sprintf("%d", val)
+	case float64:
+		return fmt.Sprintf("%g", val)
+	case string:
+		return "'" + strings.ReplaceAll(val, "'", "''") + "'"
+	case []byte:
+		// Store blobs as hex text strings (PizzaSQL has no X'' literal support)
+		return "'" + hex.EncodeToString(val) + "'"
+	case bool:
+		if val {
+			return "1"
+		}
+		return "0"
+	default:
+		s := fmt.Sprintf("%v", val)
+		return "'" + strings.ReplaceAll(s, "'", "''") + "'"
+	}
+}
+
+func quoteIdent(s string) string {
+	return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+}
+
+func quoteIdentList(cols []string) string {
+	parts := make([]string, len(cols))
+	for i, c := range cols {
+		parts[i] = quoteIdent(c)
+	}
+	return strings.Join(parts, ", ")
+}
+
+func execStatement(exec *executor.Executor, sql string) error {
+	l := lexer.New(sql)
+	p := parser.New(l)
+	stmt, err := p.Parse()
+	if err != nil {
+		return fmt.Errorf("parse: %w", err)
+	}
+	_, err = exec.Execute(stmt)
+	return err
+}
+
+// pizzasqlReservedKeywords is the set of tokens that PizzaSQL reserves but are
+// commonly used as column names in SQLite schemas.
+var pizzasqlReservedKeywords = map[string]bool{
+	"key": true, "value": true, "type": true, "name": true,
+	"index": true, "view": true, "table": true, "column": true,
+	"group": true, "order": true, "range": true, "match": true,
+}
+
+var (
+	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.
+	// 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*)*`)
+	// 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*)*`)
+	// Table-level CHECK constraints
+	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
+	reComplexDefault = regexp.MustCompile(`(?i)\bDEFAULT\s*\([^)]*\)`)
+	// Trailing comma before closing paren
+	reTableTrailing  = regexp.MustCompile(`(?m),\s*\)`)
+	// DESC/ASC in index column lists
+	reIndexColOrder  = regexp.MustCompile(`(?i)\b(ASC|DESC)\b`)
+	// Column name (first word) followed by a type keyword on each column line
+	reColumnName     = regexp.MustCompile(`(?m)^\s{1,}(\w+)(\s+)`)
+)
+
+// sanitizeDDL strips SQLite-specific clauses that PizzaSQL doesn't support.
+func sanitizeDDL(ddl string) string {
+	ddl = reAutoincrement.ReplaceAllString(ddl, "")
+	ddl = reWithoutRowid.ReplaceAllString(ddl, "")
+	ddl = reStrict.ReplaceAllString(ddl, "")
+	ddl = reTableFK.ReplaceAllString(ddl, "")
+	ddl = reTableCheck.ReplaceAllString(ddl, "")
+	ddl = reComplexDefault.ReplaceAllString(ddl, "")
+	ddl = reInlineRefs.ReplaceAllString(ddl, "")
+	ddl = reOnConflict.ReplaceAllString(ddl, "")
+
+	// Strip ASC/DESC from index column lists
+	upper := strings.ToUpper(ddl)
+	if strings.Contains(upper, "CREATE INDEX") || strings.Contains(upper, "CREATE UNIQUE INDEX") {
+		ddl = reIndexColOrder.ReplaceAllString(ddl, "")
+	}
+
+	// Quote column names that clash with PizzaSQL reserved keywords
+	ddl = reColumnName.ReplaceAllStringFunc(ddl, func(m string) string {
+		// Extract leading whitespace, word, trailing whitespace
+		sub := reColumnName.FindStringSubmatch(m)
+		if len(sub) < 3 {
+			return m
+		}
+		word, ws := sub[1], sub[2]
+		if pizzasqlReservedKeywords[strings.ToLower(word)] {
+			leading := m[:len(m)-len(word)-len(ws)]
+			return leading + `"` + word + `"` + ws
+		}
+		return m
+	})
+
+	// Clean up trailing commas before closing paren
+	ddl = reTableTrailing.ReplaceAllStringFunc(ddl, func(s string) string {
+		return ")"
+	})
+	return strings.TrimSpace(ddl)
+}

+ 15 - 2
pkg/storage/table.go

@@ -391,19 +391,32 @@ func (m *TableManager) Select(table string, filter func(Row) bool) ([]Row, error
 
 	if filter == nil {
 		result := make([]Row, len(cached))
-		copy(result, cached)
+		for i, row := range cached {
+			result[i] = cloneRow(row)
+		}
 		return result, nil
 	}
 
 	rows := make([]Row, 0, len(cached))
 	for _, row := range cached {
 		if filter(row) {
-			rows = append(rows, row)
+			rows = append(rows, cloneRow(row))
 		}
 	}
 	return rows, nil
 }
 
+func cloneRow(row Row) Row {
+	if row == nil {
+		return nil
+	}
+	cloned := make(Row, len(row))
+	for k, v := range row {
+		cloned[k] = v
+	}
+	return cloned
+}
+
 // SelectWithLimit retrieves rows with limit and offset.
 func (m *TableManager) SelectWithLimit(table string, filter func(Row) bool, limit, offset int) ([]Row, error) {
 	rows, err := m.Select(table, filter)