Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.out
fail_ci_if_error: false

# The example (testdata/example) is a separate module with its own dependencies. It is checked
# but deliberately excluded from coverage, so it runs as its own job.
example:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
with:
cache: true
- run: mise run example
9 changes: 9 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,12 @@ run = 'go test -coverpkg=./... -covermode=atomic -coverprofile=coverage.out ./..
[tasks.check]
description = "Full gate: fmt, build, vet, lint, deadcode, test"
depends = ["fmt", "build", "vet", "lint", "deadcode", "test"]

# The example is a separate module (testdata/example, joined via go.work) with its own deps
# (modernc.org/sqlite, samber/lo). It is checked but deliberately kept out of coverage, so it
# runs as its own task/CI job rather than under `check`/`cover`. Formatting is already covered by
# the root `fmt` task (gofmt -l . recurses into testdata/example).
[tasks.example]
description = "Check the example module (build, vet, lint, deadcode, test -race); no coverage"
dir = "testdata/example"
run = 'go build ./... && go vet ./... && golangci-lint run ./... && deadcode -test ./... && go test -race ./...'
40 changes: 21 additions & 19 deletions e2e_test.go → snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ import (
"github.com/mpyw/bisql/dialect"
)

// TestE2E exercises one comprehensive template that uses every directive kind at once — a CTE
// bind, a recursive @include of reusable fragments, an if/elseif/else branch, an IN-list bind, a
// PostgreSQL array bind, a tuple (row) IN, a /*%for*/ loop, an order-by conditional, and a /*^ */
// inline literal — rendered across all four dialects to lock placeholder numbering.
// TestSnapshots exercises one comprehensive template that uses every directive kind at once — a
// CTE bind, a recursive @include of reusable fragments, an if/elseif/else branch, an IN-list
// bind, a PostgreSQL array bind, a tuple (row) IN, a /*%for*/ loop, an order-by conditional, and
// a /*^ */ inline literal — rendered across all four dialects to lock placeholder numbering.
//
// All fixtures are flat files under testdata/e2e, named by the template and (per case) the case:
// The template and its fragments live under testdata/; every generated artifact lives under
// testdata/snapshots/, named by the template and (per case) the case:
//
// all_in_one.sql the template (@include-ing all_in_one.scope.sql, which in turn
// @include-s all_in_one.active.sql)
// all_in_one.expanded.sql every @include resolved (dialect-independent)
// all_in_one.<case>.prepared.sql parameterized SQL (placeholder form)
// all_in_one.<case>.embedded.sql values-embedded SQL (when checked)
// testdata/all_in_one.sql the template (@include-s _all_in_one.scope.sql,
// which in turn @include-s _all_in_one.active.sql)
// testdata/_all_in_one.{scope,active}.sql reusable fragments (underscore-prefixed by convention)
// testdata/snapshots/all_in_one.expanded.sql every @include resolved (dialect-independent)
// testdata/snapshots/all_in_one.<case>.prepared.sql parameterized SQL (placeholder form)
// testdata/snapshots/all_in_one.<case>.embedded.sql values-embedded SQL (when checked)
//
// Regenerate with: go test -run TestE2E -update
var update = flag.Bool("update", false, "update golden files in testdata/")
// Regenerate with: go test -run TestSnapshots -update
var update = flag.Bool("update", false, "update golden files in testdata/snapshots")

func checkGolden(t *testing.T, path, got string) {
t.Helper()
Expand All @@ -38,23 +40,23 @@ func checkGolden(t *testing.T, path, got string) {
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden %s: %v (regenerate with: go test -run TestE2E -update)", path, err)
t.Fatalf("read golden %s: %v (regenerate with: go test -run TestSnapshots -update)", path, err)
}
if got != string(want) {
t.Errorf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, got, want)
}
}

func TestE2E(t *testing.T) {
const dir = "testdata/e2e"
fsys := os.DirFS(dir)
func TestSnapshots(t *testing.T) {
fsys := os.DirFS("testdata")
snap := func(name string) string { return filepath.Join("testdata", "snapshots", name) }

// Expanded snapshot: every @include resolved, still two-way, independent of dialect.
expanded, err := bisql.ExpandFile(fsys, "all_in_one.sql")
if err != nil {
t.Fatalf("expand: %v", err)
}
checkGolden(t, filepath.Join(dir, "all_in_one.expanded.sql"), expanded)
checkGolden(t, snap("all_in_one.expanded.sql"), expanded)

// The full case supplies every optional predicate; rendered under all four dialects it locks
// the placeholder numbering. pg_array adds the PostgreSQL-only array bind; the pending/else
Expand Down Expand Up @@ -109,9 +111,9 @@ func TestE2E(t *testing.T) {
if !reflect.DeepEqual(stmt.Args, c.args) {
t.Errorf("Args\n got: %#v\nwant: %#v", stmt.Args, c.args)
}
checkGolden(t, filepath.Join(dir, "all_in_one."+c.name+".prepared.sql"), stmt.SQL)
checkGolden(t, snap("all_in_one."+c.name+".prepared.sql"), stmt.SQL)
if c.embedded {
checkGolden(t, filepath.Join(dir, "all_in_one."+c.name+".embedded.sql"), stmt.SQLWithArgs())
checkGolden(t, snap("all_in_one."+c.name+".embedded.sql"), stmt.SQLWithArgs())
}
})
}
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/*%! @include all_in_one.active.sql */
/*%! @include _all_in_one.active.sql */
/*%if departmentId != null*/and u.department_id = /*departmentId*/0/*%end*/
2 changes: 1 addition & 1 deletion testdata/e2e/all_in_one.sql → testdata/all_in_one.sql
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ select u.id, u.name
from users u
join active_depts d on d.id = u.department_id
where 1 = 1
/*%! @include all_in_one.scope.sql */
/*%! @include _all_in_one.scope.sql */
/*%if ageBand == 'adult'*/ and u.age >= 18/*%elseif ageBand == 'senior'*/ and u.age >= 65/*%else*/ and u.age >= 0/*%end*/
/*%if ids != null*/and u.id in /*ids*/(0)/*%end*/
/*%if tags != null*/and u.tags && /*tags*/'{}'::text[]/*%end*/
Expand Down
5 changes: 3 additions & 2 deletions testdata/example/src/app/infrastructure/query/query.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package query implements the app/query ports over a *sql.DB, building each statement from the
// embedded bisql templates (SQLite dialect). The .sql templates live under sql/, organized by
// domain, with reusable fragments under a fragment/ subdirectory pulled in via @include.
// domain; reusable fragments are underscore-prefixed (_name.sql) by convention and pulled in via
// @include. The embed uses the all: prefix so those _-prefixed files are included.
package query

import (
Expand All @@ -16,7 +17,7 @@ import (
port "github.com/mpyw/bisql/example/src/app/query"
)

//go:embed sql
//go:embed all:sql
var embedded embed.FS

// templates is the query tree rooted at sql/, so a name like "users/search.sql" resolves and its
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/*%! @include audit_logs/fragment/since.sql */
/*%! @include audit_logs/_since.sql */
/*%if until != null*/and a.created_at < /*until*/'2026-01-01'/*%end*/
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ from audit_logs a
join users u on u.id = a.user_id
join departments d on d.id = u.department_id
where 1 = 1
/*%! @include audit_logs/fragment/window.sql */
/*%! @include audit_logs/_window.sql */
/*%if actions != null*/and a.action in /*actions*/('login')/*%end*/
group by d.name
order by events desc, d.name
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
-- Row-visibility scope shared by every users query: an optional active-only filter (its own
-- reusable fragment) and an optional single-department filter. Both are conjunctions, so the
-- scope contributes nothing when neither is requested.
/*%! @include users/fragment/active.sql */
/*%! @include users/_active.sql */
/*%if departmentId != null*/and u.department_id = /*departmentId*/0/*%end*/
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ select
from users u
/*%if withDepartment*/join departments d on d.id = u.department_id/*%end*/
where 1 = 1
/*%! @include users/fragment/scope.sql */
/*%! @include users/_scope.sql */
/*%if ageBand == 'adult'*/ and u.age >= 18/*%elseif ageBand == 'senior'*/ and u.age >= 65/*%else*/ and u.age >= 0/*%end*/
/*%if q != null*/and u.name like /*q*/'%alice%'/*%end*/
/*%if departmentIds != null*/and u.department_id in /*departmentIds*/(0)/*%end*/
Expand Down
Loading