arfi 0.5.1 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.env.sample +2 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +8 -1
  5. data/.rubocop_todo.yml +12 -0
  6. data/CHANGELOG.md +94 -0
  7. data/CODE_OF_CONDUCT.md +5 -1
  8. data/CONTRIBUTING.md +26 -0
  9. data/README.md +327 -118
  10. data/SECURITY.md +17 -0
  11. data/Steepfile +1 -29
  12. data/compose.yml +33 -0
  13. data/docscribe.yml +92 -0
  14. data/gemfiles/rails_6_0.gemfile +11 -0
  15. data/gemfiles/rails_6_1.gemfile +11 -0
  16. data/gemfiles/rails_7_0.gemfile +9 -0
  17. data/gemfiles/rails_7_1.gemfile +10 -0
  18. data/gemfiles/rails_7_2.gemfile +10 -0
  19. data/gemfiles/rails_8_0.gemfile +10 -0
  20. data/gemfiles/rails_8_1.gemfile +10 -0
  21. data/lib/arfi/cli.rb +24 -9
  22. data/lib/arfi/commands/f_idx.rb +5 -230
  23. data/lib/arfi/commands/functions.rb +128 -0
  24. data/lib/arfi/commands/functions_candidates.rb +133 -0
  25. data/lib/arfi/commands/functions_creation.rb +157 -0
  26. data/lib/arfi/commands/functions_helpers.rb +181 -0
  27. data/lib/arfi/commands/functions_paths.rb +131 -0
  28. data/lib/arfi/commands/functions_rendering.rb +137 -0
  29. data/lib/arfi/commands/init.rb +88 -0
  30. data/lib/arfi/commands/project.rb +5 -51
  31. data/lib/arfi/errors.rb +15 -3
  32. data/lib/arfi/extensions/active_record/base.rb +33 -23
  33. data/lib/arfi/extensions/active_record/connection_adapters/postgresql/database_statements.rb +159 -24
  34. data/lib/arfi/sql_function_loader.rb +289 -90
  35. data/lib/arfi/tasks/db.rake +60 -18
  36. data/lib/arfi/version.rb +1 -1
  37. data/lib/arfi.rb +2 -0
  38. data/rbs_collection.lock.yaml +93 -61
  39. data/sig/compat/active_record_base_compat.rbs +17 -0
  40. data/sig/compat/thor_dsl.rbs +8 -0
  41. data/sig/lib/arfi/commands/f_idx.rbs +5 -103
  42. data/sig/lib/arfi/commands/functions.rbs +99 -0
  43. data/sig/lib/arfi/commands/functions_candidates.rbs +25 -0
  44. data/sig/lib/arfi/commands/functions_creation.rbs +29 -0
  45. data/sig/lib/arfi/commands/functions_helpers.rbs +41 -0
  46. data/sig/lib/arfi/commands/functions_paths.rbs +25 -0
  47. data/sig/lib/arfi/commands/functions_rendering.rbs +25 -0
  48. data/sig/lib/arfi/commands/init.rbs +22 -0
  49. data/sig/lib/arfi/commands/project.rbs +5 -24
  50. data/sig/lib/arfi/extensions/active_record/base.rbs +5 -10
  51. data/sig/lib/arfi/extensions/active_record/connection_adapters/postgresql/database_statements.rbs +28 -0
  52. data/sig/lib/arfi/sql_function_loader.rbs +45 -87
  53. data/sig/lib/arfi/tasks/db.rbs +3 -0
  54. data/sig/lib/arfi/version.rbs +1 -1
  55. metadata +125 -14
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arfi
4
+ module Commands
5
+ # Table rendering helpers for {Arfi::Commands::Functions}.
6
+ module FunctionsRendering
7
+ private
8
+
9
+ # Render the resolved function list in the selected format (paths, json, or table).
10
+ #
11
+ # @private
12
+ # @param [Array<Hash<Symbol, Object>>] rows Resolved function rows
13
+ # @return [void]
14
+ def render_list(rows)
15
+ case options[:format].to_s
16
+ when 'paths'
17
+ rows.each { puts rel(_1[:path]) }
18
+ when 'json'
19
+ puts JSON.pretty_generate(rows.map { |r| r.merge(path: rel(r[:path])) })
20
+ else
21
+ print_table(rows)
22
+ end
23
+ end
24
+
25
+ # Print the function list as a formatted ASCII table.
26
+ #
27
+ # @private
28
+ # @param [Array<Hash<Symbol, Object>>] rows Resolved function rows
29
+ # @return [void]
30
+ def print_table(rows)
31
+ cols = table_columns
32
+ table = stringify_rows(rows)
33
+ widths = calculate_widths(cols, table)
34
+ puts cols.map { |c| c.ljust(widths[c]) }.join(' ')
35
+ puts cols.map { |c| '-' * widths[c] }.join(' ')
36
+ render_table_rows(table, cols, widths)
37
+ end
38
+
39
+ # Determine which columns to display based on the --all flag.
40
+ #
41
+ # @private
42
+ # @return [Array<String>] List of column names
43
+ def table_columns
44
+ if options[:all]
45
+ %w[chosen schema function source origin priority path shadowed_by]
46
+ else
47
+ %w[schema function source origin priority path shadowed]
48
+ end
49
+ end
50
+
51
+ # Convert all row values to strings for display, handling special columns.
52
+ #
53
+ # @private
54
+ # @param [Array<Hash<Symbol, Object>>] rows Resolved function rows
55
+ # @return [Array<Hash<Symbol, Object>>] Rows with string values
56
+ def stringify_rows(rows)
57
+ rows.map do |r|
58
+ r = r.dup
59
+ r[:path] = rel(r[:path])
60
+ r[:chosen] = r[:chosen] ? 'yes' : 'no' if r.key?(:chosen)
61
+ r[:shadowed] = (r[:shadowed] || []).join(', ') if r.key?(:shadowed)
62
+ r
63
+ end
64
+ end
65
+
66
+ # Calculate the maximum display width for each column.
67
+ #
68
+ # @private
69
+ # @param [Array<String>] cols Column names
70
+ # @param [Array<Hash<Symbol, Object>>] table Stringified table rows
71
+ # @return [Hash<String, Integer>] Column widths keyed by column name
72
+ def calculate_widths(cols, table)
73
+ widths = {} # steep:ignore
74
+ cols.each do |c|
75
+ widths[c] = ([c.length] + table.map { |r| r[c.to_sym].to_s.length }).max
76
+ end
77
+ widths
78
+ end
79
+
80
+ # Render each table row with proper column alignment.
81
+ #
82
+ # @private
83
+ # @param [Array<Hash<Symbol, Object>>] table Stringified table rows
84
+ # @param [Array<String>] cols Column names
85
+ # @param [Hash<String, Integer>] widths Calculated column widths
86
+ # @return [void]
87
+ def render_table_rows(table, cols, widths)
88
+ table.each do |r|
89
+ puts cols.map { |c| r[c.to_sym].to_s.ljust(widths[c]) }.join(' ')
90
+ end
91
+ end
92
+
93
+ # Resolve a group of same-key candidates to the chosen one with shadowed info.
94
+ #
95
+ # @private
96
+ # @param [Array<Arfi::Commands::candidate>] arr Candidates for one function key
97
+ # @return [Array<Hash<Symbol, Object>>] Resolved row(s) for display
98
+ def resolve_key_group(arr)
99
+ chosen = arr.max_by { |c| c[:priority] }
100
+ return [] unless chosen
101
+
102
+ if options[:all]
103
+ all_mode_rows(arr, chosen)
104
+ else
105
+ default_mode_row(chosen, arr)
106
+ end
107
+ end
108
+
109
+ # Build display rows for --all mode (shows all candidates including overridden ones).
110
+ #
111
+ # @private
112
+ # @param [Array<Arfi::Commands::candidate>] arr All candidates for one function key
113
+ # @param [Arfi::Commands::candidate] chosen The selected (highest-priority) candidate
114
+ # @return [Array<Hash<Symbol, Object>>] Display rows
115
+ def all_mode_rows(arr, chosen)
116
+ chosen_path = chosen[:path]
117
+ arr.sort_by { |c| [-c[:priority], c[:schema], c[:function]] }.map do |c|
118
+ c.merge(
119
+ chosen: (c == chosen),
120
+ shadowed_by: (c == chosen ? nil : rel(chosen_path))
121
+ )
122
+ end
123
+ end
124
+
125
+ # Build a single display row for default mode (only the chosen candidate).
126
+ #
127
+ # @private
128
+ # @param [Arfi::Commands::candidate] chosen The selected (highest-priority) candidate
129
+ # @param [Array<Arfi::Commands::candidate>] arr All candidates for one function key
130
+ # @return [Array<Hash<Symbol, Object>>] Single display row
131
+ def default_mode_row(chosen, arr)
132
+ shadowed = (arr - [chosen])
133
+ [chosen.merge(chosen: true, shadowed: shadowed.map { rel(_1[:path]) })]
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'fileutils'
5
+ require 'rails'
6
+
7
+ module Arfi
8
+ module Commands
9
+ # Initializes ARFI directory structure inside a Rails project.
10
+ #
11
+ # Creates/ensures:
12
+ # - db/functions/public
13
+ # - db/functions/<adapter>/public (when adapter provided)
14
+ #
15
+ # @api public
16
+ class Init < Thor
17
+ ADAPTERS = %i[postgresql mysql].freeze
18
+ ROOT_DIR = 'db/functions'
19
+ DEFAULT_SCHEMA = 'public'
20
+
21
+ default_task :create
22
+
23
+ # UX aliases
24
+ map %w[setup] => :create
25
+
26
+ desc 'create', 'Initialize project by creating db/functions structure (explicit public schema dirs)'
27
+ option :adapter, type: :string,
28
+ desc: "Specify database adapter. Available adapters: #{ADAPTERS.join(', ')}",
29
+ banner: 'adapter'
30
+
31
+ # Run the full init flow: validate schema, create base dirs, and optionally create adapter dirs.
32
+ #
33
+ # @return [void]
34
+ def create
35
+ validate_schema_format!
36
+ create_base_dirs
37
+ create_adapter_dirs if options[:adapter] # steep:ignore NoMethod
38
+ end
39
+
40
+ private
41
+
42
+ # Validate that the Rails schema format is set to :ruby.
43
+ #
44
+ # @private
45
+ # @raise [Arfi::Errors::InvalidSchemaFormat] If schema format is not :ruby
46
+ # @return [void]
47
+ def validate_schema_format!
48
+ fmt =
49
+ if defined?(Rails) && Rails.application
50
+ Rails.application.config.active_record.schema_format
51
+ elsif defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:schema_format)
52
+ ActiveRecord::Base.schema_format
53
+ end
54
+
55
+ raise Arfi::Errors::InvalidSchemaFormat unless fmt == :ruby
56
+ end
57
+
58
+ # Create the base db/functions and db/functions/public directories.
59
+ #
60
+ # @private
61
+ # @return [void]
62
+ def create_base_dirs
63
+ root = Rails.root.join(ROOT_DIR)
64
+ FileUtils.mkdir_p(root)
65
+ puts "Ensured: #{root}"
66
+ FileUtils.mkdir_p(root.join(DEFAULT_SCHEMA))
67
+ puts "Ensured: #{root.join(DEFAULT_SCHEMA)}"
68
+ end
69
+
70
+ # Create the adapter-specific function directories (e.g. db/functions/postgresql/public).
71
+ #
72
+ # @private
73
+ # @raise [Arfi::Errors::AdapterNotSupported] If adapter is not in the supported list
74
+ # @return [void]
75
+ def create_adapter_dirs
76
+ adapter = options[:adapter].to_s # steep:ignore NoMethod
77
+ raise Arfi::Errors::AdapterNotSupported unless ADAPTERS.include?(adapter.to_sym)
78
+
79
+ root = Rails.root.join(ROOT_DIR)
80
+ adapter_root = root.join(adapter)
81
+ FileUtils.mkdir_p(adapter_root)
82
+ puts "Ensured: #{adapter_root}"
83
+ FileUtils.mkdir_p(adapter_root.join(DEFAULT_SCHEMA))
84
+ puts "Ensured: #{adapter_root.join(DEFAULT_SCHEMA)}"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -1,59 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'thor'
4
- require 'fileutils'
5
- require 'rails'
3
+ require_relative 'init'
6
4
 
7
5
  module Arfi
8
6
  module Commands
9
7
  # +Arfi::Commands::Project+ class is used to create `db/functions` directory.
10
- class Project < Thor
11
- ADAPTERS = %i[postgresql mysql].freeze
12
-
13
- # steep:ignore:start
14
- desc 'create', 'Initialize project by creating db/functions directory'
15
- option :adapter, type: :string,
16
- desc: 'Specify database adapter, used for projects with multiple database architecture. ' \
17
- "Available adapters: #{ADAPTERS.join(', ')}",
18
- banner: 'adapter'
19
- # steep:ignore:end
20
-
21
- # +Arfi::Commands::Project#create+ -> void
22
- #
23
- # This command is used to create `db/functions` directory.
24
- #
25
- # @example
26
- # bundle exec arfi project create
27
- # @return [void]
28
- # @raise [Arfi::Errors::InvalidSchemaFormat] if ActiveRecord.schema_format is not :ruby.
29
- def create
30
- raise Arfi::Errors::InvalidSchemaFormat unless ActiveRecord.schema_format == :ruby # steep:ignore NoMethod
31
- return puts "Directory #{functions_dir} already exists" if Dir.exist?(functions_dir)
32
-
33
- FileUtils.mkdir_p(functions_dir)
34
- puts "Created: #{functions_dir}"
35
- end
36
-
37
- private
38
-
39
- # +Arfi::Commands::Project#functions_dir+ -> Pathname
40
- #
41
- # Helper method to get path to `db/functions` directory.
42
- #
43
- # @!visibility private
44
- # @private
45
- # @return [Pathname] Path to `db/functions` directory
46
- def functions_dir
47
- # steep:ignore:start
48
- if options[:adapter]
49
- raise Arfi::Errors::AdapterNotSupported unless ADAPTERS.include?(options[:adapter].to_sym)
50
-
51
- Rails.root.join("db/functions/#{options[:adapter]}")
52
- # steep:ignore:end
53
- else
54
- Rails.root.join('db/functions')
55
- end
56
- end
57
- end
8
+ #
9
+ # Backward-compatible constant for code that references Arfi::Commands::Project.
10
+ # @deprecated
11
+ Project = Init
58
12
  end
59
13
  end
data/lib/arfi/errors.rb CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  module Arfi
4
4
  module Errors
5
- # This error is raised when there is no `db/functions` directory
5
+ # Raised when there is no `db/functions` directory in the Rails project.
6
6
  class NoFunctionsDir < StandardError
7
+ # Initialize a new NoFunctionsDir error with an optional custom message.
8
+ #
9
+ # @param [String] message Error message
10
+ # @return [void]
7
11
  def initialize(message =
8
12
  'There is no such directory: db/functions. Did you run `bundle exec arfi project:create`?')
9
13
  @message = message
@@ -11,16 +15,24 @@ module Arfi
11
15
  end
12
16
  end
13
17
 
14
- # This error is raised when Rails project schema format is not +schema.rb+
18
+ # Raised when Rails schema format is not ruby (`schema.rb`).
15
19
  class InvalidSchemaFormat < StandardError
20
+ # Initialize a new InvalidSchemaFormat error with an optional custom message.
21
+ #
22
+ # @param [String] message Error message
23
+ # @return [void]
16
24
  def initialize(message = 'Invalid schema format. ARFI supports only ruby format schemas.')
17
25
  @message = message
18
26
  super
19
27
  end
20
28
  end
21
29
 
22
- # This error is raised when database adapter is not supported (for e.g., SQLite3).
30
+ # Raised when the configured/selected adapter is not supported by ARFI.
23
31
  class AdapterNotSupported < StandardError
32
+ # Initialize a new AdapterNotSupported error with an optional custom message.
33
+ #
34
+ # @param [String] message Error message
35
+ # @return [void]
24
36
  def initialize(message = 'Adapter not supported')
25
37
  @message = message
26
38
  super
@@ -4,33 +4,43 @@ require 'active_record'
4
4
 
5
5
  module ActiveRecord
6
6
  class Base # :nodoc:
7
- # +ActiveRecord::Base.function_exists?+ -> bool
7
+ # Check if a SQL function exists in the database, dispatching to the correct adapter method.
8
8
  #
9
- # This method checks if a custom SQL function exists in the database.
10
- #
11
- # @example
12
- # ActiveRecord::Base.function_exists?('my_function') #=> true
13
- # ActiveRecord::Base.function_exists?('my_function123') #=> false
14
- # @param [String] function_name The name of the function to check.
15
- # @return [Boolean] Returns true if the function exists, false otherwise.
16
- def self.function_exists?(function_name) # rubocop:disable Metrics/MethodLength
17
- case connection
18
- when ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
19
- connection.execute("SELECT * FROM pg_proc WHERE proname = '#{function_name}'").any?
20
- when ActiveRecord::ConnectionAdapters::Mysql2Adapter
21
- sql = <<~SQL
22
- SELECT 1
23
- FROM information_schema.ROUTINES
24
- WHERE ROUTINE_TYPE = 'FUNCTION'
25
- AND ROUTINE_SCHEMA = '#{connection.current_database}'
26
- AND ROUTINE_NAME = '#{function_name}'
27
- LIMIT 1;
28
- SQL
29
-
30
- !!connection.execute(sql).first
9
+ # @param [String] function_name Function name to check
10
+ # @raise [ActiveRecord::AdapterNotFound] If adapter is not supported
11
+ # @return [Boolean] Whether the function exists
12
+ def self.function_exists?(function_name)
13
+ case connection.class.name
14
+ when 'ActiveRecord::ConnectionAdapters::PostgreSQLAdapter'
15
+ pg_function_exists?(function_name)
16
+ when 'ActiveRecord::ConnectionAdapters::Mysql2Adapter', 'ActiveRecord::ConnectionAdapters::TrilogyAdapter'
17
+ mysql_function_exists?(function_name)
31
18
  else
32
19
  raise ActiveRecord::AdapterNotFound, "adapter #{connection.class.name} is not supported"
33
20
  end
34
21
  end
22
+
23
+ # Check if a function exists in PostgreSQL via pg_proc catalog table.
24
+ #
25
+ # @param [String] function_name Function name to check
26
+ # @return [Boolean] Whether the function exists
27
+ def self.pg_function_exists?(function_name)
28
+ sql = "SELECT 1 FROM pg_proc WHERE proname = #{connection.quote(function_name)} LIMIT 1"
29
+ !connection.select_value(sql).nil?
30
+ end
31
+
32
+ # Check if a function exists in MySQL/MariaDB via information_schema.ROUTINES.
33
+ #
34
+ # @param [String] function_name Function name to check
35
+ # @return [Boolean] Whether the function exists
36
+ def self.mysql_function_exists?(function_name)
37
+ !connection.select_value(<<~SQL).nil?
38
+ SELECT 1 FROM information_schema.ROUTINES
39
+ WHERE ROUTINE_TYPE = 'FUNCTION'
40
+ AND ROUTINE_SCHEMA = #{connection.quote(connection.current_database)}
41
+ AND ROUTINE_NAME = #{connection.quote(function_name)}
42
+ LIMIT 1;
43
+ SQL
44
+ end
35
45
  end
36
46
  end
@@ -1,32 +1,167 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_record/connection_adapters/postgresql/database_statements'
4
-
5
- module ActiveRecord
6
- module ConnectionAdapters
7
- module PostgreSQL
8
- module DatabaseStatements
9
- # This patch is used for db:prepare task.
10
- def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
11
- log(sql, name, async: async) do |notification_payload|
12
- with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn|
13
- result = conn.async_exec(sql)
14
- verified!
15
- handle_warnings(result)
16
- notification_payload[:row_count] = result.count
17
- result
18
- rescue => e
19
- if e.message.match?(/ERROR:\s{2}function (\S+\(\w+\)) does not exist/)
20
- function_name = e.message.match(/ERROR:\s{2}function (\S+\(\w+\)) does not exist/)[1][/^[^(]*/]
21
- if (function_file = Dir.glob(Rails.root.join('db', 'functions', "#{function_name}_v*.sql").to_s).first)
22
- conn.async_exec(File.read(function_file).chop)
23
- retry
24
- end
25
- end
26
- end
3
+ begin
4
+ require "active_record/connection_adapters/postgresql_adapter"
5
+ rescue LoadError
6
+ # no postgres adapter; ok
7
+ end
8
+
9
+ require "arfi/sql_function_loader"
10
+
11
+ module Arfi
12
+ module PostgreSQL
13
+ # ActiveRecord adapter patch for PostgreSQL.
14
+ #
15
+ # When a query fails with `PG::UndefinedFunction`, ARFI checks whether the missing function
16
+ # is managed by ARFI (file exists under db/functions). If yes, ARFI loads function SQL files
17
+ # via {Arfi::SqlFunctionLoader.load!} and retries the failed query once.
18
+ #
19
+ # @api public
20
+ module DatabaseStatementsPatch
21
+ ARFI_UNDEFINED_FUNCTION = /
22
+ function\s+([a-zA-Z0-9_."]+)\s*\(.*?\)\s+does\s+not\s+exist
23
+ /ix.freeze
24
+
25
+ THREAD_GUARD_KEY = :arfi_reloading_functions
26
+
27
+ # Execute a SQL query, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
28
+ #
29
+ # @param [Array<Object>] args Positional arguments forwarded to the original exec_query
30
+ # @param [Object] kwargs Keyword arguments forwarded to the original exec_query
31
+ # @raise [StandardError] If the error is not recoverable
32
+ # @return [Object] Query result
33
+ # @return [Object] if StandardError (retry successful)
34
+ def exec_query(*args, **kwargs)
35
+ super
36
+ rescue StandardError => e
37
+ raise unless arfi_try_reload_and_retry?(e)
38
+ retry
39
+ end
40
+
41
+ # Execute raw SQL, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
42
+ #
43
+ # @param [Array<Object>] args Positional arguments forwarded to the original execute
44
+ # @param [Object] kwargs Keyword arguments forwarded to the original execute
45
+ # @raise [StandardError] If the error is not recoverable
46
+ # @return [Object] Execution result
47
+ # @return [Object] if StandardError (retry successful)
48
+ def execute(*args, **kwargs)
49
+ super
50
+ rescue StandardError => e
51
+ raise unless arfi_try_reload_and_retry?(e)
52
+ retry
53
+ end
54
+
55
+ # Execute a raw SQL statement, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
56
+ #
57
+ # @param [Array<Object>] args Positional arguments forwarded to the original raw_execute
58
+ # @param [Object] kwargs Keyword arguments forwarded to the original raw_execute
59
+ # @raise [StandardError] If the error is not recoverable
60
+ # @return [Object] Execution result
61
+ # @return [Object] if StandardError (retry successful)
62
+ def raw_execute(*args, **kwargs)
63
+ super
64
+ rescue StandardError => e
65
+ raise unless arfi_try_reload_and_retry?(e)
66
+ retry
67
+ end
68
+
69
+ # Execute an internal query, reloading missing ARFI-managed functions and retrying on PG::UndefinedFunction.
70
+ #
71
+ # @param [Array<Object>] args Positional arguments forwarded to the original internal_exec_query
72
+ # @param [Object] kwargs Keyword arguments forwarded to the original internal_exec_query
73
+ # @raise [StandardError] If the error is not recoverable
74
+ # @return [Object] Query result
75
+ # @return [Object] if StandardError (retry successful)
76
+ def internal_exec_query(*args, **kwargs)
77
+ super
78
+ rescue StandardError => e
79
+ raise unless arfi_try_reload_and_retry?(e)
80
+ retry
81
+ end
82
+
83
+ private
84
+
85
+ # Check if the error is caused by a missing ARFI-managed function and attempt to reload it.
86
+ #
87
+ # Uses a thread guard to prevent recursive retries.
88
+ #
89
+ # @private
90
+ # @param [Object] e The raised exception (StandardError with possible PG::UndefinedFunction cause)
91
+ # @return [Boolean] Whether the error was handled (reload attempted)
92
+ def arfi_try_reload_and_retry?(e)
93
+ pg_error = e.cause || e
94
+ return false unless pg_error.class.name == "PG::UndefinedFunction"
95
+ return false if Thread.current[THREAD_GUARD_KEY]
96
+
97
+ schema, fn = arfi_extract_function_ident(pg_error.message)
98
+ return false unless arfi_has_function_file_for?(schema, fn)
99
+
100
+ Thread.current[THREAD_GUARD_KEY] = true
101
+ begin
102
+ Arfi::SqlFunctionLoader.load!(
103
+ task_name: "arfi:runtime",
104
+ connection: self, # same connection
105
+ clear_active_connections: false, # runtime retry path
106
+ verbose: false
107
+ )
108
+ ensure
109
+ Thread.current[THREAD_GUARD_KEY] = false
110
+ end
111
+
112
+ true
113
+ end
114
+
115
+ # Extract the schema and function name from a PG::UndefinedFunction error message.
116
+ #
117
+ # @private
118
+ # @param [Object] message The error message string
119
+ # @return [Array<nil>, Object] Array of [schema, function_name] or [nil, nil] if not matched
120
+ def arfi_extract_function_ident(message)
121
+ m = message.to_s.match(ARFI_UNDEFINED_FUNCTION)
122
+ return [nil, nil] unless m
123
+
124
+ ident = m[1].to_s.delete('"')
125
+ parts = ident.split(".", 2)
126
+ parts.length == 2 ? [parts[0], parts[1]] : [nil, parts[0]]
127
+ end
128
+
129
+ # Check whether a function file exists under db/functions for the given schema and function name.
130
+ #
131
+ # @private
132
+ # @param [Object] schema Schema name (possibly nil)
133
+ # @param [Object] fn Function name
134
+ # @return [Boolean, Object] Whether a matching file exists on disk
135
+ def arfi_has_function_file_for?(schema, fn)
136
+ return false if fn.nil? || fn.empty?
137
+
138
+ root = Rails.root.join("db", "functions")
139
+ return false unless root.directory?
140
+
141
+ candidates = []
142
+ candidates << root.join("public", "#{fn}.sql")
143
+ candidates << root.join("#{fn}.sql") # legacy generic
144
+
145
+ pg_root = root.join("postgresql")
146
+ if pg_root.directory?
147
+ candidates << pg_root.join("public", "#{fn}.sql")
148
+ candidates << pg_root.join("#{fn}.sql") # legacy adapter
149
+
150
+ if schema && !schema.empty?
151
+ candidates << pg_root.join(schema, "#{fn}.sql")
152
+ else
153
+ candidates.concat Dir.glob(pg_root.join("*", "#{fn}.sql").to_s)
27
154
  end
28
155
  end
156
+
157
+ candidates.any? { |p| File.exist?(p) }
29
158
  end
30
159
  end
31
160
  end
32
161
  end
162
+
163
+ if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
164
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(
165
+ Arfi::PostgreSQL::DatabaseStatementsPatch
166
+ )
167
+ end