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
This commit is contained in:
@@ -172,6 +172,26 @@ When `include_audit` is enabled, adds:
|
||||
- Concurrent index creation (`CREATE INDEX CONCURRENTLY`) via `Index.Concurrent`
|
||||
- Check constraints with expressions
|
||||
- Extension types and indexes: PostGIS, pgvector, citext, hstore, ltree (see below)
|
||||
- DBML dialect directives (`@postgres:` — see below)
|
||||
|
||||
### DBML dialect directives
|
||||
|
||||
`@postgres:` directives carried on a model object's `Metadata` (typically from a
|
||||
DBML source file) are translated to SQL:
|
||||
|
||||
| Directive | Location | Emitted |
|
||||
|-----------|----------|---------|
|
||||
| `@postgres: partition by …` | table | `PARTITION BY …` on `CREATE TABLE` |
|
||||
| `@postgres: inherits …` | table | `INHERITS (…)` |
|
||||
| `@postgres: with (…)` | table, index | `WITH (…)` (on an index, overrides the comment-derived `WITH`) |
|
||||
| `@postgres: tablespace …` | table, index | `TABLESPACE …` |
|
||||
| `@postgres(col): storage …` | column | `STORAGE …` |
|
||||
| `@postgres(col): compression …` | column | `COMPRESSION …` |
|
||||
| `@postgres(col): identity always` / `identity by default` | column | `GENERATED ALWAYS/BY DEFAULT AS IDENTITY` |
|
||||
|
||||
Directives for other dialects (`@sqlite:` …) are ignored. With
|
||||
`WriterOptions.StrictDirectives` (CLI `--strict-directives`) an untranslatable
|
||||
`@postgres:` key is an error. Full reference: [`docs/DBML_DIRECTIVES.md`](../../../docs/DBML_DIRECTIVES.md).
|
||||
|
||||
## Data Types
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func directiveTestDB(t *testing.T) *models.Database {
|
||||
t.Helper()
|
||||
db := models.InitDatabase("testdb")
|
||||
schema := models.InitSchema("public")
|
||||
table := models.InitTable("events", "public")
|
||||
|
||||
id := models.InitColumn("id", "events", "public")
|
||||
id.Type = "bigint"
|
||||
id.IsPrimaryKey = true
|
||||
id.NotNull = true
|
||||
models.AddDirective(id.Metadata, models.Directive{Namespace: "postgres", Args: "identity always"})
|
||||
models.AddDirective(id.Metadata, models.Directive{Namespace: "sqlite", Args: "collate NOCASE"})
|
||||
table.Columns["id"] = id
|
||||
|
||||
created := models.InitColumn("created_at", "events", "public")
|
||||
created.Type = "timestamp"
|
||||
created.NotNull = true
|
||||
table.Columns["created_at"] = created
|
||||
|
||||
models.AddDirective(table.Metadata, models.Directive{Namespace: "postgres", Args: "partition by RANGE (created_at)"})
|
||||
models.AddDirective(table.Metadata, models.Directive{Namespace: "postgres", Args: "tablespace fast_data"})
|
||||
models.AddDirective(table.Metadata, models.Directive{Namespace: "sqlite", Args: "without rowid"})
|
||||
|
||||
idx := models.InitIndex("idx_events_created", "events", "public")
|
||||
idx.Columns = []string{"created_at"}
|
||||
models.AddDirective(idx.Metadata, models.Directive{Namespace: "postgres", Args: "with (fillfactor=90)"})
|
||||
models.AddDirective(idx.Metadata, models.Directive{Namespace: "postgres", Args: "tablespace idx_space"})
|
||||
table.Indexes["idx_events_created"] = idx
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestPgDirectives_WriteDatabasePath(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&writers.WriterOptions{})
|
||||
w.writer = &buf
|
||||
if err := w.WriteDatabase(directiveTestDB(t)); err != nil {
|
||||
t.Fatalf("WriteDatabase: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
") PARTITION BY RANGE (created_at) TABLESPACE fast_data",
|
||||
"GENERATED ALWAYS AS IDENTITY",
|
||||
"WITH (fillfactor=90) TABLESPACE idx_space",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// sqlite directives must never reach PG output.
|
||||
if strings.Contains(out, "WITHOUT ROWID") || strings.Contains(strings.ToUpper(out), "COLLATE NOCASE") {
|
||||
t.Errorf("sqlite directive leaked into PG output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgDirectives_WriteSchemaPath(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&writers.WriterOptions{})
|
||||
w.writer = &buf
|
||||
if err := w.WriteSchema(directiveTestDB(t).Schemas[0]); err != nil {
|
||||
t.Fatalf("WriteSchema: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, ") PARTITION BY RANGE (created_at) TABLESPACE fast_data;") {
|
||||
t.Errorf("table suffix missing from WriteSchema path:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "WITH (fillfactor=90) TABLESPACE idx_space") {
|
||||
t.Errorf("index clauses missing from WriteSchema path:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgDirectives_StrictUnknownKeyErrors(t *testing.T) {
|
||||
db := directiveTestDB(t)
|
||||
models.AddDirective(db.Schemas[0].Tables[0].Metadata, models.Directive{Namespace: "postgres", Args: "frobnicate x"})
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&writers.WriterOptions{StrictDirectives: true})
|
||||
w.writer = &buf
|
||||
err := w.WriteSchema(db.Schemas[0])
|
||||
if err == nil || !strings.Contains(err.Error(), "frobnicate") {
|
||||
t.Fatalf("want strict error for unknown postgres key, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgDirectives_StrictIgnoresOtherNamespaces(t *testing.T) {
|
||||
// sqlite directives are present but must not trip PG strict mode.
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&writers.WriterOptions{StrictDirectives: true})
|
||||
w.writer = &buf
|
||||
if err := w.WriteSchema(directiveTestDB(t).Schemas[0]); err != nil {
|
||||
t.Fatalf("strict mode should ignore non-postgres directives, got %v", err)
|
||||
}
|
||||
}
|
||||
+29
-10
@@ -143,6 +143,10 @@ func (w *Writer) GenerateDatabaseStatements(db *models.Database) ([]string, erro
|
||||
func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, error) {
|
||||
statements := []string{}
|
||||
|
||||
if err := w.checkDirectives(schema); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Phase 1: Create schema (skip entirely when flattening)
|
||||
if schema.Name != "public" && !w.options.FlattenSchema {
|
||||
statements = append(statements, fmt.Sprintf("-- Schema: %s", schema.Name))
|
||||
@@ -277,17 +281,22 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
columnExprs := buildIndexColumnExpressions(table, index, indexType)
|
||||
|
||||
withClause := ""
|
||||
if params := indexStorageParameters(index.Comment); params != "" {
|
||||
if params := pgIndexWithParams(index, indexStorageParameters(index.Comment)); params != "" {
|
||||
withClause = fmt.Sprintf(" WITH (%s)", params)
|
||||
}
|
||||
|
||||
tablespaceClause := ""
|
||||
if ts := pgIndexDirectiveTablespace(index); ts != "" {
|
||||
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if index.Where != "" {
|
||||
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s USING %s (%s)%s%s",
|
||||
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, whereClause)
|
||||
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s USING %s (%s)%s%s%s",
|
||||
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
@@ -581,8 +590,9 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
|
||||
columnDefs = append(columnDefs, " "+def)
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n)",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), strings.Join(columnDefs, ",\n"))
|
||||
stmt := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n)%s",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), strings.Join(columnDefs, ",\n"),
|
||||
pgTableDirectiveSuffix(table))
|
||||
statements = append(statements, stmt)
|
||||
|
||||
return statements, nil
|
||||
@@ -611,7 +621,7 @@ func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
return strings.Join(parts, " ") + pgColumnDirectiveSuffix(col)
|
||||
}
|
||||
|
||||
func effectiveColumnSQLType(col *models.Column) string {
|
||||
@@ -678,6 +688,10 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
w.writer = os.Stdout
|
||||
}
|
||||
|
||||
if err := w.checkDirectives(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Phase 1: Create schema (priority 1)
|
||||
if err := w.writeCreateSchema(schema); err != nil {
|
||||
return err
|
||||
@@ -884,7 +898,7 @@ func (w *Writer) writeCreateTables(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "%s\n", strings.Join(columnDefs, ",\n"))
|
||||
fmt.Fprintf(w.writer, ");\n\n")
|
||||
fmt.Fprintf(w.writer, ")%s;\n\n", pgTableDirectiveSuffix(table))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1079,10 +1093,15 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
withClause := ""
|
||||
if params := indexStorageParameters(index.Comment); params != "" {
|
||||
if params := pgIndexWithParams(index, indexStorageParameters(index.Comment)); params != "" {
|
||||
withClause = fmt.Sprintf(" WITH (%s)", params)
|
||||
}
|
||||
|
||||
tablespaceClause := ""
|
||||
if ts := pgIndexDirectiveTablespace(index); ts != "" {
|
||||
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if index.Where != "" {
|
||||
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
|
||||
@@ -1095,8 +1114,8 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
|
||||
fmt.Fprintf(w.writer, "CREATE %sINDEX %sIF NOT EXISTS %s\n",
|
||||
unique, concurrently, indexName)
|
||||
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s%s;\n\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, whereClause)
|
||||
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s%s%s;\n\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user