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
238 lines
7.2 KiB
Go
238 lines
7.2 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Directive is a dialect-specific instruction embedded in a source schema
|
|
// (currently DBML) that is preserved losslessly in the intermediate model and
|
|
// consumed only by the writer for its namespace. Directives are stored in the
|
|
// Metadata map of the object they apply to, under DirectivesMetadataKey.
|
|
//
|
|
// Example DBML: `@postgres: partition by RANGE (created_at)` parses to
|
|
// Directive{Namespace: "postgres", Key: "partition", Args: "partition by RANGE (created_at)"}.
|
|
type Directive struct {
|
|
// Namespace is the dialect the directive targets, e.g. "postgres" or "sqlite".
|
|
Namespace string `json:"namespace" yaml:"namespace"`
|
|
// Key is the lowercased first token of Args, used for duplicate detection
|
|
// and writer dispatch.
|
|
Key string `json:"key,omitempty" yaml:"key,omitempty"`
|
|
// Args is the verbatim argument text following the "@namespace:" prefix.
|
|
Args string `json:"args" yaml:"args"`
|
|
// Line is the 1-based source line the directive was read from, when known.
|
|
Line int `json:"line,omitempty" yaml:"line,omitempty"`
|
|
}
|
|
|
|
// DirectivesMetadataKey is the Metadata map key under which the ordered list of
|
|
// dialect directives for an object is stored.
|
|
const DirectivesMetadataKey = "directives"
|
|
|
|
// DirectiveKey derives the Key for a directive from its argument text: the
|
|
// lowercased first whitespace-delimited token.
|
|
func DirectiveKey(args string) string {
|
|
fields := strings.Fields(args)
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(fields[0])
|
|
}
|
|
|
|
// AddDirective appends d to the directive list stored in meta. The caller is
|
|
// responsible for ensuring meta is non-nil (all Init* constructors allocate it).
|
|
// If d.Key is empty it is derived from d.Args.
|
|
func AddDirective(meta map[string]any, d Directive) {
|
|
if meta == nil {
|
|
return
|
|
}
|
|
if d.Key == "" {
|
|
d.Key = DirectiveKey(d.Args)
|
|
}
|
|
existing := GetDirectives(meta)
|
|
existing = append(existing, d)
|
|
meta[DirectivesMetadataKey] = existing
|
|
}
|
|
|
|
// GetDirectives returns the directives stored in meta, sorted deterministically
|
|
// by (Namespace, Line, Args). It tolerates both a freshly built []Directive and
|
|
// the []any of map[string]any produced by a JSON/YAML round-trip.
|
|
func GetDirectives(meta map[string]any) []Directive {
|
|
if meta == nil {
|
|
return nil
|
|
}
|
|
raw, ok := meta[DirectivesMetadataKey]
|
|
if !ok || raw == nil {
|
|
return nil
|
|
}
|
|
|
|
var out []Directive
|
|
switch v := raw.(type) {
|
|
case []Directive:
|
|
out = append(out, v...)
|
|
case []any:
|
|
for _, item := range v {
|
|
if d, ok := directiveFromAny(item); ok {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].Namespace != out[j].Namespace {
|
|
return out[i].Namespace < out[j].Namespace
|
|
}
|
|
if out[i].Line != out[j].Line {
|
|
return out[i].Line < out[j].Line
|
|
}
|
|
return out[i].Args < out[j].Args
|
|
})
|
|
return out
|
|
}
|
|
|
|
// directiveFromAny decodes a single directive from the loosely typed forms that
|
|
// survive a JSON or YAML round-trip (map[string]any / map[any]any).
|
|
func directiveFromAny(item any) (Directive, bool) {
|
|
switch m := item.(type) {
|
|
case Directive:
|
|
return m, true
|
|
case map[string]any:
|
|
return directiveFromStringMap(m), true
|
|
case map[any]any:
|
|
sm := make(map[string]any, len(m))
|
|
for k, val := range m {
|
|
if ks, ok := k.(string); ok {
|
|
sm[ks] = val
|
|
}
|
|
}
|
|
return directiveFromStringMap(sm), true
|
|
}
|
|
return Directive{}, false
|
|
}
|
|
|
|
func directiveFromStringMap(m map[string]any) Directive {
|
|
d := Directive{}
|
|
if s, ok := m["namespace"].(string); ok {
|
|
d.Namespace = s
|
|
}
|
|
if s, ok := m["key"].(string); ok {
|
|
d.Key = s
|
|
}
|
|
if s, ok := m["args"].(string); ok {
|
|
d.Args = s
|
|
}
|
|
switch n := m["line"].(type) {
|
|
case int:
|
|
d.Line = n
|
|
case int64:
|
|
d.Line = int(n)
|
|
case float64:
|
|
d.Line = int(n)
|
|
}
|
|
if d.Key == "" {
|
|
d.Key = DirectiveKey(d.Args)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// DirectivesForNamespace returns the directives in meta that target ns, in the
|
|
// deterministic order of GetDirectives.
|
|
func DirectivesForNamespace(meta map[string]any, ns string) []Directive {
|
|
all := GetDirectives(meta)
|
|
if len(all) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]Directive, 0, len(all))
|
|
for _, d := range all {
|
|
if d.Namespace == ns {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HasDirective reports whether meta contains a directive with the given
|
|
// namespace and key.
|
|
func HasDirective(meta map[string]any, ns, key string) bool {
|
|
for _, d := range GetDirectives(meta) {
|
|
if d.Namespace == ns && d.Key == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DirectiveSpec describes a documented directive in the catalog.
|
|
type DirectiveSpec struct {
|
|
// Singleton means only one directive with this namespace/key may appear at
|
|
// a single location; a second one is a parse error.
|
|
Singleton bool
|
|
// Locations lists the location kinds the directive is valid at
|
|
// ("database", "table", "column", "index").
|
|
Locations []string
|
|
}
|
|
|
|
// Location kinds a directive may attach to.
|
|
const (
|
|
DirectiveLocationDatabase = "database"
|
|
DirectiveLocationTable = "table"
|
|
DirectiveLocationColumn = "column"
|
|
DirectiveLocationIndex = "index"
|
|
)
|
|
|
|
// DirectiveCatalog is the set of documented directives per namespace. It is used
|
|
// for strict-mode validation in readers and writers; unknown namespaces/keys are
|
|
// still preserved losslessly when strict mode is off.
|
|
var DirectiveCatalog = map[string]map[string]DirectiveSpec{
|
|
"postgres": {
|
|
"partition": {Singleton: true, Locations: []string{DirectiveLocationTable}},
|
|
"tablespace": {Singleton: true, Locations: []string{DirectiveLocationTable, DirectiveLocationIndex}},
|
|
"inherits": {Singleton: true, Locations: []string{DirectiveLocationTable}},
|
|
"with": {Singleton: false, Locations: []string{DirectiveLocationTable, DirectiveLocationIndex}},
|
|
"storage": {Singleton: true, Locations: []string{DirectiveLocationColumn}},
|
|
"compression": {Singleton: true, Locations: []string{DirectiveLocationColumn}},
|
|
"identity": {Singleton: true, Locations: []string{DirectiveLocationColumn}},
|
|
},
|
|
"sqlite": {
|
|
"without": {Singleton: true, Locations: []string{DirectiveLocationTable}},
|
|
"strict": {Singleton: true, Locations: []string{DirectiveLocationTable}},
|
|
"collate": {Singleton: true, Locations: []string{DirectiveLocationColumn}},
|
|
},
|
|
}
|
|
|
|
// LookupDirectiveSpec returns the catalog spec for a namespace/key and whether
|
|
// it is documented.
|
|
func LookupDirectiveSpec(ns, key string) (DirectiveSpec, bool) {
|
|
keys, ok := DirectiveCatalog[ns]
|
|
if !ok {
|
|
return DirectiveSpec{}, false
|
|
}
|
|
spec, ok := keys[key]
|
|
return spec, ok
|
|
}
|
|
|
|
// DirectiveLocationAllowed reports whether a documented directive may appear at
|
|
// the given location. Unknown directives (not in the catalog) are allowed
|
|
// everywhere so they can be preserved.
|
|
func DirectiveLocationAllowed(ns, key, location string) bool {
|
|
spec, ok := LookupDirectiveSpec(ns, key)
|
|
if !ok {
|
|
return true
|
|
}
|
|
for _, l := range spec.Locations {
|
|
if l == location {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FormatDirectiveLine renders a directive back to its DBML source form, e.g.
|
|
// "@postgres: partition by RANGE (created_at)" or "@postgres(id): identity always".
|
|
func FormatDirectiveLine(d Directive, target string) string {
|
|
if target != "" {
|
|
return fmt.Sprintf("@%s(%s): %s", d.Namespace, target, d.Args)
|
|
}
|
|
return fmt.Sprintf("@%s: %s", d.Namespace, d.Args)
|
|
}
|