Add parseable `@<namespace>[(<target>)]: <args>` directives embedded in DBML.
They are stored losslessly on each object's Metadata, round-trip unchanged
through the DBML writer, and are translated to SQL only by the writer for the
matching dialect.
- models: Directive type + catalog; Metadata map added to Column and Index
- dbml reader: parse and attach directives at database/table/column/index
level; line-numbered errors; repeatable by default with singleton duplicate
detection. Fixes a preexisting bug where an `indexes {}` closing brace ended
the table early, dropping trailing Note: and directive lines.
- dbml writer: re-emit directives at their location; idempotent output
- pgsql writer: PARTITION BY / INHERITS / WITH / TABLESPACE (table),
STORAGE / COMPRESSION / identity (column), WITH / TABLESPACE (index)
- sqlite writer: WITHOUT ROWID / STRICT (table), COLLATE (column)
- --strict-directives flag on ReaderOptions and WriterOptions
- docs/DBML_DIRECTIVES.md + reader/writer READMEs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ss2MY5J11cRGwEz86ZXk7d
138 lines
4.2 KiB
Go
138 lines
4.2 KiB
Go
package dbml
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
)
|
|
|
|
// directiveLineRegex matches a dialect directive line:
|
|
//
|
|
// @postgres: partition by RANGE (created_at)
|
|
// @postgres(id): identity always
|
|
//
|
|
// Group 1 is the namespace, group 2 the optional (column) target, group 3 the
|
|
// raw argument text (validated separately so error messages can be specific).
|
|
var directiveLineRegex = regexp.MustCompile(`^@([^():]*)(?:\(([^()]*)\))?\s*:(.*)$`)
|
|
|
|
// namespaceRegex is the grammar for a directive namespace.
|
|
var namespaceRegex = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
|
|
// parsedDirective is a directive line that has been parsed but not yet attached
|
|
// to a model object.
|
|
type parsedDirective struct {
|
|
namespace string
|
|
target string // column name; "" when absent
|
|
args string
|
|
line int
|
|
}
|
|
|
|
// parseDirectiveLine parses a single "@namespace[(target)]: args" line.
|
|
func parseDirectiveLine(line string, lineNo int) (parsedDirective, error) {
|
|
m := directiveLineRegex.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return parsedDirective{}, fmt.Errorf(
|
|
"dbml: line %d: malformed directive %q (expected \"@namespace: args\")", lineNo, line)
|
|
}
|
|
|
|
ns := strings.TrimSpace(m[1])
|
|
target := strings.TrimSpace(m[2])
|
|
args := strings.TrimSpace(m[3])
|
|
|
|
if !namespaceRegex.MatchString(ns) {
|
|
return parsedDirective{}, fmt.Errorf(
|
|
"dbml: line %d: invalid directive namespace %q (must match [a-z][a-z0-9_]*)", lineNo, ns)
|
|
}
|
|
if args == "" {
|
|
return parsedDirective{}, fmt.Errorf("dbml: line %d: directive @%s has no arguments", lineNo, ns)
|
|
}
|
|
if target != "" {
|
|
target = stripQuotes(target)
|
|
}
|
|
|
|
return parsedDirective{namespace: ns, target: target, args: args, line: lineNo}, nil
|
|
}
|
|
|
|
// attachDirective resolves the target model object from the current parser state
|
|
// and stores the directive in its Metadata, enforcing location, duplicate and
|
|
// strict-mode rules.
|
|
func (r *Reader) attachDirective(
|
|
pd parsedDirective,
|
|
db *models.Database,
|
|
table *models.Table,
|
|
inTable, inIndexes bool,
|
|
lastIndex *models.Index,
|
|
) error {
|
|
strict := r.options != nil && r.options.StrictDirectives
|
|
key := models.DirectiveKey(pd.args)
|
|
|
|
var meta map[string]any
|
|
var location string
|
|
|
|
switch {
|
|
case inIndexes:
|
|
if pd.target != "" {
|
|
return fmt.Errorf("dbml: line %d: directive target (%s) is not allowed inside an indexes block", pd.line, pd.target)
|
|
}
|
|
if lastIndex == nil {
|
|
return fmt.Errorf("dbml: line %d: directive @%s must follow an index definition", pd.line, pd.namespace)
|
|
}
|
|
if lastIndex.Metadata == nil {
|
|
lastIndex.Metadata = make(map[string]any)
|
|
}
|
|
meta = lastIndex.Metadata
|
|
location = models.DirectiveLocationIndex
|
|
|
|
case inTable && table != nil:
|
|
if pd.target != "" {
|
|
col, ok := table.Columns[pd.target]
|
|
if !ok {
|
|
return fmt.Errorf("dbml: line %d: directive target column %q not found in table %q", pd.line, pd.target, table.Name)
|
|
}
|
|
if col.Metadata == nil {
|
|
col.Metadata = make(map[string]any)
|
|
}
|
|
meta = col.Metadata
|
|
location = models.DirectiveLocationColumn
|
|
} else {
|
|
if table.Metadata == nil {
|
|
table.Metadata = make(map[string]any)
|
|
}
|
|
meta = table.Metadata
|
|
location = models.DirectiveLocationTable
|
|
}
|
|
|
|
default:
|
|
if pd.target != "" {
|
|
return fmt.Errorf("dbml: line %d: directive target (%s) is only valid inside a table", pd.line, pd.target)
|
|
}
|
|
if db.Metadata == nil {
|
|
db.Metadata = make(map[string]any)
|
|
}
|
|
meta = db.Metadata
|
|
location = models.DirectiveLocationDatabase
|
|
}
|
|
|
|
spec, documented := models.LookupDirectiveSpec(pd.namespace, key)
|
|
|
|
if strict && !documented {
|
|
return fmt.Errorf("dbml: line %d: unknown directive @%s: %s (strict mode)", pd.line, pd.namespace, key)
|
|
}
|
|
if documented && !models.DirectiveLocationAllowed(pd.namespace, key, location) {
|
|
return fmt.Errorf("dbml: line %d: directive @%s: %s is not valid at %s level", pd.line, pd.namespace, key, location)
|
|
}
|
|
if documented && spec.Singleton && models.HasDirective(meta, pd.namespace, key) {
|
|
return fmt.Errorf("dbml: line %d: duplicate @%s directive %q at %s level", pd.line, pd.namespace, key, location)
|
|
}
|
|
|
|
models.AddDirective(meta, models.Directive{
|
|
Namespace: pd.namespace,
|
|
Key: key,
|
|
Args: pd.args,
|
|
Line: pd.line,
|
|
})
|
|
return nil
|
|
}
|