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
185 lines
6.2 KiB
Go
185 lines
6.2 KiB
Go
package pgsql
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
)
|
|
|
|
// directiveNamespace is the dialect namespace this writer consumes. Directives
|
|
// for other namespaces (e.g. "sqlite") are ignored and never emitted as SQL.
|
|
const directiveNamespace = "postgres"
|
|
|
|
// pgHandledDirectives maps a directive location to the set of postgres keys this
|
|
// writer knows how to translate. In strict mode an unknown key for this
|
|
// namespace at a supported location is a hard error.
|
|
var pgHandledDirectives = map[string]map[string]bool{
|
|
models.DirectiveLocationTable: {"partition": true, "inherits": true, "with": true, "tablespace": true},
|
|
models.DirectiveLocationColumn: {"storage": true, "compression": true, "identity": true},
|
|
models.DirectiveLocationIndex: {"with": true, "tablespace": true},
|
|
}
|
|
|
|
// checkDirectives validates postgres directives across a schema when strict mode
|
|
// is enabled. It returns an error for any postgres directive whose key this
|
|
// writer cannot translate. With strict mode off it is a no-op.
|
|
func (w *Writer) checkDirectives(schema *models.Schema) error {
|
|
if w.options == nil || !w.options.StrictDirectives {
|
|
return nil
|
|
}
|
|
for _, table := range schema.Tables {
|
|
if err := checkObjectDirectives(table.Metadata, models.DirectiveLocationTable, table.Name); err != nil {
|
|
return err
|
|
}
|
|
for _, col := range table.Columns {
|
|
if err := checkObjectDirectives(col.Metadata, models.DirectiveLocationColumn, table.Name+"."+col.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, idx := range table.Indexes {
|
|
if err := checkObjectDirectives(idx.Metadata, models.DirectiveLocationIndex, idx.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkObjectDirectives(meta map[string]any, location, owner string) error {
|
|
for _, d := range models.DirectivesForNamespace(meta, directiveNamespace) {
|
|
if !pgHandledDirectives[location][d.Key] {
|
|
return fmt.Errorf("pgsql: %s: unsupported @postgres directive %q at %s level (strict mode)", owner, d.Key, location)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// upperLeadingClause upcases a known leading keyword phrase in a directive
|
|
// argument so the emitted SQL reads conventionally. Identifiers that follow are
|
|
// left untouched.
|
|
func upperLeadingClause(args, lowerPrefix, upperPrefix string) string {
|
|
args = strings.TrimSpace(args)
|
|
if strings.HasPrefix(strings.ToLower(args), lowerPrefix) {
|
|
return upperPrefix + args[len(lowerPrefix):]
|
|
}
|
|
return args
|
|
}
|
|
|
|
// pgTableDirectiveSuffix returns the clause appended after the closing ")" of a
|
|
// CREATE TABLE statement, e.g. " PARTITION BY RANGE (created_at) TABLESPACE fast".
|
|
func pgTableDirectiveSuffix(table *models.Table) string {
|
|
directives := models.DirectivesForNamespace(table.Metadata, directiveNamespace)
|
|
if len(directives) == 0 {
|
|
return ""
|
|
}
|
|
|
|
byKey := firstByKey(directives)
|
|
|
|
var parts []string
|
|
if d, ok := byKey["partition"]; ok {
|
|
parts = append(parts, upperLeadingClause(d.Args, "partition by", "PARTITION BY"))
|
|
}
|
|
if d, ok := byKey["inherits"]; ok {
|
|
parts = append(parts, upperLeadingClause(d.Args, "inherits", "INHERITS"))
|
|
}
|
|
if d, ok := byKey["with"]; ok {
|
|
parts = append(parts, upperLeadingClause(d.Args, "with", "WITH"))
|
|
}
|
|
if d, ok := byKey["tablespace"]; ok {
|
|
parts = append(parts, upperLeadingClause(d.Args, "tablespace", "TABLESPACE"))
|
|
}
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return " " + strings.Join(parts, " ")
|
|
}
|
|
|
|
// pgColumnDirectiveSuffix returns the clause appended to a column definition,
|
|
// e.g. " STORAGE PLAIN" or " GENERATED ALWAYS AS IDENTITY".
|
|
func pgColumnDirectiveSuffix(col *models.Column) string {
|
|
directives := models.DirectivesForNamespace(col.Metadata, directiveNamespace)
|
|
if len(directives) == 0 {
|
|
return ""
|
|
}
|
|
byKey := firstByKey(directives)
|
|
|
|
var parts []string
|
|
if d, ok := byKey["storage"]; ok {
|
|
parts = append(parts, upperLeadingClause(d.Args, "storage", "STORAGE"))
|
|
}
|
|
if d, ok := byKey["compression"]; ok {
|
|
parts = append(parts, upperLeadingClause(d.Args, "compression", "COMPRESSION"))
|
|
}
|
|
if d, ok := byKey["identity"]; ok {
|
|
parts = append(parts, identityClause(d.Args))
|
|
}
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return " " + strings.Join(parts, " ")
|
|
}
|
|
|
|
// identityClause maps the two documented identity forms to standard SQL,
|
|
// falling back to a verbatim (upcased-keyword) rendering.
|
|
func identityClause(args string) string {
|
|
switch strings.ToLower(strings.Join(strings.Fields(args), " ")) {
|
|
case "identity always":
|
|
return "GENERATED ALWAYS AS IDENTITY"
|
|
case "identity default", "identity by default":
|
|
return "GENERATED BY DEFAULT AS IDENTITY"
|
|
default:
|
|
return upperLeadingClause(args, "identity", "IDENTITY")
|
|
}
|
|
}
|
|
|
|
// pgIndexDirectiveWith returns the parenthesised storage-parameter list from an
|
|
// @postgres: with (...) index directive, e.g. "fillfactor=90", or "".
|
|
func pgIndexDirectiveWith(index *models.Index) string {
|
|
for _, d := range models.DirectivesForNamespace(index.Metadata, directiveNamespace) {
|
|
if d.Key != "with" {
|
|
continue
|
|
}
|
|
inner := d.Args
|
|
if i := strings.Index(inner, "("); i >= 0 {
|
|
if j := strings.LastIndex(inner, ")"); j > i {
|
|
return strings.TrimSpace(inner[i+1 : j])
|
|
}
|
|
}
|
|
return strings.TrimSpace(strings.TrimPrefix(strings.ToLower(inner), "with"))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pgIndexWithParams returns the storage-parameter list to use for an index,
|
|
// preferring an @postgres: with (...) directive over the given fallback (e.g.
|
|
// one derived from the index comment).
|
|
func pgIndexWithParams(index *models.Index, fallback string) string {
|
|
if p := pgIndexDirectiveWith(index); p != "" {
|
|
return p
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// pgIndexDirectiveTablespace returns the tablespace name from an
|
|
// @postgres: tablespace <name> index directive, or "".
|
|
func pgIndexDirectiveTablespace(index *models.Index) string {
|
|
for _, d := range models.DirectivesForNamespace(index.Metadata, directiveNamespace) {
|
|
if d.Key == "tablespace" {
|
|
return strings.TrimSpace(strings.TrimPrefix(strings.ToLower(d.Args), "tablespace"))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstByKey indexes directives by key, keeping the first occurrence (the
|
|
// documented postgres keys used here are all singletons).
|
|
func firstByKey(directives []models.Directive) map[string]models.Directive {
|
|
byKey := make(map[string]models.Directive, len(directives))
|
|
for _, d := range directives {
|
|
if _, exists := byKey[d.Key]; !exists {
|
|
byKey[d.Key] = d
|
|
}
|
|
}
|
|
return byKey
|
|
}
|