rails-ai-context 5.35.0 → 5.35.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 +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- metadata +14 -3
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module DatabaseFamily
|
|
5
|
+
# A PostgreSQL EXPLAIN (VERBOSE, FORMAT JSON) plan, read for the relations
|
|
6
|
+
# and base columns its expressions name.
|
|
7
|
+
class PostgresqlPlan
|
|
8
|
+
# A plain qualified Var, either part bare or quoted. A set operation's "*SELECT* 1"
|
|
9
|
+
# is no relation's alias, so only a real relation's column is read.
|
|
10
|
+
NAME = /(?:([A-Za-z_]\w*)|"((?:""|[^"])+)")/
|
|
11
|
+
BASE_VAR = /\A#{NAME}\.#{NAME}\z/
|
|
12
|
+
# Nodes whose every read of a child's column is an expression of their own that names it.
|
|
13
|
+
READS_BY_NAME = [ "Aggregate", "Hash Join", "Merge Join", "Nested Loop" ].freeze
|
|
14
|
+
|
|
15
|
+
# Catalogs and TOAST tables: the never-read list and the catalog credentials hold what is secret there.
|
|
16
|
+
SYSTEM_SCHEMA = /\A(?:pg_catalog|information_schema|pg_toast(?:_temp_\d+)?)\z/
|
|
17
|
+
|
|
18
|
+
APPENDS = [ "Append", "Merge Append" ].freeze
|
|
19
|
+
REMOTE_NAME = /"(?:""|[^"])+"|[^\s()."]+/
|
|
20
|
+
REMOTE_RELATION = /\((?:(#{REMOTE_NAME})\.)?(#{REMOTE_NAME})(?: (#{REMOTE_NAME}))?\)/
|
|
21
|
+
|
|
22
|
+
# aliases: alias => the relations it stands for, each as `schema.relation` where the plan gives a schema that is no system one.
|
|
23
|
+
attr_reader :nodes, :aliases
|
|
24
|
+
|
|
25
|
+
# @param top [Hash] the plan's top node, as Family#plan returns it
|
|
26
|
+
def initialize(top)
|
|
27
|
+
@top = top
|
|
28
|
+
@nodes = []
|
|
29
|
+
@parents = {}.compare_by_identity
|
|
30
|
+
@appended = {}.compare_by_identity
|
|
31
|
+
collect(top)
|
|
32
|
+
# The alias defaults to the relation name when the query gave none, which is how `users.*` resolves.
|
|
33
|
+
# Keyed as EXPLAIN prints it: `t` and "T" are two aliases.
|
|
34
|
+
@names = {}
|
|
35
|
+
@aliases = @nodes.each_with_object({}) do |node, map|
|
|
36
|
+
scanned(node).each do |schema, rel, as|
|
|
37
|
+
key = [ (schema unless schema.to_s.match?(SYSTEM_SCHEMA)), rel ].compact.join(".").downcase
|
|
38
|
+
@names[key] = rel.downcase
|
|
39
|
+
# The members of an Append over partitions are `name_1`, `name_2`, and a node over them names their columns by `name`.
|
|
40
|
+
# Anywhere else `name_1` is the plan's own name for a second use of `name`, which stands for another relation.
|
|
41
|
+
[ as, (as[/\A(.+)_\d+\z/m, 1] if @appended[node]) ].compact.each { |name| (map[name] ||= []) << key }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
@aliases.each_value(&:uniq!)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The relation's own name, with no schema, for an answer.
|
|
48
|
+
def name(relation)
|
|
49
|
+
@names.fetch(relation, relation)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array<Array(String, String)>] [schema, `schema.relation`] for each relation the plan reads
|
|
53
|
+
# outside the system schemas
|
|
54
|
+
def relations
|
|
55
|
+
@nodes.flat_map { |node| scanned(node) }.filter_map do |schema, rel, _as|
|
|
56
|
+
[ schema, "#{schema}.#{rel}".downcase ] if schema && !schema.match?(SYSTEM_SCHEMA)
|
|
57
|
+
end.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Array<Array<String>>] the expressions that can stand at result column i, with any sort or group key
|
|
61
|
+
# past the result width. An Append prints no Output, and each of its members hands up a row of its own.
|
|
62
|
+
def output
|
|
63
|
+
return Array(@top["Output"]).map { |expr| [ expr ] } if @top.key?("Output") || !APPENDS.include?(@top["Node Type"])
|
|
64
|
+
|
|
65
|
+
members = Array(@top["Plans"])
|
|
66
|
+
outputs = members.map { |member| Array(member["Output"]) }
|
|
67
|
+
# Read only where each member is a relation that hands up the same columns in the same places.
|
|
68
|
+
columns = outputs.map { |exprs| exprs.map { |expr| expr.to_s.match(BASE_VAR)&.then { |m| m[3] || m[4] } } }.uniq
|
|
69
|
+
members.all? { |member| member["Relation Name"] } && columns.size == 1 ? outputs.transpose : []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array<Array(String, String)>] [relation, column] for a plain Var of a base relation, the column
|
|
73
|
+
# folded as the secret column rule folds a name: one for each relation its alias stands for
|
|
74
|
+
def base_columns(expr)
|
|
75
|
+
m = expr.to_s.strip.match(BASE_VAR) or return []
|
|
76
|
+
column = SecretColumns.fold(m[3] || m[4].gsub('""', '"'))
|
|
77
|
+
Array(aliases[m[1] || m[2].gsub('""', '"')]).map { |rel| [ rel, column ] }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The node that takes this node's rows as its input and reads their columns by name, or nil. A scan
|
|
81
|
+
# under one hands up every column of its table, read or not. A subplan's columns are read by position.
|
|
82
|
+
def reader(node)
|
|
83
|
+
parent = @parents[node]
|
|
84
|
+
parent if parent && READS_BY_NAME.include?(parent["Node Type"]) && %w[Outer Inner].include?(node["Parent Relationship"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Every expression the node itself holds, under whatever key.
|
|
88
|
+
def expressions(node)
|
|
89
|
+
strings(node.except("Plans"))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# [schema, relation, alias] of each relation the node scans. A join or an aggregate that a foreign server runs has
|
|
95
|
+
# no relation of its own: postgres_fdw names its tables there, each with its alias, as `(schema.table alias)`.
|
|
96
|
+
def scanned(node)
|
|
97
|
+
rel = node["Relation Name"]
|
|
98
|
+
return [ [ node["Schema"], rel.to_s, (node["Alias"] || rel).to_s ] ] if rel
|
|
99
|
+
|
|
100
|
+
node["Relations"].to_s.scan(REMOTE_RELATION).map do |parts|
|
|
101
|
+
schema, name, as = parts.map { |part| part&.delete_prefix('"')&.delete_suffix('"')&.gsub('""', '"') }
|
|
102
|
+
[ schema, name, as || name ]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def collect(node, parent = nil)
|
|
107
|
+
return unless node.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
@nodes << node
|
|
110
|
+
@parents[node] = parent
|
|
111
|
+
@appended[node] = true if parent && (@appended[parent] || APPENDS.include?(parent["Node Type"]))
|
|
112
|
+
Array(node["Plans"]).each { |child| collect(child, node) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def strings(value)
|
|
116
|
+
case value
|
|
117
|
+
when Hash then value.values.flat_map { |held| strings(held) }
|
|
118
|
+
when Array then value.flat_map { |held| strings(held) }
|
|
119
|
+
else [ value.to_s ]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
module DatabaseFamily
|
|
7
|
+
# PostgreSQL bounds a statement with SET LOCAL statement_timeout inside a read-only transaction.
|
|
8
|
+
module PostgresqlRuntime
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def read_only(conn, sql, timeout)
|
|
12
|
+
result = bounded(conn, timeout) { conn.select_all(sql) }
|
|
13
|
+
Rows.new(result.columns, result.rows, false)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# statement_timeout ends each statement in the block, a wait on a lock included. Read-only, rolled back.
|
|
17
|
+
# Inside a transaction that is already open it is a savepoint, whose rollback puts both settings back.
|
|
18
|
+
def bounded(conn, timeout)
|
|
19
|
+
result = nil
|
|
20
|
+
DatabaseFamily.bounding do
|
|
21
|
+
conn.transaction(requires_new: true) do
|
|
22
|
+
conn.execute("SET TRANSACTION READ ONLY")
|
|
23
|
+
conn.execute("SET LOCAL statement_timeout = '#{[ (timeout * 1000).to_i, 1 ].max}'")
|
|
24
|
+
result = yield
|
|
25
|
+
raise ActiveRecord::Rollback
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def explain(conn, sql, timeout)
|
|
32
|
+
raw = read_only(conn, "EXPLAIN (FORMAT JSON, ANALYZE) #{sql}", timeout).rows.map { |r| r.first.to_s }.join("\n")
|
|
33
|
+
scans = []
|
|
34
|
+
warnings = []
|
|
35
|
+
begin
|
|
36
|
+
data = JSON.parse(raw)
|
|
37
|
+
plan = data.first["Plan"] if data.is_a?(Array) && data.first.is_a?(Hash)
|
|
38
|
+
scan_nodes(plan, scans, warnings) if plan
|
|
39
|
+
rescue JSON::ParserError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
{ scan_types: scans, warnings: warnings, raw: raw, unbounded: false }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Planning only: EXPLAIN without ANALYZE runs nothing, not even a VOLATILE function.
|
|
46
|
+
# A timeout and an SQL error are raised, so the caller never runs what it could not plan.
|
|
47
|
+
def plan(conn, sql, timeout)
|
|
48
|
+
json = read_only(conn, "EXPLAIN (VERBOSE, FORMAT JSON) #{sql}", timeout).rows.first&.first
|
|
49
|
+
data = JSON.parse(json.to_s)
|
|
50
|
+
data.is_a?(Array) && data.first.is_a?(Hash) ? data.first["Plan"] : nil
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The described statement's ftable/ftablecol, then the plan's top Output,
|
|
56
|
+
# which also sees through a view, CTE or subquery that renamed a column.
|
|
57
|
+
def column_sources(conn, sql, plan)
|
|
58
|
+
raise ArgumentError, "PostgreSQL column sources are read from the statement's plan, and none was given" unless plan
|
|
59
|
+
|
|
60
|
+
described_sources(conn, sql) + planned_sources(plan)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def described_sources(conn, sql)
|
|
64
|
+
raw = conn.raw_connection
|
|
65
|
+
stmt = "rac_prov_#{Process.pid}_#{rand(1 << 30)}"
|
|
66
|
+
begin
|
|
67
|
+
raw.prepare(stmt, sql)
|
|
68
|
+
desc = raw.describe_prepared(stmt)
|
|
69
|
+
ensure
|
|
70
|
+
raw.exec("DEALLOCATE #{stmt}") rescue nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
relations = {}
|
|
74
|
+
desc.nfields.times.filter_map do |i|
|
|
75
|
+
oid = desc.ftable(i).to_i
|
|
76
|
+
col = desc.ftablecol(i).to_i
|
|
77
|
+
next if oid.zero? || col.zero?
|
|
78
|
+
|
|
79
|
+
relation, names = relations[oid] ||= [ relation_name(conn, oid), attribute_names(conn, oid) ]
|
|
80
|
+
# A relation whose columns could not be read still names itself, for the caller to fail closed on.
|
|
81
|
+
next [ i, nil, relation ] unless names
|
|
82
|
+
[ i, names[col], relation ] if names[col]
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
# The driver raises its own class here. Half a trace is no answer, so a timeout is raised as the call's.
|
|
86
|
+
raise TimedOut, e.message if defined?(PG::QueryCanceled) && e.is_a?(PG::QueryCanceled)
|
|
87
|
+
raise if e.is_a?(ActiveRecord::StatementInvalid) && DatabaseFamily.timed_out?(e)
|
|
88
|
+
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def planned_sources(plan)
|
|
93
|
+
read = PostgresqlPlan.new(plan)
|
|
94
|
+
read.output.each_with_index.flat_map do |exprs, i|
|
|
95
|
+
exprs.flat_map { |expr| read.base_columns(expr) }.map { |rel, col| [ i, col, rel ] }.uniq { |_, col, _| col }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def unqualified_schemas(_conn)
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A bare name folds to lower case and a quoted one does not, so both spellings are asked for.
|
|
104
|
+
def schemas_named(conn, names)
|
|
105
|
+
asked = (names | names.map(&:downcase)).map { |name| conn.quote(name) }.join(", ")
|
|
106
|
+
conn.select_values(
|
|
107
|
+
"SELECT nspname FROM pg_namespace WHERE nspname IN (#{asked}) AND nspname !~ '^pg_' " \
|
|
108
|
+
"AND nspname <> 'information_schema' AND nspname <> ALL (current_schemas(false))"
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A plan names the session's temporary schema pg_temp, whatever number its own name carries.
|
|
113
|
+
SCHEMA = "CASE WHEN n.oid = pg_my_temp_schema() THEN 'pg_temp' ELSE n.nspname END"
|
|
114
|
+
|
|
115
|
+
def in_schemas(conn, schemas)
|
|
116
|
+
schemas.empty? ? "n.nspname = ANY (current_schemas(false))" : "#{SCHEMA} IN (#{schemas.map { |name| conn.quote(name) }.join(", ")})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Every relation a plan can name that stores rows: tables, partitioned tables, materialized views, foreign tables and sequences.
|
|
120
|
+
# One read that takes no lock on any of them.
|
|
121
|
+
def relation_columns(conn, schemas = [])
|
|
122
|
+
rows = conn.select_rows(
|
|
123
|
+
"SELECT #{SCHEMA}, c.relname, a.attname FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid " \
|
|
124
|
+
"JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p', 'm', 'f', 'S') AND a.attnum > 0 " \
|
|
125
|
+
"AND NOT a.attisdropped AND #{in_schemas(conn, schemas)} ORDER BY c.relname, a.attnum"
|
|
126
|
+
)
|
|
127
|
+
rows.each_with_object({}) do |(schema, relation, column), found|
|
|
128
|
+
(found[relation] ||= []) << column
|
|
129
|
+
(found["#{schema}.#{relation}"] ||= []) << column
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
VIEWS = "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind"
|
|
134
|
+
|
|
135
|
+
# A materialized view stores its rows, so its result columns have no table column to be traced to. The
|
|
136
|
+
# views are read with it for what a materialized view built on one reads. Each is also under `schema.view`.
|
|
137
|
+
def view_definitions(conn, schemas = [])
|
|
138
|
+
rows = conn.select_rows("SELECT #{SCHEMA}, c.relname, pg_get_viewdef(c.oid) #{VIEWS} IN ('v', 'm') AND #{in_schemas(conn, schemas)}")
|
|
139
|
+
DatabaseFamily.by_view(rows.flat_map { |schema, view, definition| [ [ view, definition ], [ "#{schema}.#{view}", definition ] ] })
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def traced_views(conn, schemas = [])
|
|
143
|
+
conn.select_rows("SELECT #{SCHEMA}, c.relname #{VIEWS} = 'v' AND #{in_schemas(conn, schemas)}")
|
|
144
|
+
.flat_map { |schema, view| [ view, "#{schema}.#{view}" ] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A stored generated column is a plain column to the plan, so its expression is read here.
|
|
148
|
+
def generated_columns(conn, _schemas = [])
|
|
149
|
+
return {} if conn.database_version < 12_00_00
|
|
150
|
+
|
|
151
|
+
DatabaseFamily.keep_expressions({}, conn.select_rows(
|
|
152
|
+
"SELECT c.relname, a.attname, pg_get_expr(d.adbin, d.adrelid) FROM pg_attribute a " \
|
|
153
|
+
"JOIN pg_class c ON c.oid = a.attrelid JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum " \
|
|
154
|
+
"WHERE a.attgenerated <> '' AND NOT a.attisdropped"
|
|
155
|
+
))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def relation_name(conn, oid)
|
|
159
|
+
conn.select_value("SELECT relname FROM pg_class WHERE oid = #{oid.to_i}")&.to_s
|
|
160
|
+
rescue StandardError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# attnum => column name for one relation oid, or nil when they cannot be read.
|
|
165
|
+
def attribute_names(conn, oid)
|
|
166
|
+
conn.select_rows(
|
|
167
|
+
"SELECT attnum, attname FROM pg_attribute WHERE attrelid = #{oid.to_i} AND attnum > 0 AND NOT attisdropped"
|
|
168
|
+
).to_h { |num, name| [ num.to_i, name.to_s ] }
|
|
169
|
+
rescue StandardError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def index_tables(_conn, _schemas = [])
|
|
174
|
+
{}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def table_sizes(conn)
|
|
178
|
+
Introspectors::PgPartitions.table_bytes(conn).sort_by { |r| [ -r[:bytes], r[:name] ] }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def row_estimates(conn)
|
|
182
|
+
Introspectors::PgPartitions.table_rows(conn).sort_by { |t| [ -t[:rows], t[:name] ] }.map do |t|
|
|
183
|
+
entry = { table: t[:name], approximate_rows: t[:rows] }
|
|
184
|
+
entry[:dead_rows] = t[:dead_rows] if t[:dead_rows] > 0
|
|
185
|
+
entry
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def index_usage(conn)
|
|
190
|
+
Introspectors::PgPartitions.index_stats(conn).sort_by { |i| [ i[:scans], i[:table], i[:index] ] }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def scan_nodes(node, scans, warnings)
|
|
194
|
+
return unless node.is_a?(Hash)
|
|
195
|
+
|
|
196
|
+
node_type = node["Node Type"].to_s
|
|
197
|
+
table = node["Relation Name"]
|
|
198
|
+
rows = node["Actual Rows"] || node["Plan Rows"]
|
|
199
|
+
|
|
200
|
+
if node_type.include?("Seq Scan")
|
|
201
|
+
scans << { table: table, type: "sequential scan", index: nil }
|
|
202
|
+
warnings << "Sequential scan on #{table} (#{rows} rows)" if rows.to_i > 1000
|
|
203
|
+
elsif node_type.include?("Index")
|
|
204
|
+
scans << { table: table, type: node_type.downcase, index: node["Index Name"] }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
(node["Plans"] || []).each { |child| scan_nodes(child, scans, warnings) }
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|