Files
relspecgo/pkg/writers/pgsql/directives.go
T
HeinandClaude Sonnet 5 ce3b615b0a feat(dbml): @postgres/@sqlite dialect directives (#19)
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
2026-09-08 16:17:37 +02:00

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
}