Skip to content
Open
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [public-8core-24.04, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
Expand Down
152 changes: 152 additions & 0 deletions spec/cb/saved_query_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
require "../spec_helper"

Spectator.describe CB::SavedQueryList do
subject(action) { described_class.new client: client, output: IO::Memory.new }

mock_client

let(saved_queries) { [Factory.saved_query, Factory.saved_query(id: "sqpvoqooxzdrriu6w3bhqo55c4", name: "Other Query")] }

describe "#validate" do
it "validates that required arguments are present" do
expect_missing_arg_error
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
expect(&.validate).to be_true
end
end

describe "#run" do
before_each do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
end

it "displays empty message when no queries" do
expect(client).to receive(:get_saved_queries).and_return([] of CB::Model::SavedQuery)
action.call
expect(&.output.to_s).to eq "no saved queries\n"
end

it "outputs table format" do
expect(client).to receive(:get_saved_queries).and_return(saved_queries)
action.call
expect(&.output.to_s).to contain "Test Query"
end

it "outputs json format" do
action.format = CB::Format::JSON
expect(client).to receive(:get_saved_queries).and_return(saved_queries)
action.call
expect(&.output.to_s).to contain "\"name\":"
end
end
end

Spectator.describe CB::SavedQueryExport do
subject(action) { described_class.new client: client, output: IO::Memory.new }

mock_client

let(saved_query) { Factory.saved_query }

describe "#validate" do
it "validates that required arguments are present" do
expect_missing_arg_error
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
expect_missing_arg_error
action.query_id = "sqpvoqooxzdrriu6w3bhqo55c4"
expect(&.validate).to be_true
end
end

describe "#run" do
before_each do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
action.query_id = "sqpvoqooxzdrriu6w3bhqo55c4"
end

it "exports to specified file" do
action.file = "/tmp/test_export.sql"
expect(client).to receive(:get_saved_query).and_return(saved_query)
action.call
expect(File.read("/tmp/test_export.sql")).to eq "SELECT 1"
expect(&.output.to_s).to contain "exported"
File.delete("/tmp/test_export.sql")
end

it "uses sanitized name as default filename" do
expect(client).to receive(:get_saved_query).and_return(saved_query)
action.call
expect(File.exists?("Test_Query.sql")).to be_true
expect(&.output.to_s).to contain "Test_Query.sql"
File.delete("Test_Query.sql")
end
end
end

Spectator.describe CB::SavedQueryImport do
subject(action) { described_class.new client: client, output: IO::Memory.new }

mock_client

let(saved_query) { Factory.saved_query }

describe "#validate" do
it "validates that required arguments are present" do
expect_missing_arg_error
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
expect_missing_arg_error
action.file = "/tmp/test_import.sql"
expect_missing_arg_error
action.name = "My Query"
expect(&.validate).to be_true
end
end

describe "#run" do
before_each do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
action.file = "/tmp/test_import.sql"
action.name = "My Query"
File.write("/tmp/test_import.sql", "SELECT 42")
end

after_each do
File.delete("/tmp/test_import.sql") if File.exists?("/tmp/test_import.sql")
end

it "imports from file and prints confirmation" do
expect(client).to receive(:create_saved_query).and_return(saved_query)
action.call
expect(&.output.to_s).to contain "created saved query"
end
end
end

Spectator.describe CB::SavedQueryDestroy do
subject(action) { described_class.new client: client, output: IO::Memory.new }

mock_client

describe "#validate" do
it "validates that required arguments are present" do
expect_missing_arg_error
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
expect_missing_arg_error
action.query_id = "sqpvoqooxzdrriu6w3bhqo55c4"
expect(&.validate).to be_true
end
end

describe "#run" do
before_each do
action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4"
action.query_id = "sqpvoqooxzdrriu6w3bhqo55c4"
end

it "destroys and prints confirmation" do
expect(client).to receive(:destroy_saved_query).and_return("")
action.call
expect(&.output.to_s).to eq "saved query destroyed\n"
end
end
end
15 changes: 15 additions & 0 deletions spec/support/factory.cr
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,19 @@ module Factory

CB::Tempkey.new **params
end

def saved_query(**params)
params = {
id: "sqpvoqooxzdrriu6w3bhqo55c4",
name: "Test Query",
sql: "SELECT 1",
cluster_id: "pkdpq6yynjgjbps4otxd7il2u4",
team_id: "l2gnkxjv3beifk6abkraerv7de",
saved_query_folder_id: nil,
created_at: Time.utc(2023, 1, 1, 0, 0, 0),
updated_at: Time.utc(2023, 1, 1, 0, 0, 0),
}.merge(params)

CB::Model::SavedQuery.new **params
end
end
120 changes: 120 additions & 0 deletions src/cb/saved_query.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
require "./action"
require "./table"

module CB
class SavedQueryList < APIAction
eid_setter cluster_id
format_setter format
bool_setter? no_header

def validate
check_required_args do |missing|
missing << "cluster" unless cluster_id
end
end

def run
validate
queries = client.get_saved_queries cluster_id

if queries.empty?
output.puts "no saved queries"
return
end

case @format
when Format::JSON
output << queries.to_pretty_json << '\n'
else
table = Table::TableBuilder.new(border: :none) do
columns do
add "ID"
add "Name"
add "Query"
end

header unless no_header

queries.each do |q|
row [q.id, q.name, truncate_sql(q.sql)]
end
end

output << table.render << '\n'
end
end

private def truncate_sql(sql : String?) : String
return "" if sql.nil?
collapsed = sql.gsub(/\s+/, " ").strip
collapsed.size > 30 ? "#{collapsed[0, 50]}..." : collapsed
end
end

class SavedQueryExport < APIAction
eid_setter cluster_id
eid_setter query_id
property file : String?

def validate
check_required_args do |missing|
missing << "cluster" unless cluster_id
missing << "query" unless query_id
end
end

def run
validate
query = client.get_saved_query query_id

filename = @file || "#{query.name.gsub(/[^a-zA-Z0-9_\-]/, "_")}.sql"
File.write(filename, query.sql)
output << "exported " << query.name << " to " << filename << '\n'
end
end

class SavedQueryImport < APIAction
eid_setter cluster_id
property file : String?
property name : String?

def validate
check_required_args do |missing|
missing << "cluster" unless cluster_id
missing << "file" unless file
missing << "name" unless name
end
end

def run
validate
sql = File.read(@file.to_s)

query = client.create_saved_query(Client::SavedQueryCreateParams.new(
cluster_id: cluster_id,
name: @name,
sql: sql,
))

output << "created saved query " << query.id << '\n'
end
end

class SavedQueryDestroy < APIAction
eid_setter cluster_id
eid_setter query_id

def validate
check_required_args do |missing|
missing << "cluster" unless cluster_id
missing << "query" unless query_id
end
end

def run
validate
client.destroy_saved_query query_id
output << "saved query destroyed" << '\n'
end
end
end
35 changes: 35 additions & 0 deletions src/cli.cr
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,41 @@ op = OptionParser.new do |parser|
end
end

parser.on("saved-query", "Manage saved queries") do
parser.banner = "cb saved-query <list|export|import|destroy>"

parser.on("list", "List saved queries for a cluster") do
list = set_action SavedQueryList
parser.banner = "cb saved-query list <--cluster>"
parser.on("--cluster ID", "Choose cluster") { |arg| list.cluster_id = arg }
parser.on("--format FORMAT", "Choose output format (default: table)") { |arg| list.format = arg }
parser.on("--no-header", "Do not display table header") { list.no_header = true }
end

parser.on("export", "Export a saved query to a .sql file") do
export = set_action SavedQueryExport
parser.banner = "cb saved-query export <--cluster> <--query>"
parser.on("--cluster ID", "Choose cluster") { |arg| export.cluster_id = arg }
parser.on("--query ID", "Saved query ID") { |arg| export.query_id = arg }
parser.on("--file PATH", "Output file path (default: <name>.sql)") { |arg| export.file = arg }
end

parser.on("import", "Import a saved query from a .sql file") do
import = set_action SavedQueryImport
parser.banner = "cb saved-query import <--cluster> <--file> <--name>"
parser.on("--cluster ID", "Choose cluster") { |arg| import.cluster_id = arg }
parser.on("--file PATH", "Path to .sql file") { |arg| import.file = arg }
parser.on("--name NAME", "Name for the saved query") { |arg| import.name = arg }
end

parser.on("destroy", "Destroy a saved query") do
destroy = set_action SavedQueryDestroy
parser.banner = "cb saved-query destroy <--cluster> <--query>"
parser.on("--cluster ID", "Choose cluster") { |arg| destroy.cluster_id = arg }
parser.on("--query ID", "Saved query ID") { |arg| destroy.query_id = arg }
end
end

parser.on("suspend", "Temporarily turn off a cluster") do
parser.banner = "cb suspend <cluster id>"
suspend = set_action ClusterSuspend
Expand Down
54 changes: 54 additions & 0 deletions src/client/saved_query.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
require "./client"

module CB
class Client
struct SavedQueryListResponse
include JSON::Serializable
pagination_properties
property saved_queries : Array(CB::Model::SavedQuery) = [] of CB::Model::SavedQuery
end

def get_saved_queries(cluster_id)
saved_queries = [] of CB::Model::SavedQuery
query_params = Hash(String, String).new
query_params["cluster_id"] = cluster_id.to_s
query_params["order_field"] = "name"

loop do
resp = get "saved-queries?#{HTTP::Params.encode(query_params)}"
data = SavedQueryListResponse.from_json resp.body
saved_queries.concat(data.saved_queries)
break unless data.has_more
query_params["cursor"] = data.next_cursor.to_s
end

saved_queries
end

def get_saved_query(saved_query_id)
resp = get "saved-queries/#{saved_query_id}"
CB::Model::SavedQuery.from_json resp.body
end

struct SavedQueryCreateParams
include JSON::Serializable
property cluster_id : String?
property name : String?
property sql : String?
property? skip_enqueue : Bool = true

def initialize(@cluster_id, @name, @sql, @skip_enqueue = true)
end
end

def create_saved_query(params : SavedQueryCreateParams)
resp = post "saved-queries", params
CB::Model::SavedQuery.from_json resp.body
end

def destroy_saved_query(saved_query_id)
resp = delete "saved-queries/#{saved_query_id}"
resp.body
end
end
end
Loading
Loading