rails-ai-context 5.32.0 → 5.32.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/CONTEXT.md +10 -8
- data/CONTRIBUTING.md +2 -2
- data/README.md +14 -11
- data/SECURITY.md +4 -4
- data/docs/ARCHITECTURE.md +11 -10
- data/docs/CLI.md +33 -7
- data/docs/COMPATIBILITY.md +69 -32
- data/docs/CONFIGURATION.md +10 -8
- data/docs/CUSTOM_TOOLS.md +8 -8
- data/docs/FAQ.md +6 -5
- data/docs/GUIDE.md +154 -80
- data/docs/INTROSPECTORS.md +25 -17
- data/docs/QUICKSTART.md +14 -3
- data/docs/RECIPES.md +50 -40
- data/docs/SECURITY.md +23 -13
- data/docs/SETUP.md +23 -26
- data/docs/STANDALONE.md +7 -3
- data/docs/TOOLS.md +79 -45
- data/docs/TROUBLESHOOTING.md +12 -11
- data/exe/rails-ai-context +8 -4
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/database_yml.rb +52 -2
- data/lib/rails_ai_context/install/ai_tool.rb +11 -2
- data/lib/rails_ai_context/install/cleanup.rb +69 -23
- data/lib/rails_ai_context/install/program.rb +4 -1
- data/lib/rails_ai_context/install/selection_record.rb +20 -2
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +30 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/pg_naming.rb +133 -0
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +0 -6
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +188 -51
- data/lib/rails_ai_context/introspectors/schema_reader.rb +98 -12
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +56 -33
- data/lib/rails_ai_context/payload.rb +25 -2
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/server.rb +2 -2
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
- data/lib/rails_ai_context/test_helper.rb +2 -0
- data/lib/rails_ai_context/tools/base_tool.rb +3 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
- data/lib/rails_ai_context/tools/get_concern.rb +1 -1
- data/lib/rails_ai_context/tools/get_env.rb +1 -1
- data/lib/rails_ai_context/tools/get_gems.rb +2 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +2 -0
- data/lib/rails_ai_context/tools/get_schema.rb +17 -14
- data/lib/rails_ai_context/tools/query.rb +107 -39
- data/lib/rails_ai_context/tools/search_code.rb +20 -16
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
|
@@ -13,8 +13,11 @@ module RailsAiContext
|
|
|
13
13
|
#
|
|
14
14
|
# context_paths lists the root file first, then the split-rule targets. The writers,
|
|
15
15
|
# doctor and cleanup all read this table, so a moved path changes every surface at once.
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
#
|
|
17
|
+
# rule_files names what the gem writes inside rules_dir. The directory is shared with
|
|
18
|
+
# rules the user writes by hand, so cleanup removes these names and nothing else.
|
|
19
|
+
AiTool = Struct.new(:number, :key, :name, :files, :context_paths, :rules_dir, :rule_files, :mcp_config,
|
|
20
|
+
:legacy_paths, :owned_dir, keyword_init: true)
|
|
18
21
|
|
|
19
22
|
class AiTool
|
|
20
23
|
ALL = [
|
|
@@ -23,6 +26,7 @@ module RailsAiContext
|
|
|
23
26
|
files: "CLAUDE.md + .claude/rules/",
|
|
24
27
|
context_paths: %w[CLAUDE.md .claude/rules],
|
|
25
28
|
rules_dir: ".claude/rules",
|
|
29
|
+
rule_files: %w[rails-context.md rails-schema.md rails-models.md rails-mcp-tools.md rails-components.md],
|
|
26
30
|
mcp_config: { path: ".mcp.json", root_key: "mcpServers", format: :mcp_json },
|
|
27
31
|
legacy_paths: [ ".claude/rules/rails-ui-patterns.md", ".claude/rules/rails-accessibility.md" ],
|
|
28
32
|
owned_dir: ".claude"
|
|
@@ -32,6 +36,7 @@ module RailsAiContext
|
|
|
32
36
|
files: ".cursor/rules/ + .cursorrules (legacy fallback)",
|
|
33
37
|
context_paths: %w[.cursor/rules .cursorrules],
|
|
34
38
|
rules_dir: ".cursor/rules",
|
|
39
|
+
rule_files: %w[rails-project.mdc rails-models.mdc rails-controllers.mdc rails-mcp-tools.mdc],
|
|
35
40
|
mcp_config: { path: ".cursor/mcp.json", root_key: "mcpServers", format: :mcp_json },
|
|
36
41
|
legacy_paths: [ ".cursor/rules/rails-ui-patterns.mdc" ],
|
|
37
42
|
owned_dir: ".cursor"
|
|
@@ -41,6 +46,10 @@ module RailsAiContext
|
|
|
41
46
|
files: ".github/copilot-instructions.md + .github/instructions/",
|
|
42
47
|
context_paths: %w[.github/copilot-instructions.md .github/instructions],
|
|
43
48
|
rules_dir: ".github/instructions",
|
|
49
|
+
rule_files: %w[
|
|
50
|
+
rails-context.instructions.md rails-models.instructions.md
|
|
51
|
+
rails-controllers.instructions.md rails-mcp-tools.instructions.md
|
|
52
|
+
],
|
|
44
53
|
mcp_config: { path: ".vscode/mcp.json", root_key: "servers", format: :vscode_json },
|
|
45
54
|
legacy_paths: [ ".github/instructions/rails-ui-patterns.instructions.md" ]
|
|
46
55
|
),
|
|
@@ -11,49 +11,95 @@ module RailsAiContext
|
|
|
11
11
|
module Cleanup
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
+
# The block SectionMarkerWriter manages inside a root context file.
|
|
15
|
+
BEGIN_MARKER = "<!-- BEGIN rails-ai-context -->"
|
|
16
|
+
MANAGED_BLOCK = /#{BEGIN_MARKER}.*?<!-- END rails-ai-context -->(?:\r?\n)?/m
|
|
17
|
+
|
|
18
|
+
# The line a split rules file generated whole carries.
|
|
19
|
+
GENERATED_NOTE = "Auto-generated by rails-ai-context. Do not edit manually."
|
|
20
|
+
GENERATED_LINE = /^> #{Regexp.escape(GENERATED_NOTE)}\r?$/
|
|
21
|
+
|
|
22
|
+
# Removes what this gem generated and nothing the user wrote: the named
|
|
23
|
+
# rule files inside a rules directory, the managed block inside a root
|
|
24
|
+
# file, and a file generated whole.
|
|
25
|
+
#
|
|
14
26
|
# @param tools [Array<Symbol>] the AI tools being dropped
|
|
15
27
|
# @param keeping [Array<Symbol>] the tools still selected, whose files
|
|
16
28
|
# must survive even when a dropped tool names the same path
|
|
17
29
|
# @param root [String, Pathname] project root
|
|
18
|
-
# @return [Hash] { removed: [paths], failed: [paths] }
|
|
19
|
-
#
|
|
20
|
-
#
|
|
30
|
+
# @return [Hash] { removed: [paths], trimmed: [paths], kept: [paths], failed: [paths] }.
|
|
31
|
+
# A directory carries a trailing slash. `trimmed` files lost their
|
|
32
|
+
# managed block and kept the user's lines; `kept` files hold nothing
|
|
33
|
+
# this gem generated. rm swallows its errors, so a path is only
|
|
21
34
|
# reported removed once it is gone.
|
|
22
35
|
def remove(tools:, keeping:, root:)
|
|
23
36
|
kept = Array(keeping).flat_map { |key| AiTool.find(key)&.context_paths || [] }.to_set
|
|
24
|
-
|
|
25
|
-
failed = []
|
|
37
|
+
outcome = { removed: [], trimmed: [], kept: [], failed: [] }
|
|
26
38
|
|
|
27
39
|
Array(tools).each do |key|
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
tool = AiTool.find(key) or next
|
|
41
|
+
tool.context_paths.each do |relative|
|
|
30
42
|
next if kept.include?(relative)
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
if File.directory?(File.join(root.to_s, relative))
|
|
45
|
+
remove_rule_files(tool, relative, root, outcome)
|
|
46
|
+
else
|
|
47
|
+
remove_generated(relative, root, outcome)
|
|
48
|
+
end
|
|
36
49
|
end
|
|
37
50
|
end
|
|
38
51
|
|
|
39
|
-
|
|
52
|
+
outcome
|
|
40
53
|
end
|
|
41
54
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
# A symlink is somebody's own arrangement, and writing through one edits
|
|
56
|
+
# a file this run was not asked about.
|
|
57
|
+
def remove_rule_files(tool, dir, root, outcome)
|
|
58
|
+
full = File.join(root.to_s, dir)
|
|
59
|
+
return if File.symlink?(full)
|
|
60
|
+
|
|
61
|
+
names = Array(tool.rule_files) + tool.legacy_paths.select { |path| File.dirname(path) == dir }.map { |path| File.basename(path) }
|
|
62
|
+
names.each { |name| delete(File.join(dir, name), root, outcome) }
|
|
63
|
+
return unless Dir.empty?(full)
|
|
64
|
+
|
|
65
|
+
Dir.rmdir(full)
|
|
66
|
+
outcome[:removed] << "#{dir}/"
|
|
67
|
+
rescue SystemCallError
|
|
68
|
+
outcome[:failed] << "#{dir}/"
|
|
69
|
+
end
|
|
70
|
+
private_class_method :remove_rule_files
|
|
71
|
+
|
|
72
|
+
# Bytes in, bytes out: the markers are ASCII, and the user's lines may not be valid UTF-8.
|
|
73
|
+
def remove_generated(relative, root, outcome)
|
|
46
74
|
full = File.join(root.to_s, relative)
|
|
75
|
+
return unless File.file?(full) || File.symlink?(full)
|
|
76
|
+
return outcome[:kept] << relative if File.symlink?(full)
|
|
47
77
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
78
|
+
content = File.binread(full)
|
|
79
|
+
rest = content.gsub(MANAGED_BLOCK, "")
|
|
80
|
+
# The writer puts one blank line under a block it prepends to a file that was already there.
|
|
81
|
+
rest = rest.sub(/\A\r?\n/, "") if content.start_with?(BEGIN_MARKER)
|
|
82
|
+
if rest == content
|
|
83
|
+
content.match?(GENERATED_LINE) ? delete(relative, root, outcome) : outcome[:kept] << relative
|
|
84
|
+
elsif rest.strip.empty?
|
|
85
|
+
delete(relative, root, outcome)
|
|
86
|
+
else
|
|
87
|
+
File.binwrite(full, rest)
|
|
88
|
+
outcome[:trimmed] << relative
|
|
54
89
|
end
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
outcome[:failed] << relative
|
|
92
|
+
end
|
|
93
|
+
private_class_method :remove_generated
|
|
94
|
+
|
|
95
|
+
def delete(relative, root, outcome)
|
|
96
|
+
full = File.join(root.to_s, relative)
|
|
97
|
+
return unless File.file?(full) && !File.symlink?(full)
|
|
98
|
+
|
|
99
|
+
FileUtils.rm_f(full)
|
|
100
|
+
(File.exist?(full) ? outcome[:failed] : outcome[:removed]) << relative
|
|
55
101
|
end
|
|
56
|
-
private_class_method :
|
|
102
|
+
private_class_method :delete
|
|
57
103
|
end
|
|
58
104
|
end
|
|
59
105
|
end
|
|
@@ -113,13 +113,16 @@ module RailsAiContext
|
|
|
113
113
|
|
|
114
114
|
outcome = Cleanup.remove(tools: [ key ], keeping: selected.map(&:to_sym), root: root)
|
|
115
115
|
outcome[:removed].each { |path| surface.say " Removed #{path}", :ok }
|
|
116
|
+
outcome[:trimmed].each { |path| surface.say " Removed the generated section from #{path}", :ok }
|
|
117
|
+
outcome[:kept].each { |path| surface.say " Kept #{path} (nothing in it was generated by rails-ai-context)", :muted }
|
|
116
118
|
outcome[:failed].each { |path| surface.say "Could not remove #{path} - check its permissions", :warn }
|
|
117
119
|
|
|
118
120
|
# Merge-safe MCP config cleanup - removes only the rails-ai-context entry
|
|
119
121
|
cleaned = RailsAiContext::McpConfigGenerator.remove(tools: [ key ], output_dir: root.to_s)
|
|
120
122
|
cleaned.each { |f| surface.say " Removed MCP entry from #{relative_to(f, root)}", :ok }
|
|
121
123
|
|
|
122
|
-
|
|
124
|
+
gone = outcome[:removed].any? || outcome[:trimmed].any? || cleaned.any?
|
|
125
|
+
surface.say " #{tool.name} files removed", :ok if tool && gone && outcome[:failed].empty?
|
|
123
126
|
end
|
|
124
127
|
end
|
|
125
128
|
|
|
@@ -74,7 +74,7 @@ module RailsAiContext
|
|
|
74
74
|
recorded.nil? ? nil : !!recorded
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
# @return [Symbol] :updated, :inserted, :unchanged or :absent
|
|
77
|
+
# @return [Symbol] :updated, :inserted, :unchanged, :conflict or :absent
|
|
78
78
|
def write_context_files(value, root:)
|
|
79
79
|
write_config_line(root, " config.context_files = #{value ? 'true' : 'false'}",
|
|
80
80
|
CONTEXT_FILES_LINE, /^[ \t]*config\.context_files\s*=.*$/)
|
|
@@ -83,7 +83,7 @@ module RailsAiContext
|
|
|
83
83
|
# Only an uncommented line is rewritten - the generated initializer ships
|
|
84
84
|
# a commented-out default that must stay a comment.
|
|
85
85
|
#
|
|
86
|
-
# @return [Symbol] :updated, :inserted, :unchanged or :absent
|
|
86
|
+
# @return [Symbol] :updated, :inserted, :unchanged, :conflict or :absent
|
|
87
87
|
def write_tool_mode(mode, root:)
|
|
88
88
|
write_config_line(root, " config.tool_mode = :#{mode}",
|
|
89
89
|
MODE_LINE, /^[ \t]*config\.tool_mode\s*=.*$/)
|
|
@@ -134,6 +134,20 @@ module RailsAiContext
|
|
|
134
134
|
write((read(root: root) || []) | addition, root: root)
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
# What to say when a single-value key came back :conflict, nil otherwise.
|
|
138
|
+
def conflict_message(key, status)
|
|
139
|
+
return unless status == :conflict
|
|
140
|
+
|
|
141
|
+
[ :warn, "#{INITIALIZER} sets config.#{key} in a form this installer does not " \
|
|
142
|
+
"rewrite, and it takes precedence - edit it by hand to change it" ]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Whether the app has settled its tool mode, in the record or in an
|
|
146
|
+
# initializer line of any shape, so no entry asks again.
|
|
147
|
+
def tool_mode_set?(root:)
|
|
148
|
+
!tool_mode(root: root).nil? || initializer_content(root).to_s.match?(/^[ \t]*config\.tool_mode\s*=/)
|
|
149
|
+
end
|
|
150
|
+
|
|
137
151
|
def initializer_line(tools)
|
|
138
152
|
" config.ai_tools = %i[#{normalize(tools).join(' ')}]"
|
|
139
153
|
end
|
|
@@ -196,6 +210,10 @@ module RailsAiContext
|
|
|
196
210
|
|
|
197
211
|
File.write(path, updated)
|
|
198
212
|
:updated
|
|
213
|
+
elsif content.match?(assignment)
|
|
214
|
+
# Assigned in a shape this module will not rewrite. A second line
|
|
215
|
+
# beside it would lose at boot and win on read.
|
|
216
|
+
:conflict
|
|
199
217
|
elsif content.match?(SELECTION_LINE)
|
|
200
218
|
File.write(path, content.sub(/^([ \t]*config\.ai_tools\s*=[^\n]*)$/) { "#{Regexp.last_match(1)}\n#{line}" })
|
|
201
219
|
:inserted
|
|
@@ -52,12 +52,18 @@ module RailsAiContext
|
|
|
52
52
|
# The dumper writes a comment, not a create_table, for a table it could not describe.
|
|
53
53
|
NOT_DUMPED = /\A#\s*Could not dump table "([^"]+)" because of following (\S+)/
|
|
54
54
|
|
|
55
|
+
# raw_names: each name as the dump writes it, for a reader that names them after the walk.
|
|
56
|
+
def initialize(raw_names: false)
|
|
57
|
+
super()
|
|
58
|
+
@raw_names = raw_names
|
|
59
|
+
end
|
|
60
|
+
|
|
55
61
|
def on_program_node_enter(_node)
|
|
56
62
|
comments = Array(@comments)
|
|
57
63
|
comments.each_with_index do |comment, i|
|
|
58
64
|
match = NOT_DUMPED.match(comment.location.slice) or next
|
|
59
65
|
message = comments[i + 1]&.location&.slice.to_s[/\A#\s+(\S.*)/, 1]
|
|
60
|
-
@results << { type: :not_dumped, table:
|
|
66
|
+
@results << { type: :not_dumped, table: local_name(match[1]),
|
|
61
67
|
reason: [ match[2], message ].compact.join(": "), location: comment.location.start_line }
|
|
62
68
|
end
|
|
63
69
|
end
|
|
@@ -66,6 +72,13 @@ module RailsAiContext
|
|
|
66
72
|
note_block(node)
|
|
67
73
|
if node.receiver.nil?
|
|
68
74
|
extract_top_level_call(node)
|
|
75
|
+
elsif node.name == :[] && node.receiver.is_a?(Prism::ConstantPathNode) && node.receiver.slice == "ActiveRecord::Schema"
|
|
76
|
+
# ActiveRecord::Schema[8.1].define: the Rails version that wrote the dump.
|
|
77
|
+
stamp = node.arguments&.arguments&.first
|
|
78
|
+
@results << { type: :stamp, version: stamp.slice, location: node.location.start_line } if stamp.is_a?(Prism::FloatNode)
|
|
79
|
+
elsif node.name == :define && node.receiver.is_a?(Prism::ConstantPathNode) && node.receiver.slice == "ActiveRecord::Schema"
|
|
80
|
+
# Rails before 7.0 writes no stamp.
|
|
81
|
+
@results << { type: :stamp, version: nil, location: node.location.start_line }
|
|
69
82
|
elsif column_call?(node)
|
|
70
83
|
extract_column(node)
|
|
71
84
|
elsif index_call?(node)
|
|
@@ -87,6 +100,11 @@ module RailsAiContext
|
|
|
87
100
|
|
|
88
101
|
private
|
|
89
102
|
|
|
103
|
+
# Without a search path to read, public's are the bare names.
|
|
104
|
+
def local_name(name)
|
|
105
|
+
@raw_names ? name : name.delete_prefix("public.")
|
|
106
|
+
end
|
|
107
|
+
|
|
90
108
|
def read_elsewhere?(name)
|
|
91
109
|
OTHER_TABLE_METHODS.include?(name) || MigrationReplayListener::TABLE_OPS.key?(name)
|
|
92
110
|
end
|
|
@@ -104,6 +122,9 @@ module RailsAiContext
|
|
|
104
122
|
extract_foreign_key(node)
|
|
105
123
|
when :create_enum
|
|
106
124
|
extract_enum(node)
|
|
125
|
+
when :create_schema
|
|
126
|
+
name = literal_string(node.arguments&.arguments&.first)
|
|
127
|
+
@results << { type: :create_schema, name: name, location: node.location.start_line } if name
|
|
107
128
|
when :add_index
|
|
108
129
|
extract_top_level_add_index(node)
|
|
109
130
|
when :add_check_constraint
|
|
@@ -123,7 +144,7 @@ module RailsAiContext
|
|
|
123
144
|
name = literal_string(node.arguments&.arguments&.first) or return
|
|
124
145
|
options = extract_keyword_nodes(node)
|
|
125
146
|
@results << {
|
|
126
|
-
type: :view, name:
|
|
147
|
+
type: :view, name: local_name(name), materialized: options[:materialized].is_a?(Prism::TrueNode),
|
|
127
148
|
sql: literal_string(options[:sql_definition])&.strip, location: node.location.start_line
|
|
128
149
|
}.compact
|
|
129
150
|
end
|
|
@@ -143,7 +164,7 @@ module RailsAiContext
|
|
|
143
164
|
|
|
144
165
|
@results << {
|
|
145
166
|
type: :create_table,
|
|
146
|
-
table:
|
|
167
|
+
table: local_name(table_arg.unescaped),
|
|
147
168
|
# id: false / id: :uuid / primary_key: ... decide whether the
|
|
148
169
|
# implicit primary-key column exists and what to call it.
|
|
149
170
|
options: extract_keyword_options(node),
|
|
@@ -161,8 +182,8 @@ module RailsAiContext
|
|
|
161
182
|
|
|
162
183
|
@results << {
|
|
163
184
|
type: :foreign_key,
|
|
164
|
-
from:
|
|
165
|
-
to:
|
|
185
|
+
from: local_name(from_arg.unescaped),
|
|
186
|
+
to: local_name(to_arg.unescaped),
|
|
166
187
|
# Absent means the Rails convention holds; naming it here would
|
|
167
188
|
# make a declared column indistinguishable from a guessed one.
|
|
168
189
|
column: SchemaConventions.primary_key_value(options[:column]),
|
|
@@ -191,7 +212,7 @@ module RailsAiContext
|
|
|
191
212
|
|
|
192
213
|
@results << {
|
|
193
214
|
type: :enum,
|
|
194
|
-
name:
|
|
215
|
+
name: local_name(name_arg.unescaped),
|
|
195
216
|
values: values,
|
|
196
217
|
location: node.location.start_line
|
|
197
218
|
}
|
|
@@ -207,7 +228,7 @@ module RailsAiContext
|
|
|
207
228
|
|
|
208
229
|
@results << {
|
|
209
230
|
type: :add_index,
|
|
210
|
-
table:
|
|
231
|
+
table: local_name(table_arg.unescaped),
|
|
211
232
|
columns: columns,
|
|
212
233
|
options: options,
|
|
213
234
|
location: node.location.start_line
|
|
@@ -222,7 +243,7 @@ module RailsAiContext
|
|
|
222
243
|
|
|
223
244
|
@results << {
|
|
224
245
|
type: :add_check_constraint,
|
|
225
|
-
table:
|
|
246
|
+
table: local_name(table_arg.unescaped),
|
|
226
247
|
expression: expr_arg.unescaped,
|
|
227
248
|
name: literal_string(extract_keyword_nodes(node)[:name]),
|
|
228
249
|
location: node.location.start_line
|
|
@@ -288,7 +309,7 @@ module RailsAiContext
|
|
|
288
309
|
|
|
289
310
|
options = braced.map { |hash| hash_node_to_hash(hash) }.reduce(extract_keyword_options(node), :merge)
|
|
290
311
|
virtual = node.name == :virtual
|
|
291
|
-
options[:enum_type] =
|
|
312
|
+
options[:enum_type] = local_name(options[:enum_type]) if options[:enum_type].is_a?(String)
|
|
292
313
|
# A generated column takes its own type from type: (each adapter's virtual, 7.0 to 8.1).
|
|
293
314
|
column_type = options[:type].to_s if virtual && (options[:type].is_a?(Symbol) || options[:type].is_a?(String))
|
|
294
315
|
names.each do |col_name|
|
|
@@ -172,7 +172,7 @@ module RailsAiContext
|
|
|
172
172
|
# Rails runs no STI on a table without the type column; a table the dump does not hold is not known to lack it.
|
|
173
173
|
def lacks_column?(table, column)
|
|
174
174
|
reader = SchemaReader.for(app.root)
|
|
175
|
-
reader.
|
|
175
|
+
reader.table?(table) && column != RailsAiContext::Confidence::INFERRED && !reader.column?(table, column)
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
# Rails' default column, the literal a class sets, or nil where `inheritance_column = nil` turns STI off.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# How Rails' PostgreSQL connection and schema.rb dumper name extensions, enum types and
|
|
6
|
+
# relations, by Rails version, so the static tier names them as the booted app does.
|
|
7
|
+
# Each rule cites the activerecord source it mirrors.
|
|
8
|
+
module PgNaming
|
|
9
|
+
DEFAULT_SEARCH_PATH = %w[public].freeze
|
|
10
|
+
# 6.1 schema_dumper.rb:89 writes Schema.define, 7.0 :90 Schema[x.y]; every rule here starts at 7.0 or later.
|
|
11
|
+
UNSTAMPED_DUMP = "6.1"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# The running Rails when booted, else the lockfile's; nil when neither says.
|
|
16
|
+
def rails_version(root)
|
|
17
|
+
return Rails.version if defined?(Rails) && Rails.respond_to?(:version) && !RailsAiContext.static_tier?
|
|
18
|
+
|
|
19
|
+
lock = GemLock.for(root)
|
|
20
|
+
lock.version("rails") || lock.version("railties")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An unknown version takes the newest rules.
|
|
24
|
+
def at_least?(version, release)
|
|
25
|
+
version.nil? || Gem::Version.new(version) >= Gem::Version.new(release)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# 8.0 postgresql_adapter.rb:506-520 drops the schema only when it is current_schema;
|
|
29
|
+
# 7.0 :453, 7.1 :497 and 7.2 :496 select extname alone.
|
|
30
|
+
def extension_name(name, schema, path, version)
|
|
31
|
+
return name if schema.nil? || !at_least?(version, "8.0") || schema == path.first
|
|
32
|
+
|
|
33
|
+
"#{schema}.#{name}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 7.1 postgresql_adapter.rb:502-522 (to 8.1 :533-553): the types in current_schemas(false),
|
|
37
|
+
# bare in current_schema. 7.0 :458-469 merges same-name types' labels; the static tier keeps the first set.
|
|
38
|
+
def enum_list(enums, path, version)
|
|
39
|
+
legacy = !at_least?(version, "7.1")
|
|
40
|
+
listed = enums.filter_map do |name, values|
|
|
41
|
+
schema, dot, bare = name.rpartition(".")
|
|
42
|
+
shown = if dot.empty? || legacy then bare
|
|
43
|
+
elsif path.include?(schema) then schema == path.first ? bare : name
|
|
44
|
+
end
|
|
45
|
+
{ name: shown, values: values } if shown
|
|
46
|
+
end
|
|
47
|
+
listed.uniq { |enum| enum[:name] }.sort_by { |enum| enum[:name] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 8.1 schema_dumper.rb:154-162 (relation_name) qualifies every name in a dump of more than one
|
|
51
|
+
# schema. Before, the dumper writes connection.tables, enum_types and column.sql_type
|
|
52
|
+
# (7.2 postgresql/schema_dumper.rb:19-29, :94), the names the booted app reads.
|
|
53
|
+
def dump_qualifies_names?(version)
|
|
54
|
+
at_least?(version, "8.1")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether a bare name in the dump says its schema. Before 8.1 the dumper names each of
|
|
58
|
+
# connection.tables bare, whichever search path schema holds it, so only a one-schema path places it.
|
|
59
|
+
def dump_places_names?(version, path)
|
|
60
|
+
dump_qualifies_names?(version) || path.size < 2
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 7.1 postgresql/schema_dumper.rb:31-40 writes create_schema for every schema; 7.0 has no schemas().
|
|
64
|
+
def dump_lists_schemas?(version)
|
|
65
|
+
at_least?(version, "7.1")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# current_schemas(false) skips a schema that does not exist; pg_dump never creates public.
|
|
69
|
+
def existing_path(path, created)
|
|
70
|
+
path.select { |schema| schema == "public" || created.include?(schema) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The names a dump's relations and enum types go by, given its qualified names.
|
|
74
|
+
def names(path, relations: [], types: [])
|
|
75
|
+
Names.new(path, shadowed(relations, path), shadowed(types, path))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The qualified names an earlier search path schema hides by holding the same bare name.
|
|
79
|
+
def shadowed(names, path)
|
|
80
|
+
return Set.new if path.size < 2
|
|
81
|
+
|
|
82
|
+
ranked = names.uniq.filter_map do |name|
|
|
83
|
+
schema, dot, bare = name.rpartition(".")
|
|
84
|
+
rank = path.index(schema) unless dot.empty?
|
|
85
|
+
[ rank, bare, name ] if rank
|
|
86
|
+
end
|
|
87
|
+
first = ranked.sort_by(&:first).reverse.to_h { |_, bare, name| [ bare, name ] }
|
|
88
|
+
ranked.filter_map { |_, bare, name| name unless first[bare] == name }.to_set
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# PostgreSQL resolves a bare name through the search path (data_sources and format_type alike).
|
|
92
|
+
Names = Struct.new(:path, :shadowed_relations, :shadowed_types) do
|
|
93
|
+
def relation(name)
|
|
94
|
+
visible(name, shadowed_relations)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def type(name)
|
|
98
|
+
visible(name, shadowed_types)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def visible(name, shadowed)
|
|
104
|
+
schema, dot, bare = name.rpartition(".")
|
|
105
|
+
dot.empty? || !path.include?(schema) || shadowed.include?(name) ? name : bare
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The enum types a table's columns use: a bare column type is the first search path schema's
|
|
110
|
+
# holding it, and a bare list name is the current schema's.
|
|
111
|
+
def enums_used(enum_types, column_types, path)
|
|
112
|
+
path = Array(path).presence || DEFAULT_SEARCH_PATH
|
|
113
|
+
listed = Array(enum_types).to_h { |enum| [ enum[:name].to_s.include?(".") ? enum[:name].to_s : "#{path.first}.#{enum[:name]}", enum ] }
|
|
114
|
+
used = column_types.map do |type|
|
|
115
|
+
type.include?(".") ? type : path.map { |schema| "#{schema}.#{type}" }.find { |key| listed.key?(key) }
|
|
116
|
+
end
|
|
117
|
+
listed.select { |key, _| used.include?(key) }.values
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# 8.1 postgresql/schema_dumper.rb:13-23, :50-68 dumps only ActiveRecord.dump_schemas, the search
|
|
121
|
+
# path by default (active_record.rb:436); 7.1 to 8.0 dump every schema's create_schema and ignore it.
|
|
122
|
+
# A dump naming no schema off the path is the default; one that does has dump_schemas set.
|
|
123
|
+
def missing_from_schema_rb(name, path, created, version, dump)
|
|
124
|
+
schema, dot, = name.to_s.rpartition(".")
|
|
125
|
+
return if dot.empty? || !at_least?(version, "8.1") || path.include?(schema) || created.include?(schema)
|
|
126
|
+
return unless (created - path).empty?
|
|
127
|
+
|
|
128
|
+
"If schema '#{schema}' exists, #{dump} leaves it out: Rails 8.1 dumps only the search path schemas there by default. " \
|
|
129
|
+
"`config.active_record.dump_schemas = :all` includes it."
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -189,12 +189,6 @@ module RailsAiContext
|
|
|
189
189
|
yml.adapter(db_name, entry).first || (yml.adapter("primary", yml.primary(root)).first unless entry)
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
-
# A dump of more than one schema qualifies every name (relation_name, 8.1);
|
|
193
|
-
# the app sees a public table by its bare name under the default search_path.
|
|
194
|
-
def local_name(name)
|
|
195
|
-
name.delete_prefix("public.")
|
|
196
|
-
end
|
|
197
|
-
|
|
198
192
|
# A primary key as connection.primary_key gives it: the column's name, or
|
|
199
193
|
# the names in order for a composite key; nil for none.
|
|
200
194
|
def primary_key_value(key)
|