glancer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +96 -0
  3. data/.rubocop.yml +54 -0
  4. data/CHANGELOG.md +88 -0
  5. data/CLAUDE.md +115 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/README.md +354 -0
  8. data/app/assets/config/glancer_manifest.js +1 -0
  9. data/app/assets/javascripts/glancer/application.js +15 -0
  10. data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
  11. data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
  12. data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
  13. data/app/assets/stylesheets/glancer/application.css +350 -0
  14. data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
  15. data/app/assets/stylesheets/glancer/list.css +31 -0
  16. data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
  17. data/app/assets/stylesheets/glancer/table.css +97 -0
  18. data/app/controllers/glancer/application_controller.rb +33 -0
  19. data/app/controllers/glancer/chats_controller.rb +49 -0
  20. data/app/controllers/glancer/messages_controller.rb +144 -0
  21. data/app/controllers/glancer/schema_controller.rb +29 -0
  22. data/app/controllers/glancer/settings_controller.rb +23 -0
  23. data/app/helpers/glancer/application_helper.rb +17 -0
  24. data/app/jobs/glancer/application_job.rb +6 -0
  25. data/app/jobs/glancer/process_message_job.rb +38 -0
  26. data/app/models/glancer/audit.rb +12 -0
  27. data/app/models/glancer/chat.rb +8 -0
  28. data/app/models/glancer/code_version.rb +12 -0
  29. data/app/models/glancer/embedding.rb +6 -0
  30. data/app/models/glancer/message.rb +25 -0
  31. data/app/models/glancer/setting.rb +23 -0
  32. data/app/models/glancer/sql_version.rb +6 -0
  33. data/app/views/glancer/_data/_importmap.json.erb +7 -0
  34. data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
  35. data/app/views/glancer/chats/_show.html.erb +52 -0
  36. data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
  37. data/app/views/glancer/chats/index.html.erb +10 -0
  38. data/app/views/glancer/chats/show.html.erb +1 -0
  39. data/app/views/glancer/messages/_data_table.html.erb +268 -0
  40. data/app/views/glancer/messages/_execution_error.html.erb +26 -0
  41. data/app/views/glancer/messages/_form.html.erb +93 -0
  42. data/app/views/glancer/messages/_message.html.erb +206 -0
  43. data/app/views/glancer/messages/_message_info.html.erb +176 -0
  44. data/app/views/glancer/messages/_temp_form.html.erb +100 -0
  45. data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
  46. data/app/views/glancer/schema/show.html.erb +123 -0
  47. data/app/views/glancer/settings/show.html.erb +306 -0
  48. data/app/views/glancer/shared/_icons.html.erb +126 -0
  49. data/app/views/layouts/glancer/application.html.erb +234 -0
  50. data/config/locales/glancer.en.yml +90 -0
  51. data/config/locales/glancer.es.yml +90 -0
  52. data/config/locales/glancer.pt-BR.yml +90 -0
  53. data/config/routes.rb +20 -0
  54. data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
  55. data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
  56. data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
  57. data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
  58. data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
  59. data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
  60. data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
  61. data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
  62. data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
  63. data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
  64. data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
  65. data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
  66. data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
  67. data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
  68. data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
  69. data/lib/generators/glancer/install/install_generator.rb +74 -0
  70. data/lib/generators/glancer/install/templates/glancer.rb +227 -0
  71. data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
  72. data/lib/glancer/async_runner.rb +50 -0
  73. data/lib/glancer/chart_analyzer.rb +230 -0
  74. data/lib/glancer/configuration.rb +372 -0
  75. data/lib/glancer/engine.rb +90 -0
  76. data/lib/glancer/indexer/context_indexer.rb +58 -0
  77. data/lib/glancer/indexer/model_indexer.rb +64 -0
  78. data/lib/glancer/indexer/schema_indexer.rb +171 -0
  79. data/lib/glancer/indexer.rb +50 -0
  80. data/lib/glancer/retriever.rb +114 -0
  81. data/lib/glancer/utils/logger.rb +83 -0
  82. data/lib/glancer/utils/markdown_helper.rb +56 -0
  83. data/lib/glancer/utils/result_formatter.rb +25 -0
  84. data/lib/glancer/utils/table_stats.rb +18 -0
  85. data/lib/glancer/utils/transaction.rb +59 -0
  86. data/lib/glancer/version.rb +5 -0
  87. data/lib/glancer/workflow/ar_executor.rb +104 -0
  88. data/lib/glancer/workflow/ar_extractor.rb +25 -0
  89. data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
  90. data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
  91. data/lib/glancer/workflow/builder.rb +129 -0
  92. data/lib/glancer/workflow/cache.rb +55 -0
  93. data/lib/glancer/workflow/executor.rb +72 -0
  94. data/lib/glancer/workflow/llm.rb +123 -0
  95. data/lib/glancer/workflow/prompt_builder.rb +143 -0
  96. data/lib/glancer/workflow/query_enricher.rb +117 -0
  97. data/lib/glancer/workflow/sql_extractor.rb +42 -0
  98. data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
  99. data/lib/glancer/workflow/sql_validator.rb +67 -0
  100. data/lib/glancer/workflow.rb +158 -0
  101. data/lib/glancer.rb +50 -0
  102. data/lib/tasks/glancer/tailwind.rake +8 -0
  103. data/lib/tasks/glancer.rake +99 -0
  104. data/spec/glancer_spec.rb +62 -0
  105. data/spec/lib/glancer/async_runner_spec.rb +133 -0
  106. data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
  107. data/spec/lib/glancer/configuration_spec.rb +858 -0
  108. data/spec/lib/glancer/engine_spec.rb +209 -0
  109. data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
  110. data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
  111. data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
  112. data/spec/lib/glancer/indexer_spec.rb +95 -0
  113. data/spec/lib/glancer/retriever_spec.rb +179 -0
  114. data/spec/lib/glancer/utils/logger_spec.rb +85 -0
  115. data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
  116. data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
  117. data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
  118. data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
  119. data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
  120. data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
  121. data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
  122. data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
  123. data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
  124. data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
  125. data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
  126. data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
  127. data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
  128. data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
  129. data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
  130. data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
  131. data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
  132. data/spec/lib/glancer/workflow_spec.rb +308 -0
  133. data/spec/models/glancer/audit_spec.rb +82 -0
  134. data/spec/models/glancer/chat_spec.rb +60 -0
  135. data/spec/models/glancer/code_version_spec.rb +71 -0
  136. data/spec/models/glancer/embedding_spec.rb +73 -0
  137. data/spec/models/glancer/message_spec.rb +144 -0
  138. data/spec/models/glancer/setting_spec.rb +88 -0
  139. data/spec/models/glancer/sql_version_spec.rb +4 -0
  140. data/spec/spec_helper.rb +128 -0
  141. data/spec/support/schema.rb +55 -0
  142. metadata +255 -0
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::LLM do
6
+ let(:fake_response) { double("Response", content: "The query counts all users in the system.") }
7
+ let(:fake_chat) do
8
+ double("Chat").tap do |c|
9
+ allow(c).to receive(:ask).and_return(fake_response)
10
+ allow(c).to receive(:with_instructions).and_return(c)
11
+ end
12
+ end
13
+
14
+ before do
15
+ allow(RubyLLM).to receive(:chat).and_return(fake_chat)
16
+ end
17
+
18
+ # ── .humanized_response ───────────────────────────────────────────────────
19
+
20
+ describe ".humanized_response" do
21
+ let(:question) { "How many users are there?" }
22
+ let(:sql) { "SELECT COUNT(*) FROM users" }
23
+ let(:data) { [{ "count" => 42 }] }
24
+
25
+ it "returns the LLM response content" do
26
+ result = described_class.humanized_response(question, data, sql)
27
+ expect(result).to eq("The query counts all users in the system.")
28
+ end
29
+
30
+ it "calls RubyLLM.chat with the configured chat provider and model" do
31
+ expect(RubyLLM).to receive(:chat).with(
32
+ hash_including(provider: Glancer.configuration.resolved_chat_provider,
33
+ model: Glancer.configuration.resolved_chat_model)
34
+ ).and_return(fake_chat)
35
+ described_class.humanized_response(question, data, sql)
36
+ end
37
+
38
+ it "returns a fallback string when the LLM raises" do
39
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "timeout")
40
+ result = described_class.humanized_response(question, data, sql)
41
+ expect(result).to include("failed to generate")
42
+ end
43
+
44
+ it "appends custom instructions when they exist" do
45
+ Glancer::Setting.set("custom_instructions", "Always use bullet points")
46
+ # The system prompt should include the custom instruction;
47
+ # we verify by checking that with_instructions is called or ask receives the prompt
48
+ expect(fake_chat).to receive(:ask).and_return(fake_response)
49
+ described_class.humanized_response(question, data, sql)
50
+ end
51
+ end
52
+
53
+ # ── .explain_missing_tables ───────────────────────────────────────────────
54
+
55
+ describe ".explain_missing_tables" do
56
+ let(:question) { "Show all affiliates" }
57
+ let(:error_message) { "Table validation failed: Missing table(s) in indexed schema: affiliates" }
58
+
59
+ it "returns the LLM's explanation" do
60
+ allow(fake_response).to receive(:content).and_return("The table 'affiliates' was not found.")
61
+ allow(fake_chat).to receive(:ask).and_return(fake_response)
62
+ result = described_class.explain_missing_tables(question, error_message)
63
+ expect(result).to eq("The table 'affiliates' was not found.")
64
+ end
65
+
66
+ it "returns a fallback string when the LLM raises" do
67
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "API error")
68
+ result = described_class.explain_missing_tables(question, error_message)
69
+ expect(result).to be_a(String)
70
+ expect(result).not_to be_empty
71
+ end
72
+ end
73
+
74
+ # ── .generate_title ───────────────────────────────────────────────────────
75
+
76
+ describe ".generate_title" do
77
+ let(:question) { "Show me all revenue by region for last quarter" }
78
+
79
+ it "returns the LLM-generated title (truncated to 50 chars)" do
80
+ allow(fake_response).to receive(:content).and_return("Revenue by Region Last Quarter")
81
+ allow(fake_chat).to receive(:ask).and_return(fake_response)
82
+ result = described_class.generate_title(question)
83
+ expect(result).to eq("Revenue by Region Last Quarter")
84
+ expect(result.length).to be <= 50
85
+ end
86
+
87
+ it "truncates a very long LLM response to 50 characters" do
88
+ long_title = "A" * 100
89
+ allow(fake_response).to receive(:content).and_return(long_title)
90
+ allow(fake_chat).to receive(:ask).and_return(fake_response)
91
+ result = described_class.generate_title(question)
92
+ expect(result.length).to be <= 50
93
+ end
94
+
95
+ it "falls back to a truncated question when the LLM raises" do
96
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "error")
97
+ result = described_class.generate_title(question)
98
+ expect(result).to be_a(String)
99
+ expect(result.length).to be <= 50
100
+ end
101
+ end
102
+
103
+ # ── .explain_error ────────────────────────────────────────────────────────
104
+
105
+ describe ".explain_error" do
106
+ let(:question) { "Count orders by customer" }
107
+ let(:error_message) { "no such column: customer_id" }
108
+ let(:sql) { "SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id" }
109
+
110
+ it "returns the LLM's error explanation" do
111
+ allow(fake_response).to receive(:content).and_return("We couldn't find the column customer_id.")
112
+ allow(fake_chat).to receive(:ask).and_return(fake_response)
113
+ result = described_class.explain_error(question, error_message, sql)
114
+ expect(result).to eq("We couldn't find the column customer_id.")
115
+ end
116
+
117
+ it "calls RubyLLM.chat with the chat provider config" do
118
+ expect(RubyLLM).to receive(:chat).with(
119
+ hash_including(provider: Glancer.configuration.resolved_chat_provider)
120
+ ).and_return(fake_chat)
121
+ described_class.explain_error(question, error_message, sql)
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::PromptBuilder do
6
+ let(:question) { "How many users signed up this month?" }
7
+ let(:embedding1) do
8
+ Glancer::Embedding.create!(
9
+ content: "create_table users ...",
10
+ embedding: [],
11
+ source_type: "schema",
12
+ source_path: "db/schema.rb#users"
13
+ )
14
+ end
15
+ let(:embedding2) do
16
+ Glancer::Embedding.create!(
17
+ content: "# Foreign Key Relationships\norders.user_id → users.id",
18
+ embedding: [],
19
+ source_type: "schema",
20
+ source_path: "db/schema.rb#foreign_keys"
21
+ )
22
+ end
23
+ let(:embeddings) { [embedding1, embedding2] }
24
+
25
+ let(:chat) { Glancer::Chat.create!(title: "Test") }
26
+ let(:history) { [] }
27
+
28
+ # Mock DB connection's current_database method
29
+ before do
30
+ allow(ActiveRecord::Base.connection).to receive(:current_database).and_return("test_db")
31
+ end
32
+
33
+ # ── .call ─────────────────────────────────────────────────────────────────
34
+
35
+ describe ".call" do
36
+ subject(:prompt) { described_class.call(question, embeddings, history: history) }
37
+
38
+ it "returns a String" do
39
+ expect(prompt).to be_a(String)
40
+ end
41
+
42
+ it "includes the question" do
43
+ expect(prompt).to include(question)
44
+ end
45
+
46
+ it "includes the adapter type" do
47
+ expect(prompt).to include("SQLITE")
48
+ end
49
+
50
+ it "includes schema content from embeddings" do
51
+ expect(prompt).to include("create_table users")
52
+ end
53
+
54
+ it "places foreign key content in the SCHEMA RELATIONSHIPS section" do
55
+ expect(prompt).to include("orders.user_id → users.id")
56
+ end
57
+
58
+ it "includes OUTPUT SQL ONLY directive" do
59
+ expect(prompt).to include("OUTPUT SQL ONLY")
60
+ end
61
+
62
+ context "when history contains messages" do
63
+ let(:user_msg) do
64
+ Glancer::Message.create!(chat: chat, role: "user", content: "Show orders")
65
+ end
66
+ let(:assistant_msg) do
67
+ Glancer::Message.create!(
68
+ chat: chat, role: "assistant", content: "Here are the orders",
69
+ code: "SELECT * FROM orders"
70
+ )
71
+ end
72
+ let(:history) { [user_msg, assistant_msg] }
73
+
74
+ it "includes the user message in the conversation history" do
75
+ expect(prompt).to include("Show orders")
76
+ end
77
+
78
+ it "includes the assistant SQL in the conversation history" do
79
+ expect(prompt).to include("SELECT * FROM orders")
80
+ end
81
+ end
82
+
83
+ context "when history is empty" do
84
+ it "includes a placeholder for no prior messages" do
85
+ expect(prompt).to include("no prior messages")
86
+ end
87
+ end
88
+
89
+ context "when custom instructions are set" do
90
+ before { Glancer::Setting.set("custom_instructions", "Always use LIMIT 100") }
91
+
92
+ it "includes the custom instructions" do
93
+ expect(prompt).to include("Always use LIMIT 100")
94
+ end
95
+ end
96
+
97
+ it "raises Glancer::Error on unexpected failure" do
98
+ allow(described_class).to receive(:format_embeddings_with_stats).and_raise(RuntimeError, "boom")
99
+ expect { described_class.call(question, embeddings) }.to raise_error(Glancer::Error, /Prompt construction failed/)
100
+ end
101
+ end
102
+
103
+ # ── .custom_instructions_block ────────────────────────────────────────────
104
+
105
+ describe ".custom_instructions_block" do
106
+ it "returns empty string when Glancer::Setting.get raises" do
107
+ allow(Glancer::Setting).to receive(:get).and_raise(StandardError, "DB error")
108
+ expect(described_class.custom_instructions_block).to eq("")
109
+ end
110
+
111
+ it "returns an empty string when no custom instructions are set" do
112
+ expect(described_class.custom_instructions_block).to eq("")
113
+ end
114
+
115
+ it "returns the custom instructions block when set" do
116
+ Glancer::Setting.set("custom_instructions", "Use snake_case")
117
+ result = described_class.custom_instructions_block
118
+ expect(result).to include("Use snake_case")
119
+ expect(result).to include("CUSTOM RULES")
120
+ end
121
+ end
122
+
123
+ # ── .partition_embeddings ─────────────────────────────────────────────────
124
+
125
+ describe ".partition_embeddings" do
126
+ it "separates FK embeddings from regular embeddings" do
127
+ schema_embeds, fk_text = described_class.partition_embeddings(embeddings)
128
+ expect(schema_embeds).to include(embedding1)
129
+ expect(schema_embeds).not_to include(embedding2)
130
+ expect(fk_text).to include("Foreign Key Relationships")
131
+ end
132
+
133
+ it "returns empty FK text when no FK embeddings exist" do
134
+ _schema_embeds, fk_text = described_class.partition_embeddings([embedding1])
135
+ expect(fk_text).to eq("")
136
+ end
137
+ end
138
+
139
+ # ── .format_embeddings_with_stats ─────────────────────────────────────────
140
+
141
+ describe ".format_embeddings_with_stats" do
142
+ it "joins the content of each embedding with double newlines" do
143
+ result = described_class.format_embeddings_with_stats([embedding1])
144
+ expect(result).to include("create_table users")
145
+ end
146
+ end
147
+
148
+ # ── .format_few_shot_examples ─────────────────────────────────────────────
149
+
150
+ describe ".format_few_shot_examples" do
151
+ it "returns empty string for empty examples" do
152
+ expect(described_class.format_few_shot_examples([])).to eq("")
153
+ end
154
+
155
+ it "formats examples with question and SQL" do
156
+ examples = [["Count users", "SELECT COUNT(*) FROM users"]]
157
+ result = described_class.format_few_shot_examples(examples)
158
+ expect(result).to include("Count users")
159
+ expect(result).to include("SELECT COUNT(*) FROM users")
160
+ end
161
+
162
+ it "numbers examples starting from 1" do
163
+ examples = [
164
+ ["Q1", "SELECT 1"],
165
+ ["Q2", "SELECT 2"]
166
+ ]
167
+ result = described_class.format_few_shot_examples(examples)
168
+ expect(result).to include("Example 1:")
169
+ expect(result).to include("Example 2:")
170
+ end
171
+ end
172
+
173
+ # ── .example_sql ─────────────────────────────────────────────────────────
174
+
175
+ describe ".example_sql" do
176
+ it "returns MySQL example for mysql adapter" do
177
+ result = described_class.example_sql("mysql")
178
+ expect(result).to include("DATE_FORMAT")
179
+ end
180
+
181
+ it "returns MySQL example for mysql2 adapter" do
182
+ result = described_class.example_sql("mysql2")
183
+ expect(result).to include("DATE_FORMAT")
184
+ end
185
+
186
+ it "returns PostgreSQL example for postgres adapter" do
187
+ result = described_class.example_sql("postgres")
188
+ expect(result).to include("TO_CHAR")
189
+ end
190
+
191
+ it "returns a fallback comment for unsupported adapters" do
192
+ result = described_class.example_sql("sqlite")
193
+ expect(result).to include("Example not available")
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::QueryEnricher do
6
+ let(:fake_response) { double("Response", content: "Show all records from users table") }
7
+ let(:fake_chat) do
8
+ double("Chat").tap { |c| allow(c).to receive(:ask).and_return(fake_response) }
9
+ end
10
+
11
+ before do
12
+ allow(RubyLLM).to receive(:chat).and_return(fake_chat)
13
+ end
14
+
15
+ # ── .enrich ───────────────────────────────────────────────────────────────
16
+
17
+ describe ".enrich" do
18
+ let(:question) { "Mostre todas as vendas" }
19
+ let(:table_names) { %w[orders users products] }
20
+
21
+ it "returns the LLM-enriched question" do
22
+ result = described_class.enrich(question, table_names)
23
+ expect(result).to eq("Show all records from users table")
24
+ end
25
+
26
+ it "passes provider and model from configuration" do
27
+ expect(RubyLLM).to receive(:chat).with(
28
+ hash_including(
29
+ provider: Glancer.configuration.resolved_enrichment_provider,
30
+ model: Glancer.configuration.resolved_enrichment_model
31
+ )
32
+ ).and_return(fake_chat)
33
+ described_class.enrich(question, table_names)
34
+ end
35
+
36
+ it "returns the original question when table_names is empty" do
37
+ expect(RubyLLM).not_to receive(:chat)
38
+ result = described_class.enrich(question, [])
39
+ expect(result).to eq(question)
40
+ end
41
+
42
+ it "returns the original question when the LLM call fails" do
43
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "network error")
44
+ result = described_class.enrich(question, table_names)
45
+ expect(result).to eq(question)
46
+ end
47
+
48
+ it "returns the original question when LLM returns blank content" do
49
+ allow(fake_chat).to receive(:ask).and_return(double("Response", content: " "))
50
+ result = described_class.enrich(question, table_names)
51
+ expect(result).to eq(question)
52
+ end
53
+
54
+ it "includes the table names in the prompt sent to the LLM" do
55
+ expect(fake_chat).to receive(:ask) do |prompt|
56
+ expect(prompt).to include("orders")
57
+ expect(prompt).to include("users")
58
+ fake_response
59
+ end
60
+ described_class.enrich(question, table_names)
61
+ end
62
+
63
+ it "strips whitespace from the enriched question" do
64
+ allow(fake_chat).to receive(:ask).and_return(double("Response", content: " enriched question "))
65
+ result = described_class.enrich(question, table_names)
66
+ expect(result).to eq("enriched question")
67
+ end
68
+
69
+ it "includes assistant messages with code in the history block sent to the LLM" do
70
+ chat_obj = Glancer::Chat.create!(title: "T")
71
+ msg = Glancer::Message.create!(
72
+ chat: chat_obj, role: :assistant, content: "Found 3.",
73
+ code: "User.count", code_type: "activerecord"
74
+ )
75
+ expect(fake_chat).to receive(:ask) do |prompt|
76
+ expect(prompt).to include("ASSISTANT")
77
+ expect(prompt).to include("User.count")
78
+ fake_response
79
+ end
80
+ described_class.enrich(question, table_names, history: [msg])
81
+ end
82
+
83
+ it "includes non-code messages in the history block without code format" do
84
+ chat_obj = Glancer::Chat.create!(title: "T")
85
+ msg = Glancer::Message.create!(chat: chat_obj, role: :user, content: "What is the count?")
86
+ expect(fake_chat).to receive(:ask) do |prompt|
87
+ expect(prompt).to include("USER: What is the count?")
88
+ fake_response
89
+ end
90
+ described_class.enrich(question, table_names, history: [msg])
91
+ end
92
+
93
+ it "includes referenced schema context in prompt when question has @mentioned tables" do
94
+ Glancer::Embedding.create!(
95
+ content: "create_table orders ...", embedding: [],
96
+ source_type: "schema", source_path: "/db/schema.rb#orders"
97
+ )
98
+ expect(fake_chat).to receive(:ask) do |prompt|
99
+ expect(prompt).to include("Referenced Schema")
100
+ fake_response
101
+ end
102
+ described_class.enrich("How many @orders?", ["orders"])
103
+ end
104
+
105
+ it "continues normally when schema context lookup raises (rescue path)" do
106
+ allow(Glancer::Embedding).to receive(:where).and_raise(StandardError, "db error")
107
+ result = described_class.enrich("count @orders", ["orders"])
108
+ expect(result).to be_a(String)
109
+ end
110
+
111
+ it "omits referenced schema block when @mention does not match any table" do
112
+ expect(fake_chat).to receive(:ask) do |prompt|
113
+ expect(prompt).not_to include("Referenced Schema")
114
+ fake_response
115
+ end
116
+ described_class.enrich("How many @ghost records?", table_names)
117
+ end
118
+ end
119
+
120
+ # ── .known_table_names ────────────────────────────────────────────────────
121
+
122
+ describe ".known_table_names" do
123
+ it "returns an empty array when no embeddings exist" do
124
+ expect(described_class.known_table_names).to eq([])
125
+ end
126
+
127
+ it "extracts table names from schema embedding source_paths" do
128
+ Glancer::Embedding.create!(
129
+ content: "create_table users",
130
+ embedding: [],
131
+ source_type: "schema",
132
+ source_path: "/fake/root/db/schema.rb#users"
133
+ )
134
+ expect(described_class.known_table_names).to include("users")
135
+ end
136
+
137
+ it "excludes the foreign_keys pseudo-table" do
138
+ Glancer::Embedding.create!(
139
+ content: "fk block",
140
+ embedding: [],
141
+ source_type: "schema",
142
+ source_path: "/fake/root/db/schema.rb#foreign_keys"
143
+ )
144
+ expect(described_class.known_table_names).not_to include("foreign_keys")
145
+ end
146
+
147
+ it "excludes non-schema embeddings" do
148
+ Glancer::Embedding.create!(
149
+ content: "model content",
150
+ embedding: [],
151
+ source_type: "model",
152
+ source_path: "app/models/user.rb"
153
+ )
154
+ expect(described_class.known_table_names).to be_empty
155
+ end
156
+
157
+ it "excludes inflections chunks (paths that contain /)" do
158
+ Glancer::Embedding.create!(
159
+ content: "inflections",
160
+ embedding: [],
161
+ source_type: "schema",
162
+ source_path: "config/initializers/inflections.rb"
163
+ )
164
+ expect(described_class.known_table_names).to be_empty
165
+ end
166
+
167
+ it "deduplicates table names" do
168
+ 2.times do
169
+ Glancer::Embedding.create!(
170
+ content: "table",
171
+ embedding: [],
172
+ source_type: "schema",
173
+ source_path: "/fake/db/schema.rb#orders"
174
+ )
175
+ end
176
+ expect(described_class.known_table_names.count("orders")).to eq(1)
177
+ end
178
+
179
+ it "returns [] when the DB query raises" do
180
+ allow(Glancer::Embedding).to receive(:where).and_raise(StandardError, "db error")
181
+ expect(described_class.known_table_names).to eq([])
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::SQLExtractor do
6
+ describe ".extract" do
7
+ context "when the text contains a ```sql ... ``` code block" do
8
+ it "extracts the SQL from a fenced sql block" do
9
+ text = "Here is the query:\n```sql\nSELECT * FROM users\n```"
10
+ expect(described_class.extract(text)).to eq("SELECT * FROM users")
11
+ end
12
+
13
+ it "handles multi-line SQL inside the block" do
14
+ text = "```sql\nSELECT id, name\nFROM users\nWHERE active = 1\n```"
15
+ result = described_class.extract(text)
16
+ expect(result).to include("SELECT id, name")
17
+ expect(result).to include("FROM users")
18
+ expect(result).to include("WHERE active = 1")
19
+ end
20
+
21
+ it "handles SQL blocks with uppercase SQL language hint" do
22
+ text = "```SQL\nSELECT 1\n```"
23
+ expect(described_class.extract(text)).to eq("SELECT 1")
24
+ end
25
+
26
+ it "strips surrounding whitespace from the extracted SQL" do
27
+ text = "```sql\n SELECT 1 \n```"
28
+ expect(described_class.extract(text)).to eq("SELECT 1")
29
+ end
30
+ end
31
+
32
+ context "when the text contains a plain ``` ... ``` block without a language hint" do
33
+ it "extracts the SQL from a plain fenced block" do
34
+ text = "Result:\n```\nSELECT 1\n```"
35
+ expect(described_class.extract(text)).to eq("SELECT 1")
36
+ end
37
+ end
38
+
39
+ context "when there is no code block but a SELECT keyword exists" do
40
+ it "finds the SELECT line and takes everything from there" do
41
+ text = "Sure, I can help.\nSELECT * FROM orders\nWHERE id = 1"
42
+ result = described_class.extract(text)
43
+ expect(result).to start_with("SELECT * FROM orders")
44
+ end
45
+
46
+ it "detects a WITH (CTE) keyword as SQL start" do
47
+ text = "Here's a CTE:\nWITH cte AS (SELECT 1) SELECT * FROM cte"
48
+ result = described_class.extract(text)
49
+ expect(result).to start_with("WITH cte AS")
50
+ end
51
+
52
+ it "detects EXPLAIN keyword as SQL start" do
53
+ text = "Explanation:\nEXPLAIN SELECT * FROM users"
54
+ result = described_class.extract(text)
55
+ expect(result).to start_with("EXPLAIN SELECT")
56
+ end
57
+
58
+ it "is case-insensitive for the SQL start pattern" do
59
+ text = "some text\nselect * from t"
60
+ result = described_class.extract(text)
61
+ expect(result).to include("select * from t")
62
+ end
63
+ end
64
+
65
+ context "when no SQL can be found" do
66
+ it "returns the raw text joined into a single line (raw join fallback)" do
67
+ text = "I cannot generate a query for this request."
68
+ result = described_class.extract(text)
69
+ expect(result).to include("I cannot generate")
70
+ end
71
+ end
72
+
73
+ context "error handling" do
74
+ it "raises Glancer::Error when an unexpected exception occurs" do
75
+ allow(described_class).to receive(:extract).and_call_original
76
+ bad_text = double("bad text")
77
+ allow(bad_text).to receive(:=~).and_raise(RuntimeError, "unexpected")
78
+ expect { described_class.extract(bad_text) }.to raise_error(Glancer::Error, /SQL extraction failed/)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::SQLSanitizer do
6
+ describe ".ensure_safe!" do
7
+ context "with safe SELECT queries" do
8
+ it "passes a simple SELECT without raising" do
9
+ expect { described_class.ensure_safe!("SELECT * FROM users") }.not_to raise_error
10
+ end
11
+
12
+ it "passes a SELECT with a JOIN" do
13
+ sql = "SELECT u.id, o.total FROM users u JOIN orders o ON u.id = o.user_id"
14
+ expect { described_class.ensure_safe!(sql) }.not_to raise_error
15
+ end
16
+
17
+ it "passes a WITH (CTE) query" do
18
+ sql = "WITH cte AS (SELECT id FROM users) SELECT * FROM cte"
19
+ expect { described_class.ensure_safe!(sql) }.not_to raise_error
20
+ end
21
+ end
22
+
23
+ context "with forbidden keywords" do
24
+ %w[DELETE UPDATE INSERT DROP TRUNCATE ALTER CREATE REPLACE].each do |keyword|
25
+ it "raises Glancer::Error when SQL contains #{keyword}" do
26
+ sql = "#{keyword} FROM users"
27
+ expect { described_class.ensure_safe!(sql) }.to raise_error(Glancer::Error, /#{keyword.downcase}/i)
28
+ end
29
+
30
+ it "raises Glancer::Error regardless of case for #{keyword}" do
31
+ sql = "#{keyword.downcase} FROM users"
32
+ expect { described_class.ensure_safe!(sql) }.to raise_error(Glancer::Error)
33
+ end
34
+ end
35
+ end
36
+
37
+ context "when a forbidden keyword appears inside a string literal" do
38
+ it "does NOT raise for DELETE inside a string value" do
39
+ sql = "SELECT * FROM users WHERE action = 'delete this record'"
40
+ expect { described_class.ensure_safe!(sql) }.not_to raise_error
41
+ end
42
+
43
+ it "does NOT raise for UPDATE inside a string value" do
44
+ sql = "SELECT notes FROM logs WHERE notes = 'update pending'"
45
+ expect { described_class.ensure_safe!(sql) }.not_to raise_error
46
+ end
47
+
48
+ it "does NOT raise for DROP inside a string value" do
49
+ sql = "SELECT * FROM archive WHERE label = 'drop unused'"
50
+ expect { described_class.ensure_safe!(sql) }.not_to raise_error
51
+ end
52
+ end
53
+
54
+ context "when a forbidden keyword appears inside an inline comment" do
55
+ it "does NOT raise for DELETE inside a -- comment" do
56
+ sql = "SELECT * FROM users -- delete this comment is harmless"
57
+ expect { described_class.ensure_safe!(sql) }.not_to raise_error
58
+ end
59
+
60
+ it "does NOT raise for DROP inside a /* */ block comment" do
61
+ sql = "SELECT 1 /* DROP TABLE users */"
62
+ expect { described_class.ensure_safe!(sql) }.not_to raise_error
63
+ end
64
+ end
65
+
66
+ context "with mixed-case forbidden keywords" do
67
+ it "raises for 'Delete' (title case)" do
68
+ expect { described_class.ensure_safe!("Delete FROM users") }.to raise_error(Glancer::Error)
69
+ end
70
+
71
+ it "raises for 'dRoP'" do
72
+ expect { described_class.ensure_safe!("dRoP TABLE users") }.to raise_error(Glancer::Error)
73
+ end
74
+ end
75
+ end
76
+
77
+ describe ".strip_strings_and_comments" do
78
+ it "removes single-quoted string literals" do
79
+ result = described_class.strip_strings_and_comments("select 'hello world' from t")
80
+ expect(result).not_to include("hello world")
81
+ end
82
+
83
+ it "removes inline -- comments" do
84
+ result = described_class.strip_strings_and_comments("select 1 -- this is a comment\nfrom t")
85
+ expect(result).not_to include("this is a comment")
86
+ end
87
+
88
+ it "removes block /* */ comments" do
89
+ result = described_class.strip_strings_and_comments("select /* drop tables */ 1")
90
+ expect(result).not_to include("drop tables")
91
+ end
92
+
93
+ it "preserves the SQL structure outside of stripped sections" do
94
+ result = described_class.strip_strings_and_comments("select id from users")
95
+ expect(result).to include("select id from users")
96
+ end
97
+ end
98
+ end