From 15b94d6b4a21c7a17beea2e1977764dd4fce3dfe Mon Sep 17 00:00:00 2001 From: "@mpyw" Date: Tue, 18 Aug 2026 20:44:34 +0900 Subject: [PATCH] test(example): layered SQLite API sample app (ports & adapters) A self-contained sample project under testdata/example (its own module, joined to the root via go.work) that uses bisql the way a real service would: a small HTTP API executing its templates against an in-memory SQLite database (pure-Go modernc.org/sqlite), laid out in clean-architecture layers with manual DI. src/app/app.go application core: struct App aggregates the query ports src/app/query/ query ports (one interface per query) + DTOs src/app/schema/ the Migrator port src/app/presentation/ HTTP delivery; depends only on *app.App (+ end-to-end test) src/app/infrastructure/query/ query impl (bisql + database/sql), one file per query; .sql templates under sql/ (embedded); snapshot test + testdata src/app/infrastructure/schema/ Migrator impl + schema.sql src/cmd/serve/ composition root (manual DI wiring) Interfaces (app/query, app/schema) are the abstractions; app/infrastructure/* implements them; cmd/serve wires them by hand. presentation_test.go drives the whole stack over a real :memory: DB, proving the bisql-built SQL is valid SQLite. Two snapshot kinds per query under testdata/snapshots/: .expanded.sql and ..embedded.sql (go test ./... -update). The example code is written in samber/lo style. go work sync aligns the shared golang.org/x/sync indirect; the root library is otherwise unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YAydPGeSHjZvJv22oz4P6d --- go.mod | 1 + go.sum | 3 +- go.work | 6 + go.work.sum | 9 + testdata/example/go.mod | 25 +++ testdata/example/go.sum | 62 ++++++ testdata/example/src/app/app.go | 40 ++++ .../infrastructure/query/activity_report.go | 21 ++ .../src/app/infrastructure/query/audit_log.go | 35 ++++ .../src/app/infrastructure/query/query.go | 88 +++++++++ .../app/infrastructure/query/query_test.go | 187 ++++++++++++++++++ .../query/sql/audit_logs/activity-report.sql | 17 ++ .../query/sql/audit_logs/bulk-insert.sql | 9 + .../query/sql/audit_logs/find-by-keys.sql | 13 ++ .../query/sql/audit_logs/fragment/since.sql | 1 + .../query/sql/audit_logs/fragment/window.sql | 2 + .../query/sql/users/fragment/active.sql | 1 + .../query/sql/users/fragment/scope.sql | 5 + .../infrastructure/query/sql/users/search.sql | 32 +++ .../activity-report.all_time.embedded.sql | 24 +++ .../audit_logs/activity-report.expanded.sql | 23 +++ .../activity-report.windowed.embedded.sql | 26 +++ .../audit_logs/bulk-insert.empty.embedded.sql | 13 ++ .../audit_logs/bulk-insert.expanded.sql | 12 ++ .../bulk-insert.two_rows.embedded.sql | 13 ++ .../find-by-keys.empty.embedded.sql | 17 ++ .../audit_logs/find-by-keys.expanded.sql | 16 ++ .../find-by-keys.two_keys.embedded.sql | 17 ++ .../snapshots/users/search.expanded.sql | 41 ++++ .../snapshots/users/search.full.embedded.sql | 52 +++++ .../users/search.minimal.embedded.sql | 42 ++++ .../app/infrastructure/query/user_search.go | 41 ++++ .../src/app/infrastructure/schema/schema.go | 32 +++ .../src/app/infrastructure/schema/schema.sql | 46 +++++ testdata/example/src/app/presentation/http.go | 119 +++++++++++ .../src/app/presentation/presentation_test.go | 124 ++++++++++++ testdata/example/src/app/query/query.go | 62 ++++++ testdata/example/src/app/schema/schema.go | 11 ++ testdata/example/src/cmd/serve/main.go | 54 +++++ 39 files changed, 1340 insertions(+), 2 deletions(-) create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 testdata/example/go.mod create mode 100644 testdata/example/go.sum create mode 100644 testdata/example/src/app/app.go create mode 100644 testdata/example/src/app/infrastructure/query/activity_report.go create mode 100644 testdata/example/src/app/infrastructure/query/audit_log.go create mode 100644 testdata/example/src/app/infrastructure/query/query.go create mode 100644 testdata/example/src/app/infrastructure/query/query_test.go create mode 100644 testdata/example/src/app/infrastructure/query/sql/audit_logs/activity-report.sql create mode 100644 testdata/example/src/app/infrastructure/query/sql/audit_logs/bulk-insert.sql create mode 100644 testdata/example/src/app/infrastructure/query/sql/audit_logs/find-by-keys.sql create mode 100644 testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/since.sql create mode 100644 testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/window.sql create mode 100644 testdata/example/src/app/infrastructure/query/sql/users/fragment/active.sql create mode 100644 testdata/example/src/app/infrastructure/query/sql/users/fragment/scope.sql create mode 100644 testdata/example/src/app/infrastructure/query/sql/users/search.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.all_time.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.expanded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.windowed.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.empty.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.expanded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.two_rows.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.empty.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.expanded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.two_keys.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.expanded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.full.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.minimal.embedded.sql create mode 100644 testdata/example/src/app/infrastructure/query/user_search.go create mode 100644 testdata/example/src/app/infrastructure/schema/schema.go create mode 100644 testdata/example/src/app/infrastructure/schema/schema.sql create mode 100644 testdata/example/src/app/presentation/http.go create mode 100644 testdata/example/src/app/presentation/presentation_test.go create mode 100644 testdata/example/src/app/query/query.go create mode 100644 testdata/example/src/app/schema/schema.go create mode 100644 testdata/example/src/cmd/serve/main.go diff --git a/go.mod b/go.mod index e381d30..195fdd1 100644 --- a/go.mod +++ b/go.mod @@ -10,5 +10,6 @@ require ( require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 8b34a06..6fc5c4d 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go.work b/go.work new file mode 100644 index 0000000..f6c60eb --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.25.0 + +use ( + . + ./testdata/example +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..abe6579 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,9 @@ +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/testdata/example/go.mod b/testdata/example/go.mod new file mode 100644 index 0000000..87a95cf --- /dev/null +++ b/testdata/example/go.mod @@ -0,0 +1,25 @@ +module github.com/mpyw/bisql/example + +go 1.25.0 + +require ( + github.com/mpyw/bisql v0.0.0-00010101000000-000000000000 + github.com/samber/lo v1.53.0 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/expr-lang/expr v1.17.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.29.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) + +replace github.com/mpyw/bisql => ../.. diff --git a/testdata/example/go.sum b/testdata/example/go.sum new file mode 100644 index 0000000..1e1297e --- /dev/null +++ b/testdata/example/go.sum @@ -0,0 +1,62 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/testdata/example/src/app/app.go b/testdata/example/src/app/app.go new file mode 100644 index 0000000..b7be83d --- /dev/null +++ b/testdata/example/src/app/app.go @@ -0,0 +1,40 @@ +// Package app is the application core. App aggregates the query ports — received via manual +// dependency injection in cmd/serve — behind the methods the presentation layer calls. The logic +// here is deliberately thin: it is the seam where validation, authorization, and orchestration +// would live. +package app + +import ( + "context" + + "github.com/mpyw/bisql/example/src/app/query" +) + +// App is the application service, wiring the query ports behind request methods. +type App struct { + users query.UserSearcher + activity query.ActivityReporter + writer query.AuditLogWriter + lookup query.AuditLogLookup +} + +// New wires the query ports into an App (manual DI). +func New(users query.UserSearcher, activity query.ActivityReporter, writer query.AuditLogWriter, lookup query.AuditLogLookup) *App { + return &App{users: users, activity: activity, writer: writer, lookup: lookup} +} + +func (a *App) SearchUsers(ctx context.Context, in query.SearchUsersInput) ([]query.Row, error) { + return a.users.SearchUsers(ctx, in) +} + +func (a *App) ReportActivity(ctx context.Context, in query.ActivityReportInput) ([]query.Row, error) { + return a.activity.ReportActivity(ctx, in) +} + +func (a *App) AppendAuditLogs(ctx context.Context, events []query.AuditEvent) (int64, error) { + return a.writer.AppendAuditLogs(ctx, events) +} + +func (a *App) LookupAuditLogs(ctx context.Context, keys []query.AuditKey) ([]query.Row, error) { + return a.lookup.LookupAuditLogs(ctx, keys) +} diff --git a/testdata/example/src/app/infrastructure/query/activity_report.go b/testdata/example/src/app/infrastructure/query/activity_report.go new file mode 100644 index 0000000..225544d --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/activity_report.go @@ -0,0 +1,21 @@ +package query + +import ( + "context" + + "github.com/samber/lo" + + port "github.com/mpyw/bisql/example/src/app/query" +) + +// ReportActivity aggregates audit events per department over an optional date window. +func (q *Queries) ReportActivity(ctx context.Context, in port.ActivityReportInput) ([]port.Row, error) { + params := lo.OmitByValues(map[string]any{ + "since": in.Since, + "until": in.Until, + }, []any{""}) + if len(in.Actions) > 0 { + params["actions"] = lo.ToAnySlice(in.Actions) + } + return q.rows(ctx, "audit_logs/activity-report.sql", params) +} diff --git a/testdata/example/src/app/infrastructure/query/audit_log.go b/testdata/example/src/app/infrastructure/query/audit_log.go new file mode 100644 index 0000000..191a2fc --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/audit_log.go @@ -0,0 +1,35 @@ +package query + +import ( + "context" + + "github.com/samber/lo" + + port "github.com/mpyw/bisql/example/src/app/query" +) + +// AppendAuditLogs inserts a batch of events with one round trip (bulk-insert.sql: a zero-row +// seed plus one `union all select` per event). +func (q *Queries) AppendAuditLogs(ctx context.Context, events []port.AuditEvent) (int64, error) { + rows := lo.Map(events, func(e port.AuditEvent, _ int) any { + return map[string]any{"userId": e.UserID, "action": e.Action} + }) + stmt, err := q.build("audit_logs/bulk-insert.sql", map[string]any{"events": rows}) + if err != nil { + return 0, err + } + res, err := q.db.ExecContext(ctx, stmt.SQL, stmt.Args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// LookupAuditLogs fetches rows for a set of composite (user_id, action) keys (find-by-keys.sql: +// a row-value IN over a union-all subquery). +func (q *Queries) LookupAuditLogs(ctx context.Context, keys []port.AuditKey) ([]port.Row, error) { + rows := lo.Map(keys, func(k port.AuditKey, _ int) any { + return map[string]any{"userId": k.UserID, "action": k.Action} + }) + return q.rows(ctx, "audit_logs/find-by-keys.sql", map[string]any{"keys": rows}) +} diff --git a/testdata/example/src/app/infrastructure/query/query.go b/testdata/example/src/app/infrastructure/query/query.go new file mode 100644 index 0000000..be53373 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/query.go @@ -0,0 +1,88 @@ +// 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. +package query + +import ( + "context" + "database/sql" + "embed" + "io/fs" + + "github.com/samber/lo" + + "github.com/mpyw/bisql" + "github.com/mpyw/bisql/dialect" + port "github.com/mpyw/bisql/example/src/app/query" +) + +//go:embed sql +var embedded embed.FS + +// templates is the query tree rooted at sql/, so a name like "users/search.sql" resolves and its +// @include fragments resolve from the same FS. +var templates = lo.Must(fs.Sub(embedded, "sql")) + +// Queries implements every app/query port over db, sharing one immutable Parser. +type Queries struct { + db *sql.DB + parser *bisql.Parser +} + +var ( + _ port.UserSearcher = (*Queries)(nil) + _ port.ActivityReporter = (*Queries)(nil) + _ port.AuditLogWriter = (*Queries)(nil) + _ port.AuditLogLookup = (*Queries)(nil) +) + +// New returns a Queries backed by db. +func New(db *sql.DB) *Queries { + return &Queries{db: db, parser: bisql.NewParser(bisql.WithDialect(dialect.SQLite))} +} + +func (q *Queries) build(root string, params map[string]any) (bisql.Statement, error) { + tmpl, err := q.parser.ParseFile(templates, root) + if err != nil { + return bisql.Statement{}, err + } + return tmpl.Build(params) +} + +// rows builds root with params, runs it, and returns the result rows. +func (q *Queries) rows(ctx context.Context, root string, params map[string]any) ([]port.Row, error) { + stmt, err := q.build(root, params) + if err != nil { + return nil, err + } + rows, err := q.db.QueryContext(ctx, stmt.SQL, stmt.Args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanAll(rows) +} + +// scanAll reads every row into a column-keyed map, so a query with a conditional column (the +// optional department in users/search) needs no special handling. +func scanAll(rows *sql.Rows) ([]port.Row, error) { + cols, err := rows.Columns() + if err != nil { + return nil, err + } + out := []port.Row{} + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := lo.Map(vals, func(_ any, i int) any { return &vals[i] }) + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + out = append(out, lo.SliceToMap(lo.Range(len(cols)), func(i int) (string, any) { + if b, ok := vals[i].([]byte); ok { + return cols[i], string(b) + } + return cols[i], vals[i] + })) + } + return out, rows.Err() +} diff --git a/testdata/example/src/app/infrastructure/query/query_test.go b/testdata/example/src/app/infrastructure/query/query_test.go new file mode 100644 index 0000000..c78aa9f --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/query_test.go @@ -0,0 +1,187 @@ +package query + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "testing" + + "github.com/samber/lo" + + "github.com/mpyw/bisql" + "github.com/mpyw/bisql/dialect" +) + +var update = flag.Bool("update", false, "update the .snapshot.sql golden files") + +// TestSnapshots pins each SQL template independently of the database. For each query it: +// +// - pins testdata/snapshots/.expanded.sql (every @include resolved); and +// - builds the template for a set of parameter cases, asserting Args and pinning the +// values-embedded rendering in testdata/snapshots/..embedded.sql. +// +// Regenerate the snapshots with `go test ./... -update`. +func TestSnapshots(t *testing.T) { + p := bisql.NewParser(bisql.WithDialect(dialect.SQLite)) + + type snapCase struct { + name string + params map[string]any + args []any + } + type queryEntry struct { + root string // path under sql/ + cases []snapCase + } + queries := []queryEntry{ + { + root: "users/search.sql", + cases: []snapCase{ + { + name: "full", + params: map[string]any{ + "withDepartment": true, + "activeOnly": true, + "status": "active", + "departmentId": 3, + "ageBand": "adult", + "q": "%ali%", + "departmentIds": []any{1, 2, 3}, + "tags": []any{"vip", "beta"}, + "emailDomains": []any{"%@example.com", "%@corp.example"}, + "sortKey": "recent", + "limit": 100, + }, + args: []any{"active", 3, "%ali%", 1, 2, 3, "vip", "beta", "%@example.com", "%@corp.example"}, + }, + {name: "minimal", params: map[string]any{}, args: nil}, + }, + }, + { + root: "audit_logs/activity-report.sql", + cases: []snapCase{ + { + name: "windowed", + params: map[string]any{"since": "2025-01-01", "until": "2025-12-31", "actions": []any{"login", "logout"}}, + args: []any{"2025-01-01", "2025-12-31", "login", "logout"}, + }, + {name: "all_time", params: map[string]any{}, args: nil}, + }, + }, + { + root: "audit_logs/bulk-insert.sql", + cases: []snapCase{ + { + name: "two_rows", + params: map[string]any{"events": []any{ + map[string]any{"userId": 1, "action": "login"}, + map[string]any{"userId": 2, "action": "logout"}, + }}, + args: []any{1, "login", 2, "logout"}, + }, + {name: "empty", params: map[string]any{"events": []any{}}, args: nil}, + }, + }, + { + root: "audit_logs/find-by-keys.sql", + cases: []snapCase{ + { + name: "two_keys", + params: map[string]any{"keys": []any{ + map[string]any{"userId": 1, "action": "login"}, + map[string]any{"userId": 2, "action": "logout"}, + }}, + args: []any{1, "login", 2, "logout"}, + }, + {name: "empty", params: map[string]any{"keys": []any{}}, args: nil}, + }, + }, + } + + lo.ForEach(queries, func(q queryEntry, _ int) { + t.Run(q.root, func(t *testing.T) { + id := strings.TrimSuffix(q.root, ".sql") + + expanded, err := bisql.ExpandFile(templates, q.root) + if err != nil { + t.Fatalf("expand: %v", err) + } + checkGolden(t, filepath.Join("testdata", "snapshots", id+".expanded.sql"), expandedSnapshot(id, expanded)) + + tmpl, err := p.ParseFile(templates, q.root) + if err != nil { + t.Fatalf("parse: %v", err) + } + lo.ForEach(q.cases, func(c snapCase, _ int) { + t.Run(c.name, func(t *testing.T) { + stmt, err := tmpl.Build(c.params) + if err != nil { + t.Fatalf("build: %v", err) + } + if !reflect.DeepEqual(stmt.Args, c.args) { + t.Errorf("Args\n got: %#v\nwant: %#v", stmt.Args, c.args) + } + checkGolden(t, filepath.Join("testdata", "snapshots", id+"."+c.name+".embedded.sql"), bindSnapshot(c.params, stmt.SQLWithArgs())) + }) + }) + }) + }) +} + +// expandedSnapshot wraps an @include-expanded template in the generated-file banner written to +// testdata/snapshots/.expanded.sql. +func expandedSnapshot(id, expanded string) string { + return fmt.Sprintf("-- Code generated from sql/%s.sql; DO NOT EDIT. Regenerate with `go test ./... -update`.\n", id) + + "-- The two-way template with every @include expanded.\n\n" + + expanded +} + +// bindSnapshot is the reviewable, values-embedded rendering for one parameter case, prefixed +// with the inputs that produced it so a reviewer can read the query without cross-referencing. +func bindSnapshot(params map[string]any, embedded string) string { + keys := lo.Keys(params) + sort.Strings(keys) + lines := lo.Map(keys, func(k string, _ int) string { + return fmt.Sprintf("-- %s = %s\n", k, formatInput(params[k])) + }) + return "-- Test inputs. Values not listed here are fixed in the query.\n" + + lo.Ternary(len(keys) == 0, "-- (none)\n", strings.Join(lines, "")) + + "-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file.\n\n" + + lo.Ternary(strings.HasSuffix(embedded, "\n"), embedded, embedded+"\n") +} + +func formatInput(v any) string { + switch x := v.(type) { + case nil: + return "null" + case string: + return strconv.Quote(x) + default: + return fmt.Sprintf("%v", x) + } +} + +func checkGolden(t *testing.T, path, got string) { + t.Helper() + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read snapshot %s: %v (regenerate with: go test ./... -update)", path, err) + } + if got != string(want) { + t.Errorf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, got, want) + } +} diff --git a/testdata/example/src/app/infrastructure/query/sql/audit_logs/activity-report.sql b/testdata/example/src/app/infrastructure/query/sql/audit_logs/activity-report.sql new file mode 100644 index 0000000..6551558 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/audit_logs/activity-report.sql @@ -0,0 +1,17 @@ +-- Per-department activity report over the audit log. +-- +-- The reporting window is assembled from reusable fragments (`window` pulls in `since`), so the +-- lower and upper bounds are defined once and shared across every report. Each bound is a +-- conjunction off the `where 1 = 1` anchor and drops out when its parameter is absent, so +-- "all time" and "a bounded window" are the same template with different inputs. +select + d.name as department, + count(*) as events +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 */ +/*%if actions != null*/and a.action in /*actions*/('login')/*%end*/ +group by d.name +order by events desc, d.name diff --git a/testdata/example/src/app/infrastructure/query/sql/audit_logs/bulk-insert.sql b/testdata/example/src/app/infrastructure/query/sql/audit_logs/bulk-insert.sql new file mode 100644 index 0000000..d102ba1 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/audit_logs/bulk-insert.sql @@ -0,0 +1,9 @@ +-- Bulk-append audit events in one statement. +-- +-- A multi-row VALUES has no anchor position — a trailing comma or an empty `values ()` is +-- invalid — so the rows are written as a set instead: a zero-row `select ... where 1 = 0` seed +-- with one `union all select` per event. An empty batch renders just the seed and inserts +-- nothing, so the caller never has to special-case the empty list. +insert into audit_logs (user_id, action) +select 0, '' where 1 = 0 +/*%for e in events*/ union all select /*e.userId*/0, /*e.action*/''/*%end*/ diff --git a/testdata/example/src/app/infrastructure/query/sql/audit_logs/find-by-keys.sql b/testdata/example/src/app/infrastructure/query/sql/audit_logs/find-by-keys.sql new file mode 100644 index 0000000..4453c96 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/audit_logs/find-by-keys.sql @@ -0,0 +1,13 @@ +-- Fetch audit-log rows for a set of composite (user_id, action) keys in one round trip. +-- +-- SQLite allows a row-value `IN` only against a subquery, not a bare tuple list, so the key set +-- is built as a set: a zero-row `select ... where 1 = 0` seed with one `union all select` per +-- key. `union all` is the leading connector, so an empty key set renders just the seed — an +-- empty subquery that matches nothing, no special-casing required. +select a.id, a.user_id, a.action, a.created_at +from audit_logs a +where (a.user_id, a.action) in ( + select null as user_id, null as action where 1 = 0 + /*%for k in keys*/ union all select /*k.userId*/0, /*k.action*/''/*%end*/ +) +order by a.id diff --git a/testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/since.sql b/testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/since.sql new file mode 100644 index 0000000..a191e94 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/since.sql @@ -0,0 +1 @@ +/*%if since != null*/and a.created_at >= /*since*/'2025-01-01'/*%end*/ diff --git a/testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/window.sql b/testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/window.sql new file mode 100644 index 0000000..b28530d --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/audit_logs/fragment/window.sql @@ -0,0 +1,2 @@ +/*%! @include audit_logs/fragment/since.sql */ +/*%if until != null*/and a.created_at < /*until*/'2026-01-01'/*%end*/ diff --git a/testdata/example/src/app/infrastructure/query/sql/users/fragment/active.sql b/testdata/example/src/app/infrastructure/query/sql/users/fragment/active.sql new file mode 100644 index 0000000..316a851 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/users/fragment/active.sql @@ -0,0 +1 @@ +/*%if activeOnly*/and u.status = /*status*/'active'/*%end*/ diff --git a/testdata/example/src/app/infrastructure/query/sql/users/fragment/scope.sql b/testdata/example/src/app/infrastructure/query/sql/users/fragment/scope.sql new file mode 100644 index 0000000..1b56b33 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/users/fragment/scope.sql @@ -0,0 +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 */ +/*%if departmentId != null*/and u.department_id = /*departmentId*/0/*%end*/ diff --git a/testdata/example/src/app/infrastructure/query/sql/users/search.sql b/testdata/example/src/app/infrastructure/query/sql/users/search.sql new file mode 100644 index 0000000..d794500 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/sql/users/search.sql @@ -0,0 +1,32 @@ +-- User search endpoint. +-- +-- One template serves both the catalog search and the "my department" view; the only difference +-- is which optional predicates are supplied. Every optional predicate is a conjunction hung off +-- the `where 1 = 1` anchor, so an unsupplied filter drops out of the SQL entirely instead of +-- turning the query into a disjunction. Keeping it conjunctive is what lets one template back +-- both callers without the planner losing the index on the driving filter. +-- +-- Anchoring, fragment by fragment: +-- - WHERE: `1 = 1` anchor; each optional predicate leads with `and`. +-- - ORDER BY: `u.id` is the stable trailing key; optional sort keys are prepended with a +-- trailing comma inside their own `/*%if*/`. A sort key is whitelisted SQL, never a bound +-- value — a column name cannot be parameterized. +-- - the keyword loop leads each iteration with `and`, off the same `1 = 1` anchor. +-- - `withDepartment` gates the projected column and its join together, so the two never +-- disagree. +select + u.id, + u.name, + u.email + /*%if withDepartment*/, d.name as department/*%end*/ +from users u +/*%if withDepartment*/join departments d on d.id = u.department_id/*%end*/ +where 1 = 1 +/*%! @include users/fragment/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*/ +/*%if tags != null*/and exists (select 1 from user_tags ut where ut.user_id = u.id and ut.tag in /*tags*/('vip'))/*%end*/ +/*%for domain in emailDomains*/ and u.email like /*domain*/'%@example.com'/*%end*/ +order by /*%if sortKey == 'name'*/u.name, /*%end*//*%if sortKey == 'recent'*/u.id desc, /*%end*/u.id +limit /*^ limit ?? 50 */50 diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.all_time.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.all_time.embedded.sql new file mode 100644 index 0000000..bce701c --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.all_time.embedded.sql @@ -0,0 +1,24 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- (none) +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- Per-department activity report over the audit log. +-- +-- The reporting window is assembled from reusable fragments (`window` pulls in `since`), so the +-- lower and upper bounds are defined once and shared across every report. Each bound is a +-- conjunction off the `where 1 = 1` anchor and drops out when its parameter is absent, so +-- "all time" and "a bounded window" are the same template with different inputs. +select + d.name as department, + count(*) as events +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 + + + + + +group by d.name +order by events desc, d.name diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.expanded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.expanded.sql new file mode 100644 index 0000000..4f45dbb --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.expanded.sql @@ -0,0 +1,23 @@ +-- Code generated from sql/audit_logs/activity-report.sql; DO NOT EDIT. Regenerate with `go test ./... -update`. +-- The two-way template with every @include expanded. + +-- Per-department activity report over the audit log. +-- +-- The reporting window is assembled from reusable fragments (`window` pulls in `since`), so the +-- lower and upper bounds are defined once and shared across every report. Each bound is a +-- conjunction off the `where 1 = 1` anchor and drops out when its parameter is absent, so +-- "all time" and "a bounded window" are the same template with different inputs. +select + d.name as department, + count(*) as events +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 +/*%if since != null*/and a.created_at >= /*since*/'2025-01-01'/*%end*/ + +/*%if until != null*/and a.created_at < /*until*/'2026-01-01'/*%end*/ + +/*%if actions != null*/and a.action in /*actions*/('login')/*%end*/ +group by d.name +order by events desc, d.name diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.windowed.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.windowed.embedded.sql new file mode 100644 index 0000000..8efb187 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/activity-report.windowed.embedded.sql @@ -0,0 +1,26 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- actions = [login logout] +-- since = "2025-01-01" +-- until = "2025-12-31" +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- Per-department activity report over the audit log. +-- +-- The reporting window is assembled from reusable fragments (`window` pulls in `since`), so the +-- lower and upper bounds are defined once and shared across every report. Each bound is a +-- conjunction off the `where 1 = 1` anchor and drops out when its parameter is absent, so +-- "all time" and "a bounded window" are the same template with different inputs. +select + d.name as department, + count(*) as events +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 +and a.created_at >= '2025-01-01' + +and a.created_at < '2025-12-31' + +and a.action in ('login', 'logout') +group by d.name +order by events desc, d.name diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.empty.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.empty.embedded.sql new file mode 100644 index 0000000..8491da7 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.empty.embedded.sql @@ -0,0 +1,13 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- events = [] +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- Bulk-append audit events in one statement. +-- +-- A multi-row VALUES has no anchor position — a trailing comma or an empty `values ()` is +-- invalid — so the rows are written as a set instead: a zero-row `select ... where 1 = 0` seed +-- with one `union all select` per event. An empty batch renders just the seed and inserts +-- nothing, so the caller never has to special-case the empty list. +insert into audit_logs (user_id, action) +select 0, '' where 1 = 0 + diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.expanded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.expanded.sql new file mode 100644 index 0000000..d3a361f --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.expanded.sql @@ -0,0 +1,12 @@ +-- Code generated from sql/audit_logs/bulk-insert.sql; DO NOT EDIT. Regenerate with `go test ./... -update`. +-- The two-way template with every @include expanded. + +-- Bulk-append audit events in one statement. +-- +-- A multi-row VALUES has no anchor position — a trailing comma or an empty `values ()` is +-- invalid — so the rows are written as a set instead: a zero-row `select ... where 1 = 0` seed +-- with one `union all select` per event. An empty batch renders just the seed and inserts +-- nothing, so the caller never has to special-case the empty list. +insert into audit_logs (user_id, action) +select 0, '' where 1 = 0 +/*%for e in events*/ union all select /*e.userId*/0, /*e.action*/''/*%end*/ diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.two_rows.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.two_rows.embedded.sql new file mode 100644 index 0000000..45af0b9 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/bulk-insert.two_rows.embedded.sql @@ -0,0 +1,13 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- events = [map[action:login userId:1] map[action:logout userId:2]] +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- Bulk-append audit events in one statement. +-- +-- A multi-row VALUES has no anchor position — a trailing comma or an empty `values ()` is +-- invalid — so the rows are written as a set instead: a zero-row `select ... where 1 = 0` seed +-- with one `union all select` per event. An empty batch renders just the seed and inserts +-- nothing, so the caller never has to special-case the empty list. +insert into audit_logs (user_id, action) +select 0, '' where 1 = 0 + union all select 1, 'login' union all select 2, 'logout' diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.empty.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.empty.embedded.sql new file mode 100644 index 0000000..7a0264f --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.empty.embedded.sql @@ -0,0 +1,17 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- keys = [] +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- Fetch audit-log rows for a set of composite (user_id, action) keys in one round trip. +-- +-- SQLite allows a row-value `IN` only against a subquery, not a bare tuple list, so the key set +-- is built as a set: a zero-row `select ... where 1 = 0` seed with one `union all select` per +-- key. `union all` is the leading connector, so an empty key set renders just the seed — an +-- empty subquery that matches nothing, no special-casing required. +select a.id, a.user_id, a.action, a.created_at +from audit_logs a +where (a.user_id, a.action) in ( + select null as user_id, null as action where 1 = 0 + +) +order by a.id diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.expanded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.expanded.sql new file mode 100644 index 0000000..b4cd16e --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.expanded.sql @@ -0,0 +1,16 @@ +-- Code generated from sql/audit_logs/find-by-keys.sql; DO NOT EDIT. Regenerate with `go test ./... -update`. +-- The two-way template with every @include expanded. + +-- Fetch audit-log rows for a set of composite (user_id, action) keys in one round trip. +-- +-- SQLite allows a row-value `IN` only against a subquery, not a bare tuple list, so the key set +-- is built as a set: a zero-row `select ... where 1 = 0` seed with one `union all select` per +-- key. `union all` is the leading connector, so an empty key set renders just the seed — an +-- empty subquery that matches nothing, no special-casing required. +select a.id, a.user_id, a.action, a.created_at +from audit_logs a +where (a.user_id, a.action) in ( + select null as user_id, null as action where 1 = 0 + /*%for k in keys*/ union all select /*k.userId*/0, /*k.action*/''/*%end*/ +) +order by a.id diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.two_keys.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.two_keys.embedded.sql new file mode 100644 index 0000000..aa6f1c8 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/audit_logs/find-by-keys.two_keys.embedded.sql @@ -0,0 +1,17 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- keys = [map[action:login userId:1] map[action:logout userId:2]] +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- Fetch audit-log rows for a set of composite (user_id, action) keys in one round trip. +-- +-- SQLite allows a row-value `IN` only against a subquery, not a bare tuple list, so the key set +-- is built as a set: a zero-row `select ... where 1 = 0` seed with one `union all select` per +-- key. `union all` is the leading connector, so an empty key set renders just the seed — an +-- empty subquery that matches nothing, no special-casing required. +select a.id, a.user_id, a.action, a.created_at +from audit_logs a +where (a.user_id, a.action) in ( + select null as user_id, null as action where 1 = 0 + union all select 1, 'login' union all select 2, 'logout' +) +order by a.id diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.expanded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.expanded.sql new file mode 100644 index 0000000..930aa49 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.expanded.sql @@ -0,0 +1,41 @@ +-- Code generated from sql/users/search.sql; DO NOT EDIT. Regenerate with `go test ./... -update`. +-- The two-way template with every @include expanded. + +-- User search endpoint. +-- +-- One template serves both the catalog search and the "my department" view; the only difference +-- is which optional predicates are supplied. Every optional predicate is a conjunction hung off +-- the `where 1 = 1` anchor, so an unsupplied filter drops out of the SQL entirely instead of +-- turning the query into a disjunction. Keeping it conjunctive is what lets one template back +-- both callers without the planner losing the index on the driving filter. +-- +-- Anchoring, fragment by fragment: +-- - WHERE: `1 = 1` anchor; each optional predicate leads with `and`. +-- - ORDER BY: `u.id` is the stable trailing key; optional sort keys are prepended with a +-- trailing comma inside their own `/*%if*/`. A sort key is whitelisted SQL, never a bound +-- value — a column name cannot be parameterized. +-- - the keyword loop leads each iteration with `and`, off the same `1 = 1` anchor. +-- - `withDepartment` gates the projected column and its join together, so the two never +-- disagree. +select + u.id, + u.name, + u.email + /*%if withDepartment*/, d.name as department/*%end*/ +from users u +/*%if withDepartment*/join departments d on d.id = u.department_id/*%end*/ +where 1 = 1 +-- 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. +/*%if activeOnly*/and u.status = /*status*/'active'/*%end*/ + +/*%if departmentId != null*/and u.department_id = /*departmentId*/0/*%end*/ + +/*%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*/ +/*%if tags != null*/and exists (select 1 from user_tags ut where ut.user_id = u.id and ut.tag in /*tags*/('vip'))/*%end*/ +/*%for domain in emailDomains*/ and u.email like /*domain*/'%@example.com'/*%end*/ +order by /*%if sortKey == 'name'*/u.name, /*%end*//*%if sortKey == 'recent'*/u.id desc, /*%end*/u.id +limit /*^ limit ?? 50 */50 diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.full.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.full.embedded.sql new file mode 100644 index 0000000..76091c5 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.full.embedded.sql @@ -0,0 +1,52 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- activeOnly = true +-- ageBand = "adult" +-- departmentId = 3 +-- departmentIds = [1 2 3] +-- emailDomains = [%@example.com %@corp.example] +-- limit = 100 +-- q = "%ali%" +-- sortKey = "recent" +-- status = "active" +-- tags = [vip beta] +-- withDepartment = true +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- User search endpoint. +-- +-- One template serves both the catalog search and the "my department" view; the only difference +-- is which optional predicates are supplied. Every optional predicate is a conjunction hung off +-- the `where 1 = 1` anchor, so an unsupplied filter drops out of the SQL entirely instead of +-- turning the query into a disjunction. Keeping it conjunctive is what lets one template back +-- both callers without the planner losing the index on the driving filter. +-- +-- Anchoring, fragment by fragment: +-- - WHERE: `1 = 1` anchor; each optional predicate leads with `and`. +-- - ORDER BY: `u.id` is the stable trailing key; optional sort keys are prepended with a +-- trailing comma inside their own `/*%if*/`. A sort key is whitelisted SQL, never a bound +-- value — a column name cannot be parameterized. +-- - the keyword loop leads each iteration with `and`, off the same `1 = 1` anchor. +-- - `withDepartment` gates the projected column and its join together, so the two never +-- disagree. +select + u.id, + u.name, + u.email + , d.name as department +from users u +join departments d on d.id = u.department_id +where 1 = 1 +-- 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. +and u.status = 'active' + +and u.department_id = 3 + + and u.age >= 18 +and u.name like '%ali%' +and u.department_id in (1, 2, 3) +and exists (select 1 from user_tags ut where ut.user_id = u.id and ut.tag in ('vip', 'beta')) + and u.email like '%@example.com' and u.email like '%@corp.example' +order by u.id desc, u.id +limit 100 diff --git a/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.minimal.embedded.sql b/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.minimal.embedded.sql new file mode 100644 index 0000000..c37dcc5 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/testdata/snapshots/users/search.minimal.embedded.sql @@ -0,0 +1,42 @@ +-- Test inputs. Values not listed here are fixed in the query. +-- (none) +-- Generated by `go test ./... -update`; edit sql/**/*.sql, not this file. + +-- User search endpoint. +-- +-- One template serves both the catalog search and the "my department" view; the only difference +-- is which optional predicates are supplied. Every optional predicate is a conjunction hung off +-- the `where 1 = 1` anchor, so an unsupplied filter drops out of the SQL entirely instead of +-- turning the query into a disjunction. Keeping it conjunctive is what lets one template back +-- both callers without the planner losing the index on the driving filter. +-- +-- Anchoring, fragment by fragment: +-- - WHERE: `1 = 1` anchor; each optional predicate leads with `and`. +-- - ORDER BY: `u.id` is the stable trailing key; optional sort keys are prepended with a +-- trailing comma inside their own `/*%if*/`. A sort key is whitelisted SQL, never a bound +-- value — a column name cannot be parameterized. +-- - the keyword loop leads each iteration with `and`, off the same `1 = 1` anchor. +-- - `withDepartment` gates the projected column and its join together, so the two never +-- disagree. +select + u.id, + u.name, + u.email + +from users u + +where 1 = 1 +-- 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. + + + + + and u.age >= 0 + + + + +order by u.id +limit 50 diff --git a/testdata/example/src/app/infrastructure/query/user_search.go b/testdata/example/src/app/infrastructure/query/user_search.go new file mode 100644 index 0000000..7ff7b03 --- /dev/null +++ b/testdata/example/src/app/infrastructure/query/user_search.go @@ -0,0 +1,41 @@ +package query + +import ( + "context" + + "github.com/samber/lo" + + port "github.com/mpyw/bisql/example/src/app/query" +) + +// SearchUsers maps the input to the optional predicates of users/search.sql. Each supplied field +// becomes one conjunction off the query's 1 = 1 anchor; the absent ones drop out of the SQL. +func (q *Queries) SearchUsers(ctx context.Context, in port.SearchUsersInput) ([]port.Row, error) { + params := lo.OmitByValues(map[string]any{ + "q": in.Query, + "ageBand": in.AgeBand, + "sortKey": in.Sort, + }, []any{""}) + if in.WithDepartment { + params["withDepartment"] = true + } + if in.ActiveOnly { + params["activeOnly"], params["status"] = true, "active" + } + if in.Department != 0 { + params["departmentId"] = in.Department + } + if in.Limit != 0 { + params["limit"] = in.Limit + } + if len(in.DepartmentIDs) > 0 { + params["departmentIds"] = lo.ToAnySlice(in.DepartmentIDs) + } + if len(in.Tags) > 0 { + params["tags"] = lo.ToAnySlice(in.Tags) + } + if len(in.EmailDomains) > 0 { + params["emailDomains"] = lo.ToAnySlice(in.EmailDomains) + } + return q.rows(ctx, "users/search.sql", params) +} diff --git a/testdata/example/src/app/infrastructure/schema/schema.go b/testdata/example/src/app/infrastructure/schema/schema.go new file mode 100644 index 0000000..d5cd691 --- /dev/null +++ b/testdata/example/src/app/infrastructure/schema/schema.go @@ -0,0 +1,32 @@ +// Package schema implements the schema.Migrator port: it applies the embedded schema-and-seed +// DDL (schema.sql) to a *sql.DB. +package schema + +import ( + "context" + "database/sql" + _ "embed" + + port "github.com/mpyw/bisql/example/src/app/schema" +) + +//go:embed schema.sql +var ddl string + +// Migrator applies the schema to a database. +type Migrator struct { + db *sql.DB +} + +var _ port.Migrator = (*Migrator)(nil) + +// New returns a Migrator backed by db. +func New(db *sql.DB) *Migrator { + return &Migrator{db: db} +} + +// Migrate creates the schema and seeds the database. +func (m *Migrator) Migrate(ctx context.Context) error { + _, err := m.db.ExecContext(ctx, ddl) + return err +} diff --git a/testdata/example/src/app/infrastructure/schema/schema.sql b/testdata/example/src/app/infrastructure/schema/schema.sql new file mode 100644 index 0000000..de5a6ad --- /dev/null +++ b/testdata/example/src/app/infrastructure/schema/schema.sql @@ -0,0 +1,46 @@ +-- Schema and seed for the in-memory SQLite database the API serves. + +create table departments ( + id integer primary key, + name text not null +); + +create table users ( + id integer primary key, + name text not null, + email text not null, + age integer not null, + status text not null, -- 'active' | 'pending' | 'banned' + department_id integer references departments (id) +); + +create table user_tags ( + user_id integer not null references users (id), + tag text not null +); + +create table audit_logs ( + id integer primary key autoincrement, + user_id integer not null references users (id), + action text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +insert into departments (id, name) values (1, 'Engineering'), (2, 'Sales'); + +insert into users (id, name, email, age, status, department_id) values + (1, 'Alice', 'alice@example.com', 30, 'active', 1), + (2, 'Bob', 'bob@corp.example', 45, 'active', 1), + (3, 'Carol', 'carol@example.com', 17, 'pending', 2), + (4, 'Dave', 'dave@corp.example', 70, 'active', 2), + (5, 'Erin', 'erin@example.com', 25, 'banned', 1); + +insert into user_tags (user_id, tag) values + (1, 'vip'), (1, 'beta'), (2, 'beta'), (4, 'vip'); + +insert into audit_logs (user_id, action, created_at) values + (1, 'login', '2025-03-01T09:00:00Z'), + (1, 'logout', '2025-03-01T17:00:00Z'), + (2, 'login', '2025-06-15T08:30:00Z'), + (3, 'login', '2025-09-20T12:00:00Z'), + (4, 'login', '2025-12-31T23:59:00Z'); diff --git a/testdata/example/src/app/presentation/http.go b/testdata/example/src/app/presentation/http.go new file mode 100644 index 0000000..3c4342b --- /dev/null +++ b/testdata/example/src/app/presentation/http.go @@ -0,0 +1,119 @@ +// Package presentation is the HTTP delivery layer. Handlers parse a request into an app input, +// call the application core, and render JSON. It depends only on *app.App (injected). +package presentation + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/samber/lo" + + "github.com/mpyw/bisql/example/src/app" + "github.com/mpyw/bisql/example/src/app/query" +) + +// Handler serves the API over the application core. +type Handler struct { + app *app.App +} + +// New returns a Handler backed by the application core (manual DI). +func New(a *app.App) *Handler { + return &Handler{app: a} +} + +// Routes returns the API handler. +func (h *Handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /users", h.searchUsers) + mux.HandleFunc("GET /reports/activity", h.activityReport) + mux.HandleFunc("POST /audit-logs/batch", h.appendAuditLogs) + mux.HandleFunc("GET /audit-logs/lookup", h.lookupAuditLogs) + return mux +} + +func (h *Handler) searchUsers(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + rows, err := h.app.SearchUsers(r.Context(), query.SearchUsersInput{ + Query: q.Get("q"), + AgeBand: q.Get("age_band"), + Sort: q.Get("sort"), + WithDepartment: q.Get("with_department") == "true", + ActiveOnly: q.Get("active_only") == "true", + Department: atoi(q.Get("department")), + Limit: atoi(q.Get("limit")), + DepartmentIDs: lo.Map(csv(q.Get("department_ids")), func(s string, _ int) int { return atoi(s) }), + Tags: csv(q.Get("tags")), + EmailDomains: q["email_domain"], + }) + writeRows(w, rows, err) +} + +func (h *Handler) activityReport(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + rows, err := h.app.ReportActivity(r.Context(), query.ActivityReportInput{ + Since: q.Get("since"), + Until: q.Get("until"), + Actions: csv(q.Get("actions")), + }) + writeRows(w, rows, err) +} + +type auditEventReq struct { + UserID int `json:"userId"` + Action string `json:"action"` +} + +func (h *Handler) appendAuditLogs(w http.ResponseWriter, r *http.Request) { + var body []auditEventReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + events := lo.Map(body, func(e auditEventReq, _ int) query.AuditEvent { + return query.AuditEvent{UserID: e.UserID, Action: e.Action} + }) + n, err := h.app.AppendAuditLogs(r.Context(), events) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"inserted": n}) +} + +func (h *Handler) lookupAuditLogs(w http.ResponseWriter, r *http.Request) { + keys := lo.FilterMap(r.URL.Query()["key"], func(kv string, _ int) (query.AuditKey, bool) { + userID, action, ok := strings.Cut(kv, ",") + return query.AuditKey{UserID: atoi(userID), Action: action}, ok + }) + rows, err := h.app.LookupAuditLogs(r.Context(), keys) + writeRows(w, rows, err) +} + +func writeRows(w http.ResponseWriter, rows []query.Row, err error) { + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, rows) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func atoi(s string) int { + n, _ := strconv.Atoi(strings.TrimSpace(s)) + return n +} + +// csv splits a comma-separated query value, trimming each element; an empty value yields nil. +func csv(s string) []string { + if s == "" { + return nil + } + return lo.Map(strings.Split(s, ","), func(p string, _ int) string { return strings.TrimSpace(p) }) +} diff --git a/testdata/example/src/app/presentation/presentation_test.go b/testdata/example/src/app/presentation/presentation_test.go new file mode 100644 index 0000000..f1de044 --- /dev/null +++ b/testdata/example/src/app/presentation/presentation_test.go @@ -0,0 +1,124 @@ +package presentation_test + +import ( + "context" + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/samber/lo" + _ "modernc.org/sqlite" + + "github.com/mpyw/bisql/example/src/app" + infraquery "github.com/mpyw/bisql/example/src/app/infrastructure/query" + infraschema "github.com/mpyw/bisql/example/src/app/infrastructure/schema" + "github.com/mpyw/bisql/example/src/app/presentation" +) + +// newServer wires the whole stack — infrastructure adapters, application core, HTTP handler — +// over a freshly migrated in-memory SQLite database, exactly as cmd/serve does. Because each +// query runs for real, these tests double as proof that the bisql-built SQL is valid SQLite. +func newServer(t *testing.T) *httptest.Server { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + if err := infraschema.New(db).Migrate(context.Background()); err != nil { + t.Fatal(err) + } + q := infraquery.New(db) + h := presentation.New(app.New(q, q, q, q)) + srv := httptest.NewServer(h.Routes()) + t.Cleanup(srv.Close) + return srv +} + +func get(t *testing.T, srv *httptest.Server, path string) []map[string]any { + t.Helper() + resp, err := http.Get(srv.URL + path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("GET %s: %d: %s", path, resp.StatusCode, b) + } + var out []map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out +} + +func names(rows []map[string]any) []string { + return lo.Map(rows, func(r map[string]any, _ int) string { + name, _ := r["name"].(string) + return name + }) +} + +func TestSearchUsers_ActiveSortedByName(t *testing.T) { + srv := newServer(t) + rows := get(t, srv, "/users?active_only=true&sort=name") + if got := names(rows); strings.Join(got, ",") != "Alice,Bob,Dave" { + t.Errorf("names = %v, want [Alice Bob Dave]", got) + } +} + +func TestSearchUsers_TagAndDepartment(t *testing.T) { + srv := newServer(t) + rows := get(t, srv, "/users?tags=vip&with_department=true&sort=name") + if got := names(rows); strings.Join(got, ",") != "Alice,Dave" { + t.Fatalf("names = %v, want [Alice Dave]", got) + } + if dept, _ := rows[0]["department"].(string); dept != "Engineering" { + t.Errorf("Alice department = %q, want Engineering", dept) + } +} + +func TestActivityReport_Windowed(t *testing.T) { + srv := newServer(t) + rows := get(t, srv, "/reports/activity?since=2025-06-01") + if len(rows) != 2 { + t.Fatalf("rows = %v, want 2", rows) + } + if dept, _ := rows[0]["department"].(string); dept != "Sales" { + t.Errorf("top department = %q, want Sales", dept) + } + if events, _ := rows[0]["events"].(float64); events != 2 { + t.Errorf("Sales events = %v, want 2", rows[0]["events"]) + } +} + +func TestBulkInsertThenLookup(t *testing.T) { + srv := newServer(t) + + resp, err := http.Post(srv.URL+"/audit-logs/batch", "application/json", + strings.NewReader(`[{"userId":1,"action":"view"},{"userId":2,"action":"view"}]`)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + var ins struct { + Inserted int `json:"inserted"` + } + if err := json.NewDecoder(resp.Body).Decode(&ins); err != nil { + t.Fatal(err) + } + if ins.Inserted != 2 { + t.Fatalf("inserted = %d, want 2", ins.Inserted) + } + + rows := get(t, srv, "/audit-logs/lookup?key=1,view&key=2,view") + if len(rows) != 2 { + t.Errorf("lookup returned %d rows, want 2", len(rows)) + } +} diff --git a/testdata/example/src/app/query/query.go b/testdata/example/src/app/query/query.go new file mode 100644 index 0000000..e784537 --- /dev/null +++ b/testdata/example/src/app/query/query.go @@ -0,0 +1,62 @@ +// Package query defines the query ports the application depends on: one interface per query, +// with its input and output types. The implementations live in app/infrastructure/query. +package query + +import "context" + +// Row is one result row, keyed by column name. +type Row = map[string]any + +// SearchUsersInput holds the optional filters of the user search; a zero-valued field is omitted +// from the query. +type SearchUsersInput struct { + Query string + AgeBand string + Sort string + WithDepartment bool + ActiveOnly bool + Department int + Limit int + DepartmentIDs []int + Tags []string + EmailDomains []string +} + +// UserSearcher runs the dynamic user search. +type UserSearcher interface { + SearchUsers(ctx context.Context, in SearchUsersInput) ([]Row, error) +} + +// ActivityReportInput bounds the report to an optional date window and action set. +type ActivityReportInput struct { + Since string + Until string + Actions []string +} + +// ActivityReporter aggregates audit events per department. +type ActivityReporter interface { + ReportActivity(ctx context.Context, in ActivityReportInput) ([]Row, error) +} + +// AuditEvent is one row appended by AppendAuditLogs. +type AuditEvent struct { + UserID int + Action string +} + +// AuditLogWriter appends a batch of audit events and returns the number inserted. +type AuditLogWriter interface { + AppendAuditLogs(ctx context.Context, events []AuditEvent) (int64, error) +} + +// AuditKey is a composite (user_id, action) lookup key. +type AuditKey struct { + UserID int + Action string +} + +// AuditLogLookup fetches audit rows for a set of composite keys. +type AuditLogLookup interface { + LookupAuditLogs(ctx context.Context, keys []AuditKey) ([]Row, error) +} diff --git a/testdata/example/src/app/schema/schema.go b/testdata/example/src/app/schema/schema.go new file mode 100644 index 0000000..0c345fd --- /dev/null +++ b/testdata/example/src/app/schema/schema.go @@ -0,0 +1,11 @@ +// Package schema defines the Migrator port: the application depends on the ability to bring a +// database up to the expected schema, without knowing how. The implementation lives in +// app/infrastructure/schema. +package schema + +import "context" + +// Migrator brings a database up to the schema the application expects (creating tables, seeding). +type Migrator interface { + Migrate(ctx context.Context) error +} diff --git a/testdata/example/src/cmd/serve/main.go b/testdata/example/src/cmd/serve/main.go new file mode 100644 index 0000000..a144e94 --- /dev/null +++ b/testdata/example/src/cmd/serve/main.go @@ -0,0 +1,54 @@ +// Command serve is the sample API application. It opens an in-memory SQLite database (via the +// pure-Go modernc.org/sqlite driver), migrates it, and serves the bisql-backed HTTP API, wiring +// the layers together by hand. +// +// go run ./cmd/serve +// curl 'localhost:8080/users?active_only=true&sort=name' +// curl 'localhost:8080/reports/activity?since=2025-06-01' +// curl -XPOST localhost:8080/audit-logs/batch -d '[{"userId":1,"action":"login"}]' +// curl 'localhost:8080/audit-logs/lookup?key=1,login&key=2,login' +package main + +import ( + "context" + "database/sql" + "flag" + "log" + "net/http" + + _ "modernc.org/sqlite" + + "github.com/mpyw/bisql/example/src/app" + infraquery "github.com/mpyw/bisql/example/src/app/infrastructure/query" + infraschema "github.com/mpyw/bisql/example/src/app/infrastructure/schema" + "github.com/mpyw/bisql/example/src/app/presentation" + appschema "github.com/mpyw/bisql/example/src/app/schema" +) + +func main() { + addr := flag.String("addr", ":8080", "listen address") + flag.Parse() + + // A :memory: database lives inside a single connection, so pin the pool to one connection to + // keep the schema and seed visible to every request. + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + log.Fatal(err) + } + defer func() { _ = db.Close() }() + db.SetMaxOpenConns(1) + + // Manual dependency injection: each adapter is built from the concrete infrastructure and + // consumed through its port. The migrator brings the schema up; the query adapter backs the + // application core, which the HTTP handler wraps. + var migrator appschema.Migrator = infraschema.New(db) + if err := migrator.Migrate(context.Background()); err != nil { + log.Fatal(err) + } + + q := infraquery.New(db) + h := presentation.New(app.New(q, q, q, q)) + + log.Printf("listening on %s", *addr) + log.Fatal(http.ListenAndServe(*addr, h.Routes())) +}