tina4ruby 3.11.35 → 3.12.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.
data/lib/tina4/docs.rb ADDED
@@ -0,0 +1,636 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4::Docs — Live API RAG.
4
+ #
5
+ # Walks the running framework (`lib/tina4/`) and the user's project surface
6
+ # (`<root>/src/orm`, `routes`, `app`, `services`) and exposes them via a
7
+ # ranked search, class/method specs, a flat index, MCP-style mirrors and
8
+ # a Markdown drift / sync helper.
9
+ #
10
+ # Stdlib only — no new gems. Class introspection where safe, regex parsing
11
+ # of `.rb` source for unloaded user code (so user files with unresolved
12
+ # requires never blow up the indexer).
13
+ #
14
+ # Spec: plan/v3/22-LIVE-API-RAG.md
15
+
16
+ require "json"
17
+ require "set"
18
+
19
+ module Tina4
20
+ class Docs
21
+ # ── Constants ────────────────────────────────────────────────────
22
+
23
+ USER_DIRS = %w[orm routes app services].freeze
24
+
25
+ # Method names commonly referenced in markdown that must NOT be flagged
26
+ # as drift even when not present in the live index.
27
+ STDLIB_ALLOWLIST = %w[
28
+ puts print p pp inspect to_s to_a to_h to_i to_f to_sym
29
+ keys values each map select reject reduce inject filter
30
+ length size count first last empty? include? has_key?
31
+ push pop shift unshift slice splice
32
+ get set put patch post delete head options
33
+ json html xml render redirect text file stream call
34
+ strip lstrip rstrip chomp chop split join replace gsub sub
35
+ upcase downcase capitalize to_str
36
+ new initialize freeze dup clone tap then yield_self
37
+ raise rescue retry begin ensure end
38
+ require require_relative load
39
+ assert assert_equal assert_nil assert_not_nil expect should
40
+ ].freeze
41
+
42
+ # Module-level cache keyed by absolute project_root.
43
+ @_mcp_instances = {}
44
+ @_mcp_mutex = Mutex.new
45
+
46
+ # ── Construction ─────────────────────────────────────────────────
47
+
48
+ def initialize(project_root)
49
+ @project_root = File.expand_path(project_root.to_s)
50
+ @framework_root = File.expand_path(File.dirname(__FILE__))
51
+ @gem_root = File.expand_path(File.join(@framework_root, ".."))
52
+ @version = detect_version
53
+ @framework_entries = nil
54
+ @user_entries = nil
55
+ @user_mtime = 0
56
+ end
57
+
58
+ attr_reader :project_root
59
+
60
+ # ── Public API ───────────────────────────────────────────────────
61
+
62
+ # Search the merged framework + user index for ranked hits.
63
+ #
64
+ # @param query [String] Free-text query
65
+ # @param k [Integer] Top-K to return
66
+ # @param source [String] "all" (default), "framework", "user", "vendor"
67
+ # @param include_private [Boolean] Include private/protected/_underscore methods
68
+ # @return [Array<Hash>]
69
+ def search(query, k: 5, source: "all", include_private: false)
70
+ ensure_index
71
+ tokens = tokenise(query.to_s)
72
+ return [] if tokens.empty?
73
+
74
+ joined = query.to_s.downcase.gsub(/\s+/, "")
75
+ results = []
76
+ all_entries.each do |entry|
77
+ src = entry[:source]
78
+ next if source == "all" && src == "vendor"
79
+ next if source != "all" && src != source
80
+ next if !include_private && entry[:_private]
81
+
82
+ score = score_entry(entry, tokens, joined)
83
+ next if score <= 0
84
+ score *= 1.2 if src == "user"
85
+ hit = entry.dup
86
+ hit.delete(:_private)
87
+ hit.delete(:docstring)
88
+ hit[:score] = score.round(4)
89
+ results << hit
90
+ end
91
+ results.sort! do |a, b|
92
+ cmp = b[:score] <=> a[:score]
93
+ cmp.nonzero? || (a[:fqn] <=> b[:fqn])
94
+ end
95
+ results.first([k, 1].max)
96
+ end
97
+
98
+ # Full reflection of a single class — `nil` for unknown.
99
+ def class_spec(fqn)
100
+ ensure_index
101
+ key = normalise_fqn(fqn)
102
+ class_entry = all_entries.find { |e| e[:kind] == "class" && e[:fqn] == key }
103
+ return nil if class_entry.nil?
104
+
105
+ methods = all_entries.select do |m|
106
+ m[:kind] == "method" && m[:class_fqn] == class_entry[:fqn] && m[:visibility] == "public"
107
+ end.map { |m| method_payload(m) }
108
+
109
+ {
110
+ fqn: class_entry[:fqn],
111
+ kind: "class",
112
+ name: class_entry[:name],
113
+ file: class_entry[:file],
114
+ line: class_entry[:line],
115
+ summary: class_entry[:summary],
116
+ source: class_entry[:source],
117
+ version: class_entry[:version],
118
+ methods: methods,
119
+ properties: [],
120
+ }
121
+ end
122
+
123
+ # Single method spec — `nil` for unknown.
124
+ def method_spec(class_fqn, method_name)
125
+ ensure_index
126
+ key = normalise_fqn(class_fqn)
127
+ entry = all_entries.find do |e|
128
+ e[:kind] == "method" && e[:class_fqn] == key && e[:name] == method_name.to_s
129
+ end
130
+ entry && method_payload(entry)
131
+ end
132
+
133
+ # Flat list of every entity (classes + methods).
134
+ def index
135
+ ensure_index
136
+ all_entries.map do |e|
137
+ clean = e.dup
138
+ clean.delete(:_private)
139
+ clean
140
+ end
141
+ end
142
+
143
+ # ── MCP-style mirrors ────────────────────────────────────────────
144
+
145
+ def self.mcp_search(query, k: 5, project_root: nil, source: "all", include_private: false)
146
+ cached(project_root).search(query, k: k, source: source, include_private: include_private)
147
+ end
148
+
149
+ def self.mcp_method(class_fqn, name, project_root: nil)
150
+ cached(project_root).method_spec(class_fqn, name)
151
+ end
152
+
153
+ def self.mcp_class(fqn, project_root: nil)
154
+ cached(project_root).class_spec(fqn)
155
+ end
156
+
157
+ # ── Drift detector ───────────────────────────────────────────────
158
+
159
+ # Scan a markdown file for method-call references that don't exist in
160
+ # the live index. Returns `{drift: [...]}`.
161
+ def self.check_docs(md_file_path, project_root: nil)
162
+ return { drift: [], error: "file not found: #{md_file_path}" } unless File.file?(md_file_path)
163
+
164
+ project_root ||= File.dirname(File.expand_path(md_file_path))
165
+ docs = cached(project_root)
166
+ idx = docs.index
167
+ known = idx.each_with_object({}) do |e, acc|
168
+ acc[e[:name].to_s.downcase] = true if e[:kind] == "method"
169
+ end
170
+ allow = STDLIB_ALLOWLIST.map(&:downcase).to_set
171
+
172
+ text = File.read(md_file_path, encoding: "utf-8", invalid: :replace, undef: :replace)
173
+ drift = []
174
+
175
+ # Patterns: var.method(, Class::method(, Class#method(, var->method(
176
+ patterns = [
177
+ /(?:\b[a-z_][\w]*|\$\w+)\s*[.](\w+)\s*\(/,
178
+ /\b[A-Z]\w*::(\w+)\s*\(/,
179
+ /\b[A-Z]\w*#(\w+)\s*\(/,
180
+ /\$\w+->(\w+)\s*\(/,
181
+ ]
182
+
183
+ in_block = false
184
+ text.lines.each_with_index do |line, i|
185
+ if line.lstrip.start_with?("```")
186
+ in_block = !in_block
187
+ next
188
+ end
189
+ next unless in_block
190
+
191
+ patterns.each do |pat|
192
+ line.scan(pat) do |captures|
193
+ name = captures.first
194
+ name_lc = name.to_s.downcase
195
+ next if allow.include?(name_lc)
196
+ next if known[name_lc]
197
+ drift << {
198
+ method: name,
199
+ line: i + 1,
200
+ block: line.strip,
201
+ }
202
+ end
203
+ end
204
+ end
205
+
206
+ { drift: drift }
207
+ end
208
+
209
+ # Overwrite the `<!-- BEGIN GENERATED API -->` block in the markdown
210
+ # file. Append a fresh block if the markers are absent.
211
+ def self.sync_docs(md_file_path, project_root: nil)
212
+ project_root ||= (File.file?(md_file_path) ? File.dirname(File.expand_path(md_file_path)) : Dir.pwd)
213
+ docs = cached(project_root)
214
+ generated = docs.send(:render_generated_block)
215
+ begin_marker = "<!-- BEGIN GENERATED API -->"
216
+ end_marker = "<!-- END GENERATED API -->"
217
+ existing = File.file?(md_file_path) ? File.read(md_file_path, encoding: "utf-8") : ""
218
+
219
+ if existing.include?(begin_marker) && existing.include?(end_marker)
220
+ b = existing.index(begin_marker)
221
+ e = existing.index(end_marker)
222
+ if b && e && e > b
223
+ before = existing[0, b + begin_marker.length]
224
+ after = existing[e..-1]
225
+ File.write(md_file_path, "#{before}\n#{generated}\n#{after}")
226
+ return
227
+ end
228
+ end
229
+
230
+ block = "\n\n#{begin_marker}\n#{generated}\n#{end_marker}\n"
231
+ File.write(md_file_path, existing.rstrip + block)
232
+ end
233
+
234
+ # ── Internal: cached MCP instance ────────────────────────────────
235
+
236
+ def self.cached(project_root)
237
+ key = File.expand_path((project_root || Dir.pwd).to_s)
238
+ @_mcp_mutex.synchronize do
239
+ @_mcp_instances ||= {}
240
+ @_mcp_instances[key] ||= new(key)
241
+ end
242
+ end
243
+
244
+ def self.reset_cache!
245
+ @_mcp_mutex ||= Mutex.new
246
+ @_mcp_mutex.synchronize do
247
+ @_mcp_instances = {}
248
+ end
249
+ end
250
+
251
+ # ── Internal: index lifecycle ────────────────────────────────────
252
+
253
+ private
254
+
255
+ def all_entries
256
+ (@framework_entries || []) + (@user_entries || [])
257
+ end
258
+
259
+ def ensure_index
260
+ @framework_entries ||= build_framework_index
261
+ current = current_user_mtime
262
+ if @user_entries.nil? || current != @user_mtime
263
+ @user_entries = build_user_index
264
+ @user_mtime = current
265
+ end
266
+ end
267
+
268
+ def current_user_mtime
269
+ max = 0
270
+ USER_DIRS.each do |sub|
271
+ root = File.join(@project_root, "src", sub)
272
+ next unless File.directory?(root)
273
+ Dir.glob(File.join(root, "**", "*.rb")).each do |f|
274
+ mt = File.mtime(f).to_f rescue 0
275
+ max = mt if mt > max
276
+ end
277
+ end
278
+ max
279
+ end
280
+
281
+ # ── Internal: framework reflection ───────────────────────────────
282
+
283
+ def build_framework_index
284
+ entries = []
285
+ Dir.glob(File.join(@framework_root, "**", "*.rb")).each do |path|
286
+ parse_ruby_file(path, "framework", entries)
287
+ end
288
+ entries
289
+ end
290
+
291
+ # ── Internal: user reflection ────────────────────────────────────
292
+
293
+ def build_user_index
294
+ entries = []
295
+ USER_DIRS.each do |sub|
296
+ root = File.join(@project_root, "src", sub)
297
+ next unless File.directory?(root)
298
+ Dir.glob(File.join(root, "**", "*.rb")).each do |path|
299
+ parse_ruby_file(path, "user", entries)
300
+ end
301
+ end
302
+ entries
303
+ end
304
+
305
+ # ── Parsing — regex-based AST-lite ───────────────────────────────
306
+
307
+ # Parse a Ruby source file into class + method entries. Tracks nested
308
+ # `module`/`class` declarations to assemble `Foo::Bar` FQNs and pairs
309
+ # each `def` with the leading comment block.
310
+ def parse_ruby_file(abs_path, source, entries)
311
+ text = File.read(abs_path, encoding: "utf-8", invalid: :replace, undef: :replace)
312
+ return if text.bytesize > 1024 * 1024 # 1MB sanity cap
313
+
314
+ rel_file = relative_path(abs_path, source)
315
+ stack = [] # [{ kind:, name:, line:, doc:, fqn: }]
316
+ pending_doc = []
317
+ pending_visibility = nil
318
+ visibility_scope = {} # fqn => current visibility
319
+
320
+ text.each_line.with_index(1) do |raw_line, lineno|
321
+ line = raw_line.rstrip
322
+ stripped = line.lstrip
323
+
324
+ if stripped.start_with?("#")
325
+ # Skip shebang and frozen-string magic comments.
326
+ unless stripped.start_with?("#!") || stripped.start_with?("# frozen_string_literal")
327
+ pending_doc << stripped.sub(/\A#\s?/, "")
328
+ end
329
+ next
330
+ end
331
+
332
+ # Track scope-changing visibility modifiers on their own line.
333
+ if stack.last && %w[private protected public].include?(stripped.split(/\s/).first)
334
+ # `private` alone on a line — switches default for the class.
335
+ first_word = stripped.split(/\s/).first
336
+ if stripped.match?(/\A(private|protected|public)\s*\z/)
337
+ current_class = stack.reverse.find { |s| s[:kind] == "class" }
338
+ visibility_scope[current_class[:fqn]] = first_word if current_class
339
+ pending_doc.clear
340
+ next
341
+ end
342
+ # `private :foo` symbol form — apply to that one method
343
+ if (m = stripped.match(/\A(private|protected|public)\s+:(\w+)/))
344
+ mname = m[2]
345
+ target = entries.reverse.find { |e| e[:kind] == "method" && e[:name] == mname }
346
+ target[:visibility] = m[1] if target
347
+ target[:_private] = (m[1] != "public") if target
348
+ pending_doc.clear
349
+ next
350
+ end
351
+ if (m = stripped.match(/\A(private|protected|public)\s+def\s+/))
352
+ pending_visibility = m[1]
353
+ stripped = stripped.sub(/\A(private|protected|public)\s+/, "")
354
+ end
355
+ end
356
+
357
+ # Module / class declarations
358
+ if (m = stripped.match(/\Amodule\s+([A-Z]\w*(?:::[A-Z]\w*)*)/))
359
+ name = m[1]
360
+ fqn = qualify(stack, name)
361
+ stack << { kind: "module", name: name, line: lineno, doc: pending_doc.dup, fqn: fqn,
362
+ indent: leading_indent(line) }
363
+ pending_doc.clear
364
+ next
365
+ end
366
+
367
+ if (m = stripped.match(/\Aclass\s+([A-Z]\w*(?:::[A-Z]\w*)*)(?:\s*<\s*[\w:]+)?/))
368
+ name = m[1]
369
+ fqn = qualify(stack, name)
370
+ stack << { kind: "class", name: name, line: lineno, doc: pending_doc.dup, fqn: fqn,
371
+ indent: leading_indent(line) }
372
+ summary, doc = split_doc(pending_doc)
373
+ entries << {
374
+ fqn: fqn,
375
+ kind: "class",
376
+ name: name.split("::").last,
377
+ signature: "class #{name}",
378
+ summary: summary,
379
+ docstring: doc,
380
+ file: rel_file,
381
+ line: lineno,
382
+ version: @version,
383
+ source: source,
384
+ visibility: "public",
385
+ }
386
+ visibility_scope[fqn] = "public"
387
+ pending_doc.clear
388
+ next
389
+ end
390
+
391
+ # `end` — pop matching module/class. Match by indent so we don't
392
+ # confuse method-end with class-end.
393
+ if stripped == "end" || stripped.start_with?("end ") || stripped.start_with?("end\t")
394
+ if stack.last && leading_indent(line) == stack.last[:indent]
395
+ stack.pop
396
+ next
397
+ end
398
+ end
399
+
400
+ # Method definition.
401
+ if (m = stripped.match(/\Adef\s+(self\.)?([A-Za-z_][\w]*[!?=]?)(.*?)(?:\z|\s*#|;)/))
402
+ is_static = !m[1].nil?
403
+ mname = m[2]
404
+ rest = m[3].to_s.strip
405
+ # Capture args until the matching close paren or end-of-line.
406
+ if rest.start_with?("(")
407
+ args = balanced_paren(rest)
408
+ sig_args = args
409
+ elsif rest.empty?
410
+ sig_args = "()"
411
+ else
412
+ sig_args = "(#{rest})"
413
+ end
414
+
415
+ current_class = stack.reverse.find { |s| s[:kind] == "class" }
416
+ if current_class.nil?
417
+ pending_doc.clear
418
+ pending_visibility = nil
419
+ next
420
+ end
421
+
422
+ base_visibility = visibility_scope[current_class[:fqn]] || "public"
423
+ visibility = pending_visibility || base_visibility
424
+ # Names starting with `_` are treated as private for search filtering.
425
+ underscored = mname.start_with?("_")
426
+ private_flag = visibility != "public" || underscored
427
+
428
+ summary, doc = split_doc(pending_doc)
429
+ method_fqn = if is_static
430
+ "#{current_class[:fqn]}.#{mname}"
431
+ else
432
+ "#{current_class[:fqn]}##{mname}"
433
+ end
434
+ signature = is_static ? "self.#{mname}#{sig_args}" : "#{mname}#{sig_args}"
435
+
436
+ entries << {
437
+ fqn: method_fqn,
438
+ kind: "method",
439
+ name: mname,
440
+ class_fqn: current_class[:fqn],
441
+ class: current_class[:fqn],
442
+ signature: signature,
443
+ summary: summary,
444
+ docstring: doc,
445
+ file: rel_file,
446
+ line: lineno,
447
+ version: @version,
448
+ source: source,
449
+ visibility: visibility,
450
+ static: is_static,
451
+ _private: private_flag,
452
+ }
453
+ pending_doc.clear
454
+ pending_visibility = nil
455
+ next
456
+ end
457
+
458
+ # Anything else clears the pending docblock.
459
+ pending_doc.clear unless stripped.empty?
460
+ end
461
+ end
462
+
463
+ def qualify(stack, name)
464
+ return name if name.start_with?("::")
465
+ parts = stack.map { |s| s[:name] } + [name]
466
+ parts.join("::")
467
+ end
468
+
469
+ def leading_indent(line)
470
+ line[/\A[ \t]*/].length
471
+ end
472
+
473
+ # Read a balanced parenthesised string starting at `s[0] == "("`.
474
+ def balanced_paren(s)
475
+ depth = 0
476
+ out = +""
477
+ s.each_char do |ch|
478
+ out << ch
479
+ if ch == "("
480
+ depth += 1
481
+ elsif ch == ")"
482
+ depth -= 1
483
+ break if depth.zero?
484
+ end
485
+ end
486
+ out
487
+ end
488
+
489
+ def split_doc(lines)
490
+ cleaned = lines.map(&:strip).reject { |l| l.empty? || l.start_with?("@") }
491
+ summary = cleaned.first.to_s
492
+ [summary[0, 240], cleaned.join(" ")]
493
+ end
494
+
495
+ # ── Relative path ────────────────────────────────────────────────
496
+
497
+ def relative_path(abs_path, source)
498
+ abs = File.expand_path(abs_path)
499
+ if source == "framework"
500
+ # Framework-relative — strip the gem root so files appear as
501
+ # "lib/tina4/response.rb".
502
+ if abs.start_with?(@gem_root + File::SEPARATOR)
503
+ return abs.sub(@gem_root + File::SEPARATOR, "")
504
+ end
505
+ end
506
+ project = @project_root + File::SEPARATOR
507
+ return abs.sub(project, "") if abs.start_with?(project)
508
+ abs
509
+ end
510
+
511
+ # ── Ranking ──────────────────────────────────────────────────────
512
+
513
+ CAMEL_RE = /(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/.freeze
514
+
515
+ SPLIT_RE = /[\s_\-.\/:,;()\[\]{}\\]+/.freeze
516
+
517
+ def tokenise(text)
518
+ return [] if text.nil? || text.empty?
519
+ t = text.gsub(CAMEL_RE, " ").downcase
520
+ t.split(SPLIT_RE).reject(&:empty?)
521
+ end
522
+
523
+ def score_entry(entry, tokens, joined)
524
+ name = entry[:name].to_s.downcase
525
+ stripped = name.sub(/\A_+/, "")
526
+ summary = entry[:summary].to_s.downcase
527
+ doc = entry[:docstring].to_s.downcase
528
+ score = 0.0
529
+
530
+ # 5: exact name match (case-insensitive) on the joined query.
531
+ if name == joined || stripped == joined
532
+ score += 5
533
+ end
534
+
535
+ name_tokens = tokenise(entry[:name].to_s)
536
+ tokens.each do |tk|
537
+ next if tk.empty?
538
+ if name.start_with?(tk) || stripped.start_with?(tk)
539
+ score += 3
540
+ next
541
+ end
542
+ name_tokens.each do |nt|
543
+ if nt == tk
544
+ score += 3
545
+ break
546
+ elsif nt.start_with?(tk)
547
+ score += 2
548
+ break
549
+ end
550
+ end
551
+ end
552
+
553
+ # 2: per token in summary.
554
+ tokens.each do |tk|
555
+ score += 2 if !tk.empty? && summary.include?(tk)
556
+ end
557
+ # 1: per token in docstring body.
558
+ tokens.each do |tk|
559
+ score += 1 if !tk.empty? && doc.include?(tk)
560
+ end
561
+ # Substring fallback — full joined query inside the name.
562
+ if !joined.empty? && score.zero? && name.include?(joined)
563
+ score += 2
564
+ end
565
+ score
566
+ end
567
+
568
+ # ── Spec helpers ─────────────────────────────────────────────────
569
+
570
+ def method_payload(entry)
571
+ {
572
+ name: entry[:name],
573
+ fqn: entry[:fqn],
574
+ class: entry[:class_fqn],
575
+ kind: "method",
576
+ signature: entry[:signature],
577
+ summary: entry[:summary],
578
+ docblock: entry[:docstring],
579
+ file: entry[:file],
580
+ line: entry[:line],
581
+ visibility: entry[:visibility],
582
+ static: entry[:static] || false,
583
+ source: entry[:source],
584
+ version: entry[:version],
585
+ params: [],
586
+ return: "",
587
+ }
588
+ end
589
+
590
+ # ── Sync writer ──────────────────────────────────────────────────
591
+
592
+ def render_generated_block
593
+ ensure_index
594
+ lines = []
595
+ lines << "_Generated by `Tina4::Docs` — version #{@version}._"
596
+ lines << ""
597
+ lines << "## Framework classes"
598
+ lines << ""
599
+ fw_classes = (@framework_entries || []).select { |e| e[:kind] == "class" }.sort_by { |e| e[:fqn] }
600
+ method_counts = Hash.new(0)
601
+ (@framework_entries || []).each do |e|
602
+ method_counts[e[:class_fqn]] += 1 if e[:kind] == "method"
603
+ end
604
+ fw_classes.each do |c|
605
+ summary = c[:summary].to_s.empty? ? "" : " — #{c[:summary]}"
606
+ lines << "- `#{c[:fqn]}` (#{method_counts[c[:fqn]]} methods)#{summary}"
607
+ end
608
+
609
+ user_classes = (@user_entries || []).select { |e| e[:kind] == "class" }.sort_by { |e| e[:fqn] }
610
+ unless user_classes.empty?
611
+ lines << ""
612
+ lines << "## User code"
613
+ lines << ""
614
+ user_classes.each do |c|
615
+ summary = c[:summary].to_s.empty? ? "" : " — #{c[:summary]}"
616
+ lines << "- `#{c[:fqn]}`#{summary}"
617
+ end
618
+ end
619
+
620
+ lines.join("\n")
621
+ end
622
+
623
+ # ── Bootstrap helpers ────────────────────────────────────────────
624
+
625
+ def detect_version
626
+ if defined?(Tina4::VERSION) && !Tina4::VERSION.to_s.empty?
627
+ return Tina4::VERSION.to_s
628
+ end
629
+ "0.0.0"
630
+ end
631
+
632
+ def normalise_fqn(fqn)
633
+ fqn.to_s.sub(/\A::/, "")
634
+ end
635
+ end
636
+ end
@@ -41,10 +41,44 @@ module Tina4
41
41
  end
42
42
 
43
43
  def last_insert_id
44
- result = @connection.exec("SELECT lastval()")
45
- result.first["lastval"].to_i
46
- rescue PG::Error
47
- nil
44
+ # Issue #38: ``SELECT lastval()`` raises on tables with no sequence
45
+ # (UUID, ULID, hash PKs etc.). The exception itself isn't fatal,
46
+ # but the pg gem marks the whole transaction as aborted, so every
47
+ # subsequent statement on this connection fails with
48
+ # ``PG::InFailedSqlTransaction`` — far away from the real cause.
49
+ #
50
+ # Fix: wrap the probe in a SAVEPOINT. If ``lastval()`` raises, we
51
+ # ROLLBACK TO SAVEPOINT and the outer transaction stays usable;
52
+ # ``last_insert_id`` just returns ``nil`` (same as before for
53
+ # tables without a sequence). On success we RELEASE SAVEPOINT.
54
+ begin
55
+ @connection.exec("SAVEPOINT _t4_lastval_probe")
56
+ rescue PG::Error
57
+ # No active transaction (autocommit/idle) — fall back to a plain
58
+ # probe; psycopg2-style transaction abort can't happen here.
59
+ begin
60
+ result = @connection.exec("SELECT lastval()")
61
+ return result.first["lastval"].to_i
62
+ rescue PG::Error
63
+ return nil
64
+ end
65
+ end
66
+
67
+ begin
68
+ result = @connection.exec("SELECT lastval()")
69
+ @connection.exec("RELEASE SAVEPOINT _t4_lastval_probe")
70
+ result.first["lastval"].to_i
71
+ rescue PG::Error
72
+ begin
73
+ @connection.exec("ROLLBACK TO SAVEPOINT _t4_lastval_probe")
74
+ @connection.exec("RELEASE SAVEPOINT _t4_lastval_probe")
75
+ rescue PG::Error
76
+ # If even the rollback fails, there's nothing we can do — the
77
+ # connection is in a state we can't recover. Surface nil so
78
+ # callers don't get a half-set last_id.
79
+ end
80
+ nil
81
+ end
48
82
  end
49
83
 
50
84
  def placeholder