From 709d2bd02840f44807cea7a8cf52ee8ef759c76e Mon Sep 17 00:00:00 2001 From: Jesse Soyland Date: Fri, 1 May 2026 14:59:38 -0600 Subject: [PATCH 01/11] Add saved query management functions. --- spec/cb/saved_query_spec.cr | 152 ++++++++++++++++++++++++++++++++++++ spec/support/factory.cr | 15 ++++ src/cb/saved_query.cr | 121 ++++++++++++++++++++++++++++ src/cli.cr | 35 +++++++++ src/client/saved_query.cr | 43 ++++++++++ src/models/saved_query.cr | 11 +++ 6 files changed, 377 insertions(+) create mode 100644 spec/cb/saved_query_spec.cr create mode 100644 src/cb/saved_query.cr create mode 100644 src/client/saved_query.cr create mode 100644 src/models/saved_query.cr 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..8942db8 --- /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.not_nil! + + 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.not_nil! + + 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.not_nil!) + + query = client.create_saved_query({ + cluster_id: cluster_id.not_nil!, + name: @name.not_nil!, + 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.not_nil! + 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..b39b974 --- /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 : String) + saved_queries = [] of CB::Model::SavedQuery + query_params = Hash(String, String).new + query_params["cluster_id"] = cluster_id + 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 : String) + 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 : String) + 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 From 0c9675a6f8d27c7df736b9c7432a6060dfd10d8c Mon Sep 17 00:00:00 2001 From: Jesse Soyland Date: Mon, 4 May 2026 11:32:04 -0600 Subject: [PATCH 02/11] Fix linter errors Linter didn't like: ``` > src/cb/saved_query.cr:95:29 > [W] Lint/NotNil: Avoid using `not_nil!` > > name: @name.not_nil!, ``` So removed the `not_nil!`. It is now consistent with other functions in the existing code base by not type-annotating client method parameters --- src/cb/saved_query.cr | 12 ++++++------ src/client/saved_query.cr | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/cb/saved_query.cr b/src/cb/saved_query.cr index 8942db8..8b4907a 100644 --- a/src/cb/saved_query.cr +++ b/src/cb/saved_query.cr @@ -15,7 +15,7 @@ module CB def run validate - queries = client.get_saved_queries cluster_id.not_nil! + queries = client.get_saved_queries cluster_id if queries.empty? output.puts "no saved queries" @@ -65,7 +65,7 @@ module CB def run validate - query = client.get_saved_query query_id.not_nil! + query = client.get_saved_query query_id filename = @file || "#{query.name.gsub(/[^a-zA-Z0-9_\-]/, "_")}.sql" File.write(filename, query.sql) @@ -88,11 +88,11 @@ module CB def run validate - sql = File.read(@file.not_nil!) + sql = File.read(@file.to_s) query = client.create_saved_query({ - cluster_id: cluster_id.not_nil!, - name: @name.not_nil!, + cluster_id: cluster_id, + name: @name, sql: sql, skip_enqueue: true, }) @@ -114,7 +114,7 @@ module CB def run validate - client.destroy_saved_query query_id.not_nil! + client.destroy_saved_query query_id output << "saved query destroyed" << '\n' end end diff --git a/src/client/saved_query.cr b/src/client/saved_query.cr index b39b974..177fe84 100644 --- a/src/client/saved_query.cr +++ b/src/client/saved_query.cr @@ -8,10 +8,10 @@ module CB property saved_queries : Array(CB::Model::SavedQuery) = [] of CB::Model::SavedQuery end - def get_saved_queries(cluster_id : String) + def get_saved_queries(cluster_id) saved_queries = [] of CB::Model::SavedQuery query_params = Hash(String, String).new - query_params["cluster_id"] = cluster_id + query_params["cluster_id"] = cluster_id.to_s query_params["order_field"] = "name" loop do @@ -25,7 +25,7 @@ module CB saved_queries end - def get_saved_query(saved_query_id : String) + def get_saved_query(saved_query_id) resp = get "saved-queries/#{saved_query_id}" CB::Model::SavedQuery.from_json resp.body end @@ -35,7 +35,7 @@ module CB CB::Model::SavedQuery.from_json resp.body end - def destroy_saved_query(saved_query_id : String) + def destroy_saved_query(saved_query_id) resp = delete "saved-queries/#{saved_query_id}" resp.body end From adbdcb00e8918f0a6d6658794f50a2ae75ff6006 Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 18:11:17 -0400 Subject: [PATCH 03/11] debug: dropping new spec --- spec/cb/saved_query_spec.cr | 152 ------------------------------------ 1 file changed, 152 deletions(-) delete mode 100644 spec/cb/saved_query_spec.cr diff --git a/spec/cb/saved_query_spec.cr b/spec/cb/saved_query_spec.cr deleted file mode 100644 index 51c914f..0000000 --- a/spec/cb/saved_query_spec.cr +++ /dev/null @@ -1,152 +0,0 @@ -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 From 62d26b25a6e2adccb09c4d8f0ec7a486262397b1 Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 18:25:07 -0400 Subject: [PATCH 04/11] debug: add back saved query list spec --- spec/cb/saved_query_spec.cr | 152 ++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 spec/cb/saved_query_spec.cr diff --git a/spec/cb/saved_query_spec.cr b/spec/cb/saved_query_spec.cr new file mode 100644 index 0000000..de31608 --- /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 From 5627e7a849bd9d75402a4a815271a9326067bf0f Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 18:32:38 -0400 Subject: [PATCH 05/11] debug: remove #run block from saved query list spec --- spec/cb/saved_query_spec.cr | 48 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/spec/cb/saved_query_spec.cr b/spec/cb/saved_query_spec.cr index de31608..efe1933 100644 --- a/spec/cb/saved_query_spec.cr +++ b/spec/cb/saved_query_spec.cr @@ -15,30 +15,30 @@ Spectator.describe CB::SavedQueryList do 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 + # 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 From 9df67314e10ba300992845d9cce67a02a2d2544e Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 19:00:19 -0400 Subject: [PATCH 06/11] debug: isolate to mock --- spec/cb/saved_query_spec.cr | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/cb/saved_query_spec.cr b/spec/cb/saved_query_spec.cr index efe1933..70ecbf8 100644 --- a/spec/cb/saved_query_spec.cr +++ b/spec/cb/saved_query_spec.cr @@ -1,19 +1,19 @@ require "../spec_helper" Spectator.describe CB::SavedQueryList do - subject(action) { described_class.new client: client, output: IO::Memory.new } + # 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")] } + # 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 "#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 From da0f9a7f0f8a58f596fa999039460995d006921a Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 19:15:47 -0400 Subject: [PATCH 07/11] debug: type create_saved_query params to fix codegen crash --- src/cb/saved_query.cr | 11 +++++------ src/client/saved_query.cr | 13 ++++++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/cb/saved_query.cr b/src/cb/saved_query.cr index 8b4907a..599290e 100644 --- a/src/cb/saved_query.cr +++ b/src/cb/saved_query.cr @@ -90,12 +90,11 @@ module CB validate sql = File.read(@file.to_s) - query = client.create_saved_query({ - cluster_id: cluster_id, - name: @name, - sql: sql, - skip_enqueue: true, - }) + query = client.create_saved_query(Client::SavedQueryCreateParams.new( + cluster_id: cluster_id, + name: @name, + sql: sql, + )) output << "created saved query " << query.id << '\n' end diff --git a/src/client/saved_query.cr b/src/client/saved_query.cr index 177fe84..86666a9 100644 --- a/src/client/saved_query.cr +++ b/src/client/saved_query.cr @@ -30,7 +30,18 @@ module CB CB::Model::SavedQuery.from_json resp.body end - def create_saved_query(params) + 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 From ff62e823f7f2347716344b345be81b03d109bde7 Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 19:22:18 -0400 Subject: [PATCH 08/11] debug: use property? --- src/client/saved_query.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/saved_query.cr b/src/client/saved_query.cr index 86666a9..5a5eadf 100644 --- a/src/client/saved_query.cr +++ b/src/client/saved_query.cr @@ -35,7 +35,7 @@ module CB property cluster_id : String? property name : String? property sql : String? - property skip_enqueue : Bool = true + property? skip_enqueue : Bool = true def initialize(@cluster_id, @name, @sql, @skip_enqueue = true) end From 988a88edd9befa29e2650d069bfd6b0edc3583e4 Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 19:31:38 -0400 Subject: [PATCH 09/11] debug: restore full saved query spec after typed params fix --- spec/cb/saved_query_spec.cr | 288 ++++++++++++++++++------------------ 1 file changed, 144 insertions(+), 144 deletions(-) diff --git a/spec/cb/saved_query_spec.cr b/spec/cb/saved_query_spec.cr index 70ecbf8..51c914f 100644 --- a/spec/cb/saved_query_spec.cr +++ b/spec/cb/saved_query_spec.cr @@ -1,152 +1,152 @@ require "../spec_helper" Spectator.describe CB::SavedQueryList do - # subject(action) { described_class.new client: client, output: IO::Memory.new } + 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 + 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 +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 From 2db623aea6b6d962b75be16dde763c5a8c483937 Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Thu, 14 May 2026 19:48:58 -0400 Subject: [PATCH 10/11] debug: use single codegen thread for specs --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 193829b..5c1fac5 100644 --- a/flake.nix +++ b/flake.nix @@ -82,7 +82,7 @@ name = "specs"; src = specSrc; buildInputs = [ pkgs.libssh2 ]; - installPhase = "mkdir $out && HOME=$TMP crystal spec --progress"; + installPhase = "mkdir $out && HOME=$TMP crystal spec --progress --threads 1"; shardsFile = specSrc + "/shards.nix"; doCheck = false; dontPatch = true; From ec87313266a3ac51b3d9a24aaa6090acd2c67d88 Mon Sep 17 00:00:00 2001 From: Adam Brightwell Date: Fri, 15 May 2026 08:51:25 -0400 Subject: [PATCH 11/11] debug: move to large runner --- .github/workflows/ci.yml | 2 +- flake.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b87cfd..c6ed9c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/flake.nix b/flake.nix index 5c1fac5..193829b 100644 --- a/flake.nix +++ b/flake.nix @@ -82,7 +82,7 @@ name = "specs"; src = specSrc; buildInputs = [ pkgs.libssh2 ]; - installPhase = "mkdir $out && HOME=$TMP crystal spec --progress --threads 1"; + installPhase = "mkdir $out && HOME=$TMP crystal spec --progress"; shardsFile = specSrc + "/shards.nix"; doCheck = false; dontPatch = true;