From ce3b615b0a4448627640f9d30d2b92715d4299b1 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 8 Sep 2026 16:17:37 +0200 Subject: [PATCH] feat(dbml): @postgres/@sqlite dialect directives (#19) Add parseable `@[()]: ` 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 Claude-Session: https://claude.ai/code/session_01Ss2MY5J11cRGwEz86ZXk7d --- cmd/relspec/prisma_options.go | 16 +- cmd/relspec/root.go | 10 +- docs/DBML_DIRECTIVES.md | 115 +++++++++ pkg/models/directives.go | 237 ++++++++++++++++++ pkg/models/directives_test.go | 133 ++++++++++ pkg/models/models.go | 112 +++++---- pkg/readers/dbml/README.md | 44 ++++ pkg/readers/dbml/directives.go | 137 ++++++++++ pkg/readers/dbml/directives_test.go | 181 +++++++++++++ pkg/readers/dbml/reader.go | 26 +- pkg/readers/reader.go | 4 + pkg/writers/dbml/README.md | 35 +++ pkg/writers/dbml/directives.go | 23 ++ pkg/writers/dbml/directives_test.go | 97 +++++++ pkg/writers/dbml/writer.go | 23 ++ pkg/writers/pgsql/README.md | 20 ++ pkg/writers/pgsql/directives.go | 184 ++++++++++++++ pkg/writers/pgsql/directives_test.go | 107 ++++++++ pkg/writers/pgsql/writer.go | 39 ++- pkg/writers/sqlite/README.md | 15 ++ pkg/writers/sqlite/directives.go | 84 +++++++ pkg/writers/sqlite/directives_test.go | 82 ++++++ pkg/writers/sqlite/template_functions.go | 7 +- pkg/writers/sqlite/templates.go | 22 +- .../sqlite/templates/create_table.tmpl | 4 +- pkg/writers/sqlite/writer.go | 4 + pkg/writers/writer.go | 4 + 27 files changed, 1674 insertions(+), 91 deletions(-) create mode 100644 docs/DBML_DIRECTIVES.md create mode 100644 pkg/models/directives.go create mode 100644 pkg/models/directives_test.go create mode 100644 pkg/readers/dbml/directives.go create mode 100644 pkg/readers/dbml/directives_test.go create mode 100644 pkg/writers/dbml/directives.go create mode 100644 pkg/writers/dbml/directives_test.go create mode 100644 pkg/writers/pgsql/directives.go create mode 100644 pkg/writers/pgsql/directives_test.go create mode 100644 pkg/writers/sqlite/directives.go create mode 100644 pkg/writers/sqlite/directives_test.go diff --git a/cmd/relspec/prisma_options.go b/cmd/relspec/prisma_options.go index 11e6c8c..44d37d1 100644 --- a/cmd/relspec/prisma_options.go +++ b/cmd/relspec/prisma_options.go @@ -10,17 +10,19 @@ func newReaderOptions(filePath, connString string) *readers.ReaderOptions { FilePath: filePath, ConnectionString: connString, Prisma7: prisma7, + StrictDirectives: strictDirectives, } } func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool) *writers.WriterOptions { return &writers.WriterOptions{ - OutputPath: outputPath, - PackageName: packageName, - FlattenSchema: flattenSchema, - NullableTypes: nullableTypes, - NullableArrays: nullableArrays, - Prisma7: prisma7, - ContinueOnError: continueOnError, + OutputPath: outputPath, + PackageName: packageName, + FlattenSchema: flattenSchema, + NullableTypes: nullableTypes, + NullableArrays: nullableArrays, + Prisma7: prisma7, + ContinueOnError: continueOnError, + StrictDirectives: strictDirectives, } } diff --git a/cmd/relspec/root.go b/cmd/relspec/root.go index 1e4980c..039b7e2 100644 --- a/cmd/relspec/root.go +++ b/cmd/relspec/root.go @@ -10,10 +10,11 @@ import ( var ( // Version information, set via ldflags during build - version = "dev" - buildDate = "unknown" - prisma7 bool - noVersion bool + version = "dev" + buildDate = "unknown" + prisma7 bool + noVersion bool + strictDirectives bool ) func init() { @@ -72,6 +73,7 @@ func init() { rootCmd.AddCommand(reportCmd) rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas") rootCmd.PersistentFlags().BoolVar(&noVersion, "no-version", false, "Suppress the RelSpec version header") + rootCmd.PersistentFlags().BoolVar(&strictDirectives, "strict-directives", false, "Fail on unknown or untranslatable DBML dialect directives (@postgres:, @sqlite:, …)") } // printVersionHeader prints the "RelSpec (built: )" banner diff --git a/docs/DBML_DIRECTIVES.md b/docs/DBML_DIRECTIVES.md new file mode 100644 index 0000000..ce281e3 --- /dev/null +++ b/docs/DBML_DIRECTIVES.md @@ -0,0 +1,115 @@ +# DBML Dialect Directives + +DBML has no dialect-neutral way to express database-specific features such as +PostgreSQL table partitioning or SQLite `WITHOUT ROWID`. RelSpec adds **dialect +directives** — explicit, parseable lines embedded in a `.dbml` file that are: + +- stored losslessly in the intermediate model (under each object's `Metadata`), +- preserved unchanged through a `DBML → model → DBML` round-trip, +- translated to SQL **only** by the writer for the matching dialect + (`@postgres:` clauses appear in PostgreSQL output, never in SQLite output, and + vice-versa). + +## Grammar + +A directive is a single line, matched on its trimmed content: + +``` +@[()]: +``` + +| Part | Rules | +|------|-------| +| `namespace` | `^[a-z][a-z0-9_]*$` — e.g. `postgres`, `sqlite`. Future dialects allowed. | +| `(target)` | Optional. A **column name** only, valid only on a directive line inside a table body. Bare or single/double quoted. | +| `args` | Everything after the first `:`, trimmed. Otherwise preserved **verbatim**. Must be non-empty. | + +The **key** of a directive is derived: the lowercased first whitespace-delimited +token of `args` (`partition by RANGE (created_at)` → `partition`). It drives +duplicate detection and writer dispatch. + +## Location + +Where the line appears determines which object it attaches to: + +| Position in the file | Attaches to | +|----------------------|-------------| +| Before the first `Table {` | database (`db.Metadata`) | +| Table body, no `(target)` | that table | +| Table body, `(col)` target | column `col` of that table (error if `col` is unknown) | +| Inside an `indexes { }` block | the **most recently listed** index entry in that block; `(target)` is not allowed | + +```dbml +@postgres: search_path myapp -- database + +Table myapp.events { + id bigint [pk] + created_at timestamp [not null] + @postgres(id): identity always -- column "id" + @postgres: partition by RANGE (created_at) -- table + @postgres: tablespace fast_data -- table + @sqlite: without rowid -- table + + indexes { + (created_at) [name: 'idx_events_created'] + @postgres: with (fillfactor=90) -- index "idx_events_created" + @postgres: tablespace idx_space -- index "idx_events_created" + } +} +``` + +## Duplicate policy + +- **Repeatable by default** — every directive with the same `(namespace, key)` at + one location is kept, in source order. +- **Singletons** raise a line-numbered error on a second occurrence at the same + location. Current singletons: `postgres` `partition`, `tablespace`, `inherits`, + `storage`, `compression`, `identity`; `sqlite` `without`, `strict`, `collate`. + +## Strict mode + +CLI flag `--strict-directives` (also `ReaderOptions.StrictDirectives` / +`WriterOptions.StrictDirectives`): + +- **Reader**: an unknown namespace or key is a hard error. Without strict mode it + is stored and preserved silently, and round-trips unchanged. +- **PostgreSQL / SQLite writer**: a directive for **that** writer's own dialect + whose key it cannot translate is a hard error. Without strict mode, translatable + keys are emitted and the rest are skipped. Directives for other dialects are + always ignored, never emitted. + +## Errors + +All are line-numbered (`dbml: line N: …`): + +- no colon, or empty `args` +- namespace empty or not matching `[a-z][a-z0-9_]*` +- `(target)` naming an unknown column, or used at the top level / in an `indexes` block +- a directive in the catalog used at a location it is not valid for +- duplicate singleton at the same location +- (strict mode) unknown `(namespace, key)` + +## Supported directive matrix + +### `@postgres` + +| Key | Locations | SQL emitted | Notes | +|-----|-----------|-------------|-------| +| `partition` | table | `PARTITION BY ` appended to `CREATE TABLE` | e.g. `@postgres: partition by RANGE (created_at)` | +| `inherits` | table | `INHERITS ()` — args verbatim | | +| `with` | table, index | `WITH ()` | On an index, wins over `WITH` derived from the index comment. `@postgres: with (fillfactor=90)` | +| `tablespace` | table, index | `TABLESPACE ` | Emitted after `WITH`, before `WHERE` on indexes | +| `storage` | column | `STORAGE ` in the column definition | e.g. `@postgres(blob): storage external` | +| `compression` | column | `COMPRESSION ` | | +| `identity` | column | `identity always` → `GENERATED ALWAYS AS IDENTITY`; `identity default` / `identity by default` → `GENERATED BY DEFAULT AS IDENTITY` | | + +### `@sqlite` + +| Key | Locations | SQL emitted | Notes | +|-----|-----------|-------------|-------| +| `without` | table | `WITHOUT ROWID` table option | `@sqlite: without rowid` | +| `strict` | table | `STRICT` table option | `WITHOUT ROWID` is emitted before `STRICT` | +| `collate` | column | ` COLLATE ` in the column definition | e.g. `@sqlite(name): collate NOCASE` | + +Unknown namespaces and keys not in these tables are still preserved losslessly +(and round-trip through the DBML writer) whenever strict mode is off. diff --git a/pkg/models/directives.go b/pkg/models/directives.go new file mode 100644 index 0000000..6d3ce2f --- /dev/null +++ b/pkg/models/directives.go @@ -0,0 +1,237 @@ +package models + +import ( + "fmt" + "sort" + "strings" +) + +// Directive is a dialect-specific instruction embedded in a source schema +// (currently DBML) that is preserved losslessly in the intermediate model and +// consumed only by the writer for its namespace. Directives are stored in the +// Metadata map of the object they apply to, under DirectivesMetadataKey. +// +// Example DBML: `@postgres: partition by RANGE (created_at)` parses to +// Directive{Namespace: "postgres", Key: "partition", Args: "partition by RANGE (created_at)"}. +type Directive struct { + // Namespace is the dialect the directive targets, e.g. "postgres" or "sqlite". + Namespace string `json:"namespace" yaml:"namespace"` + // Key is the lowercased first token of Args, used for duplicate detection + // and writer dispatch. + Key string `json:"key,omitempty" yaml:"key,omitempty"` + // Args is the verbatim argument text following the "@namespace:" prefix. + Args string `json:"args" yaml:"args"` + // Line is the 1-based source line the directive was read from, when known. + Line int `json:"line,omitempty" yaml:"line,omitempty"` +} + +// DirectivesMetadataKey is the Metadata map key under which the ordered list of +// dialect directives for an object is stored. +const DirectivesMetadataKey = "directives" + +// DirectiveKey derives the Key for a directive from its argument text: the +// lowercased first whitespace-delimited token. +func DirectiveKey(args string) string { + fields := strings.Fields(args) + if len(fields) == 0 { + return "" + } + return strings.ToLower(fields[0]) +} + +// AddDirective appends d to the directive list stored in meta. The caller is +// responsible for ensuring meta is non-nil (all Init* constructors allocate it). +// If d.Key is empty it is derived from d.Args. +func AddDirective(meta map[string]any, d Directive) { + if meta == nil { + return + } + if d.Key == "" { + d.Key = DirectiveKey(d.Args) + } + existing := GetDirectives(meta) + existing = append(existing, d) + meta[DirectivesMetadataKey] = existing +} + +// GetDirectives returns the directives stored in meta, sorted deterministically +// by (Namespace, Line, Args). It tolerates both a freshly built []Directive and +// the []any of map[string]any produced by a JSON/YAML round-trip. +func GetDirectives(meta map[string]any) []Directive { + if meta == nil { + return nil + } + raw, ok := meta[DirectivesMetadataKey] + if !ok || raw == nil { + return nil + } + + var out []Directive + switch v := raw.(type) { + case []Directive: + out = append(out, v...) + case []any: + for _, item := range v { + if d, ok := directiveFromAny(item); ok { + out = append(out, d) + } + } + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].Namespace != out[j].Namespace { + return out[i].Namespace < out[j].Namespace + } + if out[i].Line != out[j].Line { + return out[i].Line < out[j].Line + } + return out[i].Args < out[j].Args + }) + return out +} + +// directiveFromAny decodes a single directive from the loosely typed forms that +// survive a JSON or YAML round-trip (map[string]any / map[any]any). +func directiveFromAny(item any) (Directive, bool) { + switch m := item.(type) { + case Directive: + return m, true + case map[string]any: + return directiveFromStringMap(m), true + case map[any]any: + sm := make(map[string]any, len(m)) + for k, val := range m { + if ks, ok := k.(string); ok { + sm[ks] = val + } + } + return directiveFromStringMap(sm), true + } + return Directive{}, false +} + +func directiveFromStringMap(m map[string]any) Directive { + d := Directive{} + if s, ok := m["namespace"].(string); ok { + d.Namespace = s + } + if s, ok := m["key"].(string); ok { + d.Key = s + } + if s, ok := m["args"].(string); ok { + d.Args = s + } + switch n := m["line"].(type) { + case int: + d.Line = n + case int64: + d.Line = int(n) + case float64: + d.Line = int(n) + } + if d.Key == "" { + d.Key = DirectiveKey(d.Args) + } + return d +} + +// DirectivesForNamespace returns the directives in meta that target ns, in the +// deterministic order of GetDirectives. +func DirectivesForNamespace(meta map[string]any, ns string) []Directive { + all := GetDirectives(meta) + if len(all) == 0 { + return nil + } + out := make([]Directive, 0, len(all)) + for _, d := range all { + if d.Namespace == ns { + out = append(out, d) + } + } + return out +} + +// HasDirective reports whether meta contains a directive with the given +// namespace and key. +func HasDirective(meta map[string]any, ns, key string) bool { + for _, d := range GetDirectives(meta) { + if d.Namespace == ns && d.Key == key { + return true + } + } + return false +} + +// DirectiveSpec describes a documented directive in the catalog. +type DirectiveSpec struct { + // Singleton means only one directive with this namespace/key may appear at + // a single location; a second one is a parse error. + Singleton bool + // Locations lists the location kinds the directive is valid at + // ("database", "table", "column", "index"). + Locations []string +} + +// Location kinds a directive may attach to. +const ( + DirectiveLocationDatabase = "database" + DirectiveLocationTable = "table" + DirectiveLocationColumn = "column" + DirectiveLocationIndex = "index" +) + +// DirectiveCatalog is the set of documented directives per namespace. It is used +// for strict-mode validation in readers and writers; unknown namespaces/keys are +// still preserved losslessly when strict mode is off. +var DirectiveCatalog = map[string]map[string]DirectiveSpec{ + "postgres": { + "partition": {Singleton: true, Locations: []string{DirectiveLocationTable}}, + "tablespace": {Singleton: true, Locations: []string{DirectiveLocationTable, DirectiveLocationIndex}}, + "inherits": {Singleton: true, Locations: []string{DirectiveLocationTable}}, + "with": {Singleton: false, Locations: []string{DirectiveLocationTable, DirectiveLocationIndex}}, + "storage": {Singleton: true, Locations: []string{DirectiveLocationColumn}}, + "compression": {Singleton: true, Locations: []string{DirectiveLocationColumn}}, + "identity": {Singleton: true, Locations: []string{DirectiveLocationColumn}}, + }, + "sqlite": { + "without": {Singleton: true, Locations: []string{DirectiveLocationTable}}, + "strict": {Singleton: true, Locations: []string{DirectiveLocationTable}}, + "collate": {Singleton: true, Locations: []string{DirectiveLocationColumn}}, + }, +} + +// LookupDirectiveSpec returns the catalog spec for a namespace/key and whether +// it is documented. +func LookupDirectiveSpec(ns, key string) (DirectiveSpec, bool) { + keys, ok := DirectiveCatalog[ns] + if !ok { + return DirectiveSpec{}, false + } + spec, ok := keys[key] + return spec, ok +} + +// DirectiveLocationAllowed reports whether a documented directive may appear at +// the given location. Unknown directives (not in the catalog) are allowed +// everywhere so they can be preserved. +func DirectiveLocationAllowed(ns, key, location string) bool { + spec, ok := LookupDirectiveSpec(ns, key) + if !ok { + return true + } + for _, l := range spec.Locations { + if l == location { + return true + } + } + return false +} + +// FormatDirectiveLine renders a directive back to its DBML source form, e.g. +// "@postgres: partition by RANGE (created_at)" or "@postgres(id): identity always". +func FormatDirectiveLine(d Directive, target string) string { + if target != "" { + return fmt.Sprintf("@%s(%s): %s", d.Namespace, target, d.Args) + } + return fmt.Sprintf("@%s: %s", d.Namespace, d.Args) +} diff --git a/pkg/models/directives_test.go b/pkg/models/directives_test.go new file mode 100644 index 0000000..71e0aad --- /dev/null +++ b/pkg/models/directives_test.go @@ -0,0 +1,133 @@ +package models + +import ( + "encoding/json" + "testing" +) + +func TestDirectiveKey(t *testing.T) { + cases := map[string]string{ + "partition by RANGE (created_at)": "partition", + "WITHOUT ROWID": "without", + " strict ": "strict", + "": "", + } + for args, want := range cases { + if got := DirectiveKey(args); got != want { + t.Errorf("DirectiveKey(%q) = %q, want %q", args, got, want) + } + } +} + +func TestAddDirectiveDerivesKey(t *testing.T) { + meta := map[string]any{} + AddDirective(meta, Directive{Namespace: "postgres", Args: "partition by RANGE (x)", Line: 2}) + AddDirective(meta, Directive{Namespace: "postgres", Key: "tablespace", Args: "tablespace fast", Line: 3}) + + got := GetDirectives(meta) + if len(got) != 2 { + t.Fatalf("got %d directives, want 2", len(got)) + } + if got[0].Key != "partition" { + t.Errorf("derived key = %q, want %q", got[0].Key, "partition") + } + if got[1].Key != "tablespace" { + t.Errorf("explicit key = %q, want %q", got[1].Key, "tablespace") + } +} + +func TestAddDirectiveNilMeta(t *testing.T) { + // Must not panic. + AddDirective(nil, Directive{Namespace: "postgres", Args: "strict"}) +} + +func TestGetDirectivesOrdering(t *testing.T) { + meta := map[string]any{} + AddDirective(meta, Directive{Namespace: "sqlite", Args: "strict", Line: 9}) + AddDirective(meta, Directive{Namespace: "postgres", Args: "with (b)", Line: 5}) + AddDirective(meta, Directive{Namespace: "postgres", Args: "with (a)", Line: 5}) + AddDirective(meta, Directive{Namespace: "postgres", Args: "partition by x", Line: 2}) + + got := GetDirectives(meta) + wantArgs := []string{"partition by x", "with (a)", "with (b)", "strict"} + if len(got) != len(wantArgs) { + t.Fatalf("got %d directives, want %d", len(got), len(wantArgs)) + } + for i, w := range wantArgs { + if got[i].Args != w { + t.Errorf("directive[%d].Args = %q, want %q", i, got[i].Args, w) + } + } +} + +func TestGetDirectivesTolerantDecodeAfterJSON(t *testing.T) { + meta := map[string]any{} + AddDirective(meta, Directive{Namespace: "postgres", Args: "partition by RANGE (created_at)", Line: 4}) + AddDirective(meta, Directive{Namespace: "sqlite", Args: "without rowid", Line: 6}) + + blob, err := json.Marshal(meta) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var round map[string]any + if err := json.Unmarshal(blob, &round); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + got := GetDirectives(round) + if len(got) != 2 { + t.Fatalf("got %d directives after JSON round-trip, want 2", len(got)) + } + if got[0].Namespace != "postgres" || got[0].Key != "partition" || got[0].Line != 4 { + t.Errorf("post-JSON directive[0] = %+v", got[0]) + } + if got[0].Args != "partition by RANGE (created_at)" { + t.Errorf("post-JSON args not verbatim: %q", got[0].Args) + } + if got[1].Namespace != "sqlite" || got[1].Key != "without" { + t.Errorf("post-JSON directive[1] = %+v", got[1]) + } +} + +func TestDirectivesForNamespaceAndHasDirective(t *testing.T) { + meta := map[string]any{} + AddDirective(meta, Directive{Namespace: "postgres", Args: "partition by x", Line: 1}) + AddDirective(meta, Directive{Namespace: "sqlite", Args: "strict", Line: 2}) + + pg := DirectivesForNamespace(meta, "postgres") + if len(pg) != 1 || pg[0].Key != "partition" { + t.Errorf("DirectivesForNamespace(postgres) = %+v", pg) + } + if !HasDirective(meta, "sqlite", "strict") { + t.Error("HasDirective(sqlite, strict) = false, want true") + } + if HasDirective(meta, "postgres", "tablespace") { + t.Error("HasDirective(postgres, tablespace) = true, want false") + } +} + +func TestDirectiveLocationAllowed(t *testing.T) { + if !DirectiveLocationAllowed("postgres", "partition", DirectiveLocationTable) { + t.Error("partition should be allowed at table level") + } + if DirectiveLocationAllowed("postgres", "partition", DirectiveLocationColumn) { + t.Error("partition should not be allowed at column level") + } + // Unknown directives are allowed everywhere so they can be preserved. + if !DirectiveLocationAllowed("postgres", "bogus", DirectiveLocationDatabase) { + t.Error("unknown key should be allowed everywhere") + } + if !DirectiveLocationAllowed("madeup", "x", DirectiveLocationTable) { + t.Error("unknown namespace should be allowed everywhere") + } +} + +func TestFormatDirectiveLine(t *testing.T) { + d := Directive{Namespace: "postgres", Key: "identity", Args: "identity always"} + if got := FormatDirectiveLine(d, ""); got != "@postgres: identity always" { + t.Errorf("FormatDirectiveLine no target = %q", got) + } + if got := FormatDirectiveLine(d, "id"); got != "@postgres(id): identity always" { + t.Errorf("FormatDirectiveLine with target = %q", got) + } +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 81311d5..dc1d9dd 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -24,16 +24,17 @@ const ( // Database represents the complete database schema type Database struct { - Name string `json:"name" yaml:"name"` - Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` - Schemas []*Schema `json:"schemas" yaml:"schemas" xml:"schemas"` - Domains []*Domain `json:"domains,omitempty" yaml:"domains,omitempty" xml:"domains,omitempty"` - Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` - DatabaseType DatabaseType `json:"database_type,omitempty" yaml:"database_type,omitempty" xml:"database_type,omitempty"` - DatabaseVersion string `json:"database_version,omitempty" yaml:"database_version,omitempty" xml:"database_version,omitempty"` - SourceFormat string `json:"source_format,omitempty" yaml:"source_format,omitempty" xml:"source_format,omitempty"` // Source Format of the database. - UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"` - GUID string `json:"guid" yaml:"guid" xml:"guid"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` + Schemas []*Schema `json:"schemas" yaml:"schemas" xml:"schemas"` + Domains []*Domain `json:"domains,omitempty" yaml:"domains,omitempty" xml:"domains,omitempty"` + Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` + DatabaseType DatabaseType `json:"database_type,omitempty" yaml:"database_type,omitempty" xml:"database_type,omitempty"` + DatabaseVersion string `json:"database_version,omitempty" yaml:"database_version,omitempty" xml:"database_version,omitempty"` + SourceFormat string `json:"source_format,omitempty" yaml:"source_format,omitempty" xml:"source_format,omitempty"` // Source Format of the database. + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"` + UpdatedAt string `json:"updatedat,omitempty" yaml:"updatedat,omitempty" xml:"updatedat,omitempty"` + GUID string `json:"guid" yaml:"guid" xml:"guid"` } // SQLName returns the database name in lowercase for SQL compatibility. @@ -226,22 +227,23 @@ func (d *Sequence) SQLName() string { // Column represents a table column type Column struct { - Name string `json:"name" yaml:"name" xml:"name"` - Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` - Table string `json:"table" yaml:"table" xml:"table"` - Schema string `json:"schema" yaml:"schema" xml:"schema"` - Type string `json:"type" yaml:"type" xml:"type"` - Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"` - Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"` - Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"` - NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"` - Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"` - AutoIncrement bool `json:"auto_increment" yaml:"auto_increment" xml:"auto_increment"` - IsPrimaryKey bool `json:"is_primary_key" yaml:"is_primary_key" xml:"is_primary_key"` - Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` - Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"` - Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"` - GUID string `json:"guid" yaml:"guid" xml:"guid"` + Name string `json:"name" yaml:"name" xml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` + Table string `json:"table" yaml:"table" xml:"table"` + Schema string `json:"schema" yaml:"schema" xml:"schema"` + Type string `json:"type" yaml:"type" xml:"type"` + Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"` + Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"` + Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"` + NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"` + Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"` + AutoIncrement bool `json:"auto_increment" yaml:"auto_increment" xml:"auto_increment"` + IsPrimaryKey bool `json:"is_primary_key" yaml:"is_primary_key" xml:"is_primary_key"` + Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` + Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"` + Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"` + GUID string `json:"guid" yaml:"guid" xml:"guid"` } // SQLName returns the column name in lowercase for SQL compatibility. @@ -252,19 +254,20 @@ func (d *Column) SQLName() string { // Index represents a database index for optimizing query performance. // Indexes can be unique, partial, or include additional columns. type Index struct { - Name string `json:"name" yaml:"name" xml:"name"` - Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` - Table string `json:"table,omitempty" yaml:"table,omitempty" xml:"table,omitempty"` - Schema string `json:"schema,omitempty" yaml:"schema,omitempty" xml:"schema,omitempty"` - Columns []string `json:"columns" yaml:"columns" xml:"columns"` - Unique bool `json:"unique" yaml:"unique" xml:"unique"` - Type string `json:"type" yaml:"type" xml:"type"` // btree, hash, gin, gist, etc. - Where string `json:"where,omitempty" yaml:"where,omitempty" xml:"where,omitempty"` // partial index condition - Concurrent bool `json:"concurrent,omitempty" yaml:"concurrent,omitempty" xml:"concurrent,omitempty"` - Include []string `json:"include,omitempty" yaml:"include,omitempty" xml:"include,omitempty"` // INCLUDE columns - Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` - Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"` - GUID string `json:"guid" yaml:"guid" xml:"guid"` + Name string `json:"name" yaml:"name" xml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"` + Table string `json:"table,omitempty" yaml:"table,omitempty" xml:"table,omitempty"` + Schema string `json:"schema,omitempty" yaml:"schema,omitempty" xml:"schema,omitempty"` + Columns []string `json:"columns" yaml:"columns" xml:"columns"` + Unique bool `json:"unique" yaml:"unique" xml:"unique"` + Type string `json:"type" yaml:"type" xml:"type"` // btree, hash, gin, gist, etc. + Where string `json:"where,omitempty" yaml:"where,omitempty" xml:"where,omitempty"` // partial index condition + Concurrent bool `json:"concurrent,omitempty" yaml:"concurrent,omitempty" xml:"concurrent,omitempty"` + Include []string `json:"include,omitempty" yaml:"include,omitempty" xml:"include,omitempty"` // INCLUDE columns + Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"` + Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"` + GUID string `json:"guid" yaml:"guid" xml:"guid"` } // SQLName returns the index name in lowercase for SQL compatibility. @@ -393,10 +396,11 @@ func (d *Script) SQLName() string { // InitDatabase initializes a new Database with empty slices func InitDatabase(name string) *Database { return &Database{ - Name: name, - Schemas: make([]*Schema, 0), - Domains: make([]*Domain, 0), - GUID: uuid.New().String(), + Name: name, + Schemas: make([]*Schema, 0), + Domains: make([]*Domain, 0), + Metadata: make(map[string]any), + GUID: uuid.New().String(), } } @@ -431,22 +435,24 @@ func InitTable(name, schema string) *Table { // InitColumn initializes a new Column func InitColumn(name, table, schema string) *Column { return &Column{ - Name: name, - Table: table, - Schema: schema, - GUID: uuid.New().String(), + Name: name, + Table: table, + Schema: schema, + Metadata: make(map[string]any), + GUID: uuid.New().String(), } } // InitIndex initializes a new Index with empty slices func InitIndex(name, table, schema string) *Index { return &Index{ - Name: name, - Table: table, - Schema: schema, - Columns: make([]string, 0), - Include: make([]string, 0), - GUID: uuid.New().String(), + Name: name, + Table: table, + Schema: schema, + Columns: make([]string, 0), + Include: make([]string, 0), + Metadata: make(map[string]any), + GUID: uuid.New().String(), } } diff --git a/pkg/readers/dbml/README.md b/pkg/readers/dbml/README.md index 2ba2502..a35d8d7 100644 --- a/pkg/readers/dbml/README.md +++ b/pkg/readers/dbml/README.md @@ -93,6 +93,50 @@ Ref: posts.user_id > users.id [delete: cascade] - Indexes and composite indexes - Table notes and column notes - Enums +- Dialect directives (`@postgres:` / `@sqlite:` — see below) + +## Dialect directives + +Lines of the form `@[()]: ` embed database-specific +features that plain DBML cannot express (partitioning, `WITHOUT ROWID`, +tablespaces, index storage parameters, …). They are stored losslessly on the +relevant object's `Metadata` and round-trip unchanged through the DBML writer; +the PostgreSQL and SQLite writers translate the ones they understand to SQL. + +```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) + } +} +``` + +| Position | Attaches to | +|----------|-------------| +| Before the first `Table {` | database | +| Table body, no `(target)` | that table | +| Table body, `(col)` target | column `col` (error if unknown) | +| Inside `indexes { }` | the most recently listed index entry | + +`args` is preserved verbatim; the **key** (lowercased first token) drives +duplicate detection. Repeated directives are kept in order; catalog "singleton" +keys error on a second occurrence at the same location. All errors are +line-numbered. + +`ReaderOptions.StrictDirectives` (CLI `--strict-directives`) turns an unknown +namespace or key into an error instead of preserving it silently. + +See [`docs/DBML_DIRECTIVES.md`](../../../docs/DBML_DIRECTIVES.md) for the full +grammar and the supported-directive matrix. ## Notes diff --git a/pkg/readers/dbml/directives.go b/pkg/readers/dbml/directives.go new file mode 100644 index 0000000..a22ca78 --- /dev/null +++ b/pkg/readers/dbml/directives.go @@ -0,0 +1,137 @@ +package dbml + +import ( + "fmt" + "regexp" + "strings" + + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +// directiveLineRegex matches a dialect directive line: +// +// @postgres: partition by RANGE (created_at) +// @postgres(id): identity always +// +// Group 1 is the namespace, group 2 the optional (column) target, group 3 the +// raw argument text (validated separately so error messages can be specific). +var directiveLineRegex = regexp.MustCompile(`^@([^():]*)(?:\(([^()]*)\))?\s*:(.*)$`) + +// namespaceRegex is the grammar for a directive namespace. +var namespaceRegex = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + +// parsedDirective is a directive line that has been parsed but not yet attached +// to a model object. +type parsedDirective struct { + namespace string + target string // column name; "" when absent + args string + line int +} + +// parseDirectiveLine parses a single "@namespace[(target)]: args" line. +func parseDirectiveLine(line string, lineNo int) (parsedDirective, error) { + m := directiveLineRegex.FindStringSubmatch(line) + if m == nil { + return parsedDirective{}, fmt.Errorf( + "dbml: line %d: malformed directive %q (expected \"@namespace: args\")", lineNo, line) + } + + ns := strings.TrimSpace(m[1]) + target := strings.TrimSpace(m[2]) + args := strings.TrimSpace(m[3]) + + if !namespaceRegex.MatchString(ns) { + return parsedDirective{}, fmt.Errorf( + "dbml: line %d: invalid directive namespace %q (must match [a-z][a-z0-9_]*)", lineNo, ns) + } + if args == "" { + return parsedDirective{}, fmt.Errorf("dbml: line %d: directive @%s has no arguments", lineNo, ns) + } + if target != "" { + target = stripQuotes(target) + } + + return parsedDirective{namespace: ns, target: target, args: args, line: lineNo}, nil +} + +// attachDirective resolves the target model object from the current parser state +// and stores the directive in its Metadata, enforcing location, duplicate and +// strict-mode rules. +func (r *Reader) attachDirective( + pd parsedDirective, + db *models.Database, + table *models.Table, + inTable, inIndexes bool, + lastIndex *models.Index, +) error { + strict := r.options != nil && r.options.StrictDirectives + key := models.DirectiveKey(pd.args) + + var meta map[string]any + var location string + + switch { + case inIndexes: + if pd.target != "" { + return fmt.Errorf("dbml: line %d: directive target (%s) is not allowed inside an indexes block", pd.line, pd.target) + } + if lastIndex == nil { + return fmt.Errorf("dbml: line %d: directive @%s must follow an index definition", pd.line, pd.namespace) + } + if lastIndex.Metadata == nil { + lastIndex.Metadata = make(map[string]any) + } + meta = lastIndex.Metadata + location = models.DirectiveLocationIndex + + case inTable && table != nil: + if pd.target != "" { + col, ok := table.Columns[pd.target] + if !ok { + return fmt.Errorf("dbml: line %d: directive target column %q not found in table %q", pd.line, pd.target, table.Name) + } + if col.Metadata == nil { + col.Metadata = make(map[string]any) + } + meta = col.Metadata + location = models.DirectiveLocationColumn + } else { + if table.Metadata == nil { + table.Metadata = make(map[string]any) + } + meta = table.Metadata + location = models.DirectiveLocationTable + } + + default: + if pd.target != "" { + return fmt.Errorf("dbml: line %d: directive target (%s) is only valid inside a table", pd.line, pd.target) + } + if db.Metadata == nil { + db.Metadata = make(map[string]any) + } + meta = db.Metadata + location = models.DirectiveLocationDatabase + } + + spec, documented := models.LookupDirectiveSpec(pd.namespace, key) + + if strict && !documented { + return fmt.Errorf("dbml: line %d: unknown directive @%s: %s (strict mode)", pd.line, pd.namespace, key) + } + if documented && !models.DirectiveLocationAllowed(pd.namespace, key, location) { + return fmt.Errorf("dbml: line %d: directive @%s: %s is not valid at %s level", pd.line, pd.namespace, key, location) + } + if documented && spec.Singleton && models.HasDirective(meta, pd.namespace, key) { + return fmt.Errorf("dbml: line %d: duplicate @%s directive %q at %s level", pd.line, pd.namespace, key, location) + } + + models.AddDirective(meta, models.Directive{ + Namespace: pd.namespace, + Key: key, + Args: pd.args, + Line: pd.line, + }) + return nil +} diff --git a/pkg/readers/dbml/directives_test.go b/pkg/readers/dbml/directives_test.go new file mode 100644 index 0000000..1bf4d9a --- /dev/null +++ b/pkg/readers/dbml/directives_test.go @@ -0,0 +1,181 @@ +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) + } +} diff --git a/pkg/readers/dbml/reader.go b/pkg/readers/dbml/reader.go index 2eab7a8..e0c115d 100644 --- a/pkg/readers/dbml/reader.go +++ b/pkg/readers/dbml/reader.go @@ -435,11 +435,14 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { var inIndexes bool var inTable bool var columnSeq uint + var lastIndex *models.Index // most recent index in the current Indexes block + lineNo := 0 tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`) refRegex := regexp.MustCompile(`^Ref:\s+(.+)`) for scanner.Scan() { + lineNo++ line := strings.TrimSpace(scanner.Text()) // Skip empty lines and comments @@ -447,6 +450,20 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { continue } + // Parse a dialect directive (@postgres:, @sqlite:, …). Handled before + // table/column/index parsing so directive lines are never mistaken for + // columns. + if strings.HasPrefix(line, "@") { + pd, err := parseDirectiveLine(line, lineNo) + if err != nil { + return nil, err + } + if err := r.attachDirective(pd, db, currentTable, inTable, inIndexes, lastIndex); err != nil { + return nil, err + } + continue + } + // Parse Table definition if matches := tableRegex.FindStringSubmatch(line); matches != nil { tableName := matches[1] @@ -474,8 +491,10 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { continue } - // End of table definition - if inTable && line == "}" { + // End of table definition. Guarded by !inIndexes so the closing brace + // of an `indexes { }` block is not mistaken for the end of the table + // (which would drop any table-level content that follows it). + if inTable && !inIndexes && line == "}" { if currentTable != nil && currentSchema != "" { schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, currentTable) currentTable = nil @@ -488,12 +507,14 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { // Parse indexes section if inTable && (strings.HasPrefix(line, "Indexes {") || strings.HasPrefix(line, "indexes {")) { inIndexes = true + lastIndex = nil continue } // End of indexes section if inIndexes && line == "}" { inIndexes = false + lastIndex = nil continue } @@ -513,6 +534,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) { index := r.parseIndex(line, currentTable.Name, currentSchema) if index != nil { currentTable.Indexes[index.Name] = index + lastIndex = index } continue } diff --git a/pkg/readers/reader.go b/pkg/readers/reader.go index ceee81f..6fff355 100644 --- a/pkg/readers/reader.go +++ b/pkg/readers/reader.go @@ -28,6 +28,10 @@ type ReaderOptions struct { // Prisma7 enables Prisma 7-specific handling for Prisma schemas. Prisma7 bool + // StrictDirectives makes DBML dialect directives (@postgres:, @sqlite:, …) + // fail on an unknown namespace or key instead of preserving them silently. + StrictDirectives bool + // Additional options can be added here as needed Metadata map[string]interface{} } diff --git a/pkg/writers/dbml/README.md b/pkg/writers/dbml/README.md index bbad3f5..84e4712 100644 --- a/pkg/writers/dbml/README.md +++ b/pkg/writers/dbml/README.md @@ -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 | diff --git a/pkg/writers/dbml/directives.go b/pkg/writers/dbml/directives.go new file mode 100644 index 0000000..1e68d7a --- /dev/null +++ b/pkg/writers/dbml/directives.go @@ -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 +} diff --git a/pkg/writers/dbml/directives_test.go b/pkg/writers/dbml/directives_test.go new file mode 100644 index 0000000..4a9fc04 --- /dev/null +++ b/pkg/writers/dbml/directives_test.go @@ -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") +} diff --git a/pkg/writers/dbml/writer.go b/pkg/writers/dbml/writer.go index d956239..a73c7c1 100644 --- a/pkg/writers/dbml/writer.go +++ b/pkg/writers/dbml/writer.go @@ -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) diff --git a/pkg/writers/pgsql/README.md b/pkg/writers/pgsql/README.md index 64dd2ff..1482ad8 100644 --- a/pkg/writers/pgsql/README.md +++ b/pkg/writers/pgsql/README.md @@ -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 diff --git a/pkg/writers/pgsql/directives.go b/pkg/writers/pgsql/directives.go new file mode 100644 index 0000000..e96238a --- /dev/null +++ b/pkg/writers/pgsql/directives.go @@ -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 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 +} diff --git a/pkg/writers/pgsql/directives_test.go b/pkg/writers/pgsql/directives_test.go new file mode 100644 index 0000000..0df3049 --- /dev/null +++ b/pkg/writers/pgsql/directives_test.go @@ -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) + } +} diff --git a/pkg/writers/pgsql/writer.go b/pkg/writers/pgsql/writer.go index e558962..dc050d7 100644 --- a/pkg/writers/pgsql/writer.go +++ b/pkg/writers/pgsql/writer.go @@ -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) } } diff --git a/pkg/writers/sqlite/README.md b/pkg/writers/sqlite/README.md index 8bf6fb5..4effeb3 100644 --- a/pkg/writers/sqlite/README.md +++ b/pkg/writers/sqlite/README.md @@ -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: diff --git a/pkg/writers/sqlite/directives.go b/pkg/writers/sqlite/directives.go new file mode 100644 index 0000000..e10dabc --- /dev/null +++ b/pkg/writers/sqlite/directives.go @@ -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 " clause for a column carrying an +// @sqlite(col): collate 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 "" +} diff --git a/pkg/writers/sqlite/directives_test.go b/pkg/writers/sqlite/directives_test.go new file mode 100644 index 0000000..f1e7335 --- /dev/null +++ b/pkg/writers/sqlite/directives_test.go @@ -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) + } +} diff --git a/pkg/writers/sqlite/template_functions.go b/pkg/writers/sqlite/template_functions.go index e0e8284..28af482 100644 --- a/pkg/writers/sqlite/template_functions.go +++ b/pkg/writers/sqlite/template_functions.go @@ -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, } } diff --git a/pkg/writers/sqlite/templates.go b/pkg/writers/sqlite/templates.go index 49304fe..77d6d5e 100644 --- a/pkg/writers/sqlite/templates.go +++ b/pkg/writers/sqlite/templates.go @@ -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), } } diff --git a/pkg/writers/sqlite/templates/create_table.tmpl b/pkg/writers/sqlite/templates/create_table.tmpl index 1139d07..7949c8b 100644 --- a/pkg/writers/sqlite/templates/create_table.tmpl +++ b/pkg/writers/sqlite/templates/create_table.tmpl @@ -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}}; diff --git a/pkg/writers/sqlite/writer.go b/pkg/writers/sqlite/writer.go index b773078..3e93663 100644 --- a/pkg/writers/sqlite/writer.go +++ b/pkg/writers/sqlite/writer.go @@ -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 != "" { diff --git a/pkg/writers/writer.go b/pkg/writers/writer.go index 33f7e87..35fc4af 100644 --- a/pkg/writers/writer.go +++ b/pkg/writers/writer.go @@ -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 -- 2.54.0