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
182 lines
4.7 KiB
Go
182 lines
4.7 KiB
Go
package dbml
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
|
)
|
|
|
|
func parse(t *testing.T, strict bool, src string) (*models.Database, error) {
|
|
t.Helper()
|
|
r := NewReader(&readers.ReaderOptions{StrictDirectives: strict})
|
|
return r.parseDBML(src)
|
|
}
|
|
|
|
func firstTable(t *testing.T, db *models.Database) *models.Table {
|
|
t.Helper()
|
|
if len(db.Schemas) == 0 || len(db.Schemas[0].Tables) == 0 {
|
|
t.Fatal("no table parsed")
|
|
}
|
|
return db.Schemas[0].Tables[0]
|
|
}
|
|
|
|
func TestDirectives_AttachAtEachLocation(t *testing.T) {
|
|
src := `@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)
|
|
|
|
indexes {
|
|
(created_at) [name: 'idx_events_created']
|
|
@postgres: with (fillfactor=90)
|
|
}
|
|
}
|
|
`
|
|
db, err := parse(t, false, src)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
|
|
if !models.HasDirective(db.Metadata, "postgres", "search_path") {
|
|
t.Errorf("database-level directive missing: %+v", db.Metadata)
|
|
}
|
|
|
|
tbl := firstTable(t, db)
|
|
if !models.HasDirective(tbl.Metadata, "postgres", "partition") {
|
|
t.Errorf("table-level directive missing: %+v", tbl.Metadata)
|
|
}
|
|
|
|
col := tbl.Columns["id"]
|
|
if col == nil || !models.HasDirective(col.Metadata, "postgres", "identity") {
|
|
t.Errorf("column-level directive missing")
|
|
}
|
|
// Verbatim args preserved.
|
|
if d := models.DirectivesForNamespace(col.Metadata, "postgres"); len(d) != 1 || d[0].Args != "identity always" {
|
|
t.Errorf("column directive args = %+v", d)
|
|
}
|
|
|
|
var idx *models.Index
|
|
for _, i := range tbl.Indexes {
|
|
idx = i
|
|
}
|
|
if idx == nil || !models.HasDirective(idx.Metadata, "postgres", "with") {
|
|
t.Errorf("index-level directive missing: %+v", idx)
|
|
}
|
|
}
|
|
|
|
func TestDirectives_RepeatablePreservedAndOrdered(t *testing.T) {
|
|
src := `Table s.t {
|
|
id int [pk]
|
|
@postgres: with (fillfactor=90)
|
|
@postgres: with (autovacuum_enabled=off)
|
|
}
|
|
`
|
|
db, err := parse(t, false, src)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
tbl := firstTable(t, db)
|
|
got := models.DirectivesForNamespace(tbl.Metadata, "postgres")
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d directives, want 2", len(got))
|
|
}
|
|
if got[0].Args != "with (fillfactor=90)" || got[1].Args != "with (autovacuum_enabled=off)" {
|
|
t.Errorf("repeatable directives out of order: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestDirectives_SingletonDuplicateErrors(t *testing.T) {
|
|
src := `Table s.t {
|
|
id int [pk]
|
|
@postgres: partition by RANGE (a)
|
|
@postgres: partition by LIST (b)
|
|
}
|
|
`
|
|
_, err := parse(t, false, src)
|
|
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
|
t.Fatalf("want duplicate error, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "line 4") {
|
|
t.Errorf("error not line-numbered: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDirectives_MalformedErrors(t *testing.T) {
|
|
cases := map[string]string{
|
|
"no colon": "@postgres partition by x",
|
|
"empty args": "@postgres:",
|
|
"bad namespace": "@Postgres: partition by x",
|
|
"numeric prefix": "@1x: foo",
|
|
}
|
|
for name, line := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
src := "Table s.t {\n id int [pk]\n " + line + "\n}\n"
|
|
_, err := parse(t, false, src)
|
|
if err == nil {
|
|
t.Fatalf("want error for %q", line)
|
|
}
|
|
if !strings.Contains(err.Error(), "line 3") {
|
|
t.Errorf("error not line-numbered: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDirectives_UnknownPreservedNonStrict(t *testing.T) {
|
|
src := `Table s.t {
|
|
id int [pk]
|
|
@postgres: frobnicate all the things
|
|
@clickhouse: engine MergeTree
|
|
}
|
|
`
|
|
db, err := parse(t, false, src)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
tbl := firstTable(t, db)
|
|
if !models.HasDirective(tbl.Metadata, "postgres", "frobnicate") {
|
|
t.Error("unknown postgres key not preserved")
|
|
}
|
|
if !models.HasDirective(tbl.Metadata, "clickhouse", "engine") {
|
|
t.Error("unknown namespace not preserved")
|
|
}
|
|
}
|
|
|
|
func TestDirectives_StrictErrors(t *testing.T) {
|
|
src := `Table s.t {
|
|
id int [pk]
|
|
@postgres: frobnicate x
|
|
}
|
|
`
|
|
_, err := parse(t, true, src)
|
|
if err == nil || !strings.Contains(err.Error(), "strict mode") {
|
|
t.Fatalf("want strict-mode error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDirectives_UnknownColumnTargetErrors(t *testing.T) {
|
|
src := `Table s.t {
|
|
id int [pk]
|
|
@postgres(missing): identity always
|
|
}
|
|
`
|
|
_, err := parse(t, false, src)
|
|
if err == nil || !strings.Contains(err.Error(), "not found") {
|
|
t.Fatalf("want unknown-column error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDirectives_WrongLocationErrors(t *testing.T) {
|
|
// partition is table-only.
|
|
src := "@postgres: partition by RANGE (x)\n\nTable s.t {\n id int [pk]\n}\n"
|
|
_, err := parse(t, false, src)
|
|
if err == nil || !strings.Contains(err.Error(), "not valid at database level") {
|
|
t.Fatalf("want location error, got %v", err)
|
|
}
|
|
}
|