diff --git a/spec/cb/saved_query_spec.cr b/spec/cb/saved_query_spec.cr new file mode 100644 index 0000000..51c914f --- /dev/null +++ b/spec/cb/saved_query_spec.cr @@ -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 diff --git a/spec/support/factory.cr b/spec/support/factory.cr index 87b2854..95f2bb9 100644 --- a/spec/support/factory.cr +++ b/spec/support/factory.cr @@ -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 diff --git a/src/cb/saved_query.cr b/src/cb/saved_query.cr new file mode 100644 index 0000000..8b4907a --- /dev/null +++ b/src/cb/saved_query.cr @@ -0,0 +1,121 @@ +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({ + cluster_id: cluster_id, + name: @name, + sql: sql, + skip_enqueue: true, + }) + + 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 diff --git a/src/cli.cr b/src/cli.cr index 8754027..108f271 100755 --- a/src/cli.cr +++ b/src/cli.cr @@ -913,6 +913,41 @@ op = OptionParser.new do |parser| end end + parser.on("saved-query", "Manage saved queries") do + parser.banner = "cb saved-query " + + 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: .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 " suspend = set_action ClusterSuspend diff --git a/src/client/saved_query.cr b/src/client/saved_query.cr new file mode 100644 index 0000000..177fe84 --- /dev/null +++ b/src/client/saved_query.cr @@ -0,0 +1,43 @@ +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 + + def create_saved_query(params) + 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 diff --git a/src/models/saved_query.cr b/src/models/saved_query.cr new file mode 100644 index 0000000..0601f7c --- /dev/null +++ b/src/models/saved_query.cr @@ -0,0 +1,11 @@ +module CB::Model + jrecord SavedQuery, + id : String, + name : String, + sql : String? = nil, + cluster_id : String = "", + team_id : String = "", + saved_query_folder_id : String? = nil, + created_at : Time = Time::ZERO, + updated_at : Time = Time::ZERO +end