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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,21 @@ CREATE TABLE "posts" (
|
||||
- **Check Constraints**: Generated as comments (should be added to CREATE TABLE manually)
|
||||
- **Indexes**: Generated without PostgreSQL-specific features (no GIN, GiST, operator classes)
|
||||
|
||||
## DBML dialect directives
|
||||
|
||||
`@sqlite:` directives carried on a model object's `Metadata` (typically from a
|
||||
DBML source file) are translated to SQL:
|
||||
|
||||
| Directive | Location | Emitted |
|
||||
|-----------|----------|---------|
|
||||
| `@sqlite: without rowid` | table | `WITHOUT ROWID` table option |
|
||||
| `@sqlite: strict` | table | `STRICT` table option (after `WITHOUT ROWID`) |
|
||||
| `@sqlite(col): collate …` | column | ` COLLATE …` in the column definition |
|
||||
|
||||
Directives for other dialects (`@postgres:` …) are ignored. With
|
||||
`WriterOptions.StrictDirectives` (CLI `--strict-directives`) an untranslatable
|
||||
`@sqlite:` key is an error. Full reference: [`docs/DBML_DIRECTIVES.md`](../../../docs/DBML_DIRECTIVES.md).
|
||||
|
||||
## Output Structure
|
||||
|
||||
Generated SQL follows this order:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// directiveNamespace is the dialect namespace this writer consumes. Directives
|
||||
// for other namespaces (e.g. "postgres") are ignored and never emitted as SQL.
|
||||
const directiveNamespace = "sqlite"
|
||||
|
||||
// sqliteHandledDirectives maps a directive location to the set of sqlite 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 sqliteHandledDirectives = map[string]map[string]bool{
|
||||
models.DirectiveLocationTable: {"without": true, "strict": true},
|
||||
models.DirectiveLocationColumn: {"collate": true},
|
||||
}
|
||||
|
||||
// checkDirectives validates sqlite directives across a schema when strict mode
|
||||
// is enabled. 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 !sqliteHandledDirectives[location][d.Key] {
|
||||
return fmt.Errorf("sqlite: %s: unsupported @sqlite directive %q at %s level (strict mode)", owner, d.Key, location)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sqliteTableOptions returns the trailing table-option clause for a CREATE TABLE
|
||||
// statement, e.g. "WITHOUT ROWID, STRICT". WITHOUT ROWID is emitted before
|
||||
// STRICT, matching SQLite's own grammar ordering.
|
||||
func sqliteTableOptions(table *models.Table) string {
|
||||
var opts []string
|
||||
if models.HasDirective(table.Metadata, directiveNamespace, "without") {
|
||||
opts = append(opts, "WITHOUT ROWID")
|
||||
}
|
||||
if models.HasDirective(table.Metadata, directiveNamespace, "strict") {
|
||||
opts = append(opts, "STRICT")
|
||||
}
|
||||
return strings.Join(opts, ", ")
|
||||
}
|
||||
|
||||
// sqliteColumnCollate returns a " COLLATE <name>" clause for a column carrying an
|
||||
// @sqlite(col): collate <name> directive, or "".
|
||||
func sqliteColumnCollate(col *models.Column) string {
|
||||
for _, d := range models.DirectivesForNamespace(col.Metadata, directiveNamespace) {
|
||||
if d.Key != "collate" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(d.Args), "collate"))
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return " COLLATE " + name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
func sqliteDirectiveDB(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
|
||||
table.Columns["id"] = id
|
||||
|
||||
name := models.InitColumn("name", "events", "public")
|
||||
name.Type = "varchar(200)"
|
||||
name.NotNull = true
|
||||
models.AddDirective(name.Metadata, models.Directive{Namespace: "sqlite", Args: "collate NOCASE"})
|
||||
// A postgres directive on the same column must be ignored by the sqlite writer.
|
||||
models.AddDirective(name.Metadata, models.Directive{Namespace: "postgres", Args: "storage plain"})
|
||||
table.Columns["name"] = name
|
||||
|
||||
models.AddDirective(table.Metadata, models.Directive{Namespace: "sqlite", Args: "without rowid"})
|
||||
models.AddDirective(table.Metadata, models.Directive{Namespace: "sqlite", Args: "strict"})
|
||||
models.AddDirective(table.Metadata, models.Directive{Namespace: "postgres", Args: "partition by RANGE (id)"})
|
||||
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSqliteDirectives_TableOptionsAndCollate(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&writers.WriterOptions{})
|
||||
w.writer = &buf
|
||||
if err := w.WriteDatabase(sqliteDirectiveDB(t)); err != nil {
|
||||
t.Fatalf("WriteDatabase: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, ") WITHOUT ROWID, STRICT;") {
|
||||
t.Errorf("missing table options clause:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"name" TEXT COLLATE NOCASE NOT NULL`) {
|
||||
t.Errorf("missing column COLLATE clause:\n%s", out)
|
||||
}
|
||||
// postgres directives must never reach sqlite output.
|
||||
if strings.Contains(strings.ToUpper(out), "PARTITION BY") || strings.Contains(strings.ToUpper(out), "STORAGE PLAIN") {
|
||||
t.Errorf("postgres directive leaked into sqlite output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteDirectives_StrictUnknownKeyErrors(t *testing.T) {
|
||||
db := sqliteDirectiveDB(t)
|
||||
models.AddDirective(db.Schemas[0].Tables[0].Metadata, models.Directive{Namespace: "sqlite", Args: "frobnicate x"})
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&writers.WriterOptions{StrictDirectives: true})
|
||||
w.writer = &buf
|
||||
err := w.WriteDatabase(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "frobnicate") {
|
||||
t.Fatalf("want strict error for unknown sqlite key, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteDirectives_StrictIgnoresPostgres(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&writers.WriterOptions{StrictDirectives: true})
|
||||
w.writer = &buf
|
||||
if err := w.WriteDatabase(sqliteDirectiveDB(t)); err != nil {
|
||||
t.Fatalf("strict mode should ignore postgres directives, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,10 @@ func GetTemplateFuncs(opts *writers.WriterOptions) template.FuncMap {
|
||||
"format_constraint_name": func(schema, table, constraint string) string {
|
||||
return FormatConstraintName(schema, table, constraint, opts)
|
||||
},
|
||||
"join": strings.Join,
|
||||
"lower": strings.ToLower,
|
||||
"upper": strings.ToUpper,
|
||||
"join": strings.Join,
|
||||
"lower": strings.ToLower,
|
||||
"upper": strings.ToUpper,
|
||||
"column_collate": sqliteColumnCollate,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,12 @@ func NewTemplateExecutor(opts *writers.WriterOptions) (*TemplateExecutor, error)
|
||||
|
||||
// TableTemplateData contains data for table template
|
||||
type TableTemplateData struct {
|
||||
Schema string
|
||||
Name string
|
||||
Columns []*models.Column
|
||||
PrimaryKey *models.Constraint
|
||||
ForeignKeys []ForeignKeyTemplateData
|
||||
Schema string
|
||||
Name string
|
||||
Columns []*models.Column
|
||||
PrimaryKey *models.Constraint
|
||||
ForeignKeys []ForeignKeyTemplateData
|
||||
TableOptions string
|
||||
}
|
||||
|
||||
// ForeignKeyTemplateData contains data for an inline FOREIGN KEY clause
|
||||
@@ -188,11 +189,12 @@ func BuildTableTemplateData(schema string, table *models.Table) TableTemplateDat
|
||||
}
|
||||
|
||||
return TableTemplateData{
|
||||
Schema: schema,
|
||||
Name: table.Name,
|
||||
Columns: columns,
|
||||
PrimaryKey: pk,
|
||||
ForeignKeys: fks,
|
||||
Schema: schema,
|
||||
Name: table.Name,
|
||||
Columns: columns,
|
||||
PrimaryKey: pk,
|
||||
ForeignKeys: fks,
|
||||
TableOptions: sqliteTableOptions(table),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
CREATE TABLE {{quote_ident (qualified_table_name .Schema .Name)}} (
|
||||
{{- $hasAutoIncrement := false}}
|
||||
{{- range $i, $col := .Columns}}{{if $i}},{{end}}
|
||||
{{quote_ident $col.Name}} {{map_type $col.Type}}{{if is_autoincrement $col}}{{$hasAutoIncrement = true}} PRIMARY KEY AUTOINCREMENT{{else}}{{if $col.NotNull}} NOT NULL{{end}}{{if ne (format_default $col) ""}} DEFAULT {{format_default $col}}{{end}}{{end}}
|
||||
{{quote_ident $col.Name}} {{map_type $col.Type}}{{column_collate $col}}{{if is_autoincrement $col}}{{$hasAutoIncrement = true}} PRIMARY KEY AUTOINCREMENT{{else}}{{if $col.NotNull}} NOT NULL{{end}}{{if ne (format_default $col) ""}} DEFAULT {{format_default $col}}{{end}}{{end}}
|
||||
{{- end}}
|
||||
{{- if and .PrimaryKey (not $hasAutoIncrement)}}{{if gt (len .Columns) 0}},{{end}}
|
||||
PRIMARY KEY ({{range $i, $colName := .PrimaryKey.Columns}}{{if $i}}, {{end}}{{quote_ident $colName}}{{end}})
|
||||
@@ -9,4 +9,4 @@ CREATE TABLE {{quote_ident (qualified_table_name .Schema .Name)}} (
|
||||
{{- range .ForeignKeys}},
|
||||
FOREIGN KEY ({{range $i, $col := .Columns}}{{if $i}}, {{end}}{{quote_ident $col}}{{end}}) REFERENCES {{quote_ident (qualified_table_name .ForeignSchema .ForeignTable)}} ({{range $i, $col := .ForeignColumns}}{{if $i}}, {{end}}{{quote_ident $col}}{{end}}){{if .OnDelete}} ON DELETE {{.OnDelete}}{{end}}{{if .OnUpdate}} ON UPDATE {{.OnUpdate}}{{end}}
|
||||
{{- end}}
|
||||
);
|
||||
){{if .TableOptions}} {{.TableOptions}}{{end}};
|
||||
|
||||
@@ -186,6 +186,10 @@ func tableSchemaName(schema string) string {
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
tableSchema := tableSchemaName(schema.Name)
|
||||
|
||||
if err := w.checkDirectives(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SQLite doesn't have schemas, so we just write a comment (skip for the
|
||||
// default schema, since its tables aren't actually being prefixed)
|
||||
if tableSchema != "" {
|
||||
|
||||
@@ -86,6 +86,10 @@ type WriterOptions struct {
|
||||
// Prisma7 enables Prisma 7-specific output for Prisma writers.
|
||||
Prisma7 bool
|
||||
|
||||
// StrictDirectives makes dialect directive translation fail on an
|
||||
// unsupported key for the writer's own namespace instead of skipping it.
|
||||
StrictDirectives bool
|
||||
|
||||
// ContinueOnError instructs SQL writers to prepend `\set ON_ERROR_STOP off`
|
||||
// to their output so that psql continues past errors instead of stopping.
|
||||
ContinueOnError bool
|
||||
|
||||
Reference in New Issue
Block a user