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:
@@ -137,6 +137,41 @@ indexes {
|
||||
}
|
||||
```
|
||||
|
||||
### Dialect directives
|
||||
|
||||
Dialect directives stored on a model object's `Metadata` (namespace `postgres`,
|
||||
`sqlite`, …) are re-emitted verbatim, one line per directive, at the location
|
||||
they belong to:
|
||||
|
||||
```dbml
|
||||
@postgres: search_path myapp
|
||||
|
||||
Table myapp.events {
|
||||
id bigint [pk]
|
||||
created_at timestamp [not null]
|
||||
@postgres(id): identity always
|
||||
@postgres: partition by RANGE (created_at)
|
||||
@sqlite: without rowid
|
||||
|
||||
indexes {
|
||||
(created_at) [name: 'idx_events_created']
|
||||
@postgres: with (fillfactor=90)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Emitted at | From |
|
||||
|------------|------|
|
||||
| Before the first table | `Database.Metadata` |
|
||||
| After a column line, as `@ns(col): …` | `Column.Metadata` |
|
||||
| After an index line, inside `indexes { }` | `Index.Metadata` |
|
||||
| After the `indexes` block, before `Note:` | `Table.Metadata` |
|
||||
|
||||
Output is deterministic (ordered by namespace, then source line, then args), so a
|
||||
`DBML → model → DBML` round-trip is idempotent. See
|
||||
[`docs/DBML_DIRECTIVES.md`](../../../docs/DBML_DIRECTIVES.md) for the grammar and
|
||||
the list of directives the PostgreSQL and SQLite writers translate to SQL.
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| SQL Type | DBML Type |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package dbml
|
||||
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// directiveLines renders every dialect directive stored in meta back to its DBML
|
||||
// source form, one line per directive, each prefixed with indent. When target is
|
||||
// non-empty it is emitted as the "(column)" target, e.g.
|
||||
// " @postgres(id): identity always". Order is deterministic (see
|
||||
// models.GetDirectives).
|
||||
func directiveLines(meta map[string]any, indent, target string) []string {
|
||||
directives := models.GetDirectives(meta)
|
||||
if len(directives) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(directives))
|
||||
for _, d := range directives {
|
||||
lines = append(lines, indent+models.FormatDirectiveLine(d, target))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package dbml
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
dbmlreader "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
const directiveSrc = `@postgres: search_path myapp
|
||||
|
||||
Table myapp.events {
|
||||
id bigint [pk]
|
||||
created_at timestamp [not null]
|
||||
@postgres(id): identity always
|
||||
@postgres: partition by RANGE (created_at)
|
||||
@postgres: tablespace fast_data
|
||||
@sqlite: without rowid
|
||||
|
||||
indexes {
|
||||
(created_at) [name: 'idx_events_created']
|
||||
@postgres: with (fillfactor=90)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func writeDBML(t *testing.T, db *models.Database) string {
|
||||
t.Helper()
|
||||
out := filepath.Join(t.TempDir(), "out.dbml")
|
||||
require.NoError(t, NewWriter(&writers.WriterOptions{OutputPath: out}).WriteDatabase(db))
|
||||
b, err := os.ReadFile(out)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func readDBML(t *testing.T, src string) *models.Database {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "in.dbml")
|
||||
require.NoError(t, os.WriteFile(f, []byte(src), 0o644))
|
||||
db, err := dbmlreader.NewReader(&readers.ReaderOptions{FilePath: f}).ReadDatabase()
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
func collectDirectives(db *models.Database) map[string][]string {
|
||||
got := map[string][]string{}
|
||||
add := func(loc string, meta map[string]any) {
|
||||
for _, d := range models.GetDirectives(meta) {
|
||||
got[loc] = append(got[loc], models.FormatDirectiveLine(d, ""))
|
||||
}
|
||||
}
|
||||
add("database", db.Metadata)
|
||||
for _, s := range db.Schemas {
|
||||
for _, tbl := range s.Tables {
|
||||
add("table:"+tbl.Name, tbl.Metadata)
|
||||
for _, c := range tbl.Columns {
|
||||
add("column:"+c.Name, c.Metadata)
|
||||
}
|
||||
for _, i := range tbl.Indexes {
|
||||
add("index:"+i.Name, i.Metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestDirectives_RoundTrip(t *testing.T) {
|
||||
db1 := readDBML(t, directiveSrc)
|
||||
out1 := writeDBML(t, db1)
|
||||
db2 := readDBML(t, out1)
|
||||
out2 := writeDBML(t, db2)
|
||||
|
||||
assert.Equal(t, out1, out2, "DBML directive output should be idempotent")
|
||||
assert.Equal(t, collectDirectives(db1), collectDirectives(db2), "directives preserved through round-trip")
|
||||
|
||||
// Spot-check each location survived.
|
||||
d := collectDirectives(db2)
|
||||
assert.Contains(t, d["database"], "@postgres: search_path myapp")
|
||||
assert.Contains(t, d["table:events"], "@postgres: partition by RANGE (created_at)")
|
||||
assert.Contains(t, d["table:events"], "@sqlite: without rowid")
|
||||
assert.Contains(t, d["column:id"], "@postgres: identity always")
|
||||
assert.Contains(t, d["index:idx_events_created"], "@postgres: with (fillfactor=90)")
|
||||
}
|
||||
|
||||
func TestDirectives_WriterEmitsColumnTarget(t *testing.T) {
|
||||
db := readDBML(t, directiveSrc)
|
||||
out := writeDBML(t, db)
|
||||
assert.Contains(t, out, "@postgres(id): identity always")
|
||||
}
|
||||
@@ -72,6 +72,14 @@ func (w *Writer) databaseToDBML(d *models.Database) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if dirLines := directiveLines(d.Metadata, "", ""); len(dirLines) > 0 {
|
||||
for _, line := range dirLines {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
for _, schema := range d.Schemas {
|
||||
sb.WriteString(w.schemaToDBML(schema))
|
||||
}
|
||||
@@ -146,6 +154,11 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
||||
fmt.Fprintf(&sb, " // %s", column.Comment)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
for _, line := range directiveLines(column.Metadata, " ", column.Name) {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(t.Indexes) > 0 {
|
||||
@@ -167,10 +180,20 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
||||
fmt.Fprintf(&sb, " [%s]", strings.Join(indexAttrs, ", "))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
for _, line := range directiveLines(index.Metadata, " ", "") {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString(" }\n")
|
||||
}
|
||||
|
||||
for _, line := range directiveLines(t.Metadata, " ", "") {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
note := strings.TrimSpace(t.Description + " " + t.Comment)
|
||||
if note != "" {
|
||||
fmt.Fprintf(&sb, "\n Note: '%s'\n", note)
|
||||
|
||||
Reference in New Issue
Block a user