Nigiri/migrate.go

318 lines
7.9 KiB
Go

package nigiri
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
type MigrationCommand struct {
Action string
Field string
To string
Type string
Store string
}
type MigrationHandler func(data []map[string]any, cmd *MigrationCommand) error
type Migrator struct {
handlers map[string]MigrationHandler
patterns map[string]*regexp.Regexp
}
// ============================================================================
// Core Migration Management
// ============================================================================
func NewMigrator() *Migrator {
m := &Migrator{
handlers: make(map[string]MigrationHandler),
patterns: make(map[string]*regexp.Regexp),
}
// Register built-in commands
m.RegisterCommand("rename", regexp.MustCompile(`^rename\s+(\w+)\s+to\s+(\w+)$`), m.handleRename)
m.RegisterCommand("add", regexp.MustCompile(`^add\s+(\w+)\s+(\w+)\s+to\s+(\w+)$`), m.handleAdd)
m.RegisterCommand("remove", regexp.MustCompile(`^remove\s+(\w+)\s+from\s+(\w+)$`), m.handleRemove)
m.RegisterCommand("change", regexp.MustCompile(`^change\s+(\w+)\s+to\s+(\w+)$`), m.handleChange)
return m
}
func (m *Migrator) RegisterCommand(name string, pattern *regexp.Regexp, handler MigrationHandler) {
m.patterns[name] = pattern
m.handlers[name] = handler
}
// ============================================================================
// Command Parsing
// ============================================================================
func (m *Migrator) ParseCommand(input string) (*MigrationCommand, string, error) {
input = strings.TrimSpace(input)
for name, pattern := range m.patterns {
if matches := pattern.FindStringSubmatch(input); matches != nil {
cmd := &MigrationCommand{Action: name}
switch name {
case "rename":
cmd.Field, cmd.To = matches[1], matches[2]
case "add":
cmd.Field, cmd.Type, cmd.Store = matches[1], matches[2], matches[3]
case "remove":
cmd.Field, cmd.Store = matches[1], matches[2]
case "change":
cmd.Field, cmd.Type = matches[1], matches[2]
default:
cmd.Field = matches[1]
if len(matches) > 2 {
cmd.To = matches[2]
}
if len(matches) > 3 {
cmd.Type = matches[3]
}
if len(matches) > 4 {
cmd.Store = matches[4]
}
}
return cmd, name, nil
}
}
return nil, "", fmt.Errorf("unknown command: %s", input)
}
func (m *Migrator) ApplyCommand(data []byte, cmdStr string) ([]byte, error) {
cmd, name, err := m.ParseCommand(cmdStr)
if err != nil {
return nil, err
}
var items []map[string]any
if err := json.Unmarshal(data, &items); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
if err := m.handlers[name](items, cmd); err != nil {
return nil, err
}
return json.MarshalIndent(items, "", "\t")
}
// ============================================================================
// File Operations
// ============================================================================
func (m *Migrator) MigrateFile(filename, command string) error {
data, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
result, err := m.ApplyCommand(data, command)
if err != nil {
return err
}
backupPath := filename + ".backup"
if err := os.WriteFile(backupPath, data, 0644); err != nil {
return fmt.Errorf("create backup: %w", err)
}
if err := os.WriteFile(filename, result, 0644); err != nil {
return fmt.Errorf("write result: %w", err)
}
fmt.Printf("✓ Applied: %s\n", command)
fmt.Printf("✓ Backup: %s\n", backupPath)
return nil
}
func (m *Migrator) RunScript(dataDir, scriptFile string) error {
file, err := os.Open(scriptFile)
if err != nil {
return fmt.Errorf("open script: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("line %d: invalid format, use 'file.json: command'", lineNum)
}
filename := strings.TrimSpace(parts[0])
command := strings.TrimSpace(parts[1])
fullPath := filepath.Join(dataDir, filename)
fmt.Printf("Line %d: %s -> %s\n", lineNum, filename, command)
if err := m.MigrateFile(fullPath, command); err != nil {
return fmt.Errorf("line %d: %w", lineNum, err)
}
}
return scanner.Err()
}
// ============================================================================
// Built-in Command Handlers
// ============================================================================
func (m *Migrator) handleRename(items []map[string]any, cmd *MigrationCommand) error {
for _, item := range items {
if val, exists := item[cmd.Field]; exists {
item[cmd.To] = val
delete(item, cmd.Field)
}
}
return nil
}
func (m *Migrator) handleAdd(items []map[string]any, cmd *MigrationCommand) error {
defaultVal := getDefaultValue(cmd.Type)
for _, item := range items {
if _, exists := item[cmd.Field]; !exists {
item[cmd.Field] = defaultVal
}
}
return nil
}
func (m *Migrator) handleRemove(items []map[string]any, cmd *MigrationCommand) error {
for _, item := range items {
delete(item, cmd.Field)
}
return nil
}
func (m *Migrator) handleChange(items []map[string]any, cmd *MigrationCommand) error {
for _, item := range items {
if val, exists := item[cmd.Field]; exists {
converted, err := convertType(val, cmd.Type)
if err != nil {
return fmt.Errorf("type conversion failed: %w", err)
}
item[cmd.Field] = converted
}
}
return nil
}
// ============================================================================
// Type Conversion Utilities
// ============================================================================
func getDefaultValue(fieldType string) any {
switch fieldType {
case "string":
return ""
case "int":
return 0
case "bool":
return false
case "time":
return time.Now().Format(time.RFC3339)
case "float":
return 0.0
default:
return nil
}
}
func convertType(val any, targetType string) (any, error) {
switch targetType {
case "string":
return fmt.Sprintf("%v", val), nil
case "int":
if str, ok := val.(string); ok {
return strconv.Atoi(str)
}
if f, ok := val.(float64); ok {
return int(f), nil
}
return val, nil
case "float":
if str, ok := val.(string); ok {
return strconv.ParseFloat(str, 64)
}
if i, ok := val.(int); ok {
return float64(i), nil
}
return val, nil
case "bool":
if str, ok := val.(string); ok {
return strconv.ParseBool(str)
}
return val, nil
default:
return val, nil
}
}
// ============================================================================
// CLI Interface
// ============================================================================
type MigrationCLI struct {
collection *Collection
}
func NewMigrationCLI(collection *Collection) *MigrationCLI {
return &MigrationCLI{collection: collection}
}
func (cli *MigrationCLI) Run(args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: migrate <file.json> 'command' OR migrate <script.txt>")
}
if strings.HasSuffix(args[0], ".txt") {
return cli.collection.RunMigrationScript(args[0])
}
if len(args) < 2 {
return fmt.Errorf("usage: migrate <file.json> 'command'")
}
filename := args[0]
storeName := strings.TrimSuffix(filepath.Base(filename), ".json")
command := args[1]
return cli.collection.MigrateStore(storeName, command)
}
func (cli *MigrationCLI) PrintUsage() {
fmt.Println("Migration Commands:")
fmt.Println(" rename oldfield to newfield")
fmt.Println(" add fieldname type to store")
fmt.Println(" remove fieldname from store")
fmt.Println(" change fieldname to type")
fmt.Println()
fmt.Println("Types: string, int, float, bool, time")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" migrate users.json 'rename Name to FullName'")
fmt.Println(" migrate migrations/001_schema.txt")
}