blob: 165af254c622f3076dec8f7134d05fddae94e67a [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"cloud.google.com/go/bigquery"
"github.com/maruel/subcommands"
"go.chromium.org/luci/auth"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
)
const queryLongDesc = `Run a SQL query and return the results as JSON.
Value types will be preserved to the extent that JSON. Booleans and strings
produced by the query will become JSON booleans and strings, respectively,
while ints and floats from the query will both become JSON floats.
`
func cmdQuery(authOpts auth.Options) *subcommands.Command {
return &subcommands.Command{
UsageLine: "query -input <sql-query-file> -json-output <output-path>",
ShortDesc: "Run a SQL query.",
LongDesc: queryLongDesc,
CommandRun: func() subcommands.CommandRun {
c := &queryCmd{}
c.Init(authOpts)
return c
},
}
}
type queryRow map[string]bigquery.Value
type queryCmd struct {
commonFlags
inputPath string
jsonOutputPath string
}
func (c *queryCmd) Init(defaultAuthOpts auth.Options) {
c.commonFlags.Init(defaultAuthOpts)
c.Flags.StringVar(&c.inputPath, "input", "", "Path to an input file containing an SQL query to run.")
c.Flags.StringVar(&c.jsonOutputPath, "json-output", "", "Path to output file. Prints to stdout if unspecified.")
}
func (c *queryCmd) parseArgs() error {
if err := c.commonFlags.Parse(); err != nil {
return err
}
if c.inputPath == "" {
return errors.New("-input is required")
}
return nil
}
func (c *queryCmd) Run(a subcommands.Application, _ []string, _ subcommands.Env) int {
if err := c.parseArgs(); err != nil {
fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
return 1
}
if err := c.main(); err != nil {
fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
return 1
}
return 0
}
func (c *queryCmd) main() error {
ctx := context.Background()
authenticator := auth.NewAuthenticator(ctx, auth.OptionalLogin, c.parsedAuthOpts)
tokenSource, err := authenticator.TokenSource()
if err != nil {
if err == auth.ErrLoginRequired {
fmt.Fprintf(os.Stderr, "You need to login first by running:\n")
fmt.Fprintf(os.Stderr, " luci-auth login -scopes %q\n", strings.Join(c.parsedAuthOpts.Scopes, " "))
}
return err
}
client, err := bigquery.NewClient(ctx, c.project, option.WithTokenSource(tokenSource))
if err != nil {
return err
}
queryBytes, err := os.ReadFile(c.inputPath)
if err != nil {
return err
}
rows, err := runQuery(ctx, client, string(queryBytes))
if err != nil {
return err
}
return writeOutput(rows, c.jsonOutputPath, os.Stdout)
}
func runQuery(ctx context.Context, client *bigquery.Client, query string) ([]map[string]bigquery.Value, error) {
q := client.Query(query)
iter, err := q.Read(ctx)
if err != nil {
return nil, err
}
var rows []map[string]bigquery.Value
for {
row := make(map[string]bigquery.Value)
err := iter.Next(&row)
if err == iterator.Done {
break
} else if err != nil {
return nil, err
}
rows = append(rows, row)
}
return rows, nil
}
// writeOutput writes the rows returned by a query to the file specified by
// `jsonOutputPath`, or to stdout if `jsonOutputPath` is unset. Takes stdout as
// a parameter to make testing easier.
func writeOutput(rows []map[string]bigquery.Value, jsonOutputPath string, stdout io.Writer) error {
var output io.Writer
var indent string
if jsonOutputPath == "" {
output = stdout
// Indent output if printing to stdout for a more user-friendly
// experience.
indent = " "
} else {
f, err := os.Create(jsonOutputPath)
if err != nil {
return err
}
defer f.Close()
output = f
}
enc := json.NewEncoder(output)
enc.SetIndent("", indent)
return enc.Encode(rows)
}