arfi 0.5.0 → 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 +291 -88
  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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arfi
4
+ module Commands
5
+ # Candidate discovery helpers for {Arfi::Commands::Functions}.
6
+ module FunctionsCandidates
7
+ private
8
+
9
+ # Collect all function candidates from generic, adapter, and (for PostgreSQL) schema directories.
10
+ #
11
+ # @private
12
+ # @param [Pathname] root Project root directory (Rails.root/db/functions)
13
+ # @param [String] adapter Database adapter name (postgresql, mysql, trilogy)
14
+ # @return [Array<Arfi::Commands::candidate>]
15
+ def collect_all_candidates(root, adapter)
16
+ candidates = generic_candidates(root)
17
+ adapter_root = root.join(adapter)
18
+ return candidates unless adapter_root.directory?
19
+
20
+ candidates.concat adapter_candidates(adapter_root, adapter)
21
+ candidates.concat collect_postgresql_schema_candidates(adapter_root, adapter) if adapter == 'postgresql'
22
+ candidates
23
+ end
24
+
25
+ # Group candidates by their schema/function key, sorted by priority within each group.
26
+ #
27
+ # @private
28
+ # @param [Array<Arfi::Commands::candidate>] candidates Flat list of candidates
29
+ # @return [Hash<String, Array<Arfi::Commands::candidate>>]
30
+ def group_candidates_by_key(candidates)
31
+ by_key = Hash.new { |h, k| h[k] = [] } # steep:ignore
32
+ candidates.each do |c|
33
+ by_key[c[:key]] << c
34
+ end
35
+ by_key.each_value { |arr| arr.sort_by! { |c| c[:priority] } }
36
+ by_key
37
+ end
38
+
39
+ # Build the final resolved rows by picking the highest-priority candidate per key.
40
+ #
41
+ # @private
42
+ # @param [Hash<String, Array<Arfi::Commands::candidate>>] by_key Candidates grouped by key
43
+ # @return [Array<Hash<Symbol, Object>>]
44
+ def build_resolved_rows(by_key)
45
+ by_key.keys.sort.flat_map do |key|
46
+ resolve_key_group(by_key[key])
47
+ end.compact
48
+ end
49
+
50
+ # Convert an absolute filesystem path to a project-relative path.
51
+ #
52
+ # @private
53
+ # @param [Pathname, String] path Absolute filesystem path
54
+ # @return [String] Relative path starting from Rails.root
55
+ def rel(path)
56
+ root = Rails.root.to_s
57
+ p = path.to_s
58
+ p.start_with?(root) ? p.sub(root + File::SEPARATOR, '') : p
59
+ end
60
+
61
+ # Collect generic function candidates from legacy root and explicit public/ directory.
62
+ #
63
+ # @private
64
+ # @param [Pathname] root Project root directory (Rails.root/db/functions)
65
+ # @return [Array<Arfi::Commands::candidate>]
66
+ def generic_candidates(root)
67
+ candidates = [] # steep:ignore
68
+ candidates.concat collect_candidates(glob: root.join('*.sql'), schema: DEFAULT_SCHEMA, source: 'generic',
69
+ origin: 'legacy', priority: 1)
70
+ candidates.concat collect_candidates(glob: root.join(DEFAULT_SCHEMA, '*.sql'), schema: DEFAULT_SCHEMA,
71
+ source: 'generic', origin: 'explicit', priority: 2)
72
+ candidates
73
+ end
74
+
75
+ # Collect function candidates from an adapter-specific directory (legacy + explicit public).
76
+ #
77
+ # @private
78
+ # @param [Pathname] adapter_root Adapter root directory (e.g. db/functions/postgresql)
79
+ # @param [String] adapter Database adapter name
80
+ # @return [Array<Arfi::Commands::candidate>]
81
+ def adapter_candidates(adapter_root, adapter)
82
+ candidates = [] # steep:ignore
83
+ candidates.concat collect_candidates(glob: adapter_root.join('*.sql'), schema: DEFAULT_SCHEMA,
84
+ source: adapter, origin: 'legacy', priority: 8)
85
+ candidates.concat collect_candidates(glob: adapter_root.join(DEFAULT_SCHEMA, '*.sql'),
86
+ schema: DEFAULT_SCHEMA, source: adapter, origin: 'explicit', priority: 9)
87
+ candidates
88
+ end
89
+
90
+ # Collect function candidates from PostgreSQL-specific schema subdirectories (except public).
91
+ #
92
+ # @private
93
+ # @param [Pathname] adapter_root PostgreSQL adapter root directory
94
+ # @param [String] adapter Database adapter name
95
+ # @return [Array<Arfi::Commands::candidate>]
96
+ def collect_postgresql_schema_candidates(adapter_root, adapter)
97
+ Dir.children(adapter_root).sort.each_with_object([]) do |child, acc|
98
+ next if child.start_with?('_')
99
+ next if child == DEFAULT_SCHEMA
100
+
101
+ dir = adapter_root.join(child)
102
+ next unless dir.directory?
103
+
104
+ acc.concat collect_candidates(glob: dir.join('*.sql'), schema: child, source: adapter,
105
+ origin: 'explicit', priority: 10)
106
+ end
107
+ end
108
+
109
+ # Collect SQL file candidates matching a glob pattern, skipping underscore-prefixed files.
110
+ #
111
+ # @private
112
+ # @param [Pathname] glob Glob pattern to match SQL files
113
+ # @param [String] schema Schema name to assign to all matched files
114
+ # @param [String] source Source type ('generic' or adapter name)
115
+ # @param [String] origin Origin type ('legacy' for root level, 'explicit' for public/ subdirectory)
116
+ # @param [Integer] priority Numeric priority for override resolution (higher = preferred)
117
+ # @return [Array<Arfi::Commands::candidate>]
118
+ def collect_candidates(glob:, schema:, source:, origin:, priority:)
119
+ Dir.glob(glob.to_s).filter_map do |path|
120
+ base = File.basename(path)
121
+ next if base.start_with?('_')
122
+
123
+ key = "#{schema}/#{base}"
124
+ function_name = File.basename(path, '.sql')
125
+ {
126
+ key: key, schema: schema, function: function_name,
127
+ source: source, origin: origin, priority: priority, path: path
128
+ }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arfi
4
+ module Commands
5
+ # Function file creation helpers for {Arfi::Commands::Functions}.
6
+ module FunctionsCreation
7
+ private
8
+
9
+ # Ensure required function directories exist, creating them if necessary.
10
+ #
11
+ # @private
12
+ # @param [String?] adapter Database adapter name (nil for generic)
13
+ # @param [String?] schema PostgreSQL schema name (optional)
14
+ # @raise [Arfi::Errors::NoFunctionsDir] If db/functions directory doesn't exist
15
+ # @return [void]
16
+ def ensure_dirs!(adapter:, schema:)
17
+ root = Rails.root.join(ROOT_DIR)
18
+ raise Arfi::Errors::NoFunctionsDir unless root.directory?
19
+
20
+ FileUtils.mkdir_p(root.join(DEFAULT_SCHEMA))
21
+ return if adapter.nil?
22
+
23
+ adapter_root = root.join(adapter)
24
+ FileUtils.mkdir_p(adapter_root)
25
+ FileUtils.mkdir_p(adapter_root.join(DEFAULT_SCHEMA))
26
+ return unless adapter == 'postgresql'
27
+
28
+ sch = schema || DEFAULT_SCHEMA
29
+ FileUtils.mkdir_p(adapter_root.join(sch))
30
+ end
31
+
32
+ # Build the SQL function content, either from a custom template or from a skeleton.
33
+ #
34
+ # @private
35
+ # @param [String?] schema PostgreSQL schema name (optional)
36
+ # @param [String] function_name Function name
37
+ # @param [String] original_ref Original function reference as passed by the user
38
+ # @raise [StandardError] If adapter is unknown
39
+ # @return [String] SQL function body
40
+ def build_sql_function(schema, function_name, original_ref:)
41
+ return build_from_file(schema, function_name, original_ref: original_ref) if options[:template]
42
+
43
+ opt = adapter_opt
44
+ if opt.nil? || opt == 'postgresql'
45
+ build_postgresql_skeleton(schema, function_name)
46
+ elsif %w[mysql trilogy].include?(opt)
47
+ build_mysql_skeleton(function_name)
48
+ else
49
+ raise "Unknown adapter: #{opt}. Supported adapters: #{ADAPTERS.join(', ')}"
50
+ end
51
+ end
52
+
53
+ # Build SQL content by evaluating a user-supplied template file.
54
+ #
55
+ # @private
56
+ # @param [String?] schema PostgreSQL schema name (optional)
57
+ # @param [String] function_name Function name
58
+ # @param [String] original_ref Original function reference as passed by the user
59
+ # @return [String] Evaluated SQL content
60
+ def build_from_file(schema, function_name, original_ref:)
61
+ schema_name = resolve_schema_name(schema)
62
+ qualified_name = schema_name ? "#{schema_name}.#{function_name}" : function_name
63
+ evaluate_template(function_name, schema_name, qualified_name, original_ref)
64
+ end
65
+
66
+ # Write the SQL function content to disk, respecting --force option.
67
+ #
68
+ # @private
69
+ # @param [String?] schema PostgreSQL schema name (optional)
70
+ # @param [String] function_name Function name
71
+ # @param [String] content SQL function body to write
72
+ # @return [void]
73
+ def write_file(schema, function_name, content)
74
+ path = canonical_path(schema, function_name)
75
+ if File.exist?(path) && !options[:force]
76
+ puts "Already exists: #{rel(path)} (use --force to overwrite)"
77
+ return
78
+ end
79
+ File.write(path, content.to_s)
80
+ puts "Created: #{rel(path)}"
81
+ end
82
+
83
+ # Delete a function file from disk, searching known locations.
84
+ #
85
+ # @private
86
+ # @param [String?] schema PostgreSQL schema name (optional)
87
+ # @param [String] function_name Function name
88
+ # @return [void]
89
+ def remove_function_file(schema, function_name)
90
+ candidates = function_paths(schema, function_name)
91
+ path = candidates.find { |p| File.exist?(p) }
92
+ unless path
93
+ puts "Not found. Looked in:\n - #{candidates.map { rel(_1) }.join("\n - ")}"
94
+ return
95
+ end
96
+ FileUtils.rm(path)
97
+ puts "Deleted: #{rel(path)}"
98
+ end
99
+
100
+ # Evaluate a user-supplied Ruby template file to produce SQL content.
101
+ #
102
+ # @private
103
+ # @param [String] function_name Function name variable available in the template
104
+ # @param [String?] schema_name Schema name variable available in the template
105
+ # @param [String] qualified_name Qualified function name variable available in the template
106
+ # @param [String] original_ref Original function reference variable available in the template
107
+ # @return [Object] Evaluated template result (expected to be a String)
108
+ def evaluate_template(function_name, schema_name, qualified_name, original_ref)
109
+ tpl = File.read(options[:template])
110
+ RubyVM::InstructionSequence.compile(<<~RUBY).eval # steep:ignore
111
+ index_name = #{function_name.inspect}
112
+ function_name = #{function_name.inspect}
113
+ schema_name = #{schema_name.inspect}
114
+ qualified_name = #{qualified_name.inspect}
115
+ original_ref = #{original_ref.inspect}
116
+ #{tpl}
117
+ RUBY
118
+ end
119
+
120
+ # Build a default PostgreSQL function skeleton.
121
+ #
122
+ # @private
123
+ # @param [String?] schema PostgreSQL schema name (defaults to 'public')
124
+ # @param [String] function_name Function name
125
+ # @return [String] SQL skeleton
126
+ def build_postgresql_skeleton(schema, function_name)
127
+ sch = schema || DEFAULT_SCHEMA
128
+ qualified = "#{sch}.#{function_name}"
129
+ <<~SQL
130
+ CREATE OR REPLACE FUNCTION #{qualified}() RETURNS TEXT[]
131
+ LANGUAGE SQL
132
+ IMMUTABLE AS
133
+ $$
134
+ -- Function body here
135
+ $$
136
+ SQL
137
+ end
138
+
139
+ # Build a default MySQL function skeleton.
140
+ #
141
+ # @private
142
+ # @param [String] function_name Function name
143
+ # @return [String] SQL skeleton
144
+ def build_mysql_skeleton(function_name)
145
+ <<~SQL
146
+ -- MySQL note: you may need to DROP FUNCTION IF EXISTS #{function_name};
147
+ -- and ensure your connection allows multi-statements if you include both.
148
+ CREATE FUNCTION #{function_name} ()
149
+ RETURNS return_type
150
+ BEGIN
151
+ -- Function body here
152
+ END;
153
+ SQL
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arfi
4
+ module Commands
5
+ # Shared helper methods for {Arfi::Commands::Functions}.
6
+ module FunctionsHelpers
7
+ private
8
+
9
+ # Validate that the Rails schema format is set to :ruby.
10
+ #
11
+ # @private
12
+ # @raise [Arfi::Errors::InvalidSchemaFormat] If schema format is not :ruby
13
+ # @return [void]
14
+ def validate_schema_format!
15
+ fmt =
16
+ if defined?(Rails) && Rails.application
17
+ Rails.application.config.active_record.schema_format
18
+ elsif defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:schema_format)
19
+ ActiveRecord::Base.schema_format
20
+ end
21
+ raise Arfi::Errors::InvalidSchemaFormat unless fmt == :ruby
22
+ end
23
+
24
+ # Validate that the --adapter option, if provided, is one of the supported adapters.
25
+ #
26
+ # @private
27
+ # @raise [Arfi::Errors::AdapterNotSupported] If adapter is not in the supported list
28
+ # @return [void]
29
+ def validate_adapter_option!
30
+ opt = adapter_opt
31
+ return if opt.nil?
32
+ raise Arfi::Errors::AdapterNotSupported unless ADAPTERS.map(&:to_s).include?(opt)
33
+ end
34
+
35
+ # Parse a function reference into an optional schema and function name.
36
+ #
37
+ # Accepts 'schema.function_name' or just 'function_name' form.
38
+ #
39
+ # @private
40
+ # @param [String] ref Function reference string
41
+ # @return [[ ::String?, ::String ]] Array of [schema, function_name]
42
+ def parse_function_ref(ref)
43
+ validate_function_ref!(ref)
44
+ parsed_schema, parsed_fn = ref.split('.', 2)
45
+ if parsed_fn.nil?
46
+ parsed_fn = parsed_schema
47
+ parsed_schema = nil
48
+ end
49
+ check_schema_conflict!(parsed_schema)
50
+ schema = schema_opt || parsed_schema
51
+ [schema, parsed_fn || '']
52
+ end
53
+
54
+ # Validate that schema and function name match the allowed identifier pattern.
55
+ #
56
+ # Schema-qualified functions are only allowed for PostgreSQL adapter.
57
+ #
58
+ # @private
59
+ # @param [String?] schema Schema name to validate (optional)
60
+ # @param [String] function_name Function name to validate
61
+ # @raise [ArgumentError] If identifiers are invalid
62
+ # @return [void]
63
+ def validate_identifiers!(schema, function_name)
64
+ raise ArgumentError, "Invalid function name: #{function_name.inspect}" unless IDENT.match?(function_name)
65
+ return if schema.nil?
66
+ raise ArgumentError, "Invalid schema name: #{schema.inspect}" unless IDENT.match?(schema)
67
+ return unless adapter_opt && adapter_opt != 'postgresql'
68
+
69
+ raise ArgumentError, 'Schema-qualified functions are only supported for PostgreSQL (adapter=postgresql).'
70
+ end
71
+
72
+ # Validate that a function reference is a non-empty string without path separators.
73
+ #
74
+ # @private
75
+ # @param [String] ref Function reference string
76
+ # @raise [ArgumentError] If reference is invalid
77
+ # @return [void]
78
+ def validate_function_ref!(ref)
79
+ raise ArgumentError, "Invalid function name: #{ref.inspect}" unless ref.is_a?(String)
80
+
81
+ sep = [File::SEPARATOR, File::ALT_SEPARATOR].compact
82
+ bad = ref.empty? || ref.include?('..') || sep.any? { |s| ref.include?(s) }
83
+ raise ArgumentError, "Invalid function name: #{ref.inspect}" if bad
84
+ end
85
+
86
+ # Raise if the schema is specified both inline and via the --schema option.
87
+ #
88
+ # @private
89
+ # @param [String?] parsed_schema Schema parsed from 'schema.function' form
90
+ # @raise [ArgumentError] If schema is specified twice
91
+ # @return [void]
92
+ def check_schema_conflict!(parsed_schema)
93
+ return unless schema_opt && parsed_schema
94
+
95
+ raise ArgumentError, "Schema specified twice (both 'schema.fn' and --schema). Pick one."
96
+ end
97
+
98
+ # Resolve the effective schema name, defaulting to 'public' for PostgreSQL/generic.
99
+ #
100
+ # @private
101
+ # @param [String?] schema User-provided schema name (optional)
102
+ # @return [String?] Resolved schema name or nil for MySQL/Trilogy
103
+ def resolve_schema_name(schema)
104
+ adapter = adapter_opt
105
+ adapter.nil? || adapter == 'postgresql' ? (schema || DEFAULT_SCHEMA) : nil
106
+ end
107
+
108
+ # Resolve the effective adapter from CLI option or Rails DB config.
109
+ #
110
+ # @private
111
+ # @raise [ArgumentError] If adapter cannot be inferred
112
+ # @return [String] Resolved adapter name
113
+ def resolve_adapter
114
+ adapter_opt || infer_adapter_from_config || raise(
115
+ ArgumentError,
116
+ 'Could not infer adapter. Pass --adapter=[postgresql|mysql|trilogy].'
117
+ )
118
+ end
119
+
120
+ # Resolve the effective list of function files for a given adapter.
121
+ #
122
+ # @private
123
+ # @param [String] adapter Adapter name (postgresql, mysql, trilogy)
124
+ # @raise [Arfi::Errors::NoFunctionsDir] If db/functions directory doesn't exist
125
+ # @return [Array<Hash<Symbol, Object>>] Resolved function rows
126
+ def resolve_functions_for(adapter:)
127
+ root = Rails.root.join(ROOT_DIR)
128
+ raise Arfi::Errors::NoFunctionsDir unless root.directory?
129
+
130
+ candidates = collect_all_candidates(root, adapter)
131
+ by_key = group_candidates_by_key(candidates)
132
+ build_resolved_rows(by_key)
133
+ end
134
+
135
+ # Read the --adapter option value from CLI options.
136
+ #
137
+ # @private
138
+ # @return [String?] Adapter name or nil if not provided
139
+ def adapter_opt
140
+ options[:adapter]&.to_s
141
+ end
142
+
143
+ # Infer the database adapter from the Rails primary DB configuration.
144
+ #
145
+ # @private
146
+ # @raise [StandardError]
147
+ # @return [String?] Adapter name, or nil if inference fails
148
+ # @return [nil] if StandardError
149
+ def infer_adapter_from_config
150
+ cfg = primary_db_config
151
+ h = cfg&.configuration_hash
152
+ adapter = h && (h[:adapter] || h['adapter'])
153
+ adapter&.to_s
154
+ rescue StandardError
155
+ nil
156
+ end
157
+
158
+ # Read the --schema option value from CLI options.
159
+ #
160
+ # @private
161
+ # @return [String?] Schema name or nil if not provided
162
+ def schema_opt
163
+ options[:schema]&.to_s
164
+ end
165
+
166
+ # Get the primary database configuration for the current Rails environment.
167
+ #
168
+ # @private
169
+ # @return [Object] ActiveRecord database config object
170
+ def primary_db_config
171
+ cfgs =
172
+ if ActiveRecord::Base.respond_to?(:configurations) && ActiveRecord::Base.configurations
173
+ ActiveRecord::Base.configurations.configurations.select { _1.env_name == Rails.env }
174
+ else
175
+ [] # steep:ignore
176
+ end
177
+ cfgs.find { _1.name == 'primary' } || cfgs.first
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arfi
4
+ module Commands
5
+ # Path resolution helpers for {Arfi::Commands::Functions}.
6
+ module FunctionsPaths
7
+ private
8
+
9
+ # Resolve the canonical output path for a function file, respecting adapter option.
10
+ #
11
+ # @private
12
+ # @param [String?] schema Schema name (optional)
13
+ # @param [String] function_name Function name
14
+ # @return [String] Absolute path to the function file
15
+ def canonical_path(schema, function_name)
16
+ root = Rails.root.join(ROOT_DIR)
17
+ if adapter_opt.nil?
18
+ generic_canonical_path(root, schema, function_name)
19
+ else
20
+ adapter_canonical_path(root, schema, function_name)
21
+ end
22
+ end
23
+
24
+ # List all possible filesystem paths where a function file might exist, respecting adapter.
25
+ #
26
+ # @private
27
+ # @param [String?] schema Schema name (optional)
28
+ # @param [String] function_name Function name
29
+ # @raise [Arfi::Errors::AdapterNotSupported] If adapter is not supported
30
+ # @return [Array<String>] List of absolute paths to check
31
+ def function_paths(schema, function_name)
32
+ root = Rails.root.join(ROOT_DIR)
33
+ out = case adapter_opt
34
+ when nil then generic_function_paths(root, function_name)
35
+ when 'postgresql' then postgresql_function_paths(root, schema, function_name)
36
+ when 'mysql', 'trilogy' then mysql_function_paths(root, function_name)
37
+ else raise Arfi::Errors::AdapterNotSupported
38
+ end
39
+ out.uniq
40
+ end
41
+
42
+ # Build the canonical path for a generic (non-adapter) function.
43
+ #
44
+ # Generic functions can only target the 'public' schema.
45
+ #
46
+ # @private
47
+ # @param [Pathname] root Project root directory (Rails.root/db/functions)
48
+ # @param [String?] schema Schema name (must be nil or 'public')
49
+ # @param [String] function_name Function name
50
+ # @raise [ArgumentError] If schema is not public
51
+ # @return [String] Absolute canonical path
52
+ def generic_canonical_path(root, schema, function_name)
53
+ sch = schema || DEFAULT_SCHEMA
54
+ unless sch == DEFAULT_SCHEMA
55
+ raise ArgumentError,
56
+ "Generic functions can only target schema '#{DEFAULT_SCHEMA}'"
57
+ end
58
+ root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s
59
+ end
60
+
61
+ # Build the canonical path for an adapter-specific function.
62
+ #
63
+ # @private
64
+ # @param [Pathname] root Project root directory (Rails.root/db/functions)
65
+ # @param [String?] schema Schema name (PostgreSQL only)
66
+ # @param [String] function_name Function name
67
+ # @raise [ArgumentError] If schema is provided for non-PostgreSQL adapter
68
+ # @raise [Arfi::Errors::AdapterNotSupported] If adapter is not supported
69
+ # @return [String] Absolute canonical path
70
+ def adapter_canonical_path(root, schema, function_name)
71
+ case adapter_opt
72
+ when 'postgresql'
73
+ root.join('postgresql', schema || DEFAULT_SCHEMA, "#{function_name}.sql").to_s
74
+ when 'mysql', 'trilogy'
75
+ raise ArgumentError, 'Schema-qualified functions are only supported for PostgreSQL.' if schema
76
+
77
+ root.join('mysql', DEFAULT_SCHEMA, "#{function_name}.sql").to_s
78
+ else
79
+ raise Arfi::Errors::AdapterNotSupported
80
+ end
81
+ end
82
+
83
+ # List file paths to check when looking for a generic function file.
84
+ #
85
+ # @private
86
+ # @param [Pathname] root Project root directory
87
+ # @param [String] function_name Function name
88
+ # @return [Array<String>] Ordered list of paths to check
89
+ def generic_function_paths(root, function_name)
90
+ [
91
+ root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
92
+ root.join("#{function_name}.sql").to_s
93
+ ]
94
+ end
95
+
96
+ # List file paths to check when looking for a PostgreSQL function file.
97
+ #
98
+ # @private
99
+ # @param [Pathname] root Project root directory
100
+ # @param [String?] schema Schema name (optional)
101
+ # @param [String] function_name Function name
102
+ # @return [Array<String>] Ordered list of paths to check
103
+ def postgresql_function_paths(root, schema, function_name)
104
+ sch = schema || DEFAULT_SCHEMA
105
+ out = [
106
+ root.join('postgresql', sch, "#{function_name}.sql").to_s,
107
+ root.join('postgresql', DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
108
+ root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
109
+ root.join("#{function_name}.sql").to_s
110
+ ]
111
+ out.insert(2, root.join('postgresql', "#{function_name}.sql").to_s) if sch == DEFAULT_SCHEMA
112
+ out
113
+ end
114
+
115
+ # List file paths to check when looking for a MySQL/Trilogy function file.
116
+ #
117
+ # @private
118
+ # @param [Pathname] root Project root directory
119
+ # @param [String] function_name Function name
120
+ # @return [Array<String>] Ordered list of paths to check
121
+ def mysql_function_paths(root, function_name)
122
+ [
123
+ root.join('mysql', DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
124
+ root.join('mysql', "#{function_name}.sql").to_s,
125
+ root.join(DEFAULT_SCHEMA, "#{function_name}.sql").to_s,
126
+ root.join("#{function_name}.sql").to_s
127
+ ]
128
+ end
129
+ end
130
+ end
131
+ end