diff --git a/cmd/semglot/config.go b/cmd/semglot/config.go
index 0ab1bee..f20ae1e 100644
--- a/cmd/semglot/config.go
+++ b/cmd/semglot/config.go
@@ -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.
@@ -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
@@ -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 == "" {
diff --git a/cmd/semglot/main.go b/cmd/semglot/main.go
index 4fa6193..f85f796 100644
--- a/cmd/semglot/main.go
+++ b/cmd/semglot/main.go
@@ -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,
})
}
diff --git a/dialect/dbt.go b/dialect/dbt.go
index 1b3e747..5dffcb5 100644
--- a/dialect/dbt.go
+++ b/dialect/dbt.go
@@ -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" }
diff --git a/dialect/dbt_emit.go b/dialect/dbt_emit.go
index 0cb5e4f..4b53dff 100644
--- a/dialect/dbt_emit.go
+++ b/dialect/dbt_emit.go
@@ -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"`
}
@@ -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
@@ -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...)
@@ -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}
}
diff --git a/dialect/dbt_test.go b/dialect/dbt_test.go
index 83e64b3..764e069 100644
--- a/dialect/dbt_test.go
+++ b/dialect/dbt_test.go
@@ -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)
+ }
+ }
+}
diff --git a/dialect/dialect.go b/dialect/dialect.go
index 3b639e1..7f73868 100644
--- a/dialect/dialect.go
+++ b/dialect/dialect.go
@@ -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.
diff --git a/dialect/supersimple.go b/dialect/supersimple.go
index 5e8468b..d9ca1d5 100644
--- a/dialect/supersimple.go
+++ b/dialect/supersimple.go
@@ -15,9 +15,18 @@ import (
func init() { Register(supersimple{}) }
// supersimple emits one supersimple config YAML per model. Zero value usable;
-// the build command sets Schema from the profile's schema field.
+// the build command sets Schema and TablePrefix from the profile.
type supersimple struct {
Schema string
+ // TablePrefix maps a logical table name to its PHYSICAL one. The IR carries
+ // logical names (fct_orders); a warehouse may materialise them under a
+ // prefix (ClickHouse marts__fct_orders), and `table:` must address the
+ // physical object or every query fails to resolve.
+ TablePrefix string
+ // LowerCase emits identifiers as-is rather than upper-casing them. The
+ // upper-case default suits Snowflake, where unquoted identifiers fold up;
+ // ClickHouse is case-SENSITIVE, so MARTS__FCT_ORDERS does not exist.
+ LowerCase bool
}
func (supersimple) Name() string { return "supersimple" }
@@ -40,7 +49,7 @@ func isRatioDef(def ir.Expr) bool {
// WithOptions lets the CLI pass the profile's schema (other identity fields are unused).
func (supersimple) WithOptions(o Options) Emitter {
- return supersimple{Schema: o.Schema}
+ return supersimple{Schema: o.Schema, TablePrefix: o.TablePrefix, LowerCase: o.LowerCaseIdentifiers}
}
const ssHeader = "# yaml-language-server: $schema=https://assets.supersimple.io/configuration_schema/1.0.0/supersimple_configuration_schema.json\n"
@@ -69,9 +78,31 @@ type ssRelation struct {
ModelID string `yaml:"model_id"`
JoinStrategy ssJoinStrategy `yaml:"join_strategy"`
}
+
+// ssJoinStrategy addresses a join. `join_key` is Supersimple's shorthand for
+// "both sides share this column name"; when the foreign key and the primary key
+// are named differently the two-sided form is REQUIRED, and the shorthand
+// silently addresses the wrong column.
+//
+// The vendor's own discovery emits the two-sided form (dim_date joins
+// fct_orders on join_key_on_base: DATE_DAY / join_key_on_related: ORDER_DATE).
+// Emitting only the shorthand made `supersimple validate` reject three
+// relations with "references unknown property 'date_day' on related model".
type ssJoinStrategy struct {
- JoinKey string `yaml:"join_key"`
+ JoinKey string `yaml:"join_key,omitempty"`
+ JoinKeyOnBase string `yaml:"join_key_on_base,omitempty"`
+ JoinKeyOnRelated string `yaml:"join_key_on_related,omitempty"`
}
+
+// joinStrategy renders the shorthand when both sides agree and the explicit
+// two-sided form when they differ.
+func joinStrategy(base, related string) ssJoinStrategy {
+ if base == related || related == "" {
+ return ssJoinStrategy{JoinKey: base}
+ }
+ return ssJoinStrategy{JoinKeyOnBase: base, JoinKeyOnRelated: related}
+}
+
type ssMetric struct {
Name string `yaml:"name"`
ModelID string `yaml:"model_id"`
@@ -122,11 +153,30 @@ type ssRelationRef struct {
// Emit does not mutate m; it reads m.Notes and accumulates its own degrade
// notes locally before writing the combined text to NOTES.md.
+// fold renders an identifier in the casing the target warehouse resolves.
+// Snowflake folds unquoted identifiers to upper case, so upper is the safe
+// default; ClickHouse is case-sensitive and MARTS__FCT_ORDERS does not exist.
+func (s supersimple) fold(v string) string {
+ if s.LowerCase {
+ return v
+ }
+ return strings.ToUpper(v)
+}
+
+// foldAll is fold over a slice.
+func (s supersimple) foldAll(vs []string) []string {
+ out := make([]string, len(vs))
+ for i, v := range vs {
+ out[i] = s.fold(v)
+ }
+ return out
+}
+
func (s supersimple) Emit(m *ir.Model, dir string) ([]string, error) {
+ // An EXPLICIT empty schema is honoured (ClickHouse has a two-part namespace,
+ // so "MAIN.marts__fct_orders" resolves to nothing); only an unset one
+ // defaults.
schema := s.Schema
- if schema == "" {
- schema = "MAIN"
- }
// relationships grouped by parent (Right) table
relsByParent := map[string][]ir.Relationship{}
for _, r := range m.Relationships {
@@ -182,7 +232,7 @@ func (s supersimple) Emit(m *ir.Model, dir string) ([]string, error) {
// and relations) and register its simple metrics.
for _, t := range m.Tables {
t.Metrics = hoist.metricsFor(t) // t is the range's own copy; m is untouched
- id := strings.ToUpper(t.Name)
+ id := s.fold(t.Name)
// Prefer the source dialect's own declared physical address. `table:`
// holds ONE opaque string, unlike cortexBaseTable's separate
// Database/Schema/Table fields, so a genuine table reference is used
@@ -192,7 +242,10 @@ func (s supersimple) Emit(m *ir.Model, dir string) ([]string, error) {
// permits) can't go here as-is — that falls back to the profile
// reconstruction with a warning rather than pasting a query into
// `table:` as if it were an address.
- table := schema + "." + id
+ table := s.TablePrefix + id
+ if schema != "" {
+ table = schema + "." + table
+ }
if t.Source != "" {
if looksLikeQuery(t.Source) {
degradeNotes = append(degradeNotes, querySourceWarning("supersimple", t.Name, t.Source))
@@ -203,12 +256,12 @@ func (s supersimple) Emit(m *ir.Model, dir string) ([]string, error) {
model := ssModel{
Name: prettify(t.Name),
Table: table,
- PrimaryKey: upperAll(t.PrimaryKey),
+ PrimaryKey: s.foldAll(t.PrimaryKey),
Description: appendClause(t.Description, synonymClause(t.Synonyms)),
Properties: map[string]ssProperty{},
}
addProp := func(f ir.Field, typ string) {
- col := strings.ToUpper(f.Expr)
+ col := s.fold(f.Expr)
if _, ok := model.Properties[col]; ok {
return
}
@@ -229,9 +282,13 @@ func (s supersimple) Emit(m *ir.Model, dir string) ([]string, error) {
}
for _, r := range relsByParent[t.Name] {
child := r.Left
- join := ""
+ // r.Right is the PARENT (this model, the base); r.Left is the child.
+ // Column pairs are (Left=child column, Right=parent column), so the
+ // base key is the parent's and the related key is the child's.
+ var baseKey, relatedKey string
if len(r.Columns) > 0 {
- join = strings.ToUpper(r.Columns[0].Right)
+ baseKey = s.fold(r.Columns[0].Right)
+ relatedKey = s.fold(r.Columns[0].Left)
}
if model.Relations == nil {
model.Relations = map[string]ssRelation{}
@@ -258,8 +315,8 @@ func (s supersimple) Emit(m *ir.Model, dir string) ([]string, error) {
}
}
model.Relations[key] = ssRelation{
- Name: label, Type: "hasMany", ModelID: strings.ToUpper(child),
- JoinStrategy: ssJoinStrategy{JoinKey: join},
+ Name: label, Type: "hasMany", ModelID: s.fold(child),
+ JoinStrategy: joinStrategy(baseKey, relatedKey),
}
}
@@ -271,12 +328,12 @@ func (s supersimple) Emit(m *ir.Model, dir string) ([]string, error) {
var key string
switch a := arg.(type) {
case ir.Col:
- key = strings.ToUpper(a.Name)
+ key = s.fold(a.Name)
case ir.Raw:
// raw.SQL is unqualified; wrap its columns and synthesize a
// property keyed by the metric name, guarding against clobbering
// a physical column that already owns that key.
- key = strings.ToUpper(mt.Name)
+ key = s.fold(mt.Name)
for {
if _, taken := model.Properties[key]; !taken {
break
diff --git a/dialect/supersimple_test.go b/dialect/supersimple_test.go
index ea77d88..9e7d31b 100644
--- a/dialect/supersimple_test.go
+++ b/dialect/supersimple_test.go
@@ -398,3 +398,21 @@ func TestSupersimpleCrossTableRatioEmit(t *testing.T) {
t.Fatal("NOTES.md should not exist when nothing is deferred")
}
}
+
+// TestSupersimpleTwoSidedJoinKey pins the two-sided join form. `join_key` is
+// Supersimple's shorthand for "both sides share this column name"; when the FK
+// and PK differ it addresses the wrong column, and `supersimple validate`
+// rejected three dim_date relations with "references unknown property
+// 'date_day' on related model" because of exactly this.
+func TestSupersimpleTwoSidedJoinKey(t *testing.T) {
+ if got := joinStrategy("date_day", "order_date"); got.JoinKeyOnBase != "date_day" ||
+ got.JoinKeyOnRelated != "order_date" || got.JoinKey != "" {
+ t.Errorf("differing keys must use the two-sided form; got %+v", got)
+ }
+ // Matching keys keep the shorthand, which is what the vendor emits and what
+ // the other 20 relations already validated with.
+ if got := joinStrategy("order_id", "order_id"); got.JoinKey != "order_id" ||
+ got.JoinKeyOnBase != "" {
+ t.Errorf("matching keys should use the shorthand; got %+v", got)
+ }
+}
diff --git a/test/models/ecommerce/dbt/supersimple/DIM_CUSTOMER.yaml b/test/models/ecommerce/dbt/supersimple/DIM_CUSTOMER.yaml
index b7eb42e..501d6aa 100644
--- a/test/models/ecommerce/dbt/supersimple/DIM_CUSTOMER.yaml
+++ b/test/models/ecommerce/dbt/supersimple/DIM_CUSTOMER.yaml
@@ -25,7 +25,8 @@ models:
type: hasMany
model_id: FCT_ORDERS
join_strategy:
- join_key: CUSTOMER_SK
+ join_key_on_base: CUSTOMER_SK
+ join_key_on_related: BILLING_CUSTOMER_SK
orders_customer_sk:
name: Orders (Customer sk)
type: hasMany
diff --git a/tools/gif/make_gif.py b/tools/gif/make_gif.py
new file mode 100644
index 0000000..01f5b5d
--- /dev/null
+++ b/tools/gif/make_gif.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""Regenerate the semglot announcement GIF.
+
+Renders N SVG frames, rasterises each with inkscape, assembles with ImageMagick.
+Palette and mark geometry are taken from logo.svg: ink #111111, oxblood #A00000.
+
+Usage: python3 tools/gif/make_gif.py [outfile.gif]
+"""
+import os
+import shutil
+import subprocess
+import sys
+
+W, H = 800, 450
+BG = "#FBF9F3"
+INK = "#111111"
+OXBLOOD = "#A00000"
+MUTED = "#6B6560"
+SERIF = "Georgia, 'Liberation Serif', serif"
+
+TITLE = "Nine Semantic Layer Translations"
+SOURCES = ["dbt", "Ossie"]
+TARGETS = [
+ "Snowflake Cortex",
+ "Snowflake semantic view",
+ "supersimple",
+ "nao / yaml",
+ "nao / context rules",
+ "Databricks metric view",
+ "Lightdash",
+ "Ossie",
+]
+FOOTNOTE = "dbt and Ossie read and write"
+FOOTER = "open source"
+URL = "benchouse.ai"
+
+CX, CY = 400.0, 232.0 # logo centre
+LOGO_SCALE = 1.45 # 64-unit viewBox -> ~93px
+TRACK_Y = CY
+SRC_X = 170.0 # right edge of the source boxes
+TGT_X = 544.0 # left edge of the target list
+SRC_GAP = 62.0 # vertical gap between source boxes
+TGT_SPAN = 34.0 # vertical gap between targets
+HIDE_R = 50.0 # radius of the mark; balls vanish inside it
+FRAMES = 40
+DELAY_CS = 5 # centiseconds per frame
+
+# One ball per target. Sources alternate, so dbt and Ossie fire in turn.
+# The jitter breaks the metronome without making it look random.
+JITTER = (0.00, 0.06, -0.04, 0.03, -0.02, 0.05, -0.05, 0.02)
+
+MARK = """
+
+
+
+
+
+
+
+
+
+
+
+
+{arrows}
+
+
+""".format(
+ ink=INK,
+ ox=OXBLOOD,
+ arrows="\n".join(
+ ' '.format(a=a)
+ for a in (0, 60, 120, 180, 240, 300)
+ ),
+)
+
+
+def source_xy(j: int) -> tuple:
+ n = len(SOURCES)
+ return SRC_X, TRACK_Y + (j - (n - 1) / 2) * SRC_GAP
+
+
+def target_xy(j: int) -> tuple:
+ n = len(TARGETS)
+ return TGT_X, TRACK_Y - (n - 1) / 2 * TGT_SPAN + j * TGT_SPAN
+
+
+def frame_svg(i: int) -> str:
+ phase = i / FRAMES
+ n = len(TARGETS)
+ dots = []
+ for k in range(n):
+ p = (phase + k / n + JITTER[k % len(JITTER)]) % 1.0
+ sx, sy = source_xy(k % len(SOURCES))
+ tx, ty = target_xy(k)
+
+ # first half converges on the mark, second half fans out to a target
+ if p < 0.5:
+ t = p / 0.5
+ x, y = sx + (CX - sx) * t, sy + (CY - sy) * t
+ else:
+ t = (p - 0.5) / 0.5
+ x, y = CX + (tx - CX) * t, CY + (ty - CY) * t
+
+ if (x - CX) ** 2 + (y - CY) ** 2 < HIDE_R**2:
+ continue
+
+ # fade in leaving the source, fade out arriving at the target
+ op = min(1.0, p / 0.10, (1.0 - p) / 0.10)
+ dots.append(
+ f''
+ )
+
+ # source boxes, vertically centred on the track
+ boxes = []
+ n = len(SOURCES)
+ for j, s in enumerate(SOURCES):
+ by = TRACK_Y + (j - (n - 1) / 2) * 62
+ boxes.append(
+ f''
+ f'{s}'
+ )
+
+ # target list
+ items = []
+ n = len(TARGETS)
+ span = 34
+ top = TRACK_Y - (n - 1) / 2 * span
+ for j, t in enumerate(TARGETS):
+ items.append(
+ f'{t}'
+ )
+
+ return f""""""
+
+
+def main() -> int:
+ out = sys.argv[1] if len(sys.argv) > 1 else "semglot-ossie.gif"
+ tmp = "/tmp/semglot-gif"
+ shutil.rmtree(tmp, ignore_errors=True)
+ os.makedirs(tmp)
+
+ for i in range(FRAMES):
+ with open(f"{tmp}/f{i:03d}.svg", "w") as fh:
+ fh.write(frame_svg(i))
+
+ subprocess.run(
+ ["inkscape", "--export-type=png", f"--export-width={W}",
+ *[f"{tmp}/f{i:03d}.svg" for i in range(FRAMES)]],
+ check=True, capture_output=True,
+ )
+ subprocess.run(
+ ["convert", "-delay", str(DELAY_CS), "-loop", "0",
+ f"{tmp}/f%03d.png[0-{FRAMES-1}]", "-layers", "Optimize", out],
+ check=True,
+ )
+ print(f"wrote {out} ({os.path.getsize(out)/1024:.0f} KB, {FRAMES} frames)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/gif/out/semglot-ossie.gif b/tools/gif/out/semglot-ossie.gif
new file mode 100644
index 0000000..3518aec
Binary files /dev/null and b/tools/gif/out/semglot-ossie.gif differ