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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow::SQLValidator do
6
+ before do
7
+ # Seed a schema embedding so the validator can find the "users" table.
8
+ Glancer::Embedding.create!(
9
+ content: "create_table users ...",
10
+ embedding: [],
11
+ source_type: "schema",
12
+ source_path: "db/schema.rb#users"
13
+ )
14
+ Glancer::Embedding.create!(
15
+ content: "create_table orders ...",
16
+ embedding: [],
17
+ source_type: "schema",
18
+ source_path: "db/schema.rb#orders"
19
+ )
20
+ end
21
+
22
+ # ── validate_tables_exist! ────────────────────────────────────────────────
23
+
24
+ describe ".validate_tables_exist!" do
25
+ it "does not raise when all tables in the SQL are indexed" do
26
+ sql = "SELECT * FROM users"
27
+ expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
28
+ end
29
+
30
+ it "does not raise for a query referencing an indexed table via FROM" do
31
+ sql = "SELECT u.id FROM users u"
32
+ expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
33
+ end
34
+
35
+ it "does not raise for a subquery referencing two indexed tables" do
36
+ sql = "SELECT * FROM orders WHERE user_id IN (SELECT id FROM users)"
37
+ expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
38
+ end
39
+
40
+ it "raises Glancer::Error when a table is not indexed" do
41
+ sql = "SELECT * FROM missing_table"
42
+ expect { described_class.validate_tables_exist!(sql) }
43
+ .to raise_error(Glancer::Error, /missing_table/)
44
+ end
45
+
46
+ it "raises Glancer::Error wrapping the underlying message" do
47
+ sql = "SELECT * FROM ghost_table"
48
+ expect { described_class.validate_tables_exist!(sql) }
49
+ .to raise_error(Glancer::Error)
50
+ end
51
+
52
+ it "does not raise for system schema tables (sqlite_master)" do
53
+ sql = "SELECT * FROM sqlite_master"
54
+ expect { described_class.validate_tables_exist!(sql) }.not_to raise_error
55
+ end
56
+ end
57
+
58
+ # ── extract_table_names ───────────────────────────────────────────────────
59
+
60
+ describe ".extract_table_names" do
61
+ it "extracts a single table name" do
62
+ result = described_class.extract_table_names("SELECT * FROM users")
63
+ expect(result).to contain_exactly("users")
64
+ end
65
+
66
+ it "extracts the FROM table (JOIN tables are not captured by the FROM regex)" do
67
+ # extract_table_names only scans for 'from tablename' patterns.
68
+ # JOIN keyword tables are NOT extracted because they have no FROM before them.
69
+ sql = "SELECT * FROM orders JOIN users ON orders.user_id = users.id"
70
+ result = described_class.extract_table_names(sql)
71
+ expect(result).to include("orders")
72
+ end
73
+
74
+ it "extracts tables from multiple FROM clauses (e.g. subqueries)" do
75
+ sql = "SELECT * FROM orders WHERE user_id IN (SELECT id FROM users)"
76
+ result = described_class.extract_table_names(sql)
77
+ expect(result).to include("orders", "users")
78
+ end
79
+
80
+ it "extracts table names from subqueries" do
81
+ sql = "SELECT * FROM (SELECT id FROM products) sub"
82
+ result = described_class.extract_table_names(sql)
83
+ expect(result).to include("products")
84
+ end
85
+
86
+ it "is case-insensitive for the FROM keyword" do
87
+ result = described_class.extract_table_names("SELECT * from users")
88
+ expect(result).to include("users")
89
+ end
90
+
91
+ it "handles quoted table names by stripping quotes" do
92
+ result = described_class.extract_table_names('SELECT * FROM "users"')
93
+ expect(result).to include("users")
94
+ end
95
+
96
+ it "downcases table names" do
97
+ result = described_class.extract_table_names("SELECT * FROM Users")
98
+ expect(result).to include("users")
99
+ end
100
+
101
+ it "returns unique table names" do
102
+ sql = "SELECT * FROM users JOIN users u2 ON users.id = u2.id"
103
+ result = described_class.extract_table_names(sql)
104
+ expect(result.count("users")).to eq(1)
105
+ end
106
+ end
107
+
108
+ # ── system_table? ─────────────────────────────────────────────────────────
109
+
110
+ describe ".system_table?" do
111
+ it "returns true for sqlite_master when adapter is sqlite" do
112
+ expect(described_class.system_table?("sqlite_master")).to be(true)
113
+ end
114
+
115
+ it "returns false for a user table" do
116
+ expect(described_class.system_table?("orders")).to be(false)
117
+ end
118
+
119
+ it "returns true for a schema-qualified system table" do
120
+ Glancer.configuration.adapter = :postgres
121
+ expect(described_class.system_table?("information_schema.tables")).to be(true)
122
+ end
123
+
124
+ it "returns true for pg_catalog when adapter is postgres" do
125
+ Glancer.configuration.adapter = :postgres
126
+ expect(described_class.system_table?("pg_catalog")).to be(true)
127
+ end
128
+
129
+ it "returns true for information_schema when adapter is mysql2" do
130
+ Glancer.configuration.adapter = :mysql2
131
+ expect(described_class.system_table?("information_schema")).to be(true)
132
+ end
133
+
134
+ it "returns false for a user table with mysql2 adapter" do
135
+ Glancer.configuration.adapter = :mysql2
136
+ expect(described_class.system_table?("orders")).to be(false)
137
+ end
138
+
139
+ it "returns false for a user table with an unknown adapter" do
140
+ allow(Glancer.configuration).to receive(:resolved_adapter).and_return("unknown_db")
141
+ expect(described_class.system_table?("orders")).to be(false)
142
+ end
143
+ end
144
+
145
+ # ── indexed_schema_table_names ────────────────────────────────────────────
146
+
147
+ describe ".indexed_schema_table_names" do
148
+ it "returns table names from schema embeddings" do
149
+ result = described_class.indexed_schema_table_names
150
+ expect(result).to include("users", "orders")
151
+ end
152
+
153
+ it "returns an empty array when no schema embeddings exist" do
154
+ Glancer::Embedding.where(source_type: "schema").delete_all
155
+ expect(described_class.indexed_schema_table_names).to eq([])
156
+ end
157
+
158
+ it "ignores non-schema embeddings" do
159
+ Glancer::Embedding.create!(
160
+ content: "model content", embedding: [], source_type: "models", source_path: "user.rb"
161
+ )
162
+ result = described_class.indexed_schema_table_names
163
+ expect(result).not_to include(nil)
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Workflow do
6
+ # ── Common doubles ────────────────────────────────────────────────────────
7
+
8
+ let(:question) { "How many users are there?" }
9
+ let(:sql) { "SELECT COUNT(*) AS cnt FROM users" }
10
+ let(:chat) { Glancer::Chat.create!(title: "Test Chat") }
11
+
12
+ let(:embedding) do
13
+ Glancer::Embedding.create!(
14
+ content: "create_table users ...",
15
+ embedding: [0.1, 0.2, 0.3],
16
+ source_type: "schema",
17
+ source_path: "db/schema.rb#users"
18
+ ).tap do |e|
19
+ # Workflow.run calls e.score on each embedding from Retriever.search.
20
+ # Retriever.search attaches score via define_singleton_method; add it here too.
21
+ e.define_singleton_method(:score) { 1.3 }
22
+ end
23
+ end
24
+
25
+ let(:fake_embed_response) { double("EmbedResponse", vectors: [0.1, 0.2, 0.3]) }
26
+ let(:fake_sql_response) { double("SqlResponse", content: "```sql\n#{sql}\n```") }
27
+ let(:fake_chat_response) { double("ChatResponse", content: "There are 42 users.") }
28
+
29
+ let(:fake_llm_chat) do
30
+ double("Chat").tap do |c|
31
+ allow(c).to receive(:ask).and_return(fake_sql_response)
32
+ allow(c).to receive(:with_instructions).and_return(c)
33
+ end
34
+ end
35
+
36
+ let(:fake_humanize_chat) do
37
+ double("HumanizeChat").tap do |c|
38
+ allow(c).to receive(:ask).and_return(fake_chat_response)
39
+ allow(c).to receive(:with_instructions).and_return(c)
40
+ end
41
+ end
42
+
43
+ before do
44
+ embedding # ensure it is persisted
45
+
46
+ allow(RubyLLM).to receive(:embed).and_return(fake_embed_response)
47
+ allow(RubyLLM).to receive(:chat).and_return(fake_llm_chat)
48
+ end
49
+
50
+ # Helper to stub the humanized response separately
51
+ def stub_humanize_separately
52
+ call_count = 0
53
+ allow(RubyLLM).to receive(:chat) do
54
+ call_count += 1
55
+ call_count == 1 ? fake_llm_chat : fake_humanize_chat
56
+ end
57
+ end
58
+
59
+ # ── Cache hit ─────────────────────────────────────────────────────────────
60
+
61
+ describe ".run — cache hit" do
62
+ before do
63
+ Glancer::Workflow::Cache.write(question, {
64
+ question: question,
65
+ content: "Cached answer",
66
+ code: sql,
67
+ code_type: "sql",
68
+ successful: true
69
+ })
70
+ end
71
+
72
+ it "returns the cached result when cache: true (default)" do
73
+ result = described_class.run(chat.id, question, cache: true)
74
+ expect(result[:content]).to eq("Cached answer")
75
+ end
76
+
77
+ it "includes from_cache: true in the returned hash" do
78
+ result = described_class.run(chat.id, question, cache: true)
79
+ expect(result[:from_cache]).to be(true)
80
+ end
81
+
82
+ it "skips the LLM entirely when a cache hit is available" do
83
+ expect(RubyLLM).not_to receive(:chat)
84
+ described_class.run(chat.id, question, cache: true)
85
+ end
86
+
87
+ it "bypasses the cache when cache: false" do
88
+ # cache: false → LLM is called
89
+ expect(Glancer::Retriever).to receive(:search).and_return([embedding])
90
+ allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return(sql)
91
+ allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
92
+ allow(Glancer::Workflow::Executor).to receive(:execute).and_return([{ "cnt" => 42 }])
93
+ allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("42 users")
94
+
95
+ result = described_class.run(chat.id, question, cache: false)
96
+ expect(result[:from_cache]).to be_nil
97
+ end
98
+ end
99
+
100
+ # ── Full happy path ───────────────────────────────────────────────────────
101
+
102
+ describe ".run — full pipeline" do
103
+ before do
104
+ # Seed the users table in the schema embeddings so validation passes
105
+ Glancer::Workflow::Cache.clear
106
+
107
+ allow(Glancer::Retriever).to receive(:search).and_return([embedding])
108
+ allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return("```sql\n#{sql}\n```")
109
+ allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
110
+ allow(Glancer::Workflow::Executor).to receive(:execute).and_return([{ "cnt" => 42 }])
111
+ allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("There are 42 users.")
112
+ end
113
+
114
+ it "returns a hash with :question" do
115
+ result = described_class.run(chat.id, question)
116
+ expect(result[:question]).to eq(question)
117
+ end
118
+
119
+ it "returns :successful true on success" do
120
+ result = described_class.run(chat.id, question)
121
+ expect(result[:successful]).to be(true)
122
+ end
123
+
124
+ it "returns :code with the extracted SQL" do
125
+ result = described_class.run(chat.id, question)
126
+ expect(result[:code]).to include("SELECT")
127
+ end
128
+
129
+ it "returns :content with the humanized response" do
130
+ result = described_class.run(chat.id, question)
131
+ expect(result[:content]).to eq("There are 42 users.")
132
+ end
133
+
134
+ it "returns :sources array from the embeddings" do
135
+ result = described_class.run(chat.id, question)
136
+ expect(result[:sources]).to be_an(Array)
137
+ expect(result[:sources].first).to have_key(:type)
138
+ end
139
+
140
+ it "writes the result to cache when cache: true" do
141
+ described_class.run(chat.id, question, cache: true)
142
+ cached = Glancer::Workflow::Cache.fetch(question)
143
+ expect(cached).not_to be_nil
144
+ end
145
+
146
+ it "does not write to cache when cache: false" do
147
+ described_class.run(chat.id, question, cache: false)
148
+ expect(Glancer::Workflow::Cache.fetch(question)).to be_nil
149
+ end
150
+ end
151
+
152
+ # ── Table validation failure ───────────────────────────────────────────────
153
+
154
+ describe ".run — table validation failure" do
155
+ before do
156
+ Glancer::Workflow::Cache.clear
157
+
158
+ allow(Glancer::Retriever).to receive(:search).and_return([embedding])
159
+ allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return(sql)
160
+ allow(Glancer::Workflow::SQLValidator)
161
+ .to receive(:validate_tables_exist!)
162
+ .and_raise(Glancer::Error, "Missing table(s) in indexed schema: users")
163
+ allow(Glancer::Workflow::LLM)
164
+ .to receive(:explain_missing_tables)
165
+ .and_return("The table 'users' was not found in the schema.")
166
+ end
167
+
168
+ it "returns :successful false" do
169
+ result = described_class.run(chat.id, question)
170
+ expect(result[:successful]).to be(false)
171
+ end
172
+
173
+ it "returns an explanation in :content" do
174
+ result = described_class.run(chat.id, question)
175
+ expect(result[:content]).to include("users")
176
+ end
177
+
178
+ it "includes the code that failed validation" do
179
+ result = described_class.run(chat.id, question)
180
+ expect(result[:code]).to be_a(String)
181
+ end
182
+ end
183
+
184
+ # ── Execution failure ─────────────────────────────────────────────────────
185
+
186
+ describe ".run — execution failure after retries" do
187
+ before do
188
+ Glancer::Workflow::Cache.clear
189
+
190
+ allow(Glancer::Retriever).to receive(:search).and_return([embedding])
191
+ allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return(sql)
192
+ allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
193
+ allow(Glancer::Workflow::Executor).to receive(:execute).and_return(
194
+ { error: true, message: "no such table: users", last_code: sql }
195
+ )
196
+ allow(Glancer::Workflow::LLM)
197
+ .to receive(:explain_error)
198
+ .and_return("I couldn't process the query after 3 attempts.")
199
+ end
200
+
201
+ it "returns :successful false" do
202
+ result = described_class.run(chat.id, question)
203
+ expect(result[:successful]).to be(false)
204
+ end
205
+
206
+ it "returns the error explanation in :content" do
207
+ result = described_class.run(chat.id, question)
208
+ expect(result[:content]).to include("3 attempts")
209
+ end
210
+ end
211
+
212
+ # ── Sanitization blocks the pipeline ─────────────────────────────────────
213
+
214
+ describe ".run — SQL sanitizer blocks execution" do
215
+ before do
216
+ Glancer::Workflow::Cache.clear
217
+
218
+ allow(Glancer::Retriever).to receive(:search).and_return([embedding])
219
+ # LLM returns a DROP statement (malicious)
220
+ allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return("DROP TABLE users")
221
+ end
222
+
223
+ it "raises Glancer::Error (via the sanitizer)" do
224
+ expect { described_class.run(chat.id, question) }.to raise_error(Glancer::Error)
225
+ end
226
+ end
227
+
228
+ # ── chat not found ────────────────────────────────────────────────────────
229
+
230
+ describe ".run — chat not found" do
231
+ it "raises Glancer::Error wrapping the ActiveRecord error" do
232
+ expect { described_class.run(999_999, question) }.to raise_error(Glancer::Error)
233
+ end
234
+ end
235
+
236
+ # ── ActiveRecord mode ─────────────────────────────────────────────────────
237
+
238
+ describe ".run — activerecord pipeline" do
239
+ let(:ar_code) { "Glancer::Chat.count" }
240
+
241
+ before do
242
+ Glancer::Workflow::Cache.clear
243
+ Glancer.configuration.query_mode = :activerecord
244
+
245
+ allow(Glancer::Retriever).to receive(:search).and_return([embedding])
246
+ allow(Glancer::Workflow::Builder).to receive(:build_ar_code).and_return("```ruby\n#{ar_code}\n```")
247
+ allow(Glancer::Workflow::ARExecutor).to receive(:execute).and_return([{ "result" => 0 }])
248
+ allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("Zero chats.")
249
+ end
250
+
251
+ after { Glancer.configuration.query_mode = :sql }
252
+
253
+ it "returns :successful true" do
254
+ result = described_class.run(chat.id, question)
255
+ expect(result[:successful]).to be(true)
256
+ end
257
+
258
+ it "returns code_type 'activerecord'" do
259
+ result = described_class.run(chat.id, question)
260
+ expect(result[:code_type]).to eq("activerecord")
261
+ end
262
+
263
+ it "returns the humanized content" do
264
+ result = described_class.run(chat.id, question)
265
+ expect(result[:content]).to eq("Zero chats.")
266
+ end
267
+
268
+ it "returns :successful false and explanation when AR executor reports an error" do
269
+ allow(Glancer::Workflow::ARExecutor).to receive(:execute).and_return(
270
+ { error: true, message: "undefined constant", last_code: ar_code }
271
+ )
272
+ allow(Glancer::Workflow::LLM).to receive(:explain_error).and_return("I couldn't fix it.")
273
+ result = described_class.run(chat.id, question)
274
+ expect(result[:successful]).to be(false)
275
+ expect(result[:content]).to eq("I couldn't fix it.")
276
+ end
277
+ end
278
+
279
+ # ── enrich_question ───────────────────────────────────────────────────────
280
+
281
+ describe ".run — question enrichment enabled" do
282
+ before do
283
+ Glancer::Workflow::Cache.clear
284
+ Glancer.configuration.query_enrichment_enabled = true
285
+
286
+ allow(Glancer::Retriever).to receive(:search).and_return([embedding])
287
+ allow(Glancer::Workflow::Builder).to receive(:build_sql).and_return("```sql\n#{sql}\n```")
288
+ allow(Glancer::Workflow::SQLValidator).to receive(:validate_tables_exist!)
289
+ allow(Glancer::Workflow::Executor).to receive(:execute).and_return([{ "cnt" => 42 }])
290
+ allow(Glancer::Workflow::LLM).to receive(:humanized_response).and_return("42 users.")
291
+ allow(Glancer::Workflow::QueryEnricher).to receive(:known_table_names).and_return(["users"])
292
+ allow(Glancer::Workflow::QueryEnricher).to receive(:enrich).and_return("enriched: #{question}")
293
+ end
294
+
295
+ after { Glancer.configuration.query_enrichment_enabled = false }
296
+
297
+ it "stores enriched_question in the result" do
298
+ result = described_class.run(chat.id, question)
299
+ expect(result[:enriched_question]).to eq("enriched: #{question}")
300
+ end
301
+
302
+ it "falls back to the original question when enrichment raises" do
303
+ allow(Glancer::Workflow::QueryEnricher).to receive(:enrich).and_raise(StandardError, "LLM timeout")
304
+ result = described_class.run(chat.id, question)
305
+ expect(result[:enriched_question]).to eq(question)
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Audit do
6
+ let(:valid_attrs) do
7
+ {
8
+ code: "SELECT 1 /*glancer,run_id:abc*/",
9
+ adapter: "sqlite",
10
+ run_id: SecureRandom.uuid,
11
+ executed_at: Time.current
12
+ }
13
+ end
14
+
15
+ subject(:audit) { described_class.new(valid_attrs) }
16
+
17
+ # ── Validations ───────────────────────────────────────────────────────────
18
+
19
+ describe "validations" do
20
+ it "is valid with all required attributes" do
21
+ expect(audit).to be_valid
22
+ end
23
+
24
+ it "is invalid without code" do
25
+ audit.code = nil
26
+ expect(audit).not_to be_valid
27
+ expect(audit.errors[:code]).to be_present
28
+ end
29
+
30
+ it "is invalid without adapter" do
31
+ audit.adapter = nil
32
+ expect(audit).not_to be_valid
33
+ end
34
+
35
+ it "is invalid without run_id" do
36
+ audit.run_id = nil
37
+ expect(audit).not_to be_valid
38
+ end
39
+
40
+ it "is invalid without executed_at" do
41
+ audit.executed_at = nil
42
+ expect(audit).not_to be_valid
43
+ end
44
+
45
+ it "enforces uniqueness of run_id" do
46
+ uuid = SecureRandom.uuid
47
+ described_class.create!(valid_attrs.merge(run_id: uuid))
48
+ duplicate = described_class.new(valid_attrs.merge(run_id: uuid))
49
+ expect(duplicate).not_to be_valid
50
+ expect(duplicate.errors[:run_id]).to be_present
51
+ end
52
+ end
53
+
54
+ # ── Associations ─────────────────────────────────────────────────────────
55
+
56
+ describe "associations" do
57
+ it "belongs_to message optionally" do
58
+ expect(audit).to be_valid # message_id is nil
59
+ end
60
+
61
+ it "links to a message when provided" do
62
+ chat = Glancer::Chat.create!(title: "C")
63
+ msg = Glancer::Message.create!(chat: chat, role: "assistant", content: "x")
64
+ audit.message = msg
65
+ audit.save!
66
+ expect(audit.reload.message).to eq(msg)
67
+ end
68
+ end
69
+
70
+ # ── Persistence ───────────────────────────────────────────────────────────
71
+
72
+ describe "persistence" do
73
+ it "saves successfully with valid attributes" do
74
+ expect { described_class.create!(valid_attrs) }.to change(described_class, :count).by(1)
75
+ end
76
+
77
+ it "stores the question (optional)" do
78
+ a = described_class.create!(valid_attrs.merge(question: "How many users?"))
79
+ expect(a.reload.question).to eq("How many users?")
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::Chat do
6
+ subject(:chat) { described_class.new(title: "Test Chat") }
7
+
8
+ # ── Validations ───────────────────────────────────────────────────────────
9
+
10
+ describe "validations" do
11
+ it "is valid with a title" do
12
+ expect(chat).to be_valid
13
+ end
14
+
15
+ it "is invalid without a title" do
16
+ chat.title = nil
17
+ expect(chat).not_to be_valid
18
+ expect(chat.errors[:title]).to include("can't be blank")
19
+ end
20
+
21
+ it "is invalid with an empty title string" do
22
+ chat.title = ""
23
+ expect(chat).not_to be_valid
24
+ end
25
+ end
26
+
27
+ # ── Associations ─────────────────────────────────────────────────────────
28
+
29
+ describe "associations" do
30
+ let!(:saved_chat) { described_class.create!(title: "Chat") }
31
+
32
+ it "has many messages" do
33
+ msg = Glancer::Message.create!(chat: saved_chat, role: "user", content: "Hello")
34
+ expect(saved_chat.messages).to include(msg)
35
+ end
36
+
37
+ it "destroys associated messages when the chat is destroyed" do
38
+ Glancer::Message.create!(chat: saved_chat, role: "user", content: "Hello")
39
+ expect { saved_chat.destroy }.to change(Glancer::Message, :count).by(-1)
40
+ end
41
+ end
42
+
43
+ # ── Persistence ───────────────────────────────────────────────────────────
44
+
45
+ describe "persistence" do
46
+ it "can be saved" do
47
+ expect(chat.save).to be(true)
48
+ end
49
+
50
+ it "increments the count on create!" do
51
+ expect { described_class.create!(title: "New") }.to change(described_class, :count).by(1)
52
+ end
53
+
54
+ it "has timestamps after save" do
55
+ chat.save!
56
+ expect(chat.created_at).not_to be_nil
57
+ expect(chat.updated_at).not_to be_nil
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Glancer::CodeVersion do
6
+ let(:chat) { Glancer::Chat.create!(title: "Chat") }
7
+ let(:message) { Glancer::Message.create!(chat: chat, role: "assistant", content: "result", code: "SELECT 1") }
8
+
9
+ subject(:code_version) { described_class.new(message: message, code: "SELECT 1", source: "generated") }
10
+
11
+ # ── Validations ───────────────────────────────────────────────────────────
12
+
13
+ describe "validations" do
14
+ it "is valid with all required attributes" do
15
+ expect(code_version).to be_valid
16
+ end
17
+
18
+ it "is invalid without code" do
19
+ code_version.code = nil
20
+ expect(code_version).not_to be_valid
21
+ expect(code_version.errors[:code]).to be_present
22
+ end
23
+
24
+ it "is invalid without source" do
25
+ code_version.source = nil
26
+ expect(code_version).not_to be_valid
27
+ end
28
+ end
29
+
30
+ # ── Enum: source ─────────────────────────────────────────────────────────
31
+
32
+ describe "source enum" do
33
+ it "accepts 'generated'" do
34
+ sv = described_class.create!(message: message, code: "SELECT 1", source: "generated")
35
+ expect(sv.generated?).to be(true)
36
+ end
37
+
38
+ it "accepts 'user_edited'" do
39
+ sv = described_class.create!(message: message, code: "SELECT 2", source: "user_edited")
40
+ expect(sv.user_edited?).to be(true)
41
+ end
42
+
43
+ it "raises on an invalid source" do
44
+ expect { described_class.create!(message: message, code: "SELECT 1", source: "unknown") }
45
+ .to raise_error(ArgumentError)
46
+ end
47
+ end
48
+
49
+ # ── Associations ─────────────────────────────────────────────────────────
50
+
51
+ describe "associations" do
52
+ it "belongs_to a message" do
53
+ sv = described_class.create!(message: message, code: "SELECT 1", source: "generated")
54
+ expect(sv.message).to eq(message)
55
+ end
56
+ end
57
+
58
+ # ── Persistence ───────────────────────────────────────────────────────────
59
+
60
+ describe "persistence" do
61
+ it "increments count on create!" do
62
+ expect { described_class.create!(message: message, code: "SELECT 1", source: "generated") }
63
+ .to change(described_class, :count).by(1)
64
+ end
65
+
66
+ it "stores the code content" do
67
+ sv = described_class.create!(message: message, code: "SELECT id FROM users", source: "generated")
68
+ expect(sv.reload.code).to eq("SELECT id FROM users")
69
+ end
70
+ end
71
+ end