package server import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // maxExecutionSeconds bounds every ClickHouse query server-side. const maxExecutionSeconds = 30 type chClient struct { url string user string password string http *http.Client } func newCHClient(chURL, user, password string) *chClient { return &chClient{ url: strings.TrimRight(chURL, "/"), user: user, password: password, http: &http.Client{Timeout: (maxExecutionSeconds + 5) * time.Second}, } } type chColumn struct { Name string `json:"name"` Type string `json:"type"` } type chResult struct { Meta []chColumn `json:"meta"` Data []map[string]any `json:"data"` Rows int `json:"rows"` } // query POSTs sql to the ClickHouse HTTP interface. Every value in params is // sent as a bound query parameter (param_), never interpolated. func (c *chClient) query(ctx context.Context, sql string, params map[string]string) (*chResult, error) { q := url.Values{} q.Set("max_execution_time", strconv.Itoa(maxExecutionSeconds)) q.Set("output_format_json_quote_64bit_integers", "0") for k, v := range params { q.Set("param_"+k, v) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/?"+q.Encode(), strings.NewReader(sql)) if err != nil { return nil, err } req.Header.Set("X-ClickHouse-User", c.user) req.Header.Set("X-ClickHouse-Key", c.password) req.Header.Set("Content-Type", "text/plain") resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { msg := strings.TrimSpace(string(body)) if len(msg) > 500 { msg = msg[:500] } return nil, fmt.Errorf("clickhouse: %s: %s", resp.Status, msg) } var res chResult if err := json.Unmarshal(body, &res); err != nil { return nil, fmt.Errorf("clickhouse: decode response: %w", err) } return &res, nil } func (c *chClient) ping(ctx context.Context) error { _, err := c.query(ctx, "SELECT 1 FORMAT JSON", nil) return err }