Add additive schema migration for package updates
ListExtColumns introspects existing columns via PRAGMA (SQLite) or information_schema (Postgres). MigrateExtTables diffs manifest db_tables against existing schema: creates new tables, adds new columns via ALTER TABLE, applies indexes idempotently. No destructive changes — columns present in DB but absent from manifest are preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -186,6 +186,130 @@ func CreateExtTables(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListExtColumns returns the set of column names for a physical table.
|
||||||
|
// Uses PRAGMA table_info on SQLite and information_schema on Postgres.
|
||||||
|
func ListExtColumns(ctx context.Context, db *sql.DB, isPostgres bool, physicalTable string) (map[string]bool, error) {
|
||||||
|
cols := make(map[string]bool)
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if isPostgres {
|
||||||
|
rows, err = db.QueryContext(ctx,
|
||||||
|
`SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public'`,
|
||||||
|
physicalTable)
|
||||||
|
} else {
|
||||||
|
rows, err = db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", physicalTable))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list columns for %s: %w", physicalTable, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
if isPostgres {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cols[name] = true
|
||||||
|
} else {
|
||||||
|
// PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
|
||||||
|
var cid int
|
||||||
|
var name, colType string
|
||||||
|
var notnull int
|
||||||
|
var dfltValue sql.NullString
|
||||||
|
var pk int
|
||||||
|
if err := rows.Scan(&cid, &name, &colType, ¬null, &dfltValue, &pk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cols[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateExtTables applies additive-only schema changes for a package update.
|
||||||
|
// For each table in the new manifest:
|
||||||
|
// - New table: CREATE TABLE (full creation, same as CreateExtTables)
|
||||||
|
// - Existing table: ALTER TABLE ADD COLUMN for each new column
|
||||||
|
//
|
||||||
|
// Indexes are applied idempotently with CREATE INDEX IF NOT EXISTS.
|
||||||
|
// New tables are registered in the ext_data_tables catalog.
|
||||||
|
// Returns a log of changes applied (for API response).
|
||||||
|
//
|
||||||
|
// No destructive changes: columns present in DB but absent from manifest are NOT dropped.
|
||||||
|
func MigrateExtTables(
|
||||||
|
ctx context.Context,
|
||||||
|
db *sql.DB,
|
||||||
|
isPostgres bool,
|
||||||
|
stores store.Stores,
|
||||||
|
packageID string,
|
||||||
|
newTables map[string]TableDef,
|
||||||
|
) ([]string, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing registered tables for this package
|
||||||
|
existingTableNames := make(map[string]bool)
|
||||||
|
if stores.ExtData != nil {
|
||||||
|
names, err := stores.ExtData.ListTables(ctx, packageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list existing tables for %s: %w", packageID, err)
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
existingTableNames[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var changes []string
|
||||||
|
|
||||||
|
for logicalName, td := range newTables {
|
||||||
|
physical := extPhysicalTable(packageID, logicalName)
|
||||||
|
|
||||||
|
if !existingTableNames[logicalName] {
|
||||||
|
// New table — full creation
|
||||||
|
if err := CreateExtTables(ctx, db, isPostgres, stores, packageID,
|
||||||
|
map[string]TableDef{logicalName: td}); err != nil {
|
||||||
|
return changes, fmt.Errorf("create new table %s: %w", physical, err)
|
||||||
|
}
|
||||||
|
changes = append(changes, fmt.Sprintf("created table %s", physical))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing table — diff columns and add missing ones
|
||||||
|
existingCols, err := ListExtColumns(ctx, db, isPostgres, physical)
|
||||||
|
if err != nil {
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for col, typ := range td.Columns {
|
||||||
|
if existingCols[col] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sqlType := mapColType(typ, isPostgres)
|
||||||
|
alterDDL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", physical, col, sqlType)
|
||||||
|
if _, err := db.ExecContext(ctx, alterDDL); err != nil {
|
||||||
|
return changes, fmt.Errorf("add column %s.%s: %w", physical, col, err)
|
||||||
|
}
|
||||||
|
changes = append(changes, fmt.Sprintf("added column '%s' to %s", col, physical))
|
||||||
|
log.Printf("[ext-db] added column %s (%s) to %s", col, sqlType, physical)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply indexes idempotently
|
||||||
|
for i, idxCols := range td.Indexes {
|
||||||
|
idxName := fmt.Sprintf("idx_%s_%d", physical, i)
|
||||||
|
idxDDL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)",
|
||||||
|
idxName, physical, strings.Join(idxCols, ", "))
|
||||||
|
if _, err := db.ExecContext(ctx, idxDDL); err != nil {
|
||||||
|
log.Printf("[ext-db] index create failed (%s): %v", idxName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DropExtTables drops all physical tables registered for a package and removes
|
// DropExtTables drops all physical tables registered for a package and removes
|
||||||
// their entries from the ext_data_tables catalog.
|
// their entries from the ext_data_tables catalog.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user