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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::ARSanitizer do
6
+ describe ".ensure_safe!" do
7
+ context "with safe read-only expressions" do
8
+ it "passes User.all" do
9
+ expect { described_class.ensure_safe!("User.all") }.not_to raise_error
10
+ end
11
+
12
+ it "passes a where chain" do
13
+ expect { described_class.ensure_safe!("User.where(active: true).count") }.not_to raise_error
14
+ end
15
+
16
+ it "passes joins + select" do
17
+ code = "Order.joins(:items).select('orders.id, count(items.id) as item_count').group('orders.id')"
18
+ expect { described_class.ensure_safe!(code) }.not_to raise_error
19
+ end
20
+
21
+ it "passes pluck" do
22
+ expect { described_class.ensure_safe!("User.pluck(:email)") }.not_to raise_error
23
+ end
24
+
25
+ it "passes aggregate methods" do
26
+ expect { described_class.ensure_safe!("Order.sum(:total)") }.not_to raise_error
27
+ expect { described_class.ensure_safe!("Order.average(:amount)") }.not_to raise_error
28
+ expect { described_class.ensure_safe!("Order.minimum(:created_at)") }.not_to raise_error
29
+ end
30
+
31
+ it "does not block .update_at (column-like word in a scope)" do
32
+ # .updated_at is a column accessor, not the .update write method
33
+ expect { described_class.ensure_safe!("User.order(:updated_at)") }.not_to raise_error
34
+ end
35
+
36
+ it "does not block .created_at" do
37
+ expect { described_class.ensure_safe!("User.where('created_at > ?', 1.week.ago)") }.not_to raise_error
38
+ end
39
+ end
40
+
41
+ context "with destructive ActiveRecord methods" do
42
+ it "blocks .destroy" do
43
+ expect { described_class.ensure_safe!("User.first.destroy") }
44
+ .to raise_error(Glancer::Error, /destroy/)
45
+ end
46
+
47
+ it "blocks .destroy_all" do
48
+ expect { described_class.ensure_safe!("User.where(active: false).destroy_all") }
49
+ .to raise_error(Glancer::Error, /destroy/)
50
+ end
51
+
52
+ it "blocks .delete" do
53
+ expect { described_class.ensure_safe!("User.first.delete") }
54
+ .to raise_error(Glancer::Error, /delete/)
55
+ end
56
+
57
+ it "blocks .delete_all" do
58
+ expect { described_class.ensure_safe!("User.delete_all") }
59
+ .to raise_error(Glancer::Error, /delete/)
60
+ end
61
+
62
+ it "blocks .update (write form)" do
63
+ expect { described_class.ensure_safe!("User.first.update(name: 'x')") }
64
+ .to raise_error(Glancer::Error, /update/)
65
+ end
66
+
67
+ it "blocks .update! (bang form)" do
68
+ expect { described_class.ensure_safe!("User.first.update!(name: 'x')") }
69
+ .to raise_error(Glancer::Error, /update/)
70
+ end
71
+
72
+ it "blocks .update_all" do
73
+ expect { described_class.ensure_safe!("User.update_all(active: false)") }
74
+ .to raise_error(Glancer::Error, /update_all/)
75
+ end
76
+
77
+ it "blocks .save" do
78
+ expect { described_class.ensure_safe!("u = User.first; u.name = 'x'; u.save") }
79
+ .to raise_error(Glancer::Error, /save/)
80
+ end
81
+
82
+ it "blocks .save!" do
83
+ expect { described_class.ensure_safe!("User.first.save!") }
84
+ .to raise_error(Glancer::Error, /save/)
85
+ end
86
+
87
+ it "blocks .create" do
88
+ expect { described_class.ensure_safe!("User.create(name: 'Eve')") }
89
+ .to raise_error(Glancer::Error, /create/)
90
+ end
91
+
92
+ it "blocks .create!" do
93
+ expect { described_class.ensure_safe!("User.create!(name: 'Eve')") }
94
+ .to raise_error(Glancer::Error, /create/)
95
+ end
96
+
97
+ it "blocks .insert" do
98
+ expect { described_class.ensure_safe!("User.insert(name: 'Eve')") }
99
+ .to raise_error(Glancer::Error, /insert/)
100
+ end
101
+
102
+ it "blocks .upsert" do
103
+ expect { described_class.ensure_safe!("User.upsert(id: 1, name: 'Eve')") }
104
+ .to raise_error(Glancer::Error, /upsert/)
105
+ end
106
+
107
+ it "blocks .touch" do
108
+ expect { described_class.ensure_safe!("User.first.touch") }
109
+ .to raise_error(Glancer::Error, /touch/)
110
+ end
111
+ end
112
+
113
+ context "with shell / OS execution" do
114
+ it "blocks backtick shell execution" do
115
+ expect { described_class.ensure_safe!("`ls -la`") }
116
+ .to raise_error(Glancer::Error, /shell/)
117
+ end
118
+
119
+ it "blocks system() call" do
120
+ expect { described_class.ensure_safe!("system('rm -rf /')") }
121
+ .to raise_error(Glancer::Error, /shell/)
122
+ end
123
+
124
+ it "blocks exec()" do
125
+ expect { described_class.ensure_safe!("exec('cat /etc/passwd')") }
126
+ .to raise_error(Glancer::Error, /shell/)
127
+ end
128
+ end
129
+
130
+ context "with eval" do
131
+ it "blocks eval()" do
132
+ expect { described_class.ensure_safe!("eval('User.where(1=1)')") }
133
+ .to raise_error(Glancer::Error, /eval/)
134
+ end
135
+
136
+ it "blocks instance_eval" do
137
+ expect { described_class.ensure_safe!("User.instance_eval { delete_all }") }
138
+ .to raise_error(Glancer::Error, /eval/)
139
+ end
140
+ end
141
+
142
+ context "with file writes" do
143
+ it "blocks FileUtils" do
144
+ expect { described_class.ensure_safe!("FileUtils.rm_rf('/')") }
145
+ .to raise_error(Glancer::Error, /file write/)
146
+ end
147
+
148
+ it "blocks File.write" do
149
+ expect { described_class.ensure_safe!("File.write('/etc/hosts', 'evil')") }
150
+ .to raise_error(Glancer::Error, /file write/)
151
+ end
152
+ end
153
+
154
+ context "with dynamic loading" do
155
+ it "blocks require" do
156
+ expect { described_class.ensure_safe!("require 'open3'") }
157
+ .to raise_error(Glancer::Error, /load/)
158
+ end
159
+
160
+ it "blocks load" do
161
+ expect { described_class.ensure_safe!("load '/evil.rb'") }
162
+ .to raise_error(Glancer::Error, /load/)
163
+ end
164
+ end
165
+
166
+ context "when an unexpected StandardError occurs during sanitization" do
167
+ it "wraps the error in Glancer::Error" do
168
+ allow(described_class).to receive(:ensure_safe!).and_call_original
169
+ allow_any_instance_of(String).to receive(:match?).and_raise(StandardError, "regex engine failure")
170
+ expect { described_class.ensure_safe!("User.count") }
171
+ .to raise_error(Glancer::Error, /AR sanitization failed/)
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::Builder do
6
+ let(:fake_response) { double("Response", content: "SELECT * FROM users") }
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
+ # ── .build_sql ────────────────────────────────────────────────────────────
19
+
20
+ describe ".build_sql" do
21
+ let(:question) { "Show all users" }
22
+ let(:embeddings) { [] }
23
+
24
+ it "returns the SQL content from the LLM response" do
25
+ result = described_class.build_sql(question, embeddings)
26
+ expect(result).to eq("SELECT * FROM users")
27
+ end
28
+
29
+ it "calls RubyLLM.chat with the configured provider and model" do
30
+ expect(RubyLLM).to receive(:chat).with(
31
+ hash_including(provider: Glancer.configuration.resolved_code_provider,
32
+ model: Glancer.configuration.resolved_code_model)
33
+ ).and_return(fake_chat)
34
+ described_class.build_sql(question, embeddings)
35
+ end
36
+
37
+ it "passes history to the prompt builder" do
38
+ chat = Glancer::Chat.create!(title: "C")
39
+ msg = Glancer::Message.create!(chat: chat, role: "user", content: "previous Q")
40
+ described_class.build_sql(question, embeddings, history: [msg])
41
+ # No error means history was processed
42
+ end
43
+
44
+ it "raises Glancer::Error when the LLM call fails" do
45
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "network error")
46
+ expect { described_class.build_sql(question, embeddings) }
47
+ .to raise_error(Glancer::Error, /code generation failed/)
48
+ end
49
+
50
+ it "includes recent audit examples as few-shot examples" do
51
+ Glancer::Audit.create!(
52
+ question: "How many users?",
53
+ code: "SELECT COUNT(*) FROM users /*glancer,run_id:abc*/",
54
+ adapter: Glancer.configuration.resolved_adapter.to_s,
55
+ run_id: SecureRandom.uuid,
56
+ executed_at: Time.current
57
+ )
58
+ expect(fake_chat).to receive(:ask).and_return(fake_response)
59
+ described_class.build_sql(question, embeddings)
60
+ end
61
+ end
62
+
63
+ # ── .recent_examples ──────────────────────────────────────────────────────
64
+
65
+ describe ".recent_examples" do
66
+ it "returns an empty array when no audits exist" do
67
+ expect(described_class.recent_examples).to eq([])
68
+ end
69
+
70
+ it "returns up to 3 most recent audit pairs [question, code]" do
71
+ 4.times do |i|
72
+ Glancer::Audit.create!(
73
+ question: "Q#{i}",
74
+ code: "SELECT #{i}",
75
+ adapter: Glancer.configuration.resolved_adapter.to_s,
76
+ run_id: SecureRandom.uuid,
77
+ executed_at: Time.current - (4 - i).seconds
78
+ )
79
+ end
80
+ examples = described_class.recent_examples
81
+ expect(examples.size).to eq(3)
82
+ # Most recent first
83
+ expect(examples.first[0]).to eq("Q3")
84
+ end
85
+
86
+ it "only returns audits matching the current adapter" do
87
+ Glancer::Audit.create!(
88
+ question: "PostgreSQL Q",
89
+ code: "SELECT 1",
90
+ adapter: "postgres",
91
+ run_id: SecureRandom.uuid,
92
+ executed_at: Time.current
93
+ )
94
+ # SQLite adapter in tests — postgres audit should NOT appear
95
+ expect(described_class.recent_examples).to be_empty
96
+ end
97
+
98
+ it "returns [] when the Audit query raises (rescue path)" do
99
+ allow(Glancer::Audit).to receive(:where).and_raise(StandardError, "DB unavailable")
100
+ expect(described_class.recent_examples).to eq([])
101
+ end
102
+
103
+ it "excludes audits without a question" do
104
+ Glancer::Audit.create!(
105
+ question: nil,
106
+ code: "SELECT 1",
107
+ adapter: Glancer.configuration.resolved_adapter.to_s,
108
+ run_id: SecureRandom.uuid,
109
+ executed_at: Time.current
110
+ )
111
+ expect(described_class.recent_examples).to be_empty
112
+ end
113
+ end
114
+
115
+ # ── .build_ar_code ────────────────────────────────────────────────────────
116
+
117
+ describe ".build_ar_code" do
118
+ let(:ar_response) { double("Response", content: "```ruby\nUser.count\n```") }
119
+ let(:ar_chat) do
120
+ double("Chat").tap { |c| allow(c).to receive(:ask).and_return(ar_response) }
121
+ end
122
+
123
+ before { allow(RubyLLM).to receive(:chat).and_return(ar_chat) }
124
+
125
+ it "returns the AR code content from the LLM response" do
126
+ result = described_class.build_ar_code("count users", [])
127
+ expect(result).to eq("```ruby\nUser.count\n```")
128
+ end
129
+
130
+ it "calls RubyLLM.chat with the configured code provider and model" do
131
+ expect(RubyLLM).to receive(:chat).with(
132
+ hash_including(provider: Glancer.configuration.resolved_code_provider,
133
+ model: Glancer.configuration.resolved_code_model)
134
+ ).and_return(ar_chat)
135
+ described_class.build_ar_code("count users", [])
136
+ end
137
+
138
+ it "raises Glancer::Error when the LLM call fails" do
139
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "timeout")
140
+ expect { described_class.build_ar_code("count users", []) }
141
+ .to raise_error(Glancer::Error, /AR code generation failed/)
142
+ end
143
+ end
144
+
145
+ # ── .fix_ar_code ──────────────────────────────────────────────────────────
146
+
147
+ describe ".fix_ar_code" do
148
+ let(:fixed_ar_response) { double("Response", content: "```ruby\nGlancer::Chat.count\n```") }
149
+ let(:fix_ar_chat) do
150
+ double("Chat").tap { |c| allow(c).to receive(:ask).and_return(fixed_ar_response) }
151
+ end
152
+
153
+ before { allow(RubyLLM).to receive(:chat).and_return(fix_ar_chat) }
154
+
155
+ it "returns the extracted AR code from the LLM correction" do
156
+ result = described_class.fix_ar_code("BadModel.all", "uninitialized constant")
157
+ expect(result).to eq("Glancer::Chat.count")
158
+ end
159
+
160
+ it "calls RubyLLM.chat to get the correction" do
161
+ expect(RubyLLM).to receive(:chat).and_return(fix_ar_chat)
162
+ described_class.fix_ar_code("BadModel.all", "uninitialized constant")
163
+ end
164
+
165
+ it "raises Glancer::Error when the LLM call fails" do
166
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "LLM down")
167
+ expect { described_class.fix_ar_code("BadModel.all", "error") }
168
+ .to raise_error(Glancer::Error, /AR code correction failed/)
169
+ end
170
+ end
171
+
172
+ # ── .fix_sql ─────────────────────────────────────────────────────────────
173
+
174
+ describe ".fix_sql" do
175
+ let(:failed_sql) { "SELCT * FROM users" }
176
+ let(:error_message) { "syntax error near SELCT" }
177
+ let(:fixed_response) { double("Response", content: "```sql\nSELECT * FROM users\n```") }
178
+ let(:fix_chat) do
179
+ double("Chat").tap do |c|
180
+ allow(c).to receive(:ask).and_return(fixed_response)
181
+ end
182
+ end
183
+
184
+ before do
185
+ allow(RubyLLM).to receive(:chat).and_return(fix_chat)
186
+ end
187
+
188
+ it "returns extracted SQL from the LLM's corrected response" do
189
+ result = described_class.fix_sql(failed_sql, error_message)
190
+ expect(result).to eq("SELECT * FROM users")
191
+ end
192
+
193
+ it "calls RubyLLM.chat to get the correction" do
194
+ expect(RubyLLM).to receive(:chat).and_return(fix_chat)
195
+ described_class.fix_sql(failed_sql, error_message)
196
+ end
197
+
198
+ it "raises Glancer::Error when the LLM call fails" do
199
+ allow(RubyLLM).to receive(:chat).and_raise(StandardError, "LLM down")
200
+ expect { described_class.fix_sql(failed_sql, error_message) }
201
+ .to raise_error(Glancer::Error, /code correction workflow failed/)
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::Cache do
6
+ let(:question) { "How many users are there?" }
7
+ let(:result) { { question: question, content: "42 users", code: "SELECT COUNT(*) FROM users", successful: true } }
8
+
9
+ before { described_class.clear }
10
+
11
+ describe ".fetch" do
12
+ context "when the cache is empty" do
13
+ it "returns nil for an unknown question" do
14
+ expect(described_class.fetch(question)).to be_nil
15
+ end
16
+ end
17
+
18
+ context "when the cache has a fresh entry" do
19
+ before { described_class.write(question, result) }
20
+
21
+ it "returns the cached entry" do
22
+ entry = described_class.fetch(question)
23
+ expect(entry).not_to be_nil
24
+ expect(entry[:content]).to eq("42 users")
25
+ end
26
+
27
+ it "includes the cached_at timestamp" do
28
+ entry = described_class.fetch(question)
29
+ expect(entry[:cached_at]).to be_a(Time)
30
+ end
31
+ end
32
+
33
+ context "when the cache entry is expired" do
34
+ before do
35
+ described_class.write(question, result)
36
+ end
37
+
38
+ it "returns nil and removes the expired entry" do
39
+ Glancer.configuration.workflow_cache_ttl = 1 # 1 second TTL
40
+ # Simulate time passing beyond TTL
41
+ old_time = Time.now - 10
42
+ allow(Time).to receive(:current).and_return(old_time + 2)
43
+
44
+ # Write with 'old' cached_at by manipulating after the fact
45
+ described_class.clear
46
+ described_class.write(question, result)
47
+ # Now override Time.current to be far in the future
48
+ allow(Time).to receive(:current).and_return(Time.now + 3600)
49
+
50
+ expect(described_class.fetch(question)).to be_nil
51
+ end
52
+ end
53
+ end
54
+
55
+ describe ".write" do
56
+ it "stores the result merged with cached_at" do
57
+ described_class.write(question, result)
58
+ entry = described_class.fetch(question)
59
+ expect(entry).not_to be_nil
60
+ expect(entry[:cached_at]).to be_a(Time)
61
+ expect(entry[:code]).to eq("SELECT COUNT(*) FROM users")
62
+ end
63
+
64
+ it "overwrites an existing entry for the same question" do
65
+ described_class.write(question, result)
66
+ new_result = result.merge(content: "Updated content")
67
+ described_class.write(question, new_result)
68
+ entry = described_class.fetch(question)
69
+ expect(entry[:content]).to eq("Updated content")
70
+ end
71
+ end
72
+
73
+ describe ".clear" do
74
+ it "removes all cached entries" do
75
+ described_class.write(question, result)
76
+ described_class.write("another question", result)
77
+ described_class.clear
78
+ expect(described_class.fetch(question)).to be_nil
79
+ expect(described_class.fetch("another question")).to be_nil
80
+ end
81
+ end
82
+
83
+ # ── rescue paths ─────────────────────────────────────────────────────────
84
+
85
+ describe "rescue paths" do
86
+ it "fetch returns nil when an internal error occurs (e.g. expired? raises)" do
87
+ described_class.write(question, result)
88
+ allow(described_class).to receive(:expired?).and_raise(RuntimeError, "unexpected")
89
+ expect(described_class.fetch(question)).to be_nil
90
+ end
91
+
92
+ it "write handles errors gracefully without raising" do
93
+ store_mock = double("store")
94
+ allow(store_mock).to receive(:[]=).and_raise(StandardError, "write error")
95
+ allow(store_mock).to receive(:clear)
96
+ allow(store_mock).to receive(:[]).and_return(nil)
97
+ Glancer::Workflow::Cache.class_variable_set(:@@store, store_mock)
98
+ allow(Glancer::Utils::Logger).to receive(:error)
99
+ allow(Glancer::Utils::Logger).to receive(:debug)
100
+ expect { described_class.write(question, result) }.not_to raise_error
101
+ ensure
102
+ Glancer::Workflow::Cache.class_variable_set(:@@store, {})
103
+ end
104
+
105
+ it "clear handles unexpected errors gracefully" do
106
+ store_mock = double("store")
107
+ allow(store_mock).to receive(:clear).and_raise(StandardError, "cannot clear")
108
+ Glancer::Workflow::Cache.class_variable_set(:@@store, store_mock)
109
+ allow(Glancer::Utils::Logger).to receive(:error)
110
+ allow(Glancer::Utils::Logger).to receive(:debug)
111
+ expect { described_class.clear }.not_to raise_error
112
+ ensure
113
+ Glancer::Workflow::Cache.class_variable_set(:@@store, {})
114
+ end
115
+ end
116
+
117
+ describe ".expired?" do
118
+ context "with a fresh entry" do
119
+ it "returns false" do
120
+ entry = { cached_at: Time.now }
121
+ allow(Time).to receive(:current).and_return(Time.now)
122
+ Glancer.configuration.workflow_cache_ttl = 300
123
+ expect(described_class.expired?(entry)).to be(false)
124
+ end
125
+ end
126
+
127
+ context "with an old entry beyond TTL" do
128
+ it "returns true" do
129
+ Glancer.configuration.workflow_cache_ttl = 1
130
+ entry = { cached_at: Time.now - 60 }
131
+ expect(described_class.expired?(entry)).to be(true)
132
+ end
133
+ end
134
+
135
+ context "when entry has no cached_at" do
136
+ it "returns true (safe default)" do
137
+ entry = {}
138
+ expect(described_class.expired?(entry)).to be(true)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::Executor do
6
+ let(:valid_sql) { "SELECT 1 AS n" }
7
+ let(:update_sql) { "UPDATE users SET name = 'x'" }
8
+
9
+ # ── Security guard ────────────────────────────────────────────────────────
10
+
11
+ describe ".execute — security guard" do
12
+ it "raises Glancer::Error for UPDATE SQL" do
13
+ expect { described_class.execute(update_sql) }
14
+ .to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
15
+ end
16
+
17
+ it "raises Glancer::Error for DELETE SQL" do
18
+ expect { described_class.execute("DELETE FROM users") }
19
+ .to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
20
+ end
21
+
22
+ it "raises Glancer::Error for INSERT SQL" do
23
+ expect { described_class.execute("INSERT INTO users VALUES (1)") }
24
+ .to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
25
+ end
26
+
27
+ it "raises Glancer::Error for DROP SQL" do
28
+ expect { described_class.execute("DROP TABLE users") }
29
+ .to raise_error(Glancer::Error, /Only SELECT queries are allowed/)
30
+ end
31
+
32
+ it "allows SELECT queries through" do
33
+ result = described_class.execute(valid_sql)
34
+ expect(result).to be_an(Array)
35
+ end
36
+
37
+ it "allows WITH (CTE) queries through" do
38
+ cte_sql = "WITH t AS (SELECT 1 AS x) SELECT x FROM t"
39
+ expect { described_class.execute(cte_sql) }.not_to raise_error
40
+ end
41
+ end
42
+
43
+ # ── Successful execution ──────────────────────────────────────────────────
44
+
45
+ describe ".execute — successful execution" do
46
+ it "returns an array of result rows" do
47
+ result = described_class.execute(valid_sql)
48
+ expect(result).to be_an(Array)
49
+ expect(result.first).to include("n" => 1)
50
+ end
51
+
52
+ it "creates a Glancer::Audit record after success" do
53
+ expect { described_class.execute(valid_sql, original_question: "What is 1?") }
54
+ .to change(Glancer::Audit, :count).by(1)
55
+ end
56
+
57
+ it "stores the run_id in the audit record" do
58
+ described_class.execute(valid_sql)
59
+ audit = Glancer::Audit.last
60
+ expect(audit.run_id).to be_a(String)
61
+ expect(audit.run_id).not_to be_empty
62
+ end
63
+
64
+ it "appends the /*glancer,run_id:...*/ comment to the stored code" do
65
+ described_class.execute(valid_sql)
66
+ audit = Glancer::Audit.last
67
+ expect(audit.code).to include("/*glancer,run_id:")
68
+ end
69
+
70
+ it "stores the original_question in the audit record" do
71
+ described_class.execute(valid_sql, original_question: "count rows")
72
+ expect(Glancer::Audit.last.question).to eq("count rows")
73
+ end
74
+
75
+ it "stores the message_id when provided" do
76
+ chat = Glancer::Chat.create!(title: "C")
77
+ msg = Glancer::Message.create!(chat: chat, role: "assistant", content: "x")
78
+ described_class.execute(valid_sql, message_id: msg.id)
79
+ expect(Glancer::Audit.last.message_id).to eq(msg.id)
80
+ end
81
+
82
+ it "rolls back the transaction (read-only safety) — any DML inside rolled back" do
83
+ # The executor always rolls back even successful reads
84
+ # We verify by checking that the glancer_audits table's contents match our expectations
85
+ # but the executed SELECT 1 has no side-effects in any case; deeper test via custom SQL
86
+ result = described_class.execute("SELECT COUNT(*) AS cnt FROM glancer_chats")
87
+ expect(result.first["cnt"]).to eq(0)
88
+ end
89
+ end
90
+
91
+ # ── Retry on failure ──────────────────────────────────────────────────────
92
+
93
+ describe ".execute — retry with Builder.fix_sql" do
94
+ let(:bad_sql) { "SELECT * FROM nonexistent_table_xyz" }
95
+ let(:fixed_sql) { "SELECT 1 AS n" }
96
+
97
+ before do
98
+ allow(Glancer::Workflow::Builder).to receive(:fix_sql).and_return(fixed_sql)
99
+ end
100
+
101
+ it "calls Builder.fix_sql on first failure and retries" do
102
+ expect(Glancer::Workflow::Builder).to receive(:fix_sql).at_least(:once).and_return(fixed_sql)
103
+ described_class.execute(bad_sql)
104
+ end
105
+
106
+ it "returns the result from the fixed SQL" do
107
+ result = described_class.execute(bad_sql)
108
+ expect(result).to be_an(Array)
109
+ end
110
+
111
+ it "returns an error hash after 3 failed attempts" do
112
+ allow(Glancer::Workflow::Builder).to receive(:fix_sql).and_return(bad_sql)
113
+ result = described_class.execute(bad_sql, attempt: 1)
114
+ expect(result).to be_a(Hash)
115
+ expect(result[:error]).to be(true)
116
+ expect(result[:message]).to be_a(String)
117
+ expect(result[:last_code]).to eq(bad_sql)
118
+ end
119
+ end
120
+
121
+ # ── apply_statement_timeout ───────────────────────────────────────────────
122
+
123
+ describe ".apply_statement_timeout" do
124
+ let(:connection) { ActiveRecord::Base.connection }
125
+
126
+ it "does not raise for sqlite adapter (no timeout command)" do
127
+ Glancer.configuration.adapter = :sqlite
128
+ expect { described_class.apply_statement_timeout(connection) }.not_to raise_error
129
+ end
130
+
131
+ it "executes SET statement_timeout for postgres adapter" do
132
+ Glancer.configuration.adapter = :postgres
133
+ expect(connection).to receive(:execute).with(/SET statement_timeout/)
134
+ described_class.apply_statement_timeout(connection)
135
+ end
136
+
137
+ it "executes SET max_execution_time for mysql adapter" do
138
+ Glancer.configuration.adapter = :mysql2
139
+ expect(connection).to receive(:execute).with(/SET max_execution_time/)
140
+ described_class.apply_statement_timeout(connection)
141
+ end
142
+
143
+ it "does not raise when the DB rejects the timeout command" do
144
+ Glancer.configuration.adapter = :postgres
145
+ allow(connection).to receive(:execute).and_raise(StandardError, "unsupported")
146
+ expect { described_class.apply_statement_timeout(connection) }.not_to raise_error
147
+ end
148
+ end
149
+ end