snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
This commit is contained in:
272
internal/goldeneye/clickhouse/analyze.go
Normal file
272
internal/goldeneye/clickhouse/analyze.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/analysis"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// analyze runs every query against the schema and fixture and records what
|
||||
// ClickHouse reports about each.
|
||||
func analyze(ctx context.Context, l local, schema, fixture string, queries []endtoend.Query) ([]analysis.Query, error) {
|
||||
out := make([]analysis.Query, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
aq, err := analyzeQuery(ctx, l, schema, fixture, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", q.Name, err)
|
||||
}
|
||||
out = append(out, aq)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func analyzeQuery(ctx context.Context, l local, schema, fixture string, q endtoend.Query) (analysis.Query, error) {
|
||||
sql, phs := bindPlaceholders(q.SQL)
|
||||
explain := returnsRows(sql)
|
||||
|
||||
var script strings.Builder
|
||||
for _, stmt := range []string{schema, fixture} {
|
||||
if s := strings.TrimRight(strings.TrimSpace(stmt), ";"); s != "" {
|
||||
script.WriteString(s)
|
||||
script.WriteString(";\n")
|
||||
}
|
||||
}
|
||||
if explain {
|
||||
script.WriteString("EXPLAIN QUERY TREE " + sql + ";\n")
|
||||
}
|
||||
script.WriteString(sql + ";\n")
|
||||
|
||||
results, err := l.run(ctx, script.String())
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
|
||||
aq := analysis.Query{
|
||||
Name: q.Name,
|
||||
Cmd: q.Cmd,
|
||||
Columns: []analysis.Column{},
|
||||
Params: []analysis.Param{},
|
||||
}
|
||||
if !explain {
|
||||
return analyzeExec(ctx, l, script.String(), sql, phs, aq)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
return analysis.Query{}, fmt.Errorf("expected the query tree and one result set, got %d results", len(results))
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, row := range results[0].Data {
|
||||
var line string
|
||||
if err := json.Unmarshal(row["explain"], &line); err != nil {
|
||||
return analysis.Query{}, fmt.Errorf("reading query tree: %w", err)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
tree, err := parseQueryTree(lines)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
|
||||
// Names and types come from the block header of the executed query, the
|
||||
// same header a driver sees. The tree only adds where each came from.
|
||||
_, nodes := projection(firstNode(tree.root.children))
|
||||
for i, col := range results[1].Meta {
|
||||
ac := column(col.Name, col.Type)
|
||||
if i < len(nodes) && len(nodes) == len(results[1].Meta) {
|
||||
ac.Table = tree.sourceTable(nodes[i])
|
||||
}
|
||||
aq.Columns = append(aq.Columns, ac)
|
||||
}
|
||||
|
||||
sentinels := tree.sentinels()
|
||||
for i, ph := range phs {
|
||||
ac := analysis.Column{}
|
||||
if sentinel := sentinels[i+1]; sentinel != nil {
|
||||
ac = tree.paramColumn(sentinel)
|
||||
}
|
||||
if ph.Name != "" {
|
||||
ac.Name = ph.Name
|
||||
}
|
||||
aq.Params = append(aq.Params, analysis.Param{Number: ph.Number, Column: ac})
|
||||
}
|
||||
return aq, nil
|
||||
}
|
||||
|
||||
func column(name, typ string) analysis.Column {
|
||||
if typ == "" {
|
||||
return analysis.Column{Name: name}
|
||||
}
|
||||
return analysis.Column{Name: name, Type: parseType(typ)}
|
||||
}
|
||||
|
||||
// returnsRows reports whether a statement produces a result set and so can
|
||||
// be explained as a query tree.
|
||||
func returnsRows(sql string) bool {
|
||||
head := strings.ToLower(strings.TrimSpace(sql))
|
||||
if strings.HasPrefix(head, "(") {
|
||||
return true
|
||||
}
|
||||
for _, kw := range []string{"select", "with", "show", "describe", "desc", "exists"} {
|
||||
if strings.HasPrefix(head, kw) && (len(head) == len(kw) || !isWordByte(head[len(kw)])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sentinels finds the constants the placeholders were substituted with,
|
||||
// keyed by placeholder ordinal.
|
||||
func (t *queryTree) sentinels() map[int]*treeNode {
|
||||
found := map[int]*treeNode{}
|
||||
var walk func(n *treeNode)
|
||||
walk = func(n *treeNode) {
|
||||
if n.kind == "CONSTANT" {
|
||||
if k, ok := sentinelOrdinal(n); ok {
|
||||
found[k] = n
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, c := range n.children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(t.root)
|
||||
return found
|
||||
}
|
||||
|
||||
// sentinelOrdinal recognises a constant folded directly from `NULL + k` or
|
||||
// `4294967295 + k`, the shapes sentinelFor produces, and returns k. A
|
||||
// constant folded from a larger expression that merely contains a sentinel
|
||||
// does not match; the sentinel is found nested inside it instead.
|
||||
func sentinelOrdinal(c *treeNode) (int, bool) {
|
||||
fn := firstNode(c.section("EXPRESSION").childrenOrNil())
|
||||
if fn == nil || fn.kind != "FUNCTION" || fn.attrs["function_name"] != "plus" {
|
||||
return 0, false
|
||||
}
|
||||
args := fn.section("ARGUMENTS").list()
|
||||
if len(args) != 2 || args[0].kind != "CONSTANT" || args[1].kind != "CONSTANT" {
|
||||
return 0, false
|
||||
}
|
||||
base := args[0].attrs["constant_value"]
|
||||
if base != "NULL" && base != "UInt64_"+limitBase {
|
||||
return 0, false
|
||||
}
|
||||
k, err := strconv.Atoi(strings.TrimPrefix(args[1].attrs["constant_value"], "UInt64_"))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return k, true
|
||||
}
|
||||
|
||||
// paramColumn describes what a placeholder is compared with or assigned to:
|
||||
// the other operand of the function it is an argument of, preferring a
|
||||
// column over an expression, or the projected column it stands for.
|
||||
func (t *queryTree) paramColumn(sentinel *treeNode) analysis.Column {
|
||||
list := sentinel.parent
|
||||
if list != nil && list.kind == "LIST" && list.parent != nil {
|
||||
switch owner := list.parent; {
|
||||
case owner.kind == "" && owner.text == "ARGUMENTS":
|
||||
// Prefer a column operand, then an expression, then a constant.
|
||||
rank := map[string]int{"COLUMN": 0, "FUNCTION": 1, "CONSTANT": 2}
|
||||
var best *treeNode
|
||||
for _, sib := range list.children {
|
||||
if sib == sentinel {
|
||||
continue
|
||||
}
|
||||
if r, ok := rank[sib.kind]; ok && (best == nil || r < rank[best.kind]) {
|
||||
best = sib
|
||||
}
|
||||
}
|
||||
if best != nil {
|
||||
return t.describe(best)
|
||||
}
|
||||
case owner.kind == "" && owner.text == "PROJECTION":
|
||||
names, nodes := projection(owner.parent)
|
||||
for i, n := range nodes {
|
||||
if n == sentinel && i < len(names) {
|
||||
ac := column(names[i], sentinel.attrs["constant_value_type"])
|
||||
return ac
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return column("", sentinel.attrs["constant_value_type"])
|
||||
}
|
||||
|
||||
// describe turns a tree expression into a column description.
|
||||
func (t *queryTree) describe(n *treeNode) analysis.Column {
|
||||
switch n.kind {
|
||||
case "COLUMN":
|
||||
ac := column(n.attrs["column_name"], n.attrs["result_type"])
|
||||
ac.Table = t.sourceTable(n)
|
||||
return ac
|
||||
case "FUNCTION":
|
||||
return column(n.attrs["function_name"], n.attrs["result_type"])
|
||||
case "CONSTANT":
|
||||
// A constant folded from a function call, such as toDate(now()),
|
||||
// still names the function, which is what a placeholder compared
|
||||
// with it is named after.
|
||||
name := ""
|
||||
if fn := firstNode(n.section("EXPRESSION").childrenOrNil()); fn != nil && fn.kind == "FUNCTION" {
|
||||
name = fn.attrs["function_name"]
|
||||
}
|
||||
return column(name, n.attrs["constant_value_type"])
|
||||
}
|
||||
return analysis.Column{}
|
||||
}
|
||||
|
||||
var insertValuesRe = regexp.MustCompile(`(?is)^insert\s+into\s+(?:table\s+)?([\w.` + "`" + `"]+)\s*(?:\(([^)]*)\))?\s*(?:format\s+)?values\b`)
|
||||
|
||||
// analyzeExec runs a statement that returns no rows. The only parameters it
|
||||
// can describe are those of an INSERT ... VALUES, which map positionally
|
||||
// onto the target columns reported by DESCRIBE TABLE.
|
||||
func analyzeExec(ctx context.Context, l local, script, sql string, phs []placeholder, aq analysis.Query) (analysis.Query, error) {
|
||||
m := insertValuesRe.FindStringSubmatch(sql)
|
||||
if m != nil {
|
||||
script += "DESCRIBE TABLE " + m[1] + ";\n"
|
||||
}
|
||||
results, err := l.run(ctx, script)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
|
||||
var targets []analysis.Column
|
||||
if m != nil && len(results) == 1 {
|
||||
byName := map[string]analysis.Column{}
|
||||
var all []analysis.Column
|
||||
table := strings.Trim(m[1][strings.LastIndexByte(m[1], '.')+1:], "`\"")
|
||||
for _, row := range results[0].Data {
|
||||
var name, typ string
|
||||
json.Unmarshal(row["name"], &name)
|
||||
json.Unmarshal(row["type"], &typ)
|
||||
ac := column(name, typ)
|
||||
ac.Table = table
|
||||
byName[name] = ac
|
||||
all = append(all, ac)
|
||||
}
|
||||
if strings.TrimSpace(m[2]) == "" {
|
||||
targets = all
|
||||
} else {
|
||||
for _, name := range strings.Split(m[2], ",") {
|
||||
targets = append(targets, byName[strings.Trim(strings.TrimSpace(name), "`\"")])
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, ph := range phs {
|
||||
ac := analysis.Column{}
|
||||
if len(targets) > 0 {
|
||||
ac = targets[i%len(targets)]
|
||||
}
|
||||
if ph.Name != "" {
|
||||
ac.Name = ph.Name
|
||||
}
|
||||
aq.Params = append(aq.Params, analysis.Param{Number: ph.Number, Column: ac})
|
||||
}
|
||||
return aq, nil
|
||||
}
|
||||
43
internal/goldeneye/clickhouse/check.go
Normal file
43
internal/goldeneye/clickhouse/check.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/analysis"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// Analyze runs a case's queries through the clickhouse binary and returns
|
||||
// the analysis in the JSON shape sqlc analyze prints.
|
||||
func Analyze(ctx context.Context, binary string, c endtoend.Case) ([]byte, error) {
|
||||
schema, err := os.ReadFile(c.Schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fixture []byte
|
||||
if c.Fixture != "" {
|
||||
if fixture, err = os.ReadFile(c.Fixture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
queries, err := c.Queries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := analyze(ctx, local{binary: binary}, string(schema), string(fixture), queries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return analysis.Encode(out)
|
||||
}
|
||||
|
||||
// Check compares what ClickHouse reports for a case with the output the
|
||||
// case committed, returning a diff when they differ.
|
||||
func Check(ctx context.Context, binary string, c endtoend.Case) (string, error) {
|
||||
got, err := Analyze(ctx, binary, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.Compare(got)
|
||||
}
|
||||
146
internal/goldeneye/clickhouse/clickhouse.go
Normal file
146
internal/goldeneye/clickhouse/clickhouse.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package clickhouse generates the ClickHouse dialect seed under
|
||||
// internal/engine/clickhouse/dialect from a clickhouse binary, run as an
|
||||
// ephemeral `clickhouse local` process that needs no server.
|
||||
//
|
||||
// types.jsonl comes from system.data_type_families: every family that is not
|
||||
// an alias becomes a type, the families that alias it become its aliases,
|
||||
// and its category is decided by its name, since ClickHouse records no such
|
||||
// thing. ClickHouse describes its functions no further than their names —
|
||||
// system.functions carries no signatures — so functions.jsonl is written by
|
||||
// hand and is not this package's business.
|
||||
//
|
||||
// The package also verifies the ClickHouse analyze cases under
|
||||
// internal/endtoend/testdata against the same binary: each case's schema and
|
||||
// fixture are loaded into a `clickhouse local` process and its queries run
|
||||
// there. Result column types come from the executed query's result header,
|
||||
// provenance from EXPLAIN QUERY TREE, and parameters from sentinel constants
|
||||
// substituted for the placeholders, since ClickHouse itself never sees a ?.
|
||||
// The answer is printed in the JSON shape sqlc analyze prints and compared
|
||||
// with the case's committed stdout.json byte for byte.
|
||||
//
|
||||
// The binary is downloaded once per pinned version by Install, or supplied
|
||||
// through the CLICKHOUSE environment variable.
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
)
|
||||
|
||||
// Engine is the name of the engine directory the dialect lives under.
|
||||
const Engine = "clickhouse"
|
||||
|
||||
// typeFamilies lists every data type ClickHouse knows and, for a spelling
|
||||
// such as INT or VARCHAR, the type it stands for.
|
||||
const typeFamilies = `
|
||||
SELECT name, alias_to
|
||||
FROM system.data_type_families
|
||||
ORDER BY name`
|
||||
|
||||
// Version reports the release a binary is.
|
||||
func Version(ctx context.Context, binary string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, binary, "local", "--version").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clickhouse local --version: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// Generate reads the dialect from the binary.
|
||||
func Generate(ctx context.Context, binary string) (dialect.Files, error) {
|
||||
results, err := local{binary: binary}.run(ctx, typeFamilies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) != 1 {
|
||||
return nil, fmt.Errorf("clickhouse: expected one result set, got %d", len(results))
|
||||
}
|
||||
var families []family
|
||||
for _, row := range results[0].Data {
|
||||
var f family
|
||||
if err := json.Unmarshal(row["name"], &f.Name); err != nil {
|
||||
return nil, fmt.Errorf("clickhouse: system.data_type_families: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(row["alias_to"], &f.AliasTo); err != nil {
|
||||
return nil, fmt.Errorf("clickhouse: system.data_type_families: %w", err)
|
||||
}
|
||||
families = append(families, f)
|
||||
}
|
||||
types, err := dialect.JSONL(readTypes(families))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialect.Files{dialect.TypesFile: types}, nil
|
||||
}
|
||||
|
||||
type family struct {
|
||||
Name string
|
||||
AliasTo string
|
||||
}
|
||||
|
||||
// readTypes turns the families into types: one per family that is not an
|
||||
// alias, carrying the spellings that alias it. The seed reads names without
|
||||
// regard to case, so an alias that only differs in case from its type —
|
||||
// bool for Bool, ENUM for Enum — is dropped.
|
||||
func readTypes(families []family) []dialect.Type {
|
||||
byName := map[string]*dialect.Type{}
|
||||
var names []string
|
||||
for _, f := range families {
|
||||
if f.AliasTo != "" {
|
||||
continue
|
||||
}
|
||||
byName[f.Name] = &dialect.Type{Name: f.Name, Category: category(f.Name)}
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
for _, f := range families {
|
||||
if f.AliasTo == "" || strings.EqualFold(f.Name, f.AliasTo) {
|
||||
continue
|
||||
}
|
||||
if t, ok := byName[f.AliasTo]; ok {
|
||||
t.Aliases = append(t.Aliases, f.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
types := make([]dialect.Type, 0, len(names))
|
||||
for _, name := range names {
|
||||
types = append(types, *byName[name])
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// category assigns a type the PostgreSQL category letter the seed package
|
||||
// uses, by the family's name: N for numbers, B for booleans, S for strings
|
||||
// and the identifiers stored as them, D for dates and times, T for time
|
||||
// spans, A for arrays and U for everything else.
|
||||
func category(name string) string {
|
||||
switch {
|
||||
case name == "Bool":
|
||||
return "B"
|
||||
case name == "Array":
|
||||
return "A"
|
||||
case strings.HasPrefix(name, "Interval"):
|
||||
return "T"
|
||||
case hasPrefix(name, "UInt", "Int", "Float", "BFloat", "Decimal"):
|
||||
return "N"
|
||||
case name == "String", name == "FixedString", name == "UUID", name == "IPv4", name == "IPv6":
|
||||
return "S"
|
||||
case hasPrefix(name, "Date", "Time"):
|
||||
return "D"
|
||||
}
|
||||
return "U"
|
||||
}
|
||||
|
||||
func hasPrefix(name string, prefixes ...string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
66
internal/goldeneye/clickhouse/clickhouse_test.go
Normal file
66
internal/goldeneye/clickhouse/clickhouse_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// TestDialect verifies the committed ClickHouse dialect against what the
|
||||
// pinned clickhouse binary reports. It skips unless the binary is installed.
|
||||
func TestDialect(t *testing.T) {
|
||||
binary, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
version, err := Version(ctx, binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := Generate(ctx, binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := dialect.Dir(Engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := dialect.Check(dir, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report != "" {
|
||||
t.Errorf("%s does not match what %s reports:\n%s", dir, version, report)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeCases verifies every ClickHouse analyze case under
|
||||
// internal/endtoend/testdata against what ClickHouse reports. It skips
|
||||
// unless the binary is installed.
|
||||
func TestAnalyzeCases(t *testing.T) {
|
||||
binary, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
cases, err := endtoend.Cases(Engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Fatal("no clickhouse analyze cases found")
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
diff, err := Check(context.Background(), binary, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff != "" {
|
||||
t.Errorf("%s does not match what ClickHouse reports (-committed +clickhouse):\n%s", c.Output, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
221
internal/goldeneye/clickhouse/install.go
Normal file
221
internal/goldeneye/clickhouse/install.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultVersion is the ClickHouse release the dialect is generated from.
|
||||
// Bumping it is a deliberate change: the query tree format and type
|
||||
// inference can shift between releases, so regenerate and review the dialect
|
||||
// after changing it, and add the new release's assets to the table below.
|
||||
const DefaultVersion = "25.8.2.29"
|
||||
|
||||
// asset is one downloadable build of ClickHouse. Linux builds are published
|
||||
// as clickhouse-common-static tarballs holding the binary at
|
||||
// usr/bin/clickhouse; macOS builds are published as bare binaries.
|
||||
type asset struct {
|
||||
Version string
|
||||
OS string
|
||||
Arch string
|
||||
Name string
|
||||
SHA512 string
|
||||
}
|
||||
|
||||
// assets lists every build Install knows how to fetch, with the SHA-512 of
|
||||
// the download. A version that is not in this table cannot be installed:
|
||||
// verifying the download is the point of the table.
|
||||
//
|
||||
// The tarball checksums are the ones in the .sha512 files ClickHouse
|
||||
// publishes next to them. ClickHouse publishes no checksum for the macOS
|
||||
// binaries, so those were computed from the downloads.
|
||||
var assets = []asset{
|
||||
{"25.8.2.29", "linux", "amd64", "clickhouse-common-static-25.8.2.29-amd64.tgz", "6ff0aa1ffac6e564970174422ecde0d645cdb96812247a6e544d39cad6d78a514265f90a2bc7b4bad49903cea96eddd16a415a45b2aeaf9164461be76331bdee"},
|
||||
{"25.8.2.29", "linux", "arm64", "clickhouse-common-static-25.8.2.29-arm64.tgz", "68204ca4d4e472790f808ee376251fae82e58066a31f35a40d15d442ce5988d697f18a1208d28b8bb8e2dfad4b20b7fcb5107e2178472abcd97251b8de7f058e"},
|
||||
{"25.8.2.29", "darwin", "amd64", "clickhouse-macos", "2805805ad2506e37a3e71b4ae9e797bdc010a9368dc28e99bcaaa2c70a72cfdd031c0fce8fc304248fad73211d28d645f75c6432cfae1e1e54d72d04e8626cd4"},
|
||||
{"25.8.2.29", "darwin", "arm64", "clickhouse-macos-aarch64", "4c9237e85c8d4e1aced2b339b32e086b4f23fa14f99754b056a7e33dda88c4fb5d52e09e29d20181ec5b580caadb00f36613802e71755ff34927535eb8babf79"},
|
||||
}
|
||||
|
||||
// releaseTag returns the GitHub release tag for a version. ClickHouse tags
|
||||
// its March and August releases as LTS and everything else as stable.
|
||||
func releaseTag(version string) (string, error) {
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) != 4 {
|
||||
return "", fmt.Errorf("invalid ClickHouse version %q: want MAJOR.MINOR.PATCH.BUILD", version)
|
||||
}
|
||||
minor, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid ClickHouse version %q: %w", version, err)
|
||||
}
|
||||
suffix := "-stable"
|
||||
if minor == 3 || minor == 8 {
|
||||
suffix = "-lts"
|
||||
}
|
||||
return "v" + version + suffix, nil
|
||||
}
|
||||
|
||||
// releaseAsset finds the build for a platform in the table.
|
||||
func releaseAsset(version, goos, goarch string) (asset, error) {
|
||||
for _, a := range assets {
|
||||
if a.Version == version && a.OS == goos && a.Arch == goarch {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
for _, a := range assets {
|
||||
if a.Version == version {
|
||||
return asset{}, fmt.Errorf("no ClickHouse %s build is listed for %s/%s", version, goos, goarch)
|
||||
}
|
||||
}
|
||||
return asset{}, fmt.Errorf("ClickHouse %s is not in the asset table; add its downloads and checksums to install.go", version)
|
||||
}
|
||||
|
||||
// url is the asset's download address on GitHub.
|
||||
func (a asset) url() (string, error) {
|
||||
tag, err := releaseTag(a.Version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "https://github.com/ClickHouse/ClickHouse/releases/download/" + tag + "/" + a.Name, nil
|
||||
}
|
||||
|
||||
// tarball reports whether the download is an archive rather than the binary.
|
||||
func (a asset) tarball() bool {
|
||||
return strings.HasSuffix(a.Name, ".tgz")
|
||||
}
|
||||
|
||||
// cachedBinary is where Install puts the binary for a version.
|
||||
func cachedBinary(version string) (string, error) {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "sqlc-clickhouse", version, "clickhouse"), nil
|
||||
}
|
||||
|
||||
// Locate finds a clickhouse binary: the CLICKHOUSE environment variable wins,
|
||||
// then the cached copy of DefaultVersion.
|
||||
func Locate() (string, error) {
|
||||
if path := os.Getenv("CLICKHOUSE"); path != "" {
|
||||
return path, nil
|
||||
}
|
||||
path, err := cachedBinary(DefaultVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", fmt.Errorf("clickhouse %s is not installed: run `go run ./cmd/goldeneye install clickhouse` in internal/goldeneye, or set CLICKHOUSE to a clickhouse binary", DefaultVersion)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Install downloads the clickhouse binary for a version into the cache and
|
||||
// returns its path. It is a no-op when the version is already cached. The
|
||||
// download is checked against the table's SHA-512 before it is installed.
|
||||
func Install(ctx context.Context, version, goos, goarch string, progress io.Writer) (string, error) {
|
||||
dest, err := cachedBinary(version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return dest, nil
|
||||
}
|
||||
a, err := releaseAsset(version, goos, goarch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
url, err := a.url()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Fprintf(progress, "downloading %s\n", url)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("downloading %s: %s", url, resp.Status)
|
||||
}
|
||||
|
||||
// Write next to the destination and rename so a partial or corrupt
|
||||
// download never masquerades as an installed binary.
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dest), "clickhouse-*.partial")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
// Hash every byte that comes off the wire, including the parts of a
|
||||
// tarball after the binary, which extraction would otherwise not read.
|
||||
sum := sha512.New()
|
||||
body := io.TeeReader(resp.Body, sum)
|
||||
var src io.Reader = body
|
||||
if a.tarball() {
|
||||
src, err = binaryInTarball(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("downloading %s: %w", url, err)
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(tmp, src); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(io.Discard, body); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if got := hex.EncodeToString(sum.Sum(nil)); got != a.SHA512 {
|
||||
return "", fmt.Errorf("downloading %s: SHA-512 mismatch: got %s, want %s", url, got, a.SHA512)
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// binaryInTarball positions a reader at the clickhouse binary inside a
|
||||
// clickhouse-common-static tarball.
|
||||
func binaryInTarball(r io.Reader) (io.Reader, error) {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("tarball does not contain usr/bin/clickhouse")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr.Typeflag == tar.TypeReg && strings.HasSuffix(hdr.Name, "/usr/bin/clickhouse") {
|
||||
return tr, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/goldeneye/clickhouse/local.go
Normal file
82
internal/goldeneye/clickhouse/local.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// local runs SQL through an ephemeral `clickhouse local` process.
|
||||
type local struct {
|
||||
binary string
|
||||
}
|
||||
|
||||
// resultSet is one JSON-format result printed by clickhouse local. Only
|
||||
// statements that return rows print one; DDL and INSERT print nothing.
|
||||
type resultSet struct {
|
||||
Meta []resultColumn `json:"meta"`
|
||||
Data []map[string]json.RawMessage `json:"data"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
type resultColumn struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// run executes a multi-statement script in a fresh process with fresh
|
||||
// storage and returns the result sets in statement order. Any statement
|
||||
// failing fails the whole run with ClickHouse's own error message.
|
||||
func (l local) run(ctx context.Context, script string) ([]resultSet, error) {
|
||||
dir, err := os.MkdirTemp("", "sqlc-goldeneye-clickhouse-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
queries := filepath.Join(dir, "queries.sql")
|
||||
if err := os.WriteFile(queries, []byte(script), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// stdin must not be inherited: clickhouse local reads it as table data
|
||||
// and blocks until it is closed.
|
||||
cmd := exec.CommandContext(ctx, l.binary, "local",
|
||||
"--multiquery",
|
||||
"--queries-file", queries,
|
||||
"--output-format", "JSON",
|
||||
"--path", filepath.Join(dir, "data"),
|
||||
)
|
||||
cmd.Stdin = nil
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
var results []resultSet
|
||||
dec := json.NewDecoder(&stdout)
|
||||
for {
|
||||
var rs resultSet
|
||||
err := dec.Decode(&rs)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return results, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding clickhouse local output: %w", err)
|
||||
}
|
||||
results = append(results, rs)
|
||||
}
|
||||
}
|
||||
68
internal/goldeneye/clickhouse/queries.go
Normal file
68
internal/goldeneye/clickhouse/queries.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// placeholder is one parameter reference in a query, in order of appearance.
|
||||
type placeholder struct {
|
||||
Number int
|
||||
Name string // sqlc.arg / sqlc.narg name, empty for ?
|
||||
}
|
||||
|
||||
// Placeholders are substituted with constant expressions that carry their
|
||||
// ordinal, so they can be told apart from each other and from literal NULLs
|
||||
// once ClickHouse has folded them: the query tree prints the expression a
|
||||
// folded constant came from. NULL coerces to any type, so a comparison
|
||||
// against it analyzes with the other operand's type. LIMIT and OFFSET
|
||||
// reject NULL and only accept unsigned integers, so those get a value no
|
||||
// query would plausibly contain.
|
||||
const limitBase = "4294967295"
|
||||
|
||||
func sentinelFor(lastWord string, ordinal int) string {
|
||||
switch strings.ToLower(lastWord) {
|
||||
case "limit", "offset":
|
||||
return fmt.Sprintf("toUInt64(%s + %d)", limitBase, ordinal)
|
||||
}
|
||||
return fmt.Sprintf("(NULL + %d)", ordinal)
|
||||
}
|
||||
|
||||
// bindPlaceholders rewrites sqlc's parameter syntax (?, sqlc.arg(name),
|
||||
// sqlc.narg(name)) into constants ClickHouse can analyze. ClickHouse binds
|
||||
// every ? positionally, so each placeholder is its own parameter even when
|
||||
// a name repeats, which is how sqlc numbers them too.
|
||||
func bindPlaceholders(sql string) (string, []placeholder) {
|
||||
var phs []placeholder
|
||||
out := endtoend.Rewrite(sql, func(name, lastWord string) string {
|
||||
phs = append(phs, placeholder{Number: len(phs) + 1, Name: name})
|
||||
return sentinelFor(lastWord, len(phs))
|
||||
})
|
||||
return out, phs
|
||||
}
|
||||
|
||||
func isWordByte(c byte) bool {
|
||||
return c == '_' || c == '.' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
// skipQuoted returns the index just past the quoted token starting at i,
|
||||
// honouring backslash escapes and doubled quotes.
|
||||
func skipQuoted(s string, i int) int {
|
||||
q := s[i]
|
||||
j := i + 1
|
||||
for j < len(s) {
|
||||
switch {
|
||||
case s[j] == '\\' && j+1 < len(s):
|
||||
j += 2
|
||||
case s[j] == q && j+1 < len(s) && s[j+1] == q:
|
||||
j += 2
|
||||
case s[j] == q:
|
||||
return j + 1
|
||||
default:
|
||||
j++
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
236
internal/goldeneye/clickhouse/tree.go
Normal file
236
internal/goldeneye/clickhouse/tree.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// treeNode is one line of EXPLAIN QUERY TREE output. Lines of the form
|
||||
// `KIND id: N, key: value, ...` are nodes with a kind and attributes; every
|
||||
// other line (section headers such as PROJECTION or ARGUMENTS, and the
|
||||
// `name Type` lines under PROJECTION COLUMNS) keeps only its text.
|
||||
type treeNode struct {
|
||||
kind string
|
||||
id int
|
||||
attrs map[string]string
|
||||
text string
|
||||
parent *treeNode
|
||||
children []*treeNode
|
||||
}
|
||||
|
||||
// queryTree is a parsed EXPLAIN QUERY TREE dump.
|
||||
type queryTree struct {
|
||||
root *treeNode
|
||||
byID map[int]*treeNode
|
||||
}
|
||||
|
||||
var nodeLineRe = regexp.MustCompile(`^([A-Z_]+) id: (\d+)(?:, (.*))?$`)
|
||||
|
||||
// parseQueryTree builds the tree from the dump's lines, using the two-space
|
||||
// indentation to recover nesting.
|
||||
func parseQueryTree(lines []string) (*queryTree, error) {
|
||||
root := &treeNode{id: -1, text: "<root>"}
|
||||
t := &queryTree{root: root, byID: map[int]*treeNode{}}
|
||||
stack := []*treeNode{root}
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(strings.TrimLeft(line, " "))
|
||||
if indent%2 != 0 {
|
||||
return nil, fmt.Errorf("query tree: unexpected indentation in %q", line)
|
||||
}
|
||||
depth := indent/2 + 1
|
||||
if depth > len(stack) {
|
||||
return nil, fmt.Errorf("query tree: line %q is nested too deeply", line)
|
||||
}
|
||||
stack = stack[:depth]
|
||||
parent := stack[len(stack)-1]
|
||||
|
||||
n := &treeNode{id: -1, text: line[indent:], parent: parent}
|
||||
if m := nodeLineRe.FindStringSubmatch(n.text); m != nil {
|
||||
n.kind = m[1]
|
||||
n.id, _ = strconv.Atoi(m[2])
|
||||
n.attrs = parseAttrs(m[3])
|
||||
t.byID[n.id] = n
|
||||
}
|
||||
parent.children = append(parent.children, n)
|
||||
stack = append(stack, n)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
var attrKeyRe = regexp.MustCompile(`^, ([a-z_]+): `)
|
||||
|
||||
// parseAttrs splits `key: value, key: value` where values may themselves
|
||||
// contain commas inside parentheses or quotes, as types and constants do.
|
||||
func parseAttrs(s string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
if s == "" {
|
||||
return attrs
|
||||
}
|
||||
// Positions at which a new `, key: ` begins at top level.
|
||||
var cuts []int
|
||||
depth := 0
|
||||
inQuote := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; {
|
||||
case c == '\'':
|
||||
inQuote = !inQuote
|
||||
case inQuote:
|
||||
case c == '(':
|
||||
depth++
|
||||
case c == ')':
|
||||
depth--
|
||||
case c == ',' && depth == 0 && attrKeyRe.MatchString(s[i:]):
|
||||
cuts = append(cuts, i)
|
||||
}
|
||||
}
|
||||
cuts = append(cuts, len(s))
|
||||
start := 0
|
||||
for _, cut := range cuts {
|
||||
pair := s[start:cut]
|
||||
if key, value, ok := strings.Cut(pair, ": "); ok {
|
||||
attrs[key] = value
|
||||
}
|
||||
start = cut + 2
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// section returns the child section header of a node, such as PROJECTION
|
||||
// or JOIN TREE, or nil.
|
||||
func (n *treeNode) section(name string) *treeNode {
|
||||
for _, c := range n.children {
|
||||
if c.kind == "" && c.text == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// list returns the nodes of the LIST under a section header.
|
||||
func (n *treeNode) list() []*treeNode {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
for _, c := range n.children {
|
||||
if c.kind == "LIST" {
|
||||
return c.children
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstQuery returns the QUERY node describing a result set: the node itself
|
||||
// or, for a UNION, its first branch, whose projection names the union's
|
||||
// columns.
|
||||
func firstQuery(n *treeNode) *treeNode {
|
||||
for depth := 0; n != nil && depth < 32; depth++ {
|
||||
switch n.kind {
|
||||
case "QUERY":
|
||||
return n
|
||||
case "UNION":
|
||||
n = firstNode(n.section("QUERIES").list())
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNode(nodes []*treeNode) *treeNode {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
// projection returns the output column names of a query and the expression
|
||||
// node producing each, in order.
|
||||
func projection(q *treeNode) (names []string, nodes []*treeNode) {
|
||||
q = firstQuery(q)
|
||||
if q == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if cols := q.section("PROJECTION COLUMNS"); cols != nil {
|
||||
for _, c := range cols.children {
|
||||
names = append(names, projectedName(c.text))
|
||||
}
|
||||
}
|
||||
return names, q.section("PROJECTION").list()
|
||||
}
|
||||
|
||||
// projectedName strips the trailing type from a `name Type` line. The type
|
||||
// is a single token that may carry a parenthesised argument list; the name
|
||||
// may contain spaces of its own, as `plus(id, 1)` does.
|
||||
func projectedName(line string) string {
|
||||
end := len(line)
|
||||
if strings.HasSuffix(line, ")") {
|
||||
depth := 0
|
||||
for end > 0 {
|
||||
end--
|
||||
if line[end] == ')' {
|
||||
depth++
|
||||
} else if line[end] == '(' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for end > 0 && isWordByte(line[end-1]) {
|
||||
end--
|
||||
}
|
||||
return strings.TrimSpace(line[:end])
|
||||
}
|
||||
|
||||
// sourceTable resolves a COLUMN node to the table it reads from, following
|
||||
// column references through subqueries and CTEs. Columns computed by an
|
||||
// expression have no source and yield "".
|
||||
func (t *queryTree) sourceTable(col *treeNode) string {
|
||||
for depth := 0; col != nil && col.kind == "COLUMN" && depth < 32; depth++ {
|
||||
id, err := strconv.Atoi(col.attrs["source_id"])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
src := t.byID[id]
|
||||
if src == nil {
|
||||
return ""
|
||||
}
|
||||
switch src.kind {
|
||||
case "TABLE":
|
||||
name := src.attrs["table_name"]
|
||||
if i := strings.LastIndexByte(name, '.'); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
return name
|
||||
case "QUERY", "UNION":
|
||||
// The column is one of the subquery's output columns; keep
|
||||
// following whatever expression produces it there.
|
||||
want := col.attrs["column_name"]
|
||||
names, nodes := projection(src)
|
||||
col = nil
|
||||
for i, name := range names {
|
||||
if name == want && i < len(nodes) {
|
||||
col = nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// childrenOrNil returns a node's children, tolerating a nil node.
|
||||
func (n *treeNode) childrenOrNil() []*treeNode {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return n.children
|
||||
}
|
||||
160
internal/goldeneye/clickhouse/types.go
Normal file
160
internal/goldeneye/clickhouse/types.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package clickhouse
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/analysis"
|
||||
)
|
||||
|
||||
// A type is a call expression, the way ClickHouse itself models one: a
|
||||
// lowercased name applied to an ordered argument list. Each argument is
|
||||
// another type, an integer, a boolean or a quoted string, optionally
|
||||
// labelled, so Array, Map and LowCardinality are ordinary names and nothing
|
||||
// about a nested type is lost. Nullability is an attribute of a type rather
|
||||
// than a wrapper, since every engine has it and only ClickHouse spells it as
|
||||
// a type: Nullable(T) becomes T with nullable set, at whatever depth it
|
||||
// appears. The shape maps one to one onto a protobuf message with a oneof
|
||||
// for the argument value:
|
||||
//
|
||||
// Map(String, Nullable(UInt32))
|
||||
// {"name": "map", "args": [
|
||||
// {"type": {"name": "string"}},
|
||||
// {"type": {"name": "uint32", "nullable": true}}]}
|
||||
//
|
||||
// Tuple(lat Float64, lon Float64)
|
||||
// {"name": "tuple", "args": [
|
||||
// {"label": "lat", "type": {"name": "float64"}},
|
||||
// {"label": "lon", "type": {"name": "float64"}}]}
|
||||
//
|
||||
// Enum8('a' = 1, 'b' = 2)
|
||||
// {"name": "enum8", "args": [{"label": "a", "int": 1}, {"label": "b", "int": 2}]}
|
||||
//
|
||||
// DateTime64(3, 'UTC')
|
||||
// {"name": "datetime64", "args": [{"int": 3}, {"string": "UTC"}]}
|
||||
//
|
||||
// An identifier argument such as the function in AggregateFunction(uniq,
|
||||
// String) is a word rather than a type. Resolving names against the
|
||||
// catalog is the reader's job; the output only records what was said.
|
||||
|
||||
// parseType turns a ClickHouse type string into its expression.
|
||||
func parseType(t string) *analysis.TypeExpr {
|
||||
name, args := splitType(t)
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "nullable" && len(args) == 1 {
|
||||
expr := parseType(args[0])
|
||||
expr.Nullable = true
|
||||
return expr
|
||||
}
|
||||
if name == "" {
|
||||
name = "nothing"
|
||||
}
|
||||
expr := &analysis.TypeExpr{Name: name}
|
||||
for _, a := range args {
|
||||
expr.Args = append(expr.Args, parseArg(a))
|
||||
}
|
||||
// LowCardinality(Nullable(T)) is the only order ClickHouse accepts for
|
||||
// a nullable low-cardinality column, and the nullability is the
|
||||
// column's: it reads as a nullable LowCardinality(T).
|
||||
if name == "lowcardinality" && len(expr.Args) == 1 && expr.Args[0].Type != nil && expr.Args[0].Type.Nullable {
|
||||
expr.Nullable = true
|
||||
expr.Args[0].Type.Nullable = false
|
||||
}
|
||||
// The function an aggregate-function type names is a word, not a type.
|
||||
if name == "aggregatefunction" || name == "simpleaggregatefunction" {
|
||||
if len(expr.Args) > 0 && expr.Args[0].Type != nil && len(expr.Args[0].Type.Args) == 0 {
|
||||
fn := expr.Args[0].Type.Name
|
||||
expr.Args[0] = analysis.TypeArg{Ident: &fn}
|
||||
}
|
||||
}
|
||||
return expr
|
||||
}
|
||||
|
||||
// parseArg parses one argument: a quoted string, an integer, a boolean, a
|
||||
// labelled argument (`lat Float64` in a Tuple, `'a' = 1` in an Enum), or a
|
||||
// type.
|
||||
func parseArg(a string) analysis.TypeArg {
|
||||
a = strings.TrimSpace(a)
|
||||
if strings.HasPrefix(a, "'") {
|
||||
end := skipQuoted(a, 0)
|
||||
lit := unquote(a[1 : end-1])
|
||||
if rest := strings.TrimSpace(a[end:]); strings.HasPrefix(rest, "=") {
|
||||
arg := parseArg(rest[1:])
|
||||
arg.Label = lit
|
||||
return arg
|
||||
}
|
||||
return analysis.TypeArg{String: &lit}
|
||||
}
|
||||
if n, err := strconv.ParseInt(a, 10, 64); err == nil {
|
||||
return analysis.TypeArg{Int: &n}
|
||||
}
|
||||
switch strings.ToLower(a) {
|
||||
case "true", "false":
|
||||
b := strings.EqualFold(a, "true")
|
||||
return analysis.TypeArg{Bool: &b}
|
||||
}
|
||||
if i := labelEnd(a); i > 0 {
|
||||
arg := parseArg(a[i+1:])
|
||||
arg.Label = a[:i]
|
||||
return arg
|
||||
}
|
||||
return analysis.TypeArg{Type: parseType(a)}
|
||||
}
|
||||
|
||||
// labelEnd returns the index of the space separating a label from the type
|
||||
// it labels, or -1 when the argument has no label: a space that comes before
|
||||
// any parenthesis, as in `lat Float64` or `tags Array(String)`.
|
||||
func labelEnd(a string) int {
|
||||
head := a
|
||||
if p := strings.IndexByte(a, '('); p >= 0 {
|
||||
head = a[:p]
|
||||
}
|
||||
return strings.IndexByte(head, ' ')
|
||||
}
|
||||
|
||||
// unquote undoes the escaping inside a single-quoted ClickHouse literal.
|
||||
func unquote(s string) string {
|
||||
s = strings.ReplaceAll(s, `\'`, `'`)
|
||||
s = strings.ReplaceAll(s, `''`, `'`)
|
||||
return strings.ReplaceAll(s, `\\`, `\`)
|
||||
}
|
||||
|
||||
// splitType splits `Base(arg, arg)` into its base name and top-level
|
||||
// arguments, leaving nested parentheses and quoted strings intact.
|
||||
func splitType(t string) (string, []string) {
|
||||
t = strings.TrimSpace(t)
|
||||
open := strings.IndexByte(t, '(')
|
||||
if open < 0 || !strings.HasSuffix(t, ")") {
|
||||
return t, nil
|
||||
}
|
||||
base := strings.TrimSpace(t[:open])
|
||||
inner := t[open+1 : len(t)-1]
|
||||
var (
|
||||
args []string
|
||||
depth int
|
||||
quote byte
|
||||
start int
|
||||
)
|
||||
for i := 0; i < len(inner); i++ {
|
||||
c := inner[i]
|
||||
switch {
|
||||
case quote != 0:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
case c == '\'' || c == '"' || c == '`':
|
||||
quote = c
|
||||
case c == '(':
|
||||
depth++
|
||||
case c == ')':
|
||||
depth--
|
||||
case c == ',' && depth == 0:
|
||||
args = append(args, strings.TrimSpace(inner[start:i]))
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
args = append(args, strings.TrimSpace(inner[start:]))
|
||||
return base, args
|
||||
}
|
||||
Reference in New Issue
Block a user