Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce3b615b0a | ||
|
|
f968e3d4a6 |
@@ -10,17 +10,19 @@ func newReaderOptions(filePath, connString string) *readers.ReaderOptions {
|
|||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
ConnectionString: connString,
|
ConnectionString: connString,
|
||||||
Prisma7: prisma7,
|
Prisma7: prisma7,
|
||||||
|
StrictDirectives: strictDirectives,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool) *writers.WriterOptions {
|
func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullableTypes, nullableArrays string, continueOnError bool) *writers.WriterOptions {
|
||||||
return &writers.WriterOptions{
|
return &writers.WriterOptions{
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
PackageName: packageName,
|
PackageName: packageName,
|
||||||
FlattenSchema: flattenSchema,
|
FlattenSchema: flattenSchema,
|
||||||
NullableTypes: nullableTypes,
|
NullableTypes: nullableTypes,
|
||||||
NullableArrays: nullableArrays,
|
NullableArrays: nullableArrays,
|
||||||
Prisma7: prisma7,
|
Prisma7: prisma7,
|
||||||
ContinueOnError: continueOnError,
|
ContinueOnError: continueOnError,
|
||||||
|
StrictDirectives: strictDirectives,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -10,10 +10,11 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// Version information, set via ldflags during build
|
// Version information, set via ldflags during build
|
||||||
version = "dev"
|
version = "dev"
|
||||||
buildDate = "unknown"
|
buildDate = "unknown"
|
||||||
prisma7 bool
|
prisma7 bool
|
||||||
noVersion bool
|
noVersion bool
|
||||||
|
strictDirectives bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -72,6 +73,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(reportCmd)
|
rootCmd.AddCommand(reportCmd)
|
||||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
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(&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 <version> (built: <date>)" banner
|
// printVersionHeader prints the "RelSpec <version> (built: <date>)" banner
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
```
|
||||||
|
@<namespace>[(<target>)]: <args>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 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 <args>` appended to `CREATE TABLE` | e.g. `@postgres: partition by RANGE (created_at)` |
|
||||||
|
| `inherits` | table | `INHERITS (<args>)` — args verbatim | |
|
||||||
|
| `with` | table, index | `WITH (<params>)` | On an index, wins over `WITH` derived from the index comment. `@postgres: with (fillfactor=90)` |
|
||||||
|
| `tablespace` | table, index | `TABLESPACE <name>` | Emitted after `WITH`, before `WHERE` on indexes |
|
||||||
|
| `storage` | column | `STORAGE <mode>` in the column definition | e.g. `@postgres(blob): storage external` |
|
||||||
|
| `compression` | column | `COMPRESSION <method>` | |
|
||||||
|
| `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 <name>` 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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-53
@@ -24,16 +24,17 @@ const (
|
|||||||
|
|
||||||
// Database represents the complete database schema
|
// Database represents the complete database schema
|
||||||
type Database struct {
|
type Database struct {
|
||||||
Name string `json:"name" yaml:"name"`
|
Name string `json:"name" yaml:"name"`
|
||||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||||
Schemas []*Schema `json:"schemas" yaml:"schemas" xml:"schemas"`
|
Schemas []*Schema `json:"schemas" yaml:"schemas" xml:"schemas"`
|
||||||
Domains []*Domain `json:"domains,omitempty" yaml:"domains,omitempty" xml:"domains,omitempty"`
|
Domains []*Domain `json:"domains,omitempty" yaml:"domains,omitempty" xml:"domains,omitempty"`
|
||||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,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"`
|
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"`
|
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.
|
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"`
|
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
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.
|
// SQLName returns the database name in lowercase for SQL compatibility.
|
||||||
@@ -226,22 +227,23 @@ func (d *Sequence) SQLName() string {
|
|||||||
|
|
||||||
// Column represents a table column
|
// Column represents a table column
|
||||||
type Column struct {
|
type Column struct {
|
||||||
Name string `json:"name" yaml:"name" xml:"name"`
|
Name string `json:"name" yaml:"name" xml:"name"`
|
||||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||||
Table string `json:"table" yaml:"table" xml:"table"`
|
Table string `json:"table" yaml:"table" xml:"table"`
|
||||||
Schema string `json:"schema" yaml:"schema" xml:"schema"`
|
Schema string `json:"schema" yaml:"schema" xml:"schema"`
|
||||||
Type string `json:"type" yaml:"type" xml:"type"`
|
Type string `json:"type" yaml:"type" xml:"type"`
|
||||||
Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"`
|
Length int `json:"length,omitempty" yaml:"length,omitempty" xml:"length,omitempty"`
|
||||||
Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"`
|
Precision int `json:"precision,omitempty" yaml:"precision,omitempty" xml:"precision,omitempty"`
|
||||||
Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"`
|
Scale int `json:"scale,omitempty" yaml:"scale,omitempty" xml:"scale,omitempty"`
|
||||||
NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"`
|
NotNull bool `json:"not_null" yaml:"not_null" xml:"not_null"`
|
||||||
Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"`
|
Default any `json:"default,omitempty" yaml:"default,omitempty" xml:"default,omitempty"`
|
||||||
AutoIncrement bool `json:"auto_increment" yaml:"auto_increment" xml:"auto_increment"`
|
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"`
|
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"`
|
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||||
Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"`
|
Collation string `json:"collation,omitempty" yaml:"collation,omitempty" xml:"collation,omitempty"`
|
||||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
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.
|
// 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.
|
// Index represents a database index for optimizing query performance.
|
||||||
// Indexes can be unique, partial, or include additional columns.
|
// Indexes can be unique, partial, or include additional columns.
|
||||||
type Index struct {
|
type Index struct {
|
||||||
Name string `json:"name" yaml:"name" xml:"name"`
|
Name string `json:"name" yaml:"name" xml:"name"`
|
||||||
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
Description string `json:"description,omitempty" yaml:"description,omitempty" xml:"description,omitempty"`
|
||||||
Table string `json:"table,omitempty" yaml:"table,omitempty" xml:"table,omitempty"`
|
Table string `json:"table,omitempty" yaml:"table,omitempty" xml:"table,omitempty"`
|
||||||
Schema string `json:"schema,omitempty" yaml:"schema,omitempty" xml:"schema,omitempty"`
|
Schema string `json:"schema,omitempty" yaml:"schema,omitempty" xml:"schema,omitempty"`
|
||||||
Columns []string `json:"columns" yaml:"columns" xml:"columns"`
|
Columns []string `json:"columns" yaml:"columns" xml:"columns"`
|
||||||
Unique bool `json:"unique" yaml:"unique" xml:"unique"`
|
Unique bool `json:"unique" yaml:"unique" xml:"unique"`
|
||||||
Type string `json:"type" yaml:"type" xml:"type"` // btree, hash, gin, gist, etc.
|
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
|
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"`
|
Concurrent bool `json:"concurrent,omitempty" yaml:"concurrent,omitempty" xml:"concurrent,omitempty"`
|
||||||
Include []string `json:"include,omitempty" yaml:"include,omitempty" xml:"include,omitempty"` // INCLUDE columns
|
Include []string `json:"include,omitempty" yaml:"include,omitempty" xml:"include,omitempty"` // INCLUDE columns
|
||||||
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
Comment string `json:"comment,omitempty" yaml:"comment,omitempty" xml:"comment,omitempty"`
|
||||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
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.
|
// 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
|
// InitDatabase initializes a new Database with empty slices
|
||||||
func InitDatabase(name string) *Database {
|
func InitDatabase(name string) *Database {
|
||||||
return &Database{
|
return &Database{
|
||||||
Name: name,
|
Name: name,
|
||||||
Schemas: make([]*Schema, 0),
|
Schemas: make([]*Schema, 0),
|
||||||
Domains: make([]*Domain, 0),
|
Domains: make([]*Domain, 0),
|
||||||
GUID: uuid.New().String(),
|
Metadata: make(map[string]any),
|
||||||
|
GUID: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,22 +435,24 @@ func InitTable(name, schema string) *Table {
|
|||||||
// InitColumn initializes a new Column
|
// InitColumn initializes a new Column
|
||||||
func InitColumn(name, table, schema string) *Column {
|
func InitColumn(name, table, schema string) *Column {
|
||||||
return &Column{
|
return &Column{
|
||||||
Name: name,
|
Name: name,
|
||||||
Table: table,
|
Table: table,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
GUID: uuid.New().String(),
|
Metadata: make(map[string]any),
|
||||||
|
GUID: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitIndex initializes a new Index with empty slices
|
// InitIndex initializes a new Index with empty slices
|
||||||
func InitIndex(name, table, schema string) *Index {
|
func InitIndex(name, table, schema string) *Index {
|
||||||
return &Index{
|
return &Index{
|
||||||
Name: name,
|
Name: name,
|
||||||
Table: table,
|
Table: table,
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Columns: make([]string, 0),
|
Columns: make([]string, 0),
|
||||||
Include: make([]string, 0),
|
Include: make([]string, 0),
|
||||||
GUID: uuid.New().String(),
|
Metadata: make(map[string]any),
|
||||||
|
GUID: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,50 @@ Ref: posts.user_id > users.id [delete: cascade]
|
|||||||
- Indexes and composite indexes
|
- Indexes and composite indexes
|
||||||
- Table notes and column notes
|
- Table notes and column notes
|
||||||
- Enums
|
- Enums
|
||||||
|
- Dialect directives (`@postgres:` / `@sqlite:` — see below)
|
||||||
|
|
||||||
|
## Dialect directives
|
||||||
|
|
||||||
|
Lines of the form `@<namespace>[(<column>)]: <args>` 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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -435,11 +435,14 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
var inIndexes bool
|
var inIndexes bool
|
||||||
var inTable bool
|
var inTable bool
|
||||||
var columnSeq uint
|
var columnSeq uint
|
||||||
|
var lastIndex *models.Index // most recent index in the current Indexes block
|
||||||
|
lineNo := 0
|
||||||
|
|
||||||
tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`)
|
tableRegex := regexp.MustCompile(`^Table\s+(.+?)\s*{`)
|
||||||
refRegex := regexp.MustCompile(`^Ref:\s+(.+)`)
|
refRegex := regexp.MustCompile(`^Ref:\s+(.+)`)
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
// Skip empty lines and comments
|
// Skip empty lines and comments
|
||||||
@@ -447,6 +450,20 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
continue
|
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
|
// Parse Table definition
|
||||||
if matches := tableRegex.FindStringSubmatch(line); matches != nil {
|
if matches := tableRegex.FindStringSubmatch(line); matches != nil {
|
||||||
tableName := matches[1]
|
tableName := matches[1]
|
||||||
@@ -474,8 +491,10 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// End of table definition
|
// End of table definition. Guarded by !inIndexes so the closing brace
|
||||||
if inTable && line == "}" {
|
// 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 != "" {
|
if currentTable != nil && currentSchema != "" {
|
||||||
schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, currentTable)
|
schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, currentTable)
|
||||||
currentTable = nil
|
currentTable = nil
|
||||||
@@ -488,12 +507,14 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
// Parse indexes section
|
// Parse indexes section
|
||||||
if inTable && (strings.HasPrefix(line, "Indexes {") || strings.HasPrefix(line, "indexes {")) {
|
if inTable && (strings.HasPrefix(line, "Indexes {") || strings.HasPrefix(line, "indexes {")) {
|
||||||
inIndexes = true
|
inIndexes = true
|
||||||
|
lastIndex = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// End of indexes section
|
// End of indexes section
|
||||||
if inIndexes && line == "}" {
|
if inIndexes && line == "}" {
|
||||||
inIndexes = false
|
inIndexes = false
|
||||||
|
lastIndex = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +534,7 @@ func (r *Reader) parseDBML(content string) (*models.Database, error) {
|
|||||||
index := r.parseIndex(line, currentTable.Name, currentSchema)
|
index := r.parseIndex(line, currentTable.Name, currentSchema)
|
||||||
if index != nil {
|
if index != nil {
|
||||||
currentTable.Indexes[index.Name] = index
|
currentTable.Indexes[index.Name] = index
|
||||||
|
lastIndex = index
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ type ReaderOptions struct {
|
|||||||
// Prisma7 enables Prisma 7-specific handling for Prisma schemas.
|
// Prisma7 enables Prisma 7-specific handling for Prisma schemas.
|
||||||
Prisma7 bool
|
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
|
// Additional options can be added here as needed
|
||||||
Metadata map[string]interface{}
|
Metadata map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
## Type Mapping
|
||||||
|
|
||||||
| SQL Type | DBML Type |
|
| SQL Type | DBML Type |
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package dbml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// directiveLines renders every dialect directive stored in meta back to its DBML
|
||||||
|
// source form, one line per directive, each prefixed with indent. When target is
|
||||||
|
// non-empty it is emitted as the "(column)" target, e.g.
|
||||||
|
// " @postgres(id): identity always". Order is deterministic (see
|
||||||
|
// models.GetDirectives).
|
||||||
|
func directiveLines(meta map[string]any, indent, target string) []string {
|
||||||
|
directives := models.GetDirectives(meta)
|
||||||
|
if len(directives) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(directives))
|
||||||
|
for _, d := range directives {
|
||||||
|
lines = append(lines, indent+models.FormatDirectiveLine(d, target))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package dbml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
dbmlreader "git.warky.dev/wdevs/relspecgo/pkg/readers/dbml"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||||
|
)
|
||||||
|
|
||||||
|
const directiveSrc = `@postgres: search_path myapp
|
||||||
|
|
||||||
|
Table myapp.events {
|
||||||
|
id bigint [pk]
|
||||||
|
created_at timestamp [not null]
|
||||||
|
@postgres(id): identity always
|
||||||
|
@postgres: partition by RANGE (created_at)
|
||||||
|
@postgres: tablespace fast_data
|
||||||
|
@sqlite: without rowid
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(created_at) [name: 'idx_events_created']
|
||||||
|
@postgres: with (fillfactor=90)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
func writeDBML(t *testing.T, db *models.Database) string {
|
||||||
|
t.Helper()
|
||||||
|
out := filepath.Join(t.TempDir(), "out.dbml")
|
||||||
|
require.NoError(t, NewWriter(&writers.WriterOptions{OutputPath: out}).WriteDatabase(db))
|
||||||
|
b, err := os.ReadFile(out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDBML(t *testing.T, src string) *models.Database {
|
||||||
|
t.Helper()
|
||||||
|
f := filepath.Join(t.TempDir(), "in.dbml")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte(src), 0o644))
|
||||||
|
db, err := dbmlreader.NewReader(&readers.ReaderOptions{FilePath: f}).ReadDatabase()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDirectives(db *models.Database) map[string][]string {
|
||||||
|
got := map[string][]string{}
|
||||||
|
add := func(loc string, meta map[string]any) {
|
||||||
|
for _, d := range models.GetDirectives(meta) {
|
||||||
|
got[loc] = append(got[loc], models.FormatDirectiveLine(d, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("database", db.Metadata)
|
||||||
|
for _, s := range db.Schemas {
|
||||||
|
for _, tbl := range s.Tables {
|
||||||
|
add("table:"+tbl.Name, tbl.Metadata)
|
||||||
|
for _, c := range tbl.Columns {
|
||||||
|
add("column:"+c.Name, c.Metadata)
|
||||||
|
}
|
||||||
|
for _, i := range tbl.Indexes {
|
||||||
|
add("index:"+i.Name, i.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectives_RoundTrip(t *testing.T) {
|
||||||
|
db1 := readDBML(t, directiveSrc)
|
||||||
|
out1 := writeDBML(t, db1)
|
||||||
|
db2 := readDBML(t, out1)
|
||||||
|
out2 := writeDBML(t, db2)
|
||||||
|
|
||||||
|
assert.Equal(t, out1, out2, "DBML directive output should be idempotent")
|
||||||
|
assert.Equal(t, collectDirectives(db1), collectDirectives(db2), "directives preserved through round-trip")
|
||||||
|
|
||||||
|
// Spot-check each location survived.
|
||||||
|
d := collectDirectives(db2)
|
||||||
|
assert.Contains(t, d["database"], "@postgres: search_path myapp")
|
||||||
|
assert.Contains(t, d["table:events"], "@postgres: partition by RANGE (created_at)")
|
||||||
|
assert.Contains(t, d["table:events"], "@sqlite: without rowid")
|
||||||
|
assert.Contains(t, d["column:id"], "@postgres: identity always")
|
||||||
|
assert.Contains(t, d["index:idx_events_created"], "@postgres: with (fillfactor=90)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectives_WriterEmitsColumnTarget(t *testing.T) {
|
||||||
|
db := readDBML(t, directiveSrc)
|
||||||
|
out := writeDBML(t, db)
|
||||||
|
assert.Contains(t, out, "@postgres(id): identity always")
|
||||||
|
}
|
||||||
@@ -72,6 +72,14 @@ func (w *Writer) databaseToDBML(d *models.Database) string {
|
|||||||
sb.WriteString("\n")
|
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 {
|
for _, schema := range d.Schemas {
|
||||||
sb.WriteString(w.schemaToDBML(schema))
|
sb.WriteString(w.schemaToDBML(schema))
|
||||||
}
|
}
|
||||||
@@ -146,6 +154,11 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
|||||||
fmt.Fprintf(&sb, " // %s", column.Comment)
|
fmt.Fprintf(&sb, " // %s", column.Comment)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
for _, line := range directiveLines(column.Metadata, " ", column.Name) {
|
||||||
|
sb.WriteString(line)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Indexes) > 0 {
|
if len(t.Indexes) > 0 {
|
||||||
@@ -167,10 +180,20 @@ func (w *Writer) tableToDBML(t *models.Table) string {
|
|||||||
fmt.Fprintf(&sb, " [%s]", strings.Join(indexAttrs, ", "))
|
fmt.Fprintf(&sb, " [%s]", strings.Join(indexAttrs, ", "))
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
for _, line := range directiveLines(index.Metadata, " ", "") {
|
||||||
|
sb.WriteString(line)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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)
|
note := strings.TrimSpace(t.Description + " " + t.Comment)
|
||||||
if note != "" {
|
if note != "" {
|
||||||
fmt.Fprintf(&sb, "\n Note: '%s'\n", note)
|
fmt.Fprintf(&sb, "\n Note: '%s'\n", note)
|
||||||
|
|||||||
@@ -172,6 +172,26 @@ When `include_audit` is enabled, adds:
|
|||||||
- Concurrent index creation (`CREATE INDEX CONCURRENTLY`) via `Index.Concurrent`
|
- Concurrent index creation (`CREATE INDEX CONCURRENTLY`) via `Index.Concurrent`
|
||||||
- Check constraints with expressions
|
- Check constraints with expressions
|
||||||
- Extension types and indexes: PostGIS, pgvector, citext, hstore, ltree (see below)
|
- 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
|
## Data Types
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package pgsql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// directiveNamespace is the dialect namespace this writer consumes. Directives
|
||||||
|
// for other namespaces (e.g. "sqlite") are ignored and never emitted as SQL.
|
||||||
|
const directiveNamespace = "postgres"
|
||||||
|
|
||||||
|
// pgHandledDirectives maps a directive location to the set of postgres keys this
|
||||||
|
// writer knows how to translate. In strict mode an unknown key for this
|
||||||
|
// namespace at a supported location is a hard error.
|
||||||
|
var pgHandledDirectives = map[string]map[string]bool{
|
||||||
|
models.DirectiveLocationTable: {"partition": true, "inherits": true, "with": true, "tablespace": true},
|
||||||
|
models.DirectiveLocationColumn: {"storage": true, "compression": true, "identity": true},
|
||||||
|
models.DirectiveLocationIndex: {"with": true, "tablespace": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDirectives validates postgres directives across a schema when strict mode
|
||||||
|
// is enabled. It returns an error for any postgres directive whose key this
|
||||||
|
// writer cannot translate. With strict mode off it is a no-op.
|
||||||
|
func (w *Writer) checkDirectives(schema *models.Schema) error {
|
||||||
|
if w.options == nil || !w.options.StrictDirectives {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, table := range schema.Tables {
|
||||||
|
if err := checkObjectDirectives(table.Metadata, models.DirectiveLocationTable, table.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, col := range table.Columns {
|
||||||
|
if err := checkObjectDirectives(col.Metadata, models.DirectiveLocationColumn, table.Name+"."+col.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, idx := range table.Indexes {
|
||||||
|
if err := checkObjectDirectives(idx.Metadata, models.DirectiveLocationIndex, idx.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkObjectDirectives(meta map[string]any, location, owner string) error {
|
||||||
|
for _, d := range models.DirectivesForNamespace(meta, directiveNamespace) {
|
||||||
|
if !pgHandledDirectives[location][d.Key] {
|
||||||
|
return fmt.Errorf("pgsql: %s: unsupported @postgres directive %q at %s level (strict mode)", owner, d.Key, location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upperLeadingClause upcases a known leading keyword phrase in a directive
|
||||||
|
// argument so the emitted SQL reads conventionally. Identifiers that follow are
|
||||||
|
// left untouched.
|
||||||
|
func upperLeadingClause(args, lowerPrefix, upperPrefix string) string {
|
||||||
|
args = strings.TrimSpace(args)
|
||||||
|
if strings.HasPrefix(strings.ToLower(args), lowerPrefix) {
|
||||||
|
return upperPrefix + args[len(lowerPrefix):]
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgTableDirectiveSuffix returns the clause appended after the closing ")" of a
|
||||||
|
// CREATE TABLE statement, e.g. " PARTITION BY RANGE (created_at) TABLESPACE fast".
|
||||||
|
func pgTableDirectiveSuffix(table *models.Table) string {
|
||||||
|
directives := models.DirectivesForNamespace(table.Metadata, directiveNamespace)
|
||||||
|
if len(directives) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
byKey := firstByKey(directives)
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
if d, ok := byKey["partition"]; ok {
|
||||||
|
parts = append(parts, upperLeadingClause(d.Args, "partition by", "PARTITION BY"))
|
||||||
|
}
|
||||||
|
if d, ok := byKey["inherits"]; ok {
|
||||||
|
parts = append(parts, upperLeadingClause(d.Args, "inherits", "INHERITS"))
|
||||||
|
}
|
||||||
|
if d, ok := byKey["with"]; ok {
|
||||||
|
parts = append(parts, upperLeadingClause(d.Args, "with", "WITH"))
|
||||||
|
}
|
||||||
|
if d, ok := byKey["tablespace"]; ok {
|
||||||
|
parts = append(parts, upperLeadingClause(d.Args, "tablespace", "TABLESPACE"))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " " + strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgColumnDirectiveSuffix returns the clause appended to a column definition,
|
||||||
|
// e.g. " STORAGE PLAIN" or " GENERATED ALWAYS AS IDENTITY".
|
||||||
|
func pgColumnDirectiveSuffix(col *models.Column) string {
|
||||||
|
directives := models.DirectivesForNamespace(col.Metadata, directiveNamespace)
|
||||||
|
if len(directives) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
byKey := firstByKey(directives)
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
if d, ok := byKey["storage"]; ok {
|
||||||
|
parts = append(parts, upperLeadingClause(d.Args, "storage", "STORAGE"))
|
||||||
|
}
|
||||||
|
if d, ok := byKey["compression"]; ok {
|
||||||
|
parts = append(parts, upperLeadingClause(d.Args, "compression", "COMPRESSION"))
|
||||||
|
}
|
||||||
|
if d, ok := byKey["identity"]; ok {
|
||||||
|
parts = append(parts, identityClause(d.Args))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " " + strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// identityClause maps the two documented identity forms to standard SQL,
|
||||||
|
// falling back to a verbatim (upcased-keyword) rendering.
|
||||||
|
func identityClause(args string) string {
|
||||||
|
switch strings.ToLower(strings.Join(strings.Fields(args), " ")) {
|
||||||
|
case "identity always":
|
||||||
|
return "GENERATED ALWAYS AS IDENTITY"
|
||||||
|
case "identity default", "identity by default":
|
||||||
|
return "GENERATED BY DEFAULT AS IDENTITY"
|
||||||
|
default:
|
||||||
|
return upperLeadingClause(args, "identity", "IDENTITY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgIndexDirectiveWith returns the parenthesised storage-parameter list from an
|
||||||
|
// @postgres: with (...) index directive, e.g. "fillfactor=90", or "".
|
||||||
|
func pgIndexDirectiveWith(index *models.Index) string {
|
||||||
|
for _, d := range models.DirectivesForNamespace(index.Metadata, directiveNamespace) {
|
||||||
|
if d.Key != "with" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inner := d.Args
|
||||||
|
if i := strings.Index(inner, "("); i >= 0 {
|
||||||
|
if j := strings.LastIndex(inner, ")"); j > i {
|
||||||
|
return strings.TrimSpace(inner[i+1 : j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(strings.ToLower(inner), "with"))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgIndexWithParams returns the storage-parameter list to use for an index,
|
||||||
|
// preferring an @postgres: with (...) directive over the given fallback (e.g.
|
||||||
|
// one derived from the index comment).
|
||||||
|
func pgIndexWithParams(index *models.Index, fallback string) string {
|
||||||
|
if p := pgIndexDirectiveWith(index); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgIndexDirectiveTablespace returns the tablespace name from an
|
||||||
|
// @postgres: tablespace <name> index directive, or "".
|
||||||
|
func pgIndexDirectiveTablespace(index *models.Index) string {
|
||||||
|
for _, d := range models.DirectivesForNamespace(index.Metadata, directiveNamespace) {
|
||||||
|
if d.Key == "tablespace" {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(strings.ToLower(d.Args), "tablespace"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstByKey indexes directives by key, keeping the first occurrence (the
|
||||||
|
// documented postgres keys used here are all singletons).
|
||||||
|
func firstByKey(directives []models.Directive) map[string]models.Directive {
|
||||||
|
byKey := make(map[string]models.Directive, len(directives))
|
||||||
|
for _, d := range directives {
|
||||||
|
if _, exists := byKey[d.Key]; !exists {
|
||||||
|
byKey[d.Key] = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byKey
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package pgsql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func directiveTestDB(t *testing.T) *models.Database {
|
||||||
|
t.Helper()
|
||||||
|
db := models.InitDatabase("testdb")
|
||||||
|
schema := models.InitSchema("public")
|
||||||
|
table := models.InitTable("events", "public")
|
||||||
|
|
||||||
|
id := models.InitColumn("id", "events", "public")
|
||||||
|
id.Type = "bigint"
|
||||||
|
id.IsPrimaryKey = true
|
||||||
|
id.NotNull = true
|
||||||
|
models.AddDirective(id.Metadata, models.Directive{Namespace: "postgres", Args: "identity always"})
|
||||||
|
models.AddDirective(id.Metadata, models.Directive{Namespace: "sqlite", Args: "collate NOCASE"})
|
||||||
|
table.Columns["id"] = id
|
||||||
|
|
||||||
|
created := models.InitColumn("created_at", "events", "public")
|
||||||
|
created.Type = "timestamp"
|
||||||
|
created.NotNull = true
|
||||||
|
table.Columns["created_at"] = created
|
||||||
|
|
||||||
|
models.AddDirective(table.Metadata, models.Directive{Namespace: "postgres", Args: "partition by RANGE (created_at)"})
|
||||||
|
models.AddDirective(table.Metadata, models.Directive{Namespace: "postgres", Args: "tablespace fast_data"})
|
||||||
|
models.AddDirective(table.Metadata, models.Directive{Namespace: "sqlite", Args: "without rowid"})
|
||||||
|
|
||||||
|
idx := models.InitIndex("idx_events_created", "events", "public")
|
||||||
|
idx.Columns = []string{"created_at"}
|
||||||
|
models.AddDirective(idx.Metadata, models.Directive{Namespace: "postgres", Args: "with (fillfactor=90)"})
|
||||||
|
models.AddDirective(idx.Metadata, models.Directive{Namespace: "postgres", Args: "tablespace idx_space"})
|
||||||
|
table.Indexes["idx_events_created"] = idx
|
||||||
|
|
||||||
|
schema.Tables = append(schema.Tables, table)
|
||||||
|
db.Schemas = append(db.Schemas, schema)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPgDirectives_WriteDatabasePath(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := NewWriter(&writers.WriterOptions{})
|
||||||
|
w.writer = &buf
|
||||||
|
if err := w.WriteDatabase(directiveTestDB(t)); err != nil {
|
||||||
|
t.Fatalf("WriteDatabase: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
") PARTITION BY RANGE (created_at) TABLESPACE fast_data",
|
||||||
|
"GENERATED ALWAYS AS IDENTITY",
|
||||||
|
"WITH (fillfactor=90) TABLESPACE idx_space",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sqlite directives must never reach PG output.
|
||||||
|
if strings.Contains(out, "WITHOUT ROWID") || strings.Contains(strings.ToUpper(out), "COLLATE NOCASE") {
|
||||||
|
t.Errorf("sqlite directive leaked into PG output:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPgDirectives_WriteSchemaPath(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := NewWriter(&writers.WriterOptions{})
|
||||||
|
w.writer = &buf
|
||||||
|
if err := w.WriteSchema(directiveTestDB(t).Schemas[0]); err != nil {
|
||||||
|
t.Fatalf("WriteSchema: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, ") PARTITION BY RANGE (created_at) TABLESPACE fast_data;") {
|
||||||
|
t.Errorf("table suffix missing from WriteSchema path:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "WITH (fillfactor=90) TABLESPACE idx_space") {
|
||||||
|
t.Errorf("index clauses missing from WriteSchema path:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPgDirectives_StrictUnknownKeyErrors(t *testing.T) {
|
||||||
|
db := directiveTestDB(t)
|
||||||
|
models.AddDirective(db.Schemas[0].Tables[0].Metadata, models.Directive{Namespace: "postgres", Args: "frobnicate x"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := NewWriter(&writers.WriterOptions{StrictDirectives: true})
|
||||||
|
w.writer = &buf
|
||||||
|
err := w.WriteSchema(db.Schemas[0])
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "frobnicate") {
|
||||||
|
t.Fatalf("want strict error for unknown postgres key, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPgDirectives_StrictIgnoresOtherNamespaces(t *testing.T) {
|
||||||
|
// sqlite directives are present but must not trip PG strict mode.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := NewWriter(&writers.WriterOptions{StrictDirectives: true})
|
||||||
|
w.writer = &buf
|
||||||
|
if err := w.WriteSchema(directiveTestDB(t).Schemas[0]); err != nil {
|
||||||
|
t.Fatalf("strict mode should ignore non-postgres directives, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-10
@@ -143,6 +143,10 @@ func (w *Writer) GenerateDatabaseStatements(db *models.Database) ([]string, erro
|
|||||||
func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, error) {
|
func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, error) {
|
||||||
statements := []string{}
|
statements := []string{}
|
||||||
|
|
||||||
|
if err := w.checkDirectives(schema); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 1: Create schema (skip entirely when flattening)
|
// Phase 1: Create schema (skip entirely when flattening)
|
||||||
if schema.Name != "public" && !w.options.FlattenSchema {
|
if schema.Name != "public" && !w.options.FlattenSchema {
|
||||||
statements = append(statements, fmt.Sprintf("-- Schema: %s", schema.Name))
|
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)
|
columnExprs := buildIndexColumnExpressions(table, index, indexType)
|
||||||
|
|
||||||
withClause := ""
|
withClause := ""
|
||||||
if params := indexStorageParameters(index.Comment); params != "" {
|
if params := pgIndexWithParams(index, indexStorageParameters(index.Comment)); params != "" {
|
||||||
withClause = fmt.Sprintf(" WITH (%s)", params)
|
withClause = fmt.Sprintf(" WITH (%s)", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tablespaceClause := ""
|
||||||
|
if ts := pgIndexDirectiveTablespace(index); ts != "" {
|
||||||
|
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
|
||||||
|
}
|
||||||
|
|
||||||
whereClause := ""
|
whereClause := ""
|
||||||
if index.Where != "" {
|
if index.Where != "" {
|
||||||
whereClause = fmt.Sprintf(" WHERE %s", 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",
|
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, whereClause)
|
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||||
statements = append(statements, stmt)
|
statements = append(statements, stmt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,8 +590,9 @@ func (w *Writer) generateCreateTableStatement(schema *models.Schema, table *mode
|
|||||||
columnDefs = append(columnDefs, " "+def)
|
columnDefs = append(columnDefs, " "+def)
|
||||||
}
|
}
|
||||||
|
|
||||||
stmt := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n)",
|
stmt := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n%s\n)%s",
|
||||||
w.qualTable(schema.SQLName(), table.SQLName()), strings.Join(columnDefs, ",\n"))
|
w.qualTable(schema.SQLName(), table.SQLName()), strings.Join(columnDefs, ",\n"),
|
||||||
|
pgTableDirectiveSuffix(table))
|
||||||
statements = append(statements, stmt)
|
statements = append(statements, stmt)
|
||||||
|
|
||||||
return statements, nil
|
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 {
|
func effectiveColumnSQLType(col *models.Column) string {
|
||||||
@@ -678,6 +688,10 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
|||||||
w.writer = os.Stdout
|
w.writer = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := w.checkDirectives(schema); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 1: Create schema (priority 1)
|
// Phase 1: Create schema (priority 1)
|
||||||
if err := w.writeCreateSchema(schema); err != nil {
|
if err := w.writeCreateSchema(schema); err != nil {
|
||||||
return err
|
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, "%s\n", strings.Join(columnDefs, ",\n"))
|
||||||
fmt.Fprintf(w.writer, ");\n\n")
|
fmt.Fprintf(w.writer, ")%s;\n\n", pgTableDirectiveSuffix(table))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1079,10 +1093,15 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
withClause := ""
|
withClause := ""
|
||||||
if params := indexStorageParameters(index.Comment); params != "" {
|
if params := pgIndexWithParams(index, indexStorageParameters(index.Comment)); params != "" {
|
||||||
withClause = fmt.Sprintf(" WITH (%s)", params)
|
withClause = fmt.Sprintf(" WITH (%s)", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tablespaceClause := ""
|
||||||
|
if ts := pgIndexDirectiveTablespace(index); ts != "" {
|
||||||
|
tablespaceClause = fmt.Sprintf(" TABLESPACE %s", ts)
|
||||||
|
}
|
||||||
|
|
||||||
whereClause := ""
|
whereClause := ""
|
||||||
if index.Where != "" {
|
if index.Where != "" {
|
||||||
whereClause = fmt.Sprintf(" WHERE %s", 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",
|
fmt.Fprintf(w.writer, "CREATE %sINDEX %sIF NOT EXISTS %s\n",
|
||||||
unique, concurrently, indexName)
|
unique, concurrently, indexName)
|
||||||
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s%s;\n\n",
|
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, whereClause)
|
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, tablespaceClause, whereClause)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,21 @@ CREATE TABLE "posts" (
|
|||||||
- **Check Constraints**: Generated as comments (should be added to CREATE TABLE manually)
|
- **Check Constraints**: Generated as comments (should be added to CREATE TABLE manually)
|
||||||
- **Indexes**: Generated without PostgreSQL-specific features (no GIN, GiST, operator classes)
|
- **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
|
## Output Structure
|
||||||
|
|
||||||
Generated SQL follows this order:
|
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 {
|
"format_constraint_name": func(schema, table, constraint string) string {
|
||||||
return FormatConstraintName(schema, table, constraint, opts)
|
return FormatConstraintName(schema, table, constraint, opts)
|
||||||
},
|
},
|
||||||
"join": strings.Join,
|
"join": strings.Join,
|
||||||
"lower": strings.ToLower,
|
"lower": strings.ToLower,
|
||||||
"upper": strings.ToUpper,
|
"upper": strings.ToUpper,
|
||||||
|
"column_collate": sqliteColumnCollate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ func NewTemplateExecutor(opts *writers.WriterOptions) (*TemplateExecutor, error)
|
|||||||
|
|
||||||
// TableTemplateData contains data for table template
|
// TableTemplateData contains data for table template
|
||||||
type TableTemplateData struct {
|
type TableTemplateData struct {
|
||||||
Schema string
|
Schema string
|
||||||
Name string
|
Name string
|
||||||
Columns []*models.Column
|
Columns []*models.Column
|
||||||
PrimaryKey *models.Constraint
|
PrimaryKey *models.Constraint
|
||||||
ForeignKeys []ForeignKeyTemplateData
|
ForeignKeys []ForeignKeyTemplateData
|
||||||
|
TableOptions string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForeignKeyTemplateData contains data for an inline FOREIGN KEY clause
|
// ForeignKeyTemplateData contains data for an inline FOREIGN KEY clause
|
||||||
@@ -188,11 +189,12 @@ func BuildTableTemplateData(schema string, table *models.Table) TableTemplateDat
|
|||||||
}
|
}
|
||||||
|
|
||||||
return TableTemplateData{
|
return TableTemplateData{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Name: table.Name,
|
Name: table.Name,
|
||||||
Columns: columns,
|
Columns: columns,
|
||||||
PrimaryKey: pk,
|
PrimaryKey: pk,
|
||||||
ForeignKeys: fks,
|
ForeignKeys: fks,
|
||||||
|
TableOptions: sqliteTableOptions(table),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
CREATE TABLE {{quote_ident (qualified_table_name .Schema .Name)}} (
|
CREATE TABLE {{quote_ident (qualified_table_name .Schema .Name)}} (
|
||||||
{{- $hasAutoIncrement := false}}
|
{{- $hasAutoIncrement := false}}
|
||||||
{{- range $i, $col := .Columns}}{{if $i}},{{end}}
|
{{- 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}}
|
{{- end}}
|
||||||
{{- if and .PrimaryKey (not $hasAutoIncrement)}}{{if gt (len .Columns) 0}},{{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}})
|
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}},
|
{{- 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}}
|
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}}
|
{{- end}}
|
||||||
);
|
){{if .TableOptions}} {{.TableOptions}}{{end}};
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ func tableSchemaName(schema string) string {
|
|||||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||||
tableSchema := tableSchemaName(schema.Name)
|
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
|
// SQLite doesn't have schemas, so we just write a comment (skip for the
|
||||||
// default schema, since its tables aren't actually being prefixed)
|
// default schema, since its tables aren't actually being prefixed)
|
||||||
if tableSchema != "" {
|
if tableSchema != "" {
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ type WriterOptions struct {
|
|||||||
// Prisma7 enables Prisma 7-specific output for Prisma writers.
|
// Prisma7 enables Prisma 7-specific output for Prisma writers.
|
||||||
Prisma7 bool
|
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`
|
// ContinueOnError instructs SQL writers to prepend `\set ON_ERROR_STOP off`
|
||||||
// to their output so that psql continues past errors instead of stopping.
|
// to their output so that psql continues past errors instead of stopping.
|
||||||
ContinueOnError bool
|
ContinueOnError bool
|
||||||
|
|||||||
Reference in New Issue
Block a user