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:
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user