Files
relspecgo/pkg/writers/sqlite/writer.go
T
Hein 2b6bb7f948 fix(sqlite): emit inline foreign keys, bare-name default schema, direct exec
SQLite can't ALTER TABLE ADD CONSTRAINT, so foreign keys are now written
as inline FOREIGN KEY clauses in CREATE TABLE instead of commented-out
ALTER statements. The default schema (public/main) now produces bare
table names instead of a "public_" prefix; other schemas are still
prefixed to avoid collisions. Also adds direct-to-file execution: the
sqlite writer can now apply generated DDL straight to a .db file via
Metadata["connection_string"], wired into `relspec merge --output-conn`.
2026-08-24 11:52:24 +02:00

360 lines
9.8 KiB
Go

package sqlite
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"strings"
_ "modernc.org/sqlite" // SQLite driver
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
// Writer implements the Writer interface for SQLite SQL output
type Writer struct {
options *writers.WriterOptions
writer io.Writer
executor *TemplateExecutor
}
// NewWriter creates a new SQLite SQL writer
// SQLite doesn't support schemas, so FlattenSchema is automatically enabled
func NewWriter(options *writers.WriterOptions) *Writer {
// Force schema flattening for SQLite
options.FlattenSchema = true
executor, _ := NewTemplateExecutor(options)
return &Writer{
options: options,
executor: executor,
}
}
// WriteDatabase writes the entire database schema as SQLite SQL.
//
// If Metadata["connection_string"] is set (a path to a SQLite database file),
// the generated DDL is executed directly against that file instead of being
// written out as a .sql script.
func (w *Writer) WriteDatabase(db *models.Database) error {
if dbPath, ok := w.options.Metadata["connection_string"].(string); ok && dbPath != "" {
return w.executeDatabaseSQL(db, dbPath)
}
var writer io.Writer
var file *os.File
var err error
// Use existing writer if already set (for testing)
if w.writer != nil {
writer = w.writer
} else if w.options.OutputPath != "" {
// Determine output destination
file, err = os.Create(w.options.OutputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
writer = file
} else {
writer = os.Stdout
}
w.writer = writer
return w.writeContent(db)
}
// writeContent writes the header, pragma, and every schema's DDL to w.writer.
func (w *Writer) writeContent(db *models.Database) error {
// Write header comment
fmt.Fprintf(w.writer, "-- SQLite Database Schema\n")
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n")
fmt.Fprintf(w.writer, "-- Note: SQLite has no schema concept; non-default schema names are flattened into table name prefixes (e.g., auth.sessions -> auth_sessions)\n\n")
// Enable foreign keys
pragma, err := w.executor.ExecutePragmaForeignKeys()
if err != nil {
return fmt.Errorf("failed to generate pragma statement: %w", err)
}
fmt.Fprintf(w.writer, "%s\n", pragma)
// Process each schema in the database
for _, schema := range db.Schemas {
if err := w.WriteSchema(schema); err != nil {
return fmt.Errorf("failed to write schema %s: %w", schema.Name, err)
}
}
return nil
}
// statementCollector captures each Write call as a single SQL statement (or
// comment line), matching the writer's convention of one Fprintf per statement.
type statementCollector struct {
statements []string
}
func (c *statementCollector) Write(p []byte) (int, error) {
if s := strings.TrimSpace(string(p)); s != "" {
c.statements = append(c.statements, s)
}
return len(p), nil
}
// executeDatabaseSQL generates the DDL for db and executes it directly
// against the SQLite database file at dbPath.
func (w *Writer) executeDatabaseSQL(db *models.Database, dbPath string) error {
collector := &statementCollector{}
w.writer = collector
if err := w.writeContent(db); err != nil {
return fmt.Errorf("failed to generate SQL statements: %w", err)
}
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open sqlite database %q: %w", dbPath, err)
}
defer conn.Close()
ctx := context.Background()
ignoreErrors := false
if val, ok := w.options.Metadata["ignore_errors"].(bool); ok {
ignoreErrors = val
}
total, executed := 0, 0
var execErrors []string
for _, stmt := range collector.statements {
if strings.HasPrefix(stmt, "--") {
continue
}
total++
if _, err := conn.ExecContext(ctx, stmt); err != nil {
execErrors = append(execErrors, fmt.Sprintf("statement %d (%s): %v", total, truncateStatement(stmt), err))
if !ignoreErrors {
break
}
continue
}
executed++
}
w.options.Metadata["execution_total"] = total
w.options.Metadata["execution_success"] = executed
w.options.Metadata["execution_failed"] = len(execErrors)
if len(execErrors) > 0 {
return fmt.Errorf("failed to execute %d/%d statement(s) against %q:\n%s", len(execErrors), total, dbPath, strings.Join(execErrors, "\n"))
}
return nil
}
// truncateStatement shortens a SQL statement for error messages.
func truncateStatement(stmt string) string {
const maxLen = 80
stmt = strings.Join(strings.Fields(stmt), " ")
if len(stmt) > maxLen {
return stmt[:maxLen] + "..."
}
return stmt
}
// defaultSchemaNames are treated as "no schema" for SQLite output: SQLite has
// no schema concept, and a lone default schema (e.g. DBML's implicit "public")
// should produce bare table names rather than a "public_" prefix.
var defaultSchemaNames = map[string]bool{
"public": true,
"main": true,
}
// tableSchemaName returns the schema name to use for table/constraint naming,
// collapsing default schema names to "" so they aren't prefixed onto table names.
func tableSchemaName(schema string) string {
if defaultSchemaNames[strings.ToLower(schema)] {
return ""
}
return schema
}
// WriteSchema writes a single schema as SQLite SQL
func (w *Writer) WriteSchema(schema *models.Schema) error {
tableSchema := tableSchemaName(schema.Name)
// SQLite doesn't have schemas, so we just write a comment (skip for the
// default schema, since its tables aren't actually being prefixed)
if tableSchema != "" {
fmt.Fprintf(w.writer, "-- Schema: %s (flattened into table names)\n\n", schema.Name)
}
// Phase 1: Create tables
for _, table := range schema.Tables {
if err := w.writeTable(tableSchema, table); err != nil {
return fmt.Errorf("failed to write table %s: %w", table.Name, err)
}
}
// Phase 2: Create indexes
for _, table := range schema.Tables {
if err := w.writeIndexes(tableSchema, table); err != nil {
return fmt.Errorf("failed to write indexes for table %s: %w", table.Name, err)
}
}
// Phase 3: Create unique constraints (as unique indexes)
for _, table := range schema.Tables {
if err := w.writeUniqueConstraints(tableSchema, table); err != nil {
return fmt.Errorf("failed to write unique constraints for table %s: %w", table.Name, err)
}
}
// Phase 4: Check constraints (as comments, since SQLite requires them in CREATE TABLE)
for _, table := range schema.Tables {
if err := w.writeCheckConstraints(tableSchema, table); err != nil {
return fmt.Errorf("failed to write check constraints for table %s: %w", table.Name, err)
}
}
return nil
}
// WriteTable writes a single table as SQLite SQL
func (w *Writer) WriteTable(table *models.Table) error {
return w.writeTable("", table)
}
// writeTable is the internal implementation
func (w *Writer) writeTable(schema string, table *models.Table) error {
// Build table template data
data := BuildTableTemplateData(schema, table)
// Execute template
sql, err := w.executor.ExecuteCreateTable(data)
if err != nil {
return fmt.Errorf("failed to execute create table template: %w", err)
}
fmt.Fprintf(w.writer, "%s\n", sql)
return nil
}
// writeIndexes writes indexes for a table
func (w *Writer) writeIndexes(schema string, table *models.Table) error {
for _, index := range sortIndexes(table.Indexes) {
// Skip primary key indexes
if strings.HasSuffix(index.Name, "_pkey") {
continue
}
// Skip unique indexes (handled separately as unique constraints)
if index.Unique {
continue
}
data := IndexTemplateData{
Schema: schema,
Table: table.Name,
Name: index.Name,
Columns: index.Columns,
}
sql, err := w.executor.ExecuteCreateIndex(data)
if err != nil {
return fmt.Errorf("failed to execute create index template: %w", err)
}
fmt.Fprintf(w.writer, "%s\n", sql)
}
return nil
}
// writeUniqueConstraints writes unique constraints as unique indexes
func (w *Writer) writeUniqueConstraints(schema string, table *models.Table) error {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.UniqueConstraint {
continue
}
data := ConstraintTemplateData{
Schema: schema,
Table: table.Name,
Name: constraint.Name,
Columns: constraint.Columns,
}
sql, err := w.executor.ExecuteCreateUniqueConstraint(data)
if err != nil {
return fmt.Errorf("failed to execute create unique constraint template: %w", err)
}
fmt.Fprintf(w.writer, "%s\n", sql)
}
// Also handle unique indexes from the Indexes map
for _, index := range sortIndexes(table.Indexes) {
if !index.Unique {
continue
}
// Skip if already handled as a constraint
alreadyHandled := false
for _, constraint := range table.Constraints {
if constraint.Type == models.UniqueConstraint && constraint.Name == index.Name {
alreadyHandled = true
break
}
}
if alreadyHandled {
continue
}
data := ConstraintTemplateData{
Schema: schema,
Table: table.Name,
Name: index.Name,
Columns: index.Columns,
}
sql, err := w.executor.ExecuteCreateUniqueConstraint(data)
if err != nil {
return fmt.Errorf("failed to execute create unique index template: %w", err)
}
fmt.Fprintf(w.writer, "%s\n", sql)
}
return nil
}
// writeCheckConstraints writes check constraints as comments
func (w *Writer) writeCheckConstraints(schema string, table *models.Table) error {
for _, constraint := range sortConstraints(table.Constraints) {
if constraint.Type != models.CheckConstraint {
continue
}
data := ConstraintTemplateData{
Schema: schema,
Table: table.Name,
Name: constraint.Name,
Expression: constraint.Expression,
}
sql, err := w.executor.ExecuteCreateCheckConstraint(data)
if err != nil {
return fmt.Errorf("failed to execute create check constraint template: %w", err)
}
fmt.Fprintf(w.writer, "%s\n", sql)
}
return nil
}