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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. 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