ruby_llm 2.0.0.rc2 → 2.0.0.rc3

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +34 -0
  4. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +332 -0
  5. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +171 -0
  6. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +197 -0
  7. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +170 -0
  8. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +14 -11
  9. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +6 -6
  10. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +31 -5
  11. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +16 -14
  12. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +36 -7
  13. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +15 -5
  14. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +14 -0
  15. data/lib/ruby_llm/accounting/usage.rb +9 -0
  16. data/lib/ruby_llm/agent.rb +10 -9
  17. data/lib/ruby_llm/aliases.json +26 -4
  18. data/lib/ruby_llm/attachment.rb +5 -0
  19. data/lib/ruby_llm/chat.rb +4 -0
  20. data/lib/ruby_llm/message.rb +14 -5
  21. data/lib/ruby_llm/models.json +3820 -1346
  22. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +2 -1
  23. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +8 -1
  24. data/lib/ruby_llm/protocols/cohere/rerank.rb +8 -1
  25. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +5 -0
  26. data/lib/ruby_llm/protocols/interactions/tools.rb +3 -1
  27. data/lib/ruby_llm/providers/deepseek/responses.rb +0 -1
  28. data/lib/ruby_llm/providers/mistral/ocr.rb +5 -1
  29. data/lib/ruby_llm/version.rb +1 -1
  30. data/lib/tasks/ruby_llm.rake +1 -1
  31. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef3c927159add14551e31ce03609ce42e150f9f161af9b68602f9c0e080fc31d
4
- data.tar.gz: b894b99f6d6e541c643d76810b9542364f9d0beef4d00efd7b87b80d36c071af
3
+ metadata.gz: bf1e2bc13df637c93268a6ee20e52c704c47f942be416c4d6728882ae2cc35bf
4
+ data.tar.gz: b63c7e4eef810843ad1bcb91a8ad55413f90764ba7e984d310c8a6b4bec3bec0
5
5
  SHA512:
6
- metadata.gz: 7dacd51baf534c7b226e1db52f09421b72d9939ec17af6cfd4b18db8bd7d9fbe2a22b488a8571d64f2194d46ed82ba045a726a309465b448f3a31a077a1f98c1
7
- data.tar.gz: 90f866305245b759ae821cd8932d518c8648f5a3afce588028b820ced1f8aa7b05ac5de7c55a6b0f89e5aac80052d348fbc3fec8cf3b9efc83dec5effa32b260
6
+ metadata.gz: 030126750af8dea0303a81093d415e361fdfb7335620d8e84da3fd024acba2e5323bb7fcbd47530a6a3d5cbc24ba3c2f771db431aabd8011502bd19b9b68aa71
7
+ data.tar.gz: c783cfc4c121db83f4e05867893b7b822ffeddb53010eb22a5053d37eca7a539ee26dc6181b669ac5b2fac856464a18cc8929303127c1e5ae1f1fe28f9c1f373
data/README.md CHANGED
@@ -38,7 +38,7 @@ In Rails, the API works on your own Chat and Message records, with Active Storag
38
38
 
39
39
  ## Show me the code
40
40
 
41
- These examples use **2.0.0.rc2**. Follow [Getting Started](https://rubyllm.com/next/getting-started/) to install it and configure the providers you want to try.
41
+ These examples use **2.0.0.rc3**. Follow [Getting Started](https://rubyllm.com/next/getting-started/) to install it and configure the providers you want to try.
42
42
 
43
43
  ```ruby
44
44
  # Just ask questions
@@ -195,7 +195,7 @@ response.parsed
195
195
  Install the 2.0 release candidate:
196
196
 
197
197
  ```bash
198
- bundle add ruby_llm --version 2.0.0.rc2
198
+ bundle add ruby_llm --version 2.0.0.rc3
199
199
  ```
200
200
 
201
201
  Configure a provider in your script, or in `config/initializers/ruby_llm.rb` in Rails:
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Generators
5
+ class LegacyContentSQL # :nodoc: all
6
+ SPACE = [9, 10, 11, 12, 13, 32, 133, 160, 5760, *8192..8202, 8232, 8233, 8239, 8287, 12_288].pack('U*').freeze
7
+
8
+ def initialize(connection)
9
+ @connection = connection
10
+ end
11
+
12
+ def render(content:, raw:)
13
+ normalized, rendered, nonblank = expressions(raw)
14
+ present = "#{normalized} NOT IN ('null', 'false', '[]', '{}') AND #{nonblank}"
15
+ "CASE WHEN #{present} THEN #{rendered} ELSE #{content} END"
16
+ end
17
+
18
+ private
19
+
20
+ def expressions(raw)
21
+ case @connection.adapter_name
22
+ when 'PostgreSQL'
23
+ ["(#{raw}::jsonb)::text", "#{raw}::text",
24
+ "btrim(#{raw}::jsonb #>> '{}', #{@connection.quote(SPACE)}) <> ''"]
25
+ when 'Mysql2'
26
+ ["CAST(#{raw} AS CHAR)", "CAST(#{raw} AS CHAR)",
27
+ "JSON_UNQUOTE(#{raw}) NOT REGEXP '^[[:space:]]*$'"]
28
+ else
29
+ ["json(#{raw})", raw, "trim(json_extract(#{raw}, '$'), #{@connection.quote(SPACE)}) <> ''"]
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../legacy_content_sql'
5
+ require_relative 'verification'
6
+
7
+ module RubyLLM
8
+ module Generators
9
+ class OnlineCopyMigration < UpgradeMigration
10
+ class Data < UpgradeMigration # :nodoc: all
11
+ BATCH_SIZE = 10_000
12
+ PROGRESS = :ruby_llm_v2_backfills
13
+ TASKS = %w[message_content tool_results usages].freeze
14
+
15
+ def initialize(connection:, settings:)
16
+ super(connection:)
17
+ @configuration = settings
18
+ end
19
+
20
+ def prepare
21
+ messages = configuration.fetch('message_table')
22
+ unless @connection.column_exists?(messages, :ruby_llm_content)
23
+ @connection.add_column(messages, :ruby_llm_content, :text)
24
+ end
25
+ %i[ruby_llm_tool_calls ruby_llm_usages].each do |table|
26
+ @connection.add_column(table, :legacy_key, :string) unless @connection.column_exists?(table, :legacy_key)
27
+ unless @connection.index_exists?(table, :legacy_key, unique: true)
28
+ @connection.add_index(table, :legacy_key, unique: true)
29
+ end
30
+ end
31
+ return if @connection.column_exists?(:ruby_llm_tool_calls, :legacy_chat_id)
32
+
33
+ @connection.add_column(:ruby_llm_tool_calls, :legacy_chat_id, :string)
34
+ @connection.add_index(:ruby_llm_tool_calls, :legacy_chat_id)
35
+ end
36
+
37
+ def backfill
38
+ prepare_progress
39
+ sync_models
40
+ sync_chat_models
41
+ TASKS.each do |task|
42
+ progress = records(PROGRESS).find_or_create_by!(task:)
43
+ next if progress.completed
44
+
45
+ unless progress.upper_id
46
+ upper = messages.order(messages.primary_key => :desc).pick(messages.primary_key)
47
+ unless upper
48
+ progress.update!(completed: true)
49
+ next
50
+ end
51
+ progress.update!(upper_id: upper&.to_s)
52
+ end
53
+ relation = legacy_messages
54
+ relation = relation.where("#{q(messages.primary_key)} <= ?", progress.upper_id) if progress.upper_id
55
+ relation = relation.where("#{q(messages.primary_key)} > ?", progress.last_id) if progress.last_id
56
+ each_batch(relation) do |batch|
57
+ messages.transaction do
58
+ copy_batch(task, batch)
59
+ progress.update!(last_id: batch.last.id)
60
+ end
61
+ end
62
+ progress.update!(completed: true)
63
+ end
64
+ end
65
+
66
+ def catch_up(passes: nil)
67
+ sync_models
68
+ pass = 0
69
+ loop do
70
+ upper = changes.maximum(:id)
71
+ break unless upper
72
+
73
+ changes.where(id: ..upper).in_batches(of: 100) do |events|
74
+ captured = events.to_a
75
+ chat_ids = affected_chats(captured)
76
+ sync_chat_models(chat_ids)
77
+ reconcile_chats(chat_ids)
78
+ acknowledge(captured)
79
+ end
80
+ pass += 1
81
+ break if passes && pass >= passes
82
+ end
83
+ end
84
+
85
+ def completed?
86
+ @connection.table_exists?(PROGRESS) &&
87
+ (TASKS - records(PROGRESS).where(completed: true).pluck(:task)).empty?
88
+ end
89
+
90
+ def finished?
91
+ @connection.table_exists?(PROGRESS) && records(PROGRESS).where(task: 'finished', completed: true).exists?
92
+ end
93
+
94
+ def sync_models
95
+ refresh = !finished?
96
+ records(configuration.fetch('model_table')).find_each(batch_size: BATCH_SIZE) do |model|
97
+ target = resolve_model(model.id)
98
+ next unless refresh
99
+
100
+ attributes = model.attributes.slice(*target.class.column_names).except(target.class.primary_key)
101
+ target.assign_attributes(attributes)
102
+ target.save! if target.changed?
103
+ end
104
+ end
105
+
106
+ def sync_chat_models(ids = nil)
107
+ relation = chats.where(VERSION_COLUMN => 1)
108
+ relation = relation.where(chats.primary_key => ids) if ids
109
+ models = records(configuration.fetch('model_table')).all.to_h do |model|
110
+ [model.id, resolve_model(model.id).id]
111
+ end
112
+ models.each do |legacy_id, target_id|
113
+ relation.where(configuration.fetch('model_foreign_key') => legacy_id)
114
+ .where('ruby_llm_model_id IS NULL OR ruby_llm_model_id != ?', target_id)
115
+ .update_all(ruby_llm_model_id: target_id)
116
+ end
117
+ end
118
+
119
+ def verify
120
+ raise 'The online copy journal is not empty; keep AI activity paused' if changes.exists?
121
+ raise 'RubyLLM 2.0 backfills are incomplete' unless completed?
122
+
123
+ tables = [configuration.fetch('chat_table'), configuration.fetch('message_table'),
124
+ 'ruby_llm_models', 'ruby_llm_tool_calls', 'ruby_llm_usages']
125
+ if @connection.adapter_name == 'PostgreSQL'
126
+ @connection.execute("ANALYZE #{tables.map { |table| qt(table) }.join(', ')}")
127
+ end
128
+ Verification.new(connection: @connection, settings: configuration).verify
129
+ end
130
+
131
+ def cleanup
132
+ table = configuration.fetch('message_table')
133
+ if @connection.column_exists?(table, :ruby_llm_content)
134
+ @connection.remove_column(table, :content) if @connection.column_exists?(table, :content)
135
+ @connection.rename_column(table, :ruby_llm_content, :content)
136
+ end
137
+ columns = %i[legacy_key legacy_chat_id]
138
+ %i[ruby_llm_tool_calls ruby_llm_usages].each do |target|
139
+ columns.each do |column|
140
+ next unless @connection.column_exists?(target, column)
141
+
142
+ @connection.remove_index(target, column) if @connection.index_exists?(target, column)
143
+ @connection.remove_column(target, column)
144
+ end
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def acknowledge(events)
151
+ matches = events.map { |event| "(id = #{Integer(event.id)} AND revision = #{Integer(event.revision)})" }
152
+ changes.where(matches.join(' OR ')).delete_all if matches.any?
153
+ end
154
+
155
+ def prepare_progress
156
+ unless @connection.table_exists?(PROGRESS)
157
+ @connection.create_table(PROGRESS) do |table|
158
+ table.string :task, null: false, index: { unique: true }
159
+ table.string :last_id
160
+ table.boolean :completed, null: false, default: false
161
+ end
162
+ end
163
+ @connection.add_column(PROGRESS, :upper_id, :string) unless @connection.column_exists?(PROGRESS, :upper_id)
164
+ records(PROGRESS).reset_column_information
165
+ end
166
+
167
+ def copy_batch(task, batch)
168
+ case task
169
+ when 'message_content' then copy_content(batch.map(&:id))
170
+ when 'tool_results' then copy_tools(batch)
171
+ when 'usages' then upsert(:ruby_llm_usages, usage_attributes(batch))
172
+ end
173
+ end
174
+
175
+ def copy_content(ids)
176
+ raw = @connection.column_exists?(messages.table_name, :content_raw) ? q(:content_raw) : 'NULL'
177
+ text = LegacyContentSQL.new(@connection).render(content: q(:content), raw:)
178
+ structured = @connection.adapter_name == 'PostgreSQL' ? "#{raw}::jsonb" : raw
179
+ messages.where(messages.primary_key => ids).update_all(
180
+ "#{q(:ruby_llm_content)} = #{text}, #{q(:raw_content)} = #{structured}"
181
+ )
182
+ end
183
+
184
+ def copy_tools(batch)
185
+ ids = batch.map(&:id)
186
+ source = records(configuration.fetch('tool_call_table'))
187
+ key = configuration.fetch('message_foreign_key')
188
+ result_key = configuration.fetch('tool_call_foreign_key')
189
+ calls = source.where(key => ids).to_a
190
+ results = messages.where(result_key => calls.map(&:id)).to_a.group_by { |message| message[result_key] }
191
+ chat_ids = batch.to_h { |message| [message.id, message[configuration.fetch('chat_foreign_key')]] }
192
+ target = records(:ruby_llm_tool_calls)
193
+ attributes = calls.map do |call|
194
+ tool_attributes(call, results.fetch(call.id, []), chat_ids.fetch(call[key]), target)
195
+ end
196
+ verify_tool_ids(target, attributes)
197
+ upsert(:ruby_llm_tool_calls, attributes)
198
+ end
199
+
200
+ def tool_attributes(call, results, chat_id, target)
201
+ raise "Multiple messages reference tool call #{call.id}" if results.size > 1
202
+
203
+ call.attributes.slice(*target.column_names).except(target.primary_key).symbolize_keys.merge(
204
+ legacy_key: call.id.to_s, legacy_chat_id: chat_id.to_s,
205
+ message_type: configuration.fetch('message_class'),
206
+ message_id: call[configuration.fetch('message_foreign_key')],
207
+ result_type: results.any? ? configuration.fetch('message_class') : nil, result_id: results.first&.id
208
+ )
209
+ end
210
+
211
+ def verify_tool_ids(target, attributes)
212
+ provider_ids = attributes.map { |row| row[:tool_call_id] }
213
+ reserved = target.where(tool_call_id: provider_ids).pluck(:tool_call_id, :legacy_key).to_h
214
+ attributes.each do |row|
215
+ next unless reserved.key?(row[:tool_call_id]) && reserved[row[:tool_call_id]] != row[:legacy_key]
216
+
217
+ raise "Tool call #{row[:legacy_key]} repeats provider ID #{row[:tool_call_id].inspect}"
218
+ end
219
+ end
220
+
221
+ def usage_attributes(batch)
222
+ models = records(configuration.fetch('model_table')).all.index_by(&:id)
223
+ chat_ids = batch.map { |message| message[configuration.fetch('chat_foreign_key')] }
224
+ chat_models = chats.where(chats.primary_key => chat_ids)
225
+ .pluck(chats.primary_key, configuration.fetch('model_foreign_key')).to_h
226
+ batch.filter_map do |message|
227
+ source = message.attributes
228
+ tokens = legacy_tokens(source)
229
+ costs = legacy_costs(source)
230
+ next unless source['role'] == 'assistant' || (tokens.values + costs.values).any? { |value| !value.nil? }
231
+
232
+ chat_id = source.fetch(configuration.fetch('chat_foreign_key'))
233
+ provider, model_id = usage_identity(message, models, chat_models[chat_id])
234
+
235
+ tokens.merge(costs).symbolize_keys.merge(
236
+ legacy_key: message.id.to_s, chat_type: configuration.fetch('chat_class'), chat_id:,
237
+ message_type: configuration.fetch('message_class'), message_id: message.id,
238
+ operation: 'chat', provider:, model: model_id, status: 'succeeded',
239
+ created_at: source['created_at'], updated_at: source['updated_at']
240
+ )
241
+ end
242
+ end
243
+
244
+ def usage_identity(message, models, chat_model_id)
245
+ key = configuration.fetch('model_foreign_key')
246
+ model = models[message[key]] || models[chat_model_id]
247
+ if string_model_reference?(message, key)
248
+ provider = message.attributes['provider'] || model&.provider
249
+ raise "Message #{message.id} has no identifiable provider" unless provider
250
+
251
+ return [provider, message[key]]
252
+ end
253
+ raise "Message #{message.id} has no identifiable legacy model" unless model
254
+
255
+ [model.provider, model.model_id]
256
+ end
257
+
258
+ def upsert(table, attributes)
259
+ return if attributes.empty?
260
+
261
+ options = { record_timestamps: false }
262
+ options[:unique_by] = :legacy_key unless @connection.adapter_name == 'Mysql2'
263
+ records(table).upsert_all(attributes, **options)
264
+ end
265
+
266
+ def string_model_reference?(message, key)
267
+ messages.columns_hash[key]&.type == :string && message[key]
268
+ end
269
+
270
+ def affected_chats(events)
271
+ ids = events.select { |event| event.kind == 'chat' }.map(&:record_id)
272
+ model_ids = events.select { |event| event.kind == 'model' }.map(&:record_id)
273
+ if model_ids.any?
274
+ sync_models
275
+ key = configuration.fetch('model_foreign_key')
276
+ ids.concat(chats.where(key => model_ids).pluck(chats.primary_key))
277
+ ids.concat(messages.where(key => model_ids).distinct.pluck(configuration.fetch('chat_foreign_key')))
278
+ end
279
+ ids.map(&:to_s).uniq
280
+ end
281
+
282
+ def reconcile_chats(ids)
283
+ protected_ids = chats.where(chats.primary_key => ids,
284
+ VERSION_COLUMN => 2).pluck(chats.primary_key).map(&:to_s)
285
+ ids -= protected_ids
286
+ return if ids.empty?
287
+
288
+ remove_deleted_copies(ids)
289
+ relation = legacy_messages.where(configuration.fetch('chat_foreign_key') => ids)
290
+ upper = relation.order(messages.primary_key => :desc).pick(messages.primary_key)
291
+ relation = relation.where("#{q(messages.primary_key)} <= ?", upper) if upper
292
+ each_batch(relation) do |batch|
293
+ messages.transaction do
294
+ TASKS.each { |task| copy_batch(task, batch) }
295
+ eligible = usage_attributes(batch).map { |row| row[:legacy_key] }
296
+ records(:ruby_llm_usages).where(legacy_key: batch.map { |message| message.id.to_s })
297
+ .where.not(legacy_key: eligible).delete_all
298
+ end
299
+ end
300
+ remove_deleted_copies(ids)
301
+ end
302
+
303
+ def remove_deleted_copies(ids)
304
+ current = messages.select(messages.primary_key)
305
+ records(:ruby_llm_usages).where(chat_type: configuration.fetch('chat_class'), chat_id: ids)
306
+ .where.not(legacy_key: nil).where.not(message_id: current).delete_all
307
+ source = records(configuration.fetch('tool_call_table'))
308
+ records(:ruby_llm_tool_calls).where(legacy_chat_id: ids).where.not(legacy_key: nil).find_in_batches do |batch|
309
+ existing = source.where(source.primary_key => batch.map(&:legacy_key)).pluck(source.primary_key).map(&:to_s)
310
+ removed = batch.reject { |call| existing.include?(call.legacy_key) }.map(&:id)
311
+ records(:ruby_llm_tool_calls).where(id: removed).delete_all if removed.any?
312
+ end
313
+ end
314
+
315
+ def each_batch(relation, &)
316
+ relation.find_in_batches(batch_size: BATCH_SIZE, &)
317
+ end
318
+
319
+ def legacy_messages
320
+ owned = chats.where(VERSION_COLUMN => 1).select(chats.primary_key)
321
+ messages.where(configuration.fetch('chat_foreign_key') => owned)
322
+ end
323
+
324
+ def messages = records(configuration.fetch('message_table'))
325
+ def chats = records(configuration.fetch('chat_table'))
326
+ def changes = records(Journal::TABLE)
327
+ def q(value) = @connection.quote_column_name(value)
328
+ def qt(value) = @connection.quote_table_name(value)
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Generators
5
+ class OnlineCopyMigration < UpgradeMigration
6
+ class Journal # :nodoc: all
7
+ TABLE = :ruby_llm_v2_changes
8
+ OPERATIONS = %w[INSERT UPDATE DELETE].freeze
9
+ MESSAGE_COLUMNS = %w[
10
+ role content content_raw input_tokens output_tokens cached_tokens cache_creation_tokens
11
+ cache_read_tokens cache_write_tokens thinking_tokens total_cost cost_details created_at updated_at
12
+ ].freeze
13
+
14
+ def initialize(connection:, settings:)
15
+ @connection = connection
16
+ @settings = settings
17
+ end
18
+
19
+ def install
20
+ unless @connection.table_exists?(TABLE)
21
+ @connection.create_table(TABLE) do |table|
22
+ table.string :kind, null: false
23
+ table.string :record_id, null: false
24
+ table.bigint :revision, null: false, default: 1
25
+ table.index %i[kind record_id], unique: true
26
+ end
27
+ end
28
+ sources.each do |kind, table|
29
+ OPERATIONS.each { |operation| install_trigger(kind, table, operation) }
30
+ end
31
+ end
32
+
33
+ def remove
34
+ sources.each do |kind, table|
35
+ OPERATIONS.each do |operation|
36
+ name = trigger_name(kind, operation)
37
+ if postgresql?
38
+ if @connection.table_exists?(table)
39
+ @connection.execute("DROP TRIGGER IF EXISTS #{q(name)} ON #{qt(table)}")
40
+ end
41
+ @connection.execute("DROP FUNCTION IF EXISTS #{q(name)}()")
42
+ else
43
+ @connection.execute("DROP TRIGGER IF EXISTS #{q(name)}")
44
+ end
45
+ end
46
+ end
47
+ @connection.drop_table(TABLE) if @connection.table_exists?(TABLE)
48
+ end
49
+
50
+ private
51
+
52
+ def sources
53
+ %w[chat message tool_call model].to_h { |kind| [kind, @settings.fetch("#{kind}_table")] }
54
+ end
55
+
56
+ def install_trigger(kind, table, operation)
57
+ name = trigger_name(kind, operation)
58
+ return if trigger_exists?(name)
59
+
60
+ body = event_sql(kind, operation)
61
+ condition = operation == 'UPDATE' ? changed_sql(kind, table) : '1 = 1'
62
+ state = qt(UpgradeMigration::TABLE)
63
+ condition = "(#{condition}) AND EXISTS (SELECT 1 FROM #{state} WHERE active_version = 1)"
64
+ sql = if postgresql?
65
+ <<~SQL
66
+ CREATE FUNCTION #{q(name)}() RETURNS trigger LANGUAGE plpgsql AS $ruby_llm$
67
+ BEGIN
68
+ IF #{condition} THEN #{body} END IF;
69
+ RETURN NULL;
70
+ END $ruby_llm$;
71
+ CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)}
72
+ FOR EACH ROW EXECUTE FUNCTION #{q(name)}();
73
+ SQL
74
+ elsif mysql?
75
+ <<~SQL
76
+ CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)} FOR EACH ROW
77
+ BEGIN IF #{condition} THEN #{body} END IF; END
78
+ SQL
79
+ else
80
+ <<~SQL
81
+ CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)} FOR EACH ROW
82
+ WHEN #{condition} BEGIN #{body} END
83
+ SQL
84
+ end
85
+ @connection.execute(sql)
86
+ end
87
+
88
+ def trigger_exists?(name)
89
+ quoted = @connection.quote(name)
90
+ sql = if postgresql?
91
+ "SELECT 1 FROM pg_trigger WHERE tgname = #{quoted} AND tgrelid IN " \
92
+ '(SELECT oid FROM pg_class WHERE relnamespace = current_schema()::regnamespace)'
93
+ elsif mysql?
94
+ "SELECT 1 FROM information_schema.triggers WHERE trigger_name = #{quoted} " \
95
+ 'AND trigger_schema = DATABASE()'
96
+ else
97
+ "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{quoted}"
98
+ end
99
+ @connection.select_value(sql).present?
100
+ end
101
+
102
+ def changed_sql(kind, table)
103
+ columns = @connection.columns(table).map(&:name)
104
+ columns &= source_columns(kind) unless kind == 'tool_call'
105
+ columns.map do |column|
106
+ old = "OLD.#{q(column)}"
107
+ new = "NEW.#{q(column)}"
108
+ if postgresql?
109
+ "#{old}::text IS DISTINCT FROM #{new}::text"
110
+ elsif mysql?
111
+ "NOT (CAST(#{old} AS BINARY) <=> CAST(#{new} AS BINARY))"
112
+ else
113
+ "#{old} IS NOT #{new}"
114
+ end
115
+ end.join(' OR ')
116
+ end
117
+
118
+ def source_columns(kind)
119
+ primary = @connection.primary_key(sources.fetch(kind))
120
+ case kind
121
+ when 'chat'
122
+ [primary, @settings.fetch('model_foreign_key')]
123
+ when 'message'
124
+ [primary,
125
+ *@settings.values_at('chat_foreign_key', 'model_foreign_key', 'tool_call_foreign_key')] + MESSAGE_COLUMNS
126
+ when 'model'
127
+ [primary, 'provider', 'model_id']
128
+ end
129
+ end
130
+
131
+ def event_sql(kind, operation)
132
+ images = if operation == 'UPDATE'
133
+ %w[OLD NEW]
134
+ else
135
+ [operation == 'INSERT' ? 'NEW' : 'OLD']
136
+ end
137
+ images.map do |row|
138
+ id = event_owner(kind, row)
139
+ event = kind == 'model' ? 'model' : 'chat'
140
+ "INSERT INTO #{qt(TABLE)} (kind, record_id) SELECT '#{event}', #{id} WHERE #{id} IS NOT NULL #{upsert_sql};"
141
+ end.join("\n")
142
+ end
143
+
144
+ def event_owner(kind, row)
145
+ case kind
146
+ when 'message'
147
+ "#{row}.#{q(@settings.fetch('chat_foreign_key'))}"
148
+ when 'tool_call'
149
+ "(SELECT #{q(@settings.fetch('chat_foreign_key'))} FROM #{qt(sources.fetch('message'))} " \
150
+ "WHERE #{q(@connection.primary_key(sources.fetch('message')))} = " \
151
+ "#{row}.#{q(@settings.fetch('message_foreign_key'))})"
152
+ else
153
+ "#{row}.#{q(@connection.primary_key(sources.fetch(kind)))}"
154
+ end
155
+ end
156
+
157
+ def upsert_sql
158
+ return 'ON DUPLICATE KEY UPDATE revision = revision + 1' if mysql?
159
+
160
+ "ON CONFLICT (kind, record_id) DO UPDATE SET revision = #{qt(TABLE)}.revision + 1"
161
+ end
162
+
163
+ def trigger_name(kind, operation) = "ruby_llm_v2_#{kind}_#{operation.downcase}"
164
+ def postgresql? = @connection.adapter_name == 'PostgreSQL'
165
+ def mysql? = @connection.adapter_name == 'Mysql2'
166
+ def q(value) = @connection.quote_column_name(value)
167
+ def qt(value) = @connection.quote_table_name(value)
168
+ end
169
+ end
170
+ end
171
+ end