Skip to content

Commit

Permalink
[Feature] Add project scaffolding option (#5)
Browse files Browse the repository at this point in the history
To make `tbd` a true alternative to `dbt init`, it needs to optionally
be able to scaffold a baseline dbt project. It can now do this! If you
choose this option your files will be generated inside the `staging`
folder of the scaffolded project.

It also adds an important guard that prevents users choosing an
existing, populated directory to build into. Rather than deal with how
to handle overwrites, I think it's much better to just error out and ask
users to either specify a new directory or choose an empty one.
  • Loading branch information
gwenwindflower authored Apr 16, 2024
2 parents bc9dde0 + 5a38b70 commit 70fe71f
Show file tree
Hide file tree
Showing 8 changed files with 368 additions and 16 deletions.
36 changes: 33 additions & 3 deletions forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ type FormResponse struct {
UseDbtProfile bool
DbtProfile string
DbtProfileOutput string
CreateProfile bool
ScaffoldProject bool
ProjectName string
}

func Forms() (formResponse FormResponse) {
Expand All @@ -41,7 +44,7 @@ Generates:
For each table in the designated schema/dataset.
To prepare, make sure you have the following:
✴︎ An existing dbt profile.yml file to reference
✴︎ An existing dbt profiles.yml file to reference
*_OR_*
✴︎ The necessary connection details for your warehouse
Expand Down Expand Up @@ -75,8 +78,23 @@ You'll need:
huh.NewConfirm().Affirmative("Yes!").Negative("Nah").
Title("Do you have a dbt profile you'd like to connect with?\n(you can enter your credentials manually if not)").
Value(&formResponse.UseDbtProfile),
huh.NewConfirm().Affirmative("Yeah!").Negative("Nope").
Title("Would you like to scaffold a basic dbt project into the output directory?").
Value(&formResponse.ScaffoldProject),
),
)
project_name_form := huh.NewForm(
huh.NewGroup(huh.NewInput().
Title("What is the name of your dbt project?").
Value(&formResponse.ProjectName).
Placeholder("gondor_patrol_analytics"),
))
profile_create_form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().Affirmative("Yes, pls").Negative("No, thx").
Title("Would you like to generate a profiles.yml file from the info you provide next?").
Value(&formResponse.CreateProfile),
))
dbt_form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Expand Down Expand Up @@ -155,8 +173,8 @@ Relative to pwd e.g. if db is in this dir -> cool_ducks.db`).
huh.NewNote().
Title("🚧🚨 Choose your build directory carefully! 🚨🚧").
Description(`Choose a _new_ or _empty_ directory.
If you use an existing directory,
tbd will overwrite any existing files of the same name.`),
If you choose an existing, populated directory
tbd will _intentionally error out_.`),
),
huh.NewGroup(
huh.NewInput().
Expand All @@ -173,6 +191,8 @@ tbd will overwrite any existing files of the same name.`),
),
)
intro_form.WithTheme(huh.ThemeCatppuccin())
profile_create_form.WithTheme(huh.ThemeCatppuccin())
project_name_form.WithTheme(huh.ThemeCatppuccin())
dbt_form.WithTheme(huh.ThemeCatppuccin())
warehouse_form.WithTheme(huh.ThemeCatppuccin())
snowflake_form.WithTheme(huh.ThemeCatppuccin())
Expand All @@ -191,6 +211,16 @@ tbd will overwrite any existing files of the same name.`),
log.Fatalf("Error running dbt form %v\n", err)
}
} else {
err = profile_create_form.Run()
if err != nil {
log.Fatalf("Error running profile create form %v\n", err)
}
if formResponse.ScaffoldProject {
err = project_name_form.Run()
if err != nil {
log.Fatalf("Error running project name form %v\n", err)
}
}
err = warehouse_form.Run()
if err != nil {
log.Fatalf("Error running warehouse form %v\n", err)
Expand Down
15 changes: 14 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,20 @@ func main() {
if formResponse.GenerateDescriptions {
GenerateColumnDescriptions(ts)
}
PrepBuildDir(bd)
err = PrepBuildDir(bd)
if err != nil {
log.Fatalf("Error preparing build directory: %v\n", err)
}
if formResponse.CreateProfile {
WriteProfile(cd, bd)
}
if formResponse.ScaffoldProject {
s, err := WriteScaffoldProject(cd, bd, formResponse.ProjectName)
if err != nil {
log.Fatalf("Error scaffolding project: %v\n", err)
}
bd = s
}
err = WriteFiles(ts, bd)
if err != nil {
log.Fatalf("Error writing files: %v\n", err)
Expand Down
22 changes: 18 additions & 4 deletions prep_build_dir.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
package main

import (
"errors"
"log"
"os"
)

func PrepBuildDir(buildDir string) {
_, err := os.Stat(buildDir)
func PrepBuildDir(bd string) error {
_, err := os.Stat(bd)
if os.IsNotExist(err) {
dirErr := os.MkdirAll(buildDir, 0755)
dirErr := os.MkdirAll(bd, 0755)
if dirErr != nil {
log.Fatalf("Failed to create directory %v", dirErr)
return dirErr
}
} else if err == nil {
files, err := os.ReadDir(bd)
if err != nil {
log.Fatalf("Failed to check build target directory %v", err)
}
if len(files) == 0 {
return nil
} else {
return errors.New("build directory is not empty")
}
} else {
return err
}
return nil
}
17 changes: 9 additions & 8 deletions shared/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ type SourceTables struct {
}

type ConnectionDetails struct {
ConnType string
Username string
Account string
Database string
Schema string
Project string
Dataset string
Path string
ConnType string
Username string
Account string
Database string
Schema string
Project string
Dataset string
Path string
ProjectName string
}
62 changes: 62 additions & 0 deletions write_profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"log"
"os"
"path"
"text/template"

"github.com/gwenwindflower/tbd/shared"
)

func WriteProfile(cd shared.ConnectionDetails, bd string) {
pt := `
{{.ConnType}}:
target: dev
outputs:
dev:
type: {{.ConnType}}
{{- if eq .ConnType "snowflake"}}
authenticator: externalbrowser
{{- end}}
{{- if eq .ConnType "bigquery"}}
method: oauth
{{- end}}
{{- if .Account}}
account: {{.Account}}
{{- end}}
{{- if .Username}}
user: {{.Username}}
{{- end}}
{{- if .Database}}
database: {{.Database}}
{{- end}}
{{- if .Project}}
project: {{.Project}}
{{- end}}
{{- if .Schema}}
schema: {{.Schema}}
{{- end}}
{{- if .Dataset}}
dataset: {{.Dataset}}
{{- end}}
{{- if .Path}}
path: {{.Path}}
{{- end}}
threads: 8
`
tmpl, err := template.New("profiles").Parse(pt)
if err != nil {
log.Fatalf("Failed to parse template %v\n", err)
}
p := path.Join(bd, "profiles.yml")
o, err := os.Create(p)
if err != nil {
log.Fatalf("Failed to create profiles.yml file %v\n", err)
}
defer o.Close()
err = tmpl.Execute(o, cd)
if err != nil {
log.Fatalf("Failed to execute template %v\n", err)
}
}
44 changes: 44 additions & 0 deletions write_profile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package main

import (
"os"
"path"
"testing"

"github.com/gwenwindflower/tbd/shared"
)

func TestWriteProfile(t *testing.T) {
cd := shared.ConnectionDetails{
ConnType: "snowflake",
Username: "aragorn",
Account: "dunedain.snowflakecomputing.com",
Database: "gondor",
Schema: "minas_tirith",
}
tmpDir := t.TempDir()
WriteProfile(cd, tmpDir)

expected := []byte(`
snowflake:
target: dev
outputs:
dev:
type: snowflake
authenticator: externalbrowser
account: dunedain.snowflakecomputing.com
user: aragorn
database: gondor
schema: minas_tirith
threads: 8
`)
tpp := path.Join(tmpDir, "profiles.yml")
got, err := os.ReadFile(tpp)
if err != nil {
t.Fatalf("Failed to read profiles.yml: %v", err)
}
// os.Remove("profiles.yml")
if string(got) != string(expected) {
t.Errorf("Expected %s, got %s", expected, got)
}
}
97 changes: 97 additions & 0 deletions write_scaffold_project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"log"
"os"
"path"
"text/template"

"github.com/gwenwindflower/tbd/shared"
)

func WriteScaffoldProject(cd shared.ConnectionDetails, bd string, pn string) (string, error) {
folders := []string{"models", "analyses", "macros", "seeds", "snapshots", "data-tests", "models/staging", "models/marts"}
emptyFolders := []string{"analyses", "macros", "seeds", "snapshots", "data-tests", "models/marts"}
for _, folder := range folders {
p := path.Join(bd, folder)
err := os.MkdirAll(p, 0755)
if err != nil {
return "", err
}
}
for _, folder := range emptyFolders {
p := path.Join(bd, folder, ".gitkeep")
err := os.MkdirAll(p, 0755)
if err != nil {
log.Fatalf("Failed to create .gitkeep in %s folder %v\n", folder, err)
}
}
projectYamlTemplate := `config-version: 2
name: {{.ProjectName}}
profile: {{.ConnType}}
model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["data-tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]
target-path: "target"
clean-targets:
- "target"
- "dbt_packages"
models:
{{.ProjectName}}:
staging:
+materialized: view
marts:
+materialized: table
`
gitignore := []byte(`.venv
venv
.env
env
target/
dbt_packages/
logs/
profiles.yml
.DS_Store
.user.yml
.ruff_cache
__pycache__
`)

tmpl, err := template.New("dbt_project.yml").Parse(projectYamlTemplate)
if err != nil {
log.Fatalf("Failed to parse dbt_project.yml template %v\n", err)
}
p := path.Join(bd, "dbt_project.yml")
o, err := os.Create(p)
if err != nil {
log.Fatalf("Failed to create dbt_project.yml file %v\n", err)
}
defer o.Close()
cd.ProjectName = pn
err = tmpl.Execute(o, cd)
if err != nil {
log.Fatalf("Failed to execute dbt_project.yml template %v\n", err)
}
gi := path.Join(bd, ".gitignore")
err = os.WriteFile(gi, gitignore, 0644)
if err != nil {
log.Fatalf("Failed to write .gitignore file %v\n", err)
}
s := path.Join(bd, "models/staging", cd.Schema)
err = os.MkdirAll(s, 0755)
if err != nil {
return "", err
}
return s, nil
}
Loading

0 comments on commit 70fe71f

Please sign in to comment.