Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 53 additions & 31 deletions cmd/semglot/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,29 @@ func (s *sourcePaths) UnmarshalYAML(node *yaml.Node) error {

// profile is one named build in semglot.yaml.
type profile struct {
Source sourcePaths `yaml:"source"`
SourceDialect string `yaml:"source-dialect"`
TargetDialect string `yaml:"target-dialect"`
Output string `yaml:"output"`
Database string `yaml:"database"`
Schema string `yaml:"schema"`
ViewSchema string `yaml:"view-schema"`
ModelName string `yaml:"model-name"`
Description string `yaml:"description"`
DbtMetaKeyPath string `yaml:"dbt-meta-key-path"`
Source sourcePaths `yaml:"source"`
SourceDialect string `yaml:"source-dialect"`
TargetDialect string `yaml:"target-dialect"`
Output string `yaml:"output"`
Database string `yaml:"database"`
// Schema is a pointer so an EXPLICIT empty value is distinguishable from an
// omitted one. Omitted defaults to MAIN (Snowflake-shaped targets need a
// qualified name); explicitly empty means a two-part namespace such as
// ClickHouse, where "db.MAIN.table" would not resolve.
Schema *string `yaml:"schema"`
ViewSchema string `yaml:"view-schema"`
ModelName string `yaml:"model-name"`
Description string `yaml:"description"`
DbtMetaKeyPath string `yaml:"dbt-meta-key-path"`
// TablePrefix maps a logical table name to its physical one (ClickHouse
// materialises fct_orders as marts__fct_orders).
TablePrefix string `yaml:"table-prefix"`
// DbtHexMeta emits Hex's config.meta.hex.table binding on each semantic
// model; Hex's Semantic Model Sync cannot resolve the physical table without it.
DbtHexMeta bool `yaml:"dbt-hex-meta"`
// LowerCaseIdentifiers keeps identifiers as spelled rather than upper-casing
// them; required for case-sensitive warehouses such as ClickHouse.
LowerCaseIdentifiers bool `yaml:"lowercase-identifiers"`
}

// configFile is the top-level shape of semglot.yaml.
Expand All @@ -47,16 +60,19 @@ type configFile struct {

// buildSpec is a fully-resolved build: a validated profile with defaults applied.
type buildSpec struct {
Sources []string
SourceDialect string
TargetDialect string
Output string
Database string
Schema string
ViewSchema string
ModelName string
Description string
DbtMetaKeyPath string
Sources []string
SourceDialect string
TargetDialect string
Output string
Database string
Schema string
ViewSchema string
ModelName string
Description string
DbtMetaKeyPath string
TablePrefix string
DbtHexMeta bool
LowerCaseIdentifiers bool
}

// warehouseTargets emit into a physical warehouse (Snowflake, or a Databricks
Expand Down Expand Up @@ -89,21 +105,27 @@ func loadProfile(configPath, name string) (buildSpec, error) {
return buildSpec{}, fmt.Errorf("profile %q: output is required", name)
}
spec := buildSpec{
Sources: []string(p.Source),
SourceDialect: p.SourceDialect,
TargetDialect: p.TargetDialect,
Output: p.Output,
Database: p.Database,
Schema: p.Schema,
ViewSchema: p.ViewSchema,
ModelName: p.ModelName,
Description: p.Description,
DbtMetaKeyPath: p.DbtMetaKeyPath,
Sources: []string(p.Source),
SourceDialect: p.SourceDialect,
TargetDialect: p.TargetDialect,
Output: p.Output,
Database: p.Database,
Schema: "",
ViewSchema: p.ViewSchema,
ModelName: p.ModelName,
Description: p.Description,
DbtMetaKeyPath: p.DbtMetaKeyPath,
TablePrefix: p.TablePrefix,
DbtHexMeta: p.DbtHexMeta,
LowerCaseIdentifiers: p.LowerCaseIdentifiers,
}
if spec.SourceDialect == "" {
spec.SourceDialect = "dbt"
}
if spec.Schema == "" {
switch {
case p.Schema != nil:
spec.Schema = *p.Schema
default:
spec.Schema = "MAIN"
}
if spec.ModelName == "" {
Expand Down
15 changes: 9 additions & 6 deletions cmd/semglot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@ func buildCmd(args []string) int {
}
if c, ok := emitter.(dialect.Configurable); ok {
emitter = c.WithOptions(dialect.Options{
Database: spec.Database,
Schema: spec.Schema,
ViewSchema: spec.ViewSchema,
Name: spec.ModelName,
Description: spec.Description,
DbtMetaKeyPath: spec.DbtMetaKeyPath,
Database: spec.Database,
Schema: spec.Schema,
ViewSchema: spec.ViewSchema,
Name: spec.ModelName,
Description: spec.Description,
DbtMetaKeyPath: spec.DbtMetaKeyPath,
TablePrefix: spec.TablePrefix,
DbtHexMeta: spec.DbtHexMeta,
LowerCaseIdentifiers: spec.LowerCaseIdentifiers,
})
}

Expand Down
7 changes: 6 additions & 1 deletion dialect/dbt.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ func init() { Register(dbt{}) }
// model properties (`models:` — table/column descriptions, data types, key and
// relationship constraints/tests) and the semantic layer (`semantic_models:` +
// `metrics:` — measures, aggregations, metrics). Either may be present alone.
type dbt struct{}
type dbt struct{ opts Options }

// WithOptions makes dbt Configurable so a consumer-specific binding (Hex's
// config.meta.hex.table) and the physical table prefix can be supplied per
// build rather than baked in.
func (d dbt) WithOptions(o Options) Emitter { d.opts = o; return d }

func (dbt) Name() string { return "dbt" }

Expand Down
43 changes: 40 additions & 3 deletions dialect/dbt_emit.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,30 @@ type dbtEmitRelTest struct {
type dbtEmitSemantic struct {
Name string `yaml:"name"`
Model string `yaml:"model"`
Config *dbtEmitSemConfig `yaml:"config,omitempty"`
Defaults *dbtEmitDefaults `yaml:"defaults,omitempty"`
Entities []dbtEmitEntity `yaml:"entities,omitempty"`
Dimensions []dbtEmitDimension `yaml:"dimensions,omitempty"`
Measures []dbtEmitMeasure `yaml:"measures,omitempty"`
}

// dbtEmitSemConfig carries the `config: meta:` block on a semantic model. Only
// Hex's binding lives here today; the shape is nested rather than flat so other
// consumers' meta keys can sit alongside without moving Hex's.
type dbtEmitSemConfig struct {
Meta dbtEmitSemMeta `yaml:"meta"`
}

type dbtEmitSemMeta struct {
Hex *dbtEmitHexMeta `yaml:"hex,omitempty"`
}

// dbtEmitHexMeta is Hex's semantic-model binding: the fully qualified physical
// table the model reads.
type dbtEmitHexMeta struct {
Table string `yaml:"table"`
}

type dbtEmitDefaults struct {
AggTimeDimension string `yaml:"agg_time_dimension,omitempty"`
}
Expand Down Expand Up @@ -145,7 +163,7 @@ type dbtEmitConversionParams struct {
// inlined aggregates are emitted rather than reported; what remains is a
// genuine dbt limit. It is applied before emitModel too, so a physical column
// that only a synthesised measure references still gets a columns[] entry.
func (dbt) Emit(m *ir.Model, dir string) ([]string, error) {
func (d dbt) Emit(m *ir.Model, dir string) ([]string, error) {
var f dbtEmitFile
var warnings []string
// A relationship reaches dbt as a `relationships` data test on the FK
Expand Down Expand Up @@ -200,7 +218,7 @@ func (dbt) Emit(m *ir.Model, dir string) ([]string, error) {
}

f.Models = append(f.Models, emitModel(m, t, pk, fk))
f.SemanticModels = append(f.SemanticModels, emitSemantic(t, pk, fk))
f.SemanticModels = append(f.SemanticModels, emitSemantic(t, pk, fk, d.opts))
metrics, warn := emitMetrics(t)
f.Metrics = append(f.Metrics, metrics...)
warnings = append(warnings, warn...)
Expand Down Expand Up @@ -338,12 +356,31 @@ func emitModel(m *ir.Model, t ir.Table, pk, fk map[string]bool) dbtEmitModel {
return em
}

// physicalTable renders the warehouse-qualified name a semantic model reads:
// [database.][schema.]prefix+name. The IR holds logical names only, so the
// prefix and container come from Options.
func physicalTable(name string, opts Options) string {
out := opts.TablePrefix + name
if opts.Schema != "" {
out = opts.Schema + "." + out
}
if opts.Database != "" {
out = opts.Database + "." + out
}
return out
}

// emitSemantic builds the semantic_models block: a primary entity per PK column,
// every non-PK/non-FK dimension as a semantic dimension (FK columns round-trip
// as plain model columns + the relationship test, so they are NOT re-emitted as
// entities), and every measure.
func emitSemantic(t ir.Table, pk, fk map[string]bool) dbtEmitSemantic {
func emitSemantic(t ir.Table, pk, fk map[string]bool, opts Options) dbtEmitSemantic {
sm := dbtEmitSemantic{Name: t.Name, Model: "ref('" + t.Name + "')"}
if opts.DbtHexMeta {
sm.Config = &dbtEmitSemConfig{Meta: dbtEmitSemMeta{
Hex: &dbtEmitHexMeta{Table: physicalTable(t.Name, opts)},
}}
}
if t.Grain != "" {
sm.Defaults = &dbtEmitDefaults{AggTimeDimension: t.Grain}
}
Expand Down
55 changes: 55 additions & 0 deletions dialect/dbt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,58 @@ func TestDBTParseLabelAndGrain(t *testing.T) {
t.Fatalf("Def = %#v, want %#v", m.Def, wantDef)
}
}

// TestDbtEmitsHexBinding covers Hex's Semantic Model Sync requirement: it parses
// MetricFlow YAML straight from a repo and cannot resolve which physical table a
// semantic model reads without config.meta.hex.table. Without the binding the
// sync imports models that resolve to nothing — the agent then answers about no
// data, which grades as a wrong answer rather than a setup failure.
func TestDbtEmitsHexBinding(t *testing.T) {
m := &ir.Model{Tables: []ir.Table{{
Name: "fct_orders",
PrimaryKey: []string{"order_id"},
Dimensions: []ir.Field{{Name: "status", Expr: "status"}},
Measures: []ir.Measure{{Field: ir.Field{Name: "gross", Expr: "gross"}, Agg: "sum"}},
}}}
dir := t.TempDir()
d := dbt{}.WithOptions(Options{Database: "ecomm", TablePrefix: "marts__", DbtHexMeta: true})
if _, err := d.Emit(m, dir); err != nil {
t.Fatalf("Emit: %v", err)
}
got := readFile(t, filepath.Join(dir, "ecommerce.yml"))
for _, want := range []string{"config:", "meta:", "hex:", "table: ecomm.marts__fct_orders"} {
if !strings.Contains(got, want) {
t.Errorf("emitted YAML missing %q:\n%s", want, got)
}
}
}

// TestDbtOmitsHexBindingByDefault: the binding is one consumer's key, so a plain
// dbt build must stay clean. Emitting it unasked would put a vendor's meta into
// the shared ground-truth reference every arm reads.
func TestDbtOmitsHexBindingByDefault(t *testing.T) {
m := &ir.Model{Tables: []ir.Table{{Name: "fct_orders", PrimaryKey: []string{"order_id"}}}}
dir := t.TempDir()
if _, err := (dbt{}).Emit(m, dir); err != nil {
t.Fatalf("Emit: %v", err)
}
if got := readFile(t, filepath.Join(dir, "ecommerce.yml")); strings.Contains(got, "hex:") {
t.Errorf("default dbt emit must not carry Hex meta:\n%s", got)
}
}

func TestPhysicalTable(t *testing.T) {
cases := []struct {
opts Options
want string
}{
{Options{Database: "ecomm", TablePrefix: "marts__"}, "ecomm.marts__fct_orders"},
{Options{Database: "EVAL_MARTS", Schema: "MAIN"}, "EVAL_MARTS.MAIN.fct_orders"},
{Options{}, "fct_orders"},
}
for _, c := range cases {
if got := physicalTable("fct_orders", c.opts); got != c.want {
t.Errorf("physicalTable(%+v) = %q, want %q", c.opts, got, c.want)
}
}
}
16 changes: 16 additions & 0 deletions dialect/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ type Options struct {
// DbtMetaKeyPath selects where Lightdash meta lives: "" / "meta" nests under
// meta: (dbt <=1.9); "config.meta" nests under config.meta: (dbt 1.10+).
DbtMetaKeyPath string
// TablePrefix is prepended to a table's IR name to form its PHYSICAL name.
// The IR carries logical names (fct_orders); a warehouse may materialise
// them under a prefix (ClickHouse marts__fct_orders). Consumers that bind a
// semantic model to a physical table need the latter.
TablePrefix string
// DbtHexMeta emits Hex's `config.meta.hex.table` binding on every semantic
// model. Hex's Semantic Model Sync parses dbt MetricFlow YAML straight from
// a repo and CANNOT resolve which physical table a semantic model reads
// without it, so without this the sync imports models that resolve to
// nothing.
DbtHexMeta bool
// LowerCaseIdentifiers keeps identifiers as the IR spells them instead of
// upper-casing. Snowflake folds unquoted identifiers up, so upper-case is
// the safe default there; ClickHouse is case-sensitive and would not
// resolve them.
LowerCaseIdentifiers bool
}

// Configurable is an Emitter that accepts model/view identity options.
Expand Down
Loading
Loading