From af7769d3f3712aa1b9c7d4eb642fb8f8ef75acaf Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:57:57 +0900 Subject: [PATCH] Add internal/planrender query plan adapter Wrap experimental spannerplan v0.3.0 and spannerplanviz v0.11.0 behind Format/Options/Render so the CLI can later emit text and graph plans without leaking library types. v0.11.0 is required for Go 1.25 and the occurrence-aware parent-link API. Linking the adapter into a scratch CLI adds about 124 KiB without Graphviz and about 6.0 MiB more for wazero; all eight CGO_ENABLED=0 release targets build. --- go.mod | 23 +- go.sum | 50 +- internal/planrender/doc.go | 12 + internal/planrender/format.go | 58 ++ internal/planrender/render.go | 226 +++++++ internal/planrender/render_test.go | 583 ++++++++++++++++++ .../testdata/singers_limit3_plan_text.golden | 10 + .../singers_limit3_profile_text.golden | 10 + 8 files changed, 969 insertions(+), 3 deletions(-) create mode 100644 internal/planrender/doc.go create mode 100644 internal/planrender/format.go create mode 100644 internal/planrender/render.go create mode 100644 internal/planrender/render_test.go create mode 100644 internal/planrender/testdata/singers_limit3_plan_text.golden create mode 100644 internal/planrender/testdata/singers_limit3_profile_text.golden diff --git a/go.mod b/go.mod index c422244..db373a8 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/apstndb/spanemuboost v0.4.6 github.com/apstndb/spaniter v0.3.1 github.com/apstndb/spannerotel v0.2.0 + github.com/apstndb/spannerplan v0.3.0 + github.com/apstndb/spannerplanviz v0.11.0 github.com/apstndb/spanvalue v0.8.0 github.com/cloudspannerecosystem/memefish v0.6.2 github.com/goccy/go-yaml v1.19.2 @@ -41,10 +43,14 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/apstndb/go-tabwrap v0.1.3 // indirect + github.com/apstndb/protoyaml v0.1.1 // indirect github.com/apstndb/spantype v0.3.11 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -52,17 +58,23 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/fatih/color v1.15.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/flopp/go-findfont v0.1.0 // indirect + github.com/fogleman/gg v1.3.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/goccy/go-graphviz v0.2.10 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect @@ -73,6 +85,9 @@ require ( github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect @@ -82,11 +97,15 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.0.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/samber/lo v1.53.0 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -94,6 +113,7 @@ require ( github.com/stretchr/testify v1.11.1 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect github.com/testcontainers/testcontainers-go/modules/gcloud v0.42.0 // indirect + github.com/tetratelabs/wazero v1.10.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect @@ -111,11 +131,12 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.51.0 // indirect + golang.org/x/image v0.43.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/go.sum b/go.sum index 7799610..f32d821 100644 --- a/go.sum +++ b/go.sum @@ -48,16 +48,24 @@ github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLV github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/apstndb/go-tabwrap v0.1.3 h1:5lO2M7Zl5NOus4cve0tu58j3txnNRAEd9MAsFitDCQw= +github.com/apstndb/go-tabwrap v0.1.3/go.mod h1:duMNZZhNjqj/VXR2AXJN1MWko2RyIytSP1NJyhMmUV4= github.com/apstndb/gsqlutils v0.0.0-20260502161854-d7d6011a36e0 h1:VySrhGRfqXUDCrUuAEz70DrDnPrvj/nXGnUHwJF3PG4= github.com/apstndb/gsqlutils v0.0.0-20260502161854-d7d6011a36e0/go.mod h1:VwhJDip+HamDWWt+Ol4ECFU0g0HiveA27DeNkt3U8S0= github.com/apstndb/memebridge v0.6.1 h1:M1FMF5kb5vP/v5rTfP/N1HlLCfBPX7ctDQ3K/gFkI6c= github.com/apstndb/memebridge v0.6.1/go.mod h1:E/HVP4iaSgiPXMIQTPT1u1uKkjmRtRECkVKE2TrF3bc= +github.com/apstndb/protoyaml v0.1.1 h1:qCxi4l6twinpF+tM3qXG2qeRq6OmIklWK+LWtuM1eBk= +github.com/apstndb/protoyaml v0.1.1/go.mod h1:bsZCSj3nYZKfLiKRogOxuUjlURUOJpBb+G5f1b3g5Fc= github.com/apstndb/spanemuboost v0.4.6 h1:9f1qQDLNrPQJiswNLtiTaR0U//zToqxjcEGWGkyThm4= github.com/apstndb/spanemuboost v0.4.6/go.mod h1:urUe85EvqomWV4vCj2pALluNhIksbu9RAQZufgq1b8E= github.com/apstndb/spaniter v0.3.1 h1:hhi4+JCF80x696bg7zIco7Vo8kmPbkbTdvSOEhItp50= github.com/apstndb/spaniter v0.3.1/go.mod h1:aBSHcHIqgAZXCxFdi734R/wAQUIuCQ6WZ+CjOCxARIM= github.com/apstndb/spannerotel v0.2.0 h1:EpGzxB9CfnRedlOlO/x4+c8+vOEbptGcd0PegEMsKYk= github.com/apstndb/spannerotel v0.2.0/go.mod h1:tD+JGppXRRgxPvlfc4OADUGgpHNSkoZ2qbHwbZ3rtJo= +github.com/apstndb/spannerplan v0.3.0 h1:EKB9Iw5iCloWoJXIFUxh93zXIO6NqoAld9fnzzkL/UA= +github.com/apstndb/spannerplan v0.3.0/go.mod h1:zVUS187Z4DGaJekIBt2E1JYb9UMKQ7s5ZZo3HOr522E= +github.com/apstndb/spannerplanviz v0.11.0 h1:vtOZVIuPF9Hm1SFf7dkseXhC/1xM6B4a2H5dRTd4OxQ= +github.com/apstndb/spannerplanviz v0.11.0/go.mod h1:dpTPxIjSGmNpe5UqV9Nfc67Tx625Z439G/ReqRjE63s= github.com/apstndb/spantype v0.3.11 h1:wKue4WLYGT82MH3B3TRSFn8tWIbk1Geczs+5BAEtnK4= github.com/apstndb/spantype v0.3.11/go.mod h1:9eHowE7LcJ155ukCYUyuNzVAw9Ne0GXPXpHmu+iaMyk= github.com/apstndb/spanvalue v0.8.0 h1:wLHl/0m5C6PvRwJOJoz1nRL4qSpS17eS1tW3Pjzcvo8= @@ -71,6 +79,10 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudspannerecosystem/memefish v0.6.2 h1:0R6C8KdJLLbL3aYk/rzWrwvE+bPRMqj/2MNlNvAzIPo= github.com/cloudspannerecosystem/memefish v0.6.2/go.mod h1:mVw0xBxy0yOgm990BuR0+nqP8J+yBAAf7N/2uL69rBU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -84,6 +96,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= +github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -92,6 +106,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -112,8 +128,14 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= +github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -126,10 +148,14 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-graphviz v0.2.10 h1:jHu/1I0Iw0xIzzYk96Ous/ZeuD11Rt2oW8juHdIE30g= +github.com/goccy/go-graphviz v0.2.10/go.mod h1:LRlMnNmY17QbN6fLnvOzY7g0rXQjLKAhzxeTHbEUM6w= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -196,8 +222,11 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -216,6 +245,14 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= +github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -230,6 +267,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= @@ -260,6 +299,8 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go/modules/gcloud v0.42.0 h1:EdLf2NCpo43CxTfC0x2R0sW3+HqzevC78pgnH9niyYc= github.com/testcontainers/testcontainers-go/modules/gcloud v0.42.0/go.mod h1:5CMn4WViUGbOGORdjWvvGEkptvM9I/vwecYTsyKoPkg= +github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8= +github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -316,6 +357,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -352,14 +396,16 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/planrender/doc.go b/internal/planrender/doc.go new file mode 100644 index 0000000..5f463a9 --- /dev/null +++ b/internal/planrender/doc.go @@ -0,0 +1,12 @@ +// Package planrender renders Cloud Spanner query plans through a thin adapter +// over github.com/apstndb/spannerplan and github.com/apstndb/spannerplanviz. +// +// The exported API accepts only this package's Format and Options types plus +// spannerpb messages (metadata.rowType and ResultSetStats). It does not leak +// those libraries' types. Both libraries are v0 and documented as experimental; +// this adapter is the CLI boundary while those APIs evolve. +// +// Graphviz SVG/PNG rendering uses github.com/goccy/go-graphviz's WASM runtime +// (wazero). The v0.11.0 graphviz.Renderer constructs that runtime per Render call +// and closes it before returning; this package does not retain it. +package planrender diff --git a/internal/planrender/format.go b/internal/planrender/format.go new file mode 100644 index 0000000..96a6f93 --- /dev/null +++ b/internal/planrender/format.go @@ -0,0 +1,58 @@ +package planrender + +import ( + "fmt" + "strings" +) + +// Format is a plan-rendering output format. +type Format string + +const ( + FormatText Format = "text" + FormatDOT Format = "dot" + FormatMermaid Format = "mermaid" + FormatD2 Format = "d2" + FormatSVG Format = "svg" + FormatPNG Format = "png" +) + +// ParseFormat parses a plan-rendering format name. +// Valid values are text, dot, mermaid, d2, svg, and png (case-insensitive). +func ParseFormat(s string) (Format, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case string(FormatText): + return FormatText, nil + case string(FormatDOT): + return FormatDOT, nil + case string(FormatMermaid): + return FormatMermaid, nil + case string(FormatD2): + return FormatD2, nil + case string(FormatSVG): + return FormatSVG, nil + case string(FormatPNG): + return FormatPNG, nil + default: + return "", fmt.Errorf("unknown plan format %q; want text, dot, mermaid, d2, svg, or png", s) + } +} + +// IsBinary reports whether format is a binary image (PNG). SVG is text XML. +func (f Format) IsBinary() bool { + return f == FormatPNG +} + +// NeedsGraphviz reports whether format requires the embedded Graphviz/WASM runtime. +func (f Format) NeedsGraphviz() bool { + return f == FormatSVG || f == FormatPNG +} + +func (f Format) isGraph() bool { + switch f { + case FormatDOT, FormatMermaid, FormatD2, FormatSVG, FormatPNG: + return true + default: + return false + } +} diff --git a/internal/planrender/render.go b/internal/planrender/render.go new file mode 100644 index 0000000..0e80260 --- /dev/null +++ b/internal/planrender/render.go @@ -0,0 +1,226 @@ +package planrender + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + sppb "cloud.google.com/go/spanner/apiv1/spannerpb" + "github.com/apstndb/spannerplan/plantree/reference" + "github.com/apstndb/spannerplanviz/d2" + "github.com/apstndb/spannerplanviz/dot" + "github.com/apstndb/spannerplanviz/graphviz" + "github.com/apstndb/spannerplanviz/mermaid" + "github.com/apstndb/spannerplanviz/visualize" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +// ErrNoQueryPlan is returned when stats does not contain a usable query plan. +var ErrNoQueryPlan = errors.New("no query plan in result; requires --query-mode=PLAN, PROFILE, or WITH_PLAN_AND_STATS") + +// Options configures plan rendering. Zero values select defaults. +// +// TextStyle, WrapWidth, and PrintSections apply only to FormatText. +// Full, ShowQuery, ShowQueryStats, and Query apply only to graph formats. +// Render rejects combinations that do not apply to the chosen format +// rather than silently ignoring them. +type Options struct { + // TextStyle is current, traditional, or compact. Empty means current. + TextStyle string + // WrapWidth is passed to the text renderer. 0 disables wrapping. + WrapWidth int + // PrintSections is basic, enhanced, full, none, or an explicit + // comma-separated section list. Empty means basic. + PrintSections string + // Full selects visualize.FullBuildOptions instead of StructureBuildOptions. + Full bool + // ShowQuery adds a query-text node to graph output. + ShowQuery bool + // ShowQueryStats adds query statistics to that node. + ShowQueryStats bool + // Query is injected as query_stats.query_text when ShowQuery is true. + // It is ignored when empty; a non-empty value without ShowQuery is an error. + Query string +} + +// Render writes a query plan in format to w. +// +// rowType may be nil. stats must contain a non-empty query plan. +// Traversal-budget and cycle-detection errors from spannerplan are returned +// as-is (wrapped). The text renderer has no context parameter; graph renderers +// receive ctx and honor cancellation where the libraries check it. +func Render(ctx context.Context, w io.Writer, format Format, rowType *sppb.StructType, stats *sppb.ResultSetStats, opts Options) error { + if err := ctx.Err(); err != nil { + return err + } + if w == nil { + return errors.New("writer is nil") + } + if !format.isGraph() && format != FormatText { + return fmt.Errorf("unknown plan format %q", format) + } + if err := opts.validate(format); err != nil { + return err + } + if stats == nil || stats.GetQueryPlan() == nil || len(stats.GetQueryPlan().GetPlanNodes()) == 0 { + return ErrNoQueryPlan + } + + if format == FormatText { + return renderText(w, stats, opts) + } + return renderGraph(ctx, w, format, rowType, stats, opts) +} + +func (o Options) validate(format Format) error { + if o.WrapWidth < 0 { + return fmt.Errorf("WrapWidth cannot be negative: %d", o.WrapWidth) + } + if o.TextStyle != "" { + if _, err := reference.ParseFormat(o.TextStyle); err != nil { + return fmt.Errorf("invalid TextStyle %q: %w", o.TextStyle, err) + } + } + if o.PrintSections != "" { + if _, err := reference.ParsePrintSections(o.PrintSections); err != nil { + return fmt.Errorf("invalid PrintSections %q: %w", o.PrintSections, err) + } + } + if o.Query != "" && !o.ShowQuery { + return errors.New("option Query is only used when ShowQuery is true") + } + + var names []string + if format == FormatText { + if o.Full { + names = append(names, "Full") + } + if o.ShowQuery { + names = append(names, "ShowQuery") + } + if o.ShowQueryStats { + names = append(names, "ShowQueryStats") + } + if o.Query != "" { + names = append(names, "Query") + } + } else { + if o.TextStyle != "" { + names = append(names, "TextStyle") + } + if o.WrapWidth != 0 { + names = append(names, "WrapWidth") + } + if o.PrintSections != "" { + names = append(names, "PrintSections") + } + } + if len(names) == 0 { + return nil + } + return fmt.Errorf("options %s do not apply to plan format %q", strings.Join(names, ", "), format) +} + +func renderText(w io.Writer, stats *sppb.ResultSetStats, opts Options) error { + styleName := opts.TextStyle + if styleName == "" { + styleName = "current" + } + style, err := reference.ParseFormat(styleName) + if err != nil { + return fmt.Errorf("invalid TextStyle %q: %w", opts.TextStyle, err) + } + + printSections := opts.PrintSections + if printSections == "" { + printSections = "basic" + } + sections, err := reference.ParsePrintSections(printSections) + if err != nil { + return fmt.Errorf("invalid PrintSections %q: %w", opts.PrintSections, err) + } + + text, err := reference.RenderTreeTableWithOptions( + stats.GetQueryPlan().GetPlanNodes(), + reference.RenderModeAuto, + style, + reference.WithWrapWidth(opts.WrapWidth), + reference.WithPrintSections(sections...), + ) + if err != nil { + return fmt.Errorf("render text plan: %w", err) + } + if _, err := io.WriteString(w, text); err != nil { + return fmt.Errorf("write plan: %w", err) + } + return nil +} + +func renderGraph(ctx context.Context, w io.Writer, format Format, rowType *sppb.StructType, stats *sppb.ResultSetStats, opts Options) error { + if err := ctx.Err(); err != nil { + return err + } + + stats = statsWithQuery(stats, opts) + buildOpts := visualize.StructureBuildOptions() + if opts.Full { + buildOpts = visualize.FullBuildOptions() + } + + plan, err := visualize.BuildPlan(rowType, stats, buildOpts) + if err != nil { + return fmt.Errorf("build plan graph: %w", err) + } + + switch format { + case FormatDOT: + return dot.NewRenderer(dot.Options{ + ShowQuery: opts.ShowQuery, + ShowQueryStats: opts.ShowQueryStats, + }).Render(ctx, w, plan) + case FormatMermaid: + return mermaid.NewRenderer(mermaid.Options{ + BuildOptions: buildOpts, + ShowQuery: opts.ShowQuery, + ShowQueryStats: opts.ShowQueryStats, + }).Render(ctx, w, plan) + case FormatD2: + return d2.NewRenderer(d2.Options{ + BuildOptions: buildOpts, + ShowQuery: opts.ShowQuery, + ShowQueryStats: opts.ShowQueryStats, + }).Render(ctx, w, plan) + case FormatSVG, FormatPNG: + gvFormat := graphviz.SVG + if format == FormatPNG { + gvFormat = graphviz.PNG + } + // graphviz.Renderer.Render (v0.11.0) calls graphviz.New(ctx) and + // defers Close on both the runtime and the parsed graph. + return graphviz.NewRenderer(graphviz.Options{ + Format: gvFormat, + ShowQuery: opts.ShowQuery, + ShowQueryStats: opts.ShowQueryStats, + }).Render(ctx, w, plan) + default: + return fmt.Errorf("unknown plan format %q", format) + } +} + +func statsWithQuery(stats *sppb.ResultSetStats, opts Options) *sppb.ResultSetStats { + if !opts.ShowQuery || opts.Query == "" { + return stats + } + cloned := proto.CloneOf(stats) + if cloned.QueryStats == nil { + cloned.QueryStats = &structpb.Struct{} + } + if cloned.QueryStats.Fields == nil { + cloned.QueryStats.Fields = make(map[string]*structpb.Value) + } + cloned.QueryStats.Fields["query_text"] = structpb.NewStringValue(opts.Query) + return cloned +} diff --git a/internal/planrender/render_test.go b/internal/planrender/render_test.go new file mode 100644 index 0000000..6c5579d --- /dev/null +++ b/internal/planrender/render_test.go @@ -0,0 +1,583 @@ +package planrender + +import ( + "bytes" + "context" + "errors" + "flag" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + sppb "cloud.google.com/go/spanner/apiv1/spannerpb" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +var updateGolden = flag.Bool("update-golden", false, "rewrite testdata/*.golden") + +func TestParseFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want Format + wantErr bool + }{ + {in: "text", want: FormatText}, + {in: "TEXT", want: FormatText}, + {in: " dot ", want: FormatDOT}, + {in: "mermaid", want: FormatMermaid}, + {in: "d2", want: FormatD2}, + {in: "svg", want: FormatSVG}, + {in: "png", want: FormatPNG}, + {in: "json", wantErr: true}, + {in: "", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + got, err := ParseFormat(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseFormat(%q) error = nil, want error", tc.in) + } + return + } + if err != nil { + t.Fatalf("ParseFormat(%q) error = %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("ParseFormat(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestFormatFlags(t *testing.T) { + t.Parallel() + + if FormatPNG.IsBinary() != true { + t.Fatal("FormatPNG.IsBinary() = false, want true") + } + if FormatSVG.IsBinary() { + t.Fatal("FormatSVG.IsBinary() = true, want false") + } + if FormatText.IsBinary() { + t.Fatal("FormatText.IsBinary() = true, want false") + } + if !FormatSVG.NeedsGraphviz() || !FormatPNG.NeedsGraphviz() { + t.Fatal("SVG and PNG should need Graphviz") + } + if FormatDOT.NeedsGraphviz() || FormatText.NeedsGraphviz() || FormatMermaid.NeedsGraphviz() || FormatD2.NeedsGraphviz() { + t.Fatal("text and source formats should not need Graphviz") + } +} + +func TestRenderTextGolden(t *testing.T) { + rs := loadSingersFixture(t) + stats := rs.GetStats() + + t.Run("profile", func(t *testing.T) { + assertTextGolden(t, "singers_limit3_profile_text.golden", rs.GetMetadata().GetRowType(), stats) + }) + t.Run("plan", func(t *testing.T) { + assertTextGolden(t, "singers_limit3_plan_text.golden", rs.GetMetadata().GetRowType(), stripNodeStats(stats)) + }) +} + +func TestGraphSourceSmoke(t *testing.T) { + t.Parallel() + + rs := loadSingersFixture(t) + rowType := rs.GetMetadata().GetRowType() + stats := rs.GetStats() + const root = "Limit" + + for _, format := range []Format{FormatDOT, FormatMermaid, FormatD2} { + t.Run(string(format), func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := Render(context.Background(), &buf, format, rowType, stats, Options{}); err != nil { + t.Fatalf("Render(%s) error = %v", format, err) + } + got := buf.String() + if got == "" { + t.Fatalf("Render(%s) produced empty output", format) + } + if !strings.Contains(got, root) { + t.Fatalf("Render(%s) output does not contain root operator %q:\n%s", format, root, got) + } + }) + } +} + +func TestRenderSVG(t *testing.T) { + if testing.Short() { + t.Skip("skipping Graphviz WASM render in short mode") + } + + var buf bytes.Buffer + if err := Render(context.Background(), &buf, FormatSVG, nil, smallScanStats(), Options{}); err != nil { + t.Fatalf("Render(svg) error = %v", err) + } + got := buf.String() + if !strings.Contains(got, " 200*time.Millisecond { + t.Fatalf("cancelled graph render took %s, want prompt return", elapsed) + } +} + +func TestRenderCancelledSVG(t *testing.T) { + if testing.Short() { + t.Skip("skipping Graphviz cancellation check in short mode") + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + err := Render(ctx, io.Discard, FormatSVG, nil, smallScanStats(), Options{}) + elapsed := time.Since(start) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Render() error = %v, want context.Canceled", err) + } + if elapsed > 200*time.Millisecond { + t.Fatalf("cancelled SVG render took %s (WASM should not start)", elapsed) + } +} + +func TestRenderWriterError(t *testing.T) { + t.Parallel() + + want := errors.New("sink closed") + err := Render(context.Background(), errWriter{err: want}, FormatText, nil, smallScanStats(), Options{}) + if !errors.Is(err, want) { + t.Fatalf("Render() error = %v, want wrapped sink error", err) + } +} + +func TestRenderNilWriter(t *testing.T) { + t.Parallel() + + err := Render(context.Background(), nil, FormatText, nil, smallScanStats(), Options{}) + if err == nil || !strings.Contains(err.Error(), "writer is nil") { + t.Fatalf("Render() error = %v, want nil writer error", err) + } +} + +func TestRenderNilRowType(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := Render(context.Background(), &buf, FormatDOT, nil, smallScanStats(), Options{}); err != nil { + t.Fatalf("Render() with nil rowType error = %v", err) + } + if !strings.Contains(buf.String(), "Scan") { + t.Fatalf("Render() output missing Scan:\n%s", buf.String()) + } +} + +func TestShowQueryInjectsQueryText(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := Render(context.Background(), &buf, FormatDOT, nil, smallScanStats(), Options{ + ShowQuery: true, + Query: "SELECT custom_query_text", + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + if !strings.Contains(buf.String(), "SELECT custom_query_text") { + t.Fatalf("Render() output missing injected query:\n%s", buf.String()) + } +} + +func assertTextGolden(t *testing.T, name string, rowType *sppb.StructType, stats *sppb.ResultSetStats) { + t.Helper() + goldenPath := filepath.Join("testdata", name) + + var buf bytes.Buffer + if err := Render(context.Background(), &buf, FormatText, rowType, stats, Options{}); err != nil { + t.Fatalf("Render(text) error = %v", err) + } + got := buf.Bytes() + + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(goldenPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(goldenPath, got, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + t.Logf("updated %s", goldenPath) + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v (run: go test -update-golden -run TestRenderTextGolden ./internal/planrender)", goldenPath, err) + } + if string(got) != string(want) { + t.Fatalf("text output mismatch for %s\n\ngot:\n%s\n\nwant:\n%s", name, got, want) + } +} + +func loadSingersFixture(t *testing.T) *sppb.ResultSet { + t.Helper() + path := filepath.Join("..", "..", "testdata", "profile", "singers_limit3.json") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + var rs sppb.ResultSet + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(b, &rs); err != nil { + t.Fatalf("unmarshal %s: %v", path, err) + } + return &rs +} + +func stripNodeStats(stats *sppb.ResultSetStats) *sppb.ResultSetStats { + cloned := proto.CloneOf(stats) + for _, n := range cloned.GetQueryPlan().GetPlanNodes() { + n.ExecutionStats = nil + } + return cloned +} + +func smallScanStats() *sppb.ResultSetStats { + return &sppb.ResultSetStats{ + QueryPlan: &sppb.QueryPlan{ + PlanNodes: []*sppb.PlanNode{ + { + Index: 0, + Kind: sppb.PlanNode_RELATIONAL, + DisplayName: "Scan", + }, + }, + }, + } +} + +func filterStatsWithPredicate(desc string) *sppb.ResultSetStats { + return &sppb.ResultSetStats{ + QueryPlan: &sppb.QueryPlan{ + PlanNodes: []*sppb.PlanNode{ + { + Index: 0, + Kind: sppb.PlanNode_RELATIONAL, + DisplayName: "Filter", + ChildLinks: []*sppb.PlanNode_ChildLink{ + {ChildIndex: 1}, + {ChildIndex: 2, Type: "Residual Condition"}, + }, + }, + { + Index: 1, + Kind: sppb.PlanNode_RELATIONAL, + DisplayName: "Scan", + }, + { + Index: 2, + Kind: sppb.PlanNode_SCALAR, + DisplayName: "Function", + ShortRepresentation: &sppb.PlanNode_ShortRepresentation{ + Description: desc, + }, + }, + }, + }, + } +} + +type errWriter struct{ err error } + +func (w errWriter) Write([]byte) (int, error) { return 0, w.err } + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/planrender/testdata/singers_limit3_plan_text.golden b/internal/planrender/testdata/singers_limit3_plan_text.golden new file mode 100644 index 0000000..db69540 --- /dev/null +++ b/internal/planrender/testdata/singers_limit3_plan_text.golden @@ -0,0 +1,10 @@ ++----+--------------------------------------------------------------------------+ +| ID | Operator | ++----+--------------------------------------------------------------------------+ +| 0 | Global Limit | +| 1 | +- Distributed Union on Singers | +| 2 | +- Serialize Result | +| 3 | +- Local Limit | +| 4 | +- Local Distributed Union | +| 5 | +- Table Scan on Singers (Full scan, scan_method: Row) | ++----+--------------------------------------------------------------------------+ diff --git a/internal/planrender/testdata/singers_limit3_profile_text.golden b/internal/planrender/testdata/singers_limit3_profile_text.golden new file mode 100644 index 0000000..4646f74 --- /dev/null +++ b/internal/planrender/testdata/singers_limit3_profile_text.golden @@ -0,0 +1,10 @@ ++----+--------------------------------------------------------------------------+------+-------+---------------+ +| ID | Operator | Rows | Exec. | Total Latency | ++----+--------------------------------------------------------------------------+------+-------+---------------+ +| 0 | Global Limit | 3 | 1 | 7.2 msecs | +| 1 | +- Distributed Union on Singers | 3 | 1 | 7.2 msecs | +| 2 | +- Serialize Result | 3 | 1 | 0.11 msecs | +| 3 | +- Local Limit | 3 | 1 | 0.1 msecs | +| 4 | +- Local Distributed Union | 3 | 1 | 0.1 msecs | +| 5 | +- Table Scan on Singers (Full scan, scan_method: Row) | 3 | 1 | 0.09 msecs | ++----+--------------------------------------------------------------------------+------+-------+---------------+