diff --git a/cmd/relspec/root.go b/cmd/relspec/root.go index cce8c97..af42efd 100644 --- a/cmd/relspec/root.go +++ b/cmd/relspec/root.go @@ -23,4 +23,5 @@ func init() { rootCmd.AddCommand(templCmd) rootCmd.AddCommand(editCmd) rootCmd.AddCommand(mergeCmd) + rootCmd.AddCommand(splitCmd) } diff --git a/cmd/relspec/split.go b/cmd/relspec/split.go new file mode 100644 index 0000000..65d32d2 --- /dev/null +++ b/cmd/relspec/split.go @@ -0,0 +1,318 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +var ( + splitSourceType string + splitSourcePath string + splitSourceConn string + splitTargetType string + splitTargetPath string + splitSchemas string + splitTables string + splitPackageName string + splitDatabaseName string + splitExcludeSchema string + splitExcludeTables string +) + +var splitCmd = &cobra.Command{ + Use: "split", + Short: "Split database schemas to extract selected tables into a separate database", + Long: `Extract selected schemas and tables from a database and write them to a separate output. + +The split command allows you to: +- Select specific schemas to include in the output +- Select specific tables within schemas +- Exclude specific schemas or tables if preferred +- Export the selected subset to any supported format + +Input formats: + - dbml: DBML schema files + - dctx: DCTX schema files + - drawdb: DrawDB JSON files + - graphql: GraphQL schema files (.graphql, SDL) + - json: JSON database schema + - yaml: YAML database schema + - gorm: GORM model files (Go, file or directory) + - bun: Bun model files (Go, file or directory) + - drizzle: Drizzle ORM schema files (TypeScript, file or directory) + - prisma: Prisma schema files (.prisma) + - typeorm: TypeORM entity files (TypeScript) + - pgsql: PostgreSQL database (live connection) + +Output formats: + - dbml: DBML schema files + - dctx: DCTX schema files + - drawdb: DrawDB JSON files + - graphql: GraphQL schema files (.graphql, SDL) + - json: JSON database schema + - yaml: YAML database schema + - gorm: GORM model files (Go) + - bun: Bun model files (Go) + - drizzle: Drizzle ORM schema files (TypeScript) + - prisma: Prisma schema files (.prisma) + - typeorm: TypeORM entity files (TypeScript) + - pgsql: PostgreSQL SQL schema + +Examples: + # Split specific schemas from DBML + relspec split --from dbml --from-path schema.dbml \ + --schemas public,auth \ + --to json --to-path subset.json + + # Extract specific tables from PostgreSQL + relspec split --from pgsql \ + --from-conn "postgres://user:pass@localhost:5432/mydb" \ + --schemas public \ + --tables users,orders,products \ + --to dbml --to-path subset.dbml + + # Exclude specific tables + relspec split --from json --from-path schema.json \ + --exclude-tables "audit_log,system_config,temp_data" \ + --to json --to-path public_schema.json + + # Split and convert to GORM + relspec split --from json --from-path schema.json \ + --tables "users,posts,comments" \ + --to gorm --to-path models/ --package models \ + --database-name MyAppDB + + # Exclude specific schema and tables + relspec split --from pgsql \ + --from-conn "postgres://user:pass@localhost/db" \ + --exclude-schema pg_catalog,information_schema \ + --exclude-tables "temp_users,debug_logs" \ + --to json --to-path public_schema.json`, + RunE: runSplit, +} + +func init() { + splitCmd.Flags().StringVar(&splitSourceType, "from", "", "Source format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)") + splitCmd.Flags().StringVar(&splitSourcePath, "from-path", "", "Source file path (for file-based formats)") + splitCmd.Flags().StringVar(&splitSourceConn, "from-conn", "", "Source connection string (for database formats)") + + splitCmd.Flags().StringVar(&splitTargetType, "to", "", "Target format (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql)") + splitCmd.Flags().StringVar(&splitTargetPath, "to-path", "", "Target output path (file or directory)") + splitCmd.Flags().StringVar(&splitPackageName, "package", "", "Package name (for code generation formats like gorm/bun)") + splitCmd.Flags().StringVar(&splitDatabaseName, "database-name", "", "Override database name in output") + + splitCmd.Flags().StringVar(&splitSchemas, "schemas", "", "Comma-separated list of schema names to include") + splitCmd.Flags().StringVar(&splitTables, "tables", "", "Comma-separated list of table names to include (case-insensitive)") + splitCmd.Flags().StringVar(&splitExcludeSchema, "exclude-schema", "", "Comma-separated list of schema names to exclude") + splitCmd.Flags().StringVar(&splitExcludeTables, "exclude-tables", "", "Comma-separated list of table names to exclude (case-insensitive)") + + err := splitCmd.MarkFlagRequired("from") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err) + } + err = splitCmd.MarkFlagRequired("to") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marking to flag as required: %v\n", err) + } + err = splitCmd.MarkFlagRequired("to-path") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marking to-path flag as required: %v\n", err) + } +} + +func runSplit(cmd *cobra.Command, args []string) error { + fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Split ===\n") + fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp()) + + // Read source database + fmt.Fprintf(os.Stderr, "[1/3] Reading source schema...\n") + fmt.Fprintf(os.Stderr, " Format: %s\n", splitSourceType) + if splitSourcePath != "" { + fmt.Fprintf(os.Stderr, " Path: %s\n", splitSourcePath) + } + if splitSourceConn != "" { + fmt.Fprintf(os.Stderr, " Conn: %s\n", maskPassword(splitSourceConn)) + } + + db, err := readDatabaseForConvert(splitSourceType, splitSourcePath, splitSourceConn) + if err != nil { + return fmt.Errorf("failed to read source: %w", err) + } + + fmt.Fprintf(os.Stderr, " ✓ Successfully read database '%s'\n", db.Name) + fmt.Fprintf(os.Stderr, " Found: %d schema(s)\n", len(db.Schemas)) + + totalTables := 0 + for _, schema := range db.Schemas { + totalTables += len(schema.Tables) + } + fmt.Fprintf(os.Stderr, " Found: %d table(s)\n\n", totalTables) + + // Filter the database + fmt.Fprintf(os.Stderr, "[2/3] Filtering schemas and tables...\n") + filteredDB, err := filterDatabase(db) + if err != nil { + return fmt.Errorf("failed to filter database: %w", err) + } + + if splitDatabaseName != "" { + filteredDB.Name = splitDatabaseName + } + + filteredTables := 0 + for _, schema := range filteredDB.Schemas { + filteredTables += len(schema.Tables) + } + fmt.Fprintf(os.Stderr, " ✓ Filtered to: %d schema(s), %d table(s)\n\n", len(filteredDB.Schemas), filteredTables) + + // Write to target format + fmt.Fprintf(os.Stderr, "[3/3] Writing to target format...\n") + fmt.Fprintf(os.Stderr, " Format: %s\n", splitTargetType) + fmt.Fprintf(os.Stderr, " Output: %s\n", splitTargetPath) + if splitPackageName != "" { + fmt.Fprintf(os.Stderr, " Package: %s\n", splitPackageName) + } + + err = writeDatabase( + filteredDB, + splitTargetType, + splitTargetPath, + splitPackageName, + "", // no schema filter for split + ) + if err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + + fmt.Fprintf(os.Stderr, " ✓ Successfully written to '%s'\n\n", splitTargetPath) + fmt.Fprintf(os.Stderr, "=== Split Completed Successfully ===\n") + fmt.Fprintf(os.Stderr, "Completed at: %s\n\n", getCurrentTimestamp()) + + return nil +} + +// filterDatabase filters the database based on provided criteria +func filterDatabase(db *models.Database) (*models.Database, error) { + filteredDB := &models.Database{ + Name: db.Name, + Description: db.Description, + Comment: db.Comment, + DatabaseType: db.DatabaseType, + DatabaseVersion: db.DatabaseVersion, + SourceFormat: db.SourceFormat, + UpdatedAt: db.UpdatedAt, + GUID: db.GUID, + Schemas: []*models.Schema{}, + Domains: db.Domains, // Keep domains for now + } + + // Parse filter flags + includeSchemas := parseCommaSeparated(splitSchemas) + includeTables := parseCommaSeparated(splitTables) + excludeSchemas := parseCommaSeparated(splitExcludeSchema) + excludeTables := parseCommaSeparated(splitExcludeTables) + + // Convert table names to lowercase for case-insensitive matching + includeTablesLower := make(map[string]bool) + for _, t := range includeTables { + includeTablesLower[strings.ToLower(t)] = true + } + + excludeTablesLower := make(map[string]bool) + for _, t := range excludeTables { + excludeTablesLower[strings.ToLower(t)] = true + } + + // Iterate through schemas + for _, schema := range db.Schemas { + // Check if schema should be excluded + if contains(excludeSchemas, schema.Name) { + continue + } + + // Check if schema should be included + if len(includeSchemas) > 0 && !contains(includeSchemas, schema.Name) { + continue + } + + // Create a copy of the schema with filtered tables + filteredSchema := &models.Schema{ + Name: schema.Name, + Description: schema.Description, + Owner: schema.Owner, + Permissions: schema.Permissions, + Comment: schema.Comment, + Metadata: schema.Metadata, + Scripts: schema.Scripts, + Sequence: schema.Sequence, + Relations: schema.Relations, + Enums: schema.Enums, + UpdatedAt: schema.UpdatedAt, + GUID: schema.GUID, + Tables: []*models.Table{}, + Views: schema.Views, + Sequences: schema.Sequences, + } + + // Filter tables within the schema + for _, table := range schema.Tables { + tableLower := strings.ToLower(table.Name) + + // Check if table should be excluded + if excludeTablesLower[tableLower] { + continue + } + + // If specific tables are requested, only include those + if len(includeTablesLower) > 0 { + if !includeTablesLower[tableLower] { + continue + } + } + filteredSchema.Tables = append(filteredSchema.Tables, table) + } + + // Only add schema if it has tables (unless no table filter was specified) + if len(filteredSchema.Tables) > 0 || (len(includeTablesLower) == 0 && len(excludeTablesLower) == 0) { + filteredDB.Schemas = append(filteredDB.Schemas, filteredSchema) + } + } + + if len(filteredDB.Schemas) == 0 { + return nil, fmt.Errorf("no schemas matched the filter criteria") + } + + return filteredDB, nil +} + +// parseCommaSeparated parses a comma-separated string into a slice, trimming whitespace +func parseCommaSeparated(s string) []string { + if s == "" { + return []string{} + } + + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// contains checks if a string is in a slice +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +}