package dbml import ( "bufio" "fmt" "os" "regexp" "strings" "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/readers" ) // Reader implements the readers.Reader interface for DBML format type Reader struct { options *readers.ReaderOptions } // NewReader creates a new DBML reader with the given options func NewReader(options *readers.ReaderOptions) *Reader { return &Reader{ options: options, } } // ReadDatabase reads and parses DBML input, returning a Database model func (r *Reader) ReadDatabase() (*models.Database, error) { if r.options.FilePath == "" { return nil, fmt.Errorf("file path is required for DBML reader") } content, err := os.ReadFile(r.options.FilePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } return r.parseDBML(string(content)) } // ReadSchema reads and parses DBML input, returning a Schema model func (r *Reader) ReadSchema() (*models.Schema, error) { db, err := r.ReadDatabase() if err != nil { return nil, err } if len(db.Schemas) == 0 { return nil, fmt.Errorf("no schemas found in DBML") } // Return the first schema return db.Schemas[0], nil } // ReadTable reads and parses DBML input, returning a Table model func (r *Reader) ReadTable() (*models.Table, error) { schema, err := r.ReadSchema() if err != nil { return nil, err } if len(schema.Tables) == 0 { return nil, fmt.Errorf("no tables found in DBML") } // Return the first table return schema.Tables[0], nil } // parseDBML parses DBML content and returns a Database model func (r *Reader) parseDBML(content string) (*models.Database, error) { db := models.InitDatabase("database") if r.options.Metadata != nil { if name, ok := r.options.Metadata["name"].(string); ok { db.Name = name } } scanner := bufio.NewScanner(strings.NewReader(content)) schemaMap := make(map[string]*models.Schema) var currentTable *models.Table var currentSchema string var inIndexes bool var inTable bool tableRegex := regexp.MustCompile(`^Table\s+([a-zA-Z0-9_.]+)\s*{`) refRegex := regexp.MustCompile(`^Ref:\s+(.+)`) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "//") { continue } // Parse Table definition if matches := tableRegex.FindStringSubmatch(line); matches != nil { tableName := matches[1] parts := strings.Split(tableName, ".") if len(parts) == 2 { currentSchema = parts[0] tableName = parts[1] } else { currentSchema = "public" } // Ensure schema exists if _, exists := schemaMap[currentSchema]; !exists { schemaMap[currentSchema] = models.InitSchema(currentSchema) } currentTable = models.InitTable(tableName, currentSchema) inTable = true inIndexes = false continue } // End of table definition if inTable && line == "}" { if currentTable != nil && currentSchema != "" { schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, currentTable) currentTable = nil } inTable = false inIndexes = false continue } // Parse indexes section if inTable && strings.HasPrefix(line, "indexes") { inIndexes = true continue } // End of indexes section if inIndexes && line == "}" { inIndexes = false continue } // Parse index definition if inIndexes && currentTable != nil { index := r.parseIndex(line, currentTable.Name, currentSchema) if index != nil { currentTable.Indexes[index.Name] = index } continue } // Parse table note if inTable && currentTable != nil && strings.HasPrefix(line, "Note:") { note := strings.TrimPrefix(line, "Note:") note = strings.Trim(note, " '\"") currentTable.Description = note continue } // Parse column definition if inTable && !inIndexes && currentTable != nil { column := r.parseColumn(line, currentTable.Name, currentSchema) if column != nil { currentTable.Columns[column.Name] = column } continue } // Parse Ref (relationship/foreign key) if matches := refRegex.FindStringSubmatch(line); matches != nil { constraint := r.parseRef(matches[1]) if constraint != nil { // Find the table and add the constraint for _, schema := range schemaMap { for _, table := range schema.Tables { if table.Schema == constraint.Schema && table.Name == constraint.Table { table.Constraints[constraint.Name] = constraint break } } } } continue } } // Add schemas to database for _, schema := range schemaMap { db.Schemas = append(db.Schemas, schema) } return db, nil } // parseColumn parses a DBML column definition func (r *Reader) parseColumn(line, tableName, schemaName string) *models.Column { // Format: column_name type [attributes] // comment parts := strings.Fields(line) if len(parts) < 2 { return nil } columnName := parts[0] columnType := parts[1] column := models.InitColumn(columnName, tableName, schemaName) column.Type = columnType // Parse attributes in brackets if strings.Contains(line, "[") && strings.Contains(line, "]") { attrStart := strings.Index(line, "[") attrEnd := strings.Index(line, "]") if attrStart < attrEnd { attrs := line[attrStart+1 : attrEnd] attrList := strings.Split(attrs, ",") for _, attr := range attrList { attr = strings.TrimSpace(attr) if strings.Contains(attr, "primary key") || attr == "pk" { column.IsPrimaryKey = true column.NotNull = true } else if strings.Contains(attr, "not null") { column.NotNull = true } else if attr == "increment" { column.AutoIncrement = true } else if strings.HasPrefix(attr, "default:") { defaultVal := strings.TrimSpace(strings.TrimPrefix(attr, "default:")) column.Default = strings.Trim(defaultVal, "'\"") } else if attr == "unique" { // Could create a unique constraint here } } } } // Parse inline comment if strings.Contains(line, "//") { commentStart := strings.Index(line, "//") column.Comment = strings.TrimSpace(line[commentStart+2:]) } return column } // parseIndex parses a DBML index definition func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index { // Format: (columns) [attributes] if !strings.Contains(line, "(") || !strings.Contains(line, ")") { return nil } colStart := strings.Index(line, "(") colEnd := strings.Index(line, ")") if colStart >= colEnd { return nil } columnsStr := line[colStart+1 : colEnd] columns := strings.Split(columnsStr, ",") for i := range columns { columns[i] = strings.TrimSpace(columns[i]) } index := models.InitIndex("") index.Table = tableName index.Schema = schemaName index.Columns = columns // Parse attributes if strings.Contains(line, "[") && strings.Contains(line, "]") { attrStart := strings.Index(line, "[") attrEnd := strings.Index(line, "]") if attrStart < attrEnd { attrs := line[attrStart+1 : attrEnd] attrList := strings.Split(attrs, ",") for _, attr := range attrList { attr = strings.TrimSpace(attr) if attr == "unique" { index.Unique = true } else if strings.HasPrefix(attr, "name:") { name := strings.TrimSpace(strings.TrimPrefix(attr, "name:")) index.Name = strings.Trim(name, "'\"") } else if strings.HasPrefix(attr, "type:") { indexType := strings.TrimSpace(strings.TrimPrefix(attr, "type:")) index.Type = strings.Trim(indexType, "'\"") } } } } // Generate name if not provided if index.Name == "" { index.Name = fmt.Sprintf("idx_%s_%s", tableName, strings.Join(columns, "_")) } return index } // parseRef parses a DBML Ref (foreign key relationship) func (r *Reader) parseRef(refStr string) *models.Constraint { // Format: schema.table.(columns) > schema.table.(columns) [actions] // Split by relationship operator (>, <, -, etc.) var fromPart, toPart string for _, op := range []string{">", "<", "-"} { if strings.Contains(refStr, op) { parts := strings.Split(refStr, op) if len(parts) == 2 { fromPart = strings.TrimSpace(parts[0]) toPart = strings.TrimSpace(parts[1]) break } } } if fromPart == "" || toPart == "" { return nil } // Remove actions part if present if strings.Contains(toPart, "[") { toPart = strings.TrimSpace(toPart[:strings.Index(toPart, "[")]) } // Parse from table and column fromSchema, fromTable, fromColumns := r.parseTableRef(fromPart) toSchema, toTable, toColumns := r.parseTableRef(toPart) if fromTable == "" || toTable == "" { return nil } constraint := models.InitConstraint( fmt.Sprintf("fk_%s_%s", fromTable, toTable), models.ForeignKeyConstraint, ) constraint.Schema = fromSchema constraint.Table = fromTable constraint.Columns = fromColumns constraint.ReferencedSchema = toSchema constraint.ReferencedTable = toTable constraint.ReferencedColumns = toColumns // Parse actions if present if strings.Contains(refStr, "[") && strings.Contains(refStr, "]") { actStart := strings.Index(refStr, "[") actEnd := strings.Index(refStr, "]") if actStart < actEnd { actions := refStr[actStart+1 : actEnd] actionList := strings.Split(actions, ",") for _, action := range actionList { action = strings.TrimSpace(action) if strings.HasPrefix(action, "ondelete:") { constraint.OnDelete = strings.TrimSpace(strings.TrimPrefix(action, "ondelete:")) } else if strings.HasPrefix(action, "onupdate:") { constraint.OnUpdate = strings.TrimSpace(strings.TrimPrefix(action, "onupdate:")) } } } } return constraint } // parseTableRef parses a table reference like "schema.table.(column1, column2)" func (r *Reader) parseTableRef(ref string) (schema, table string, columns []string) { // Extract columns if present if strings.Contains(ref, "(") && strings.Contains(ref, ")") { colStart := strings.Index(ref, "(") colEnd := strings.Index(ref, ")") if colStart < colEnd { columnsStr := ref[colStart+1 : colEnd] for _, col := range strings.Split(columnsStr, ",") { columns = append(columns, strings.TrimSpace(col)) } } ref = ref[:colStart] } // Parse schema and table parts := strings.Split(strings.TrimSpace(ref), ".") if len(parts) == 2 { schema = parts[0] table = parts[1] } else if len(parts) == 1 { schema = "public" table = parts[0] } return }