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,506 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "strscan"
|
|
5
|
+
|
|
6
|
+
module RailsAiContext
|
|
7
|
+
# The columns rails_query never returns and whose names a statement may not
|
|
8
|
+
# mention, for one connection's schema. Built once per connection and run.
|
|
9
|
+
class SecretColumns
|
|
10
|
+
# The schema of a database could not be read, so its secret columns cannot be told.
|
|
11
|
+
class Unreadable < RailsAiContext::Error; end
|
|
12
|
+
|
|
13
|
+
DEFAULT_REDACTED_COLUMNS = %w[
|
|
14
|
+
password_digest encrypted_password password_hash
|
|
15
|
+
reset_password_token confirmation_token unlock_token
|
|
16
|
+
otp_secret session_data secret_key
|
|
17
|
+
api_key api_secret access_token refresh_token jti
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# PostgreSQL catalog columns that hold a stored credential: a role's password hash, and the settings
|
|
21
|
+
# stored for a role or a database. pg_roles, pg_user and pg_shadow are views over them.
|
|
22
|
+
CATALOG_CREDENTIALS = { "pg_authid" => %w[rolpassword], "pg_db_role_setting" => %w[setconfig] }.freeze
|
|
23
|
+
|
|
24
|
+
# Matched whether or not the name is a real column, so the check holds with no
|
|
25
|
+
# connection. `config.query_allowed_columns` releases a name that only looks secret.
|
|
26
|
+
BUILT_IN_NAMES = (DEFAULT_REDACTED_COLUMNS + %w[
|
|
27
|
+
password_reset_token remember_token secret private_key authentication_string
|
|
28
|
+
] + CATALOG_CREDENTIALS.values.flatten).freeze
|
|
29
|
+
|
|
30
|
+
HEURISTIC_SUFFIXES = %w[password secret token key digest hash].freeze
|
|
31
|
+
|
|
32
|
+
# Rails' own bookkeeping tables. ar_internal_metadata.key ends in "key": counted,
|
|
33
|
+
# it would refuse every statement whose text holds the word "key".
|
|
34
|
+
BOOKKEEPING_TABLES = %w[ar_internal_metadata schema_migrations].freeze
|
|
35
|
+
|
|
36
|
+
# A wildcard in a select list. `count(*)` reads no column, so it is left out.
|
|
37
|
+
WILDCARD = /(?:\b(?:SELECT|DISTINCT|ALL)\b|[,.])\s*\*/i
|
|
38
|
+
|
|
39
|
+
# A name before a dot, matched in reversed text from the dot backward: bare, or in backticks or double quotes
|
|
40
|
+
# with the quote doubled inside. A schema name on MySQL, MariaDB or PostgreSQL is 64 characters at most.
|
|
41
|
+
NAME_END = /\.(?=\s*(?:[\p{Word}$`"]|[^\x00-\x7F]))/
|
|
42
|
+
BARE_NAME = /(?:[\p{Word}$]|[^\x00-\x7F])+/
|
|
43
|
+
QUOTED_NAME = /`((?>(?:``|[^`]){1,64}))`(?!`)|"((?>(?:""|[^"]){1,64}))"(?!")/
|
|
44
|
+
|
|
45
|
+
# How many times the schemas a statement and the view definitions name are followed into more view definitions.
|
|
46
|
+
ROUNDS = 3
|
|
47
|
+
|
|
48
|
+
# Cached per run and database: one schema read however many checks ask. With a statement, the schemas it
|
|
49
|
+
# names are read too, so the rule knows every relation the statement can reach.
|
|
50
|
+
def self.for(connection, context: nil, statement: nil)
|
|
51
|
+
secrets = RunCache.fetch([ :secret_columns, database_key(connection) ]) do
|
|
52
|
+
family = DatabaseFamily.of(connection)
|
|
53
|
+
schemas = family.known? ? family.unqualified_schemas(connection) : []
|
|
54
|
+
# The context's schema is the primary's, so no other database falls back to it.
|
|
55
|
+
relations = relations_from(connection, schemas) || (relations_from(context) if primary?(connection))
|
|
56
|
+
raise unreadable(connection) unless relations
|
|
57
|
+
|
|
58
|
+
relations = relations.merge(CATALOG_CREDENTIALS) if family.plan_checks?
|
|
59
|
+
new(relations: relations, encrypted: encrypted_from(context), connection: connection, **definitions_from(connection, schemas))
|
|
60
|
+
end
|
|
61
|
+
statement.to_s.strip.empty? ? secrets : secrets.cover(statement)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A name or a statement as the databases compare names: case folded, a dotted capital I read as i (MySQL does,
|
|
65
|
+
# Ruby's downcase keeps the dot), and runs of spaces squeezed, so a name is found in text squeezed or not.
|
|
66
|
+
def self.fold(text)
|
|
67
|
+
text.to_s.downcase.delete("\u0307").squeeze(" ")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Matches the name as a whole identifier in folded text, whatever characters it holds and however it is quoted.
|
|
71
|
+
def self.identifier(name)
|
|
72
|
+
name = fold(name)
|
|
73
|
+
spellings = [ name, name.gsub('"', '""'), name.gsub("`", "``"), name.gsub("'", "''") ].uniq.map { |spelling| Regexp.escape(spelling) }
|
|
74
|
+
/(?<!\p{Word})(?:#{spellings.join("|")})(?!\p{Word})/
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.unreadable(connection)
|
|
78
|
+
Unreadable.new("The columns of the #{connection.pool.db_config.name} database could not be read, so its secret " \
|
|
79
|
+
"columns cannot be told and no query runs on it.")
|
|
80
|
+
end
|
|
81
|
+
private_class_method :unreadable
|
|
82
|
+
|
|
83
|
+
# An error that is the call's own answer, so no other read stands in for the one that raised it.
|
|
84
|
+
def self.answered?(error)
|
|
85
|
+
DatabaseFamily.lost_connection?(error) ||
|
|
86
|
+
(error.is_a?(ActiveRecord::StatementInvalid) && (DatabaseFamily.timed_out?(error) || DatabaseFamily.busy?(error)))
|
|
87
|
+
end
|
|
88
|
+
private_class_method :answered?
|
|
89
|
+
|
|
90
|
+
def self.primary?(connection)
|
|
91
|
+
connection.pool.db_config.name == ActiveRecord::Base.connection_db_config.name
|
|
92
|
+
end
|
|
93
|
+
private_class_method :primary?
|
|
94
|
+
|
|
95
|
+
# What the database stores outside any statement: view definitions and generated column expressions.
|
|
96
|
+
def self.definitions_from(connection, schemas)
|
|
97
|
+
family = DatabaseFamily.of(connection)
|
|
98
|
+
return {} unless family.known?
|
|
99
|
+
|
|
100
|
+
views = begin
|
|
101
|
+
family.view_definitions(connection, schemas)
|
|
102
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
103
|
+
raise if answered?(e)
|
|
104
|
+
|
|
105
|
+
# Named with no definition, so each one is refused as unreadable.
|
|
106
|
+
connection.views.to_h { |view| [ view, nil ] }
|
|
107
|
+
end
|
|
108
|
+
# SQLite stores a definition as written, comments included.
|
|
109
|
+
views = views.transform_values { |definition| definition && SqlGate.comment_free(definition.to_s, family) }
|
|
110
|
+
{ views: views, traced_views: family.traced_views(connection, schemas), generated: family.generated_columns(connection, schemas),
|
|
111
|
+
index_tables: family.index_tables(connection, schemas) }
|
|
112
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
113
|
+
raise if answered?(e)
|
|
114
|
+
|
|
115
|
+
raise unreadable(connection)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.database_key(connection)
|
|
119
|
+
db_config = connection.pool.db_config
|
|
120
|
+
[ db_config.env_name, db_config.name ]
|
|
121
|
+
rescue StandardError
|
|
122
|
+
connection.object_id
|
|
123
|
+
end
|
|
124
|
+
private_class_method :database_key
|
|
125
|
+
|
|
126
|
+
# relation name => column names, from a live connection or a cached
|
|
127
|
+
# context's schema; nil when neither can be read.
|
|
128
|
+
def self.relations_from(source, schemas = [])
|
|
129
|
+
if source.respond_to?(:adapter_name)
|
|
130
|
+
family = DatabaseFamily.of(source)
|
|
131
|
+
return nil unless family.known?
|
|
132
|
+
|
|
133
|
+
family.relation_columns(source, schemas).reject { |table, _columns| BOOKKEEPING_TABLES.include?(table.to_s.downcase) }
|
|
134
|
+
else
|
|
135
|
+
tables = source&.dig(:schema, :tables)
|
|
136
|
+
return nil unless tables.is_a?(Hash) && tables.any?
|
|
137
|
+
|
|
138
|
+
tables.each_with_object({}) do |(table, data), acc|
|
|
139
|
+
next if BOOKKEEPING_TABLES.include?(table.to_s.downcase)
|
|
140
|
+
acc[table.to_s] = Array(data.is_a?(Hash) && data[:columns]).map { |col| col.is_a?(Hash) ? col[:name] : col }.compact
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
# A read the timeout ended is the call's timeout, so it is no reason to fall back.
|
|
145
|
+
raise if answered?(e)
|
|
146
|
+
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.encrypted_from(context)
|
|
151
|
+
models = context&.dig(:models)
|
|
152
|
+
return [] unless models.is_a?(Hash)
|
|
153
|
+
|
|
154
|
+
models.each_value.flat_map { |data| data.is_a?(Hash) ? Array(data[:encrypts]) : [] }
|
|
155
|
+
end
|
|
156
|
+
private_class_method :encrypted_from
|
|
157
|
+
|
|
158
|
+
# Each argument is what the DatabaseFamily::Family read of the same name answers. views is nil when the
|
|
159
|
+
# database's views are not known. connection: where more schemas are read from; with none the rule keeps what it was given.
|
|
160
|
+
def initialize(relations: {}, encrypted: [], views: nil, traced_views: [], generated: {}, index_tables: {}, connection: nil,
|
|
161
|
+
config: RailsAiContext.configuration)
|
|
162
|
+
fold = self.class.method(:fold)
|
|
163
|
+
@redacted = (Array(config.query_redacted_columns) + BUILT_IN_NAMES).to_set(&fold)
|
|
164
|
+
@allowed = Array(config.query_allowed_columns).to_set(&fold)
|
|
165
|
+
@encrypted = Array(encrypted).to_set(&fold)
|
|
166
|
+
@connection = connection
|
|
167
|
+
@columns = Hash.new { |hash, relation| hash[relation] = [] }
|
|
168
|
+
@views = views && {}
|
|
169
|
+
@traced = []
|
|
170
|
+
@expressions = {}
|
|
171
|
+
@index_tables = {}
|
|
172
|
+
@asked = Set.new
|
|
173
|
+
@read = []
|
|
174
|
+
@spelled = {}
|
|
175
|
+
learn(relations: relations, views: views, traced_views: traced_views, generated: generated, index_tables: index_tables)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Reads what the statement reaches into the rule.
|
|
179
|
+
# @raise [Unreadable] as #reach does
|
|
180
|
+
def cover(statement)
|
|
181
|
+
return self unless @connection
|
|
182
|
+
|
|
183
|
+
reach(SqlGate.comment_free(statement, DatabaseFamily.of(@connection)))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Reads the schemas given and those the text names, then follows the views the text names ROUNDS definitions deep.
|
|
187
|
+
# @raise [Unreadable] when a view the text names leads deeper, or a schema's columns cannot be read
|
|
188
|
+
def reach(text, schemas = [])
|
|
189
|
+
return self unless @connection
|
|
190
|
+
|
|
191
|
+
family = DatabaseFamily.of(@connection)
|
|
192
|
+
read_schemas(schemas)
|
|
193
|
+
(ROUNDS + 1).times do |round|
|
|
194
|
+
followed = views_reached(text)
|
|
195
|
+
names = [ text, *@views.to_h.values_at(*followed.keys).compact ].flat_map { |read| dotted_names(read) }.to_set - @asked
|
|
196
|
+
found = family.schemas_named(@connection, names.to_a) - @read
|
|
197
|
+
raise too_deep(found, followed, family) if found.any? && round == ROUNDS
|
|
198
|
+
|
|
199
|
+
@asked.merge(names)
|
|
200
|
+
return self if found.empty?
|
|
201
|
+
|
|
202
|
+
read_schemas(found)
|
|
203
|
+
end
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Reads the relations, views and generated columns of more schemas into the rule.
|
|
208
|
+
# @raise [Unreadable] when a schema's columns cannot be read
|
|
209
|
+
def read_schemas(schemas)
|
|
210
|
+
schemas = schemas.compact.uniq - @read
|
|
211
|
+
return self if schemas.empty? || @connection.nil?
|
|
212
|
+
|
|
213
|
+
relations = self.class.relations_from(@connection, schemas)
|
|
214
|
+
unless relations
|
|
215
|
+
raise Unreadable, "The columns of #{schemas.map { |name| "`#{name}`" }.join(", ")} could not be read, so the secret columns " \
|
|
216
|
+
"there cannot be told and no query that reads it runs."
|
|
217
|
+
end
|
|
218
|
+
@read.concat(schemas)
|
|
219
|
+
learn(relations: relations, **self.class.definitions_from(@connection, schemas))
|
|
220
|
+
self
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Whether the relation's columns were read. A PostgreSQL relation is asked for as `schema.relation`.
|
|
224
|
+
def knows?(relation)
|
|
225
|
+
@columns.key?(self.class.fold(relation))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def secret?(name)
|
|
229
|
+
down = self.class.fold(name)
|
|
230
|
+
return false if down.empty?
|
|
231
|
+
return true if encrypted?(down)
|
|
232
|
+
return false if @allowed.include?(down)
|
|
233
|
+
|
|
234
|
+
@generated.key?(down) || @redacted.include?(down) || heuristic?(down) || RailsAiContext::Redaction.secret_name?(down)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# True for an `encrypts` column and for a generated column that reads one: neither is released by name.
|
|
238
|
+
# Generated columns of one name on two tables are one name here, encrypted when either reads such a column.
|
|
239
|
+
def encrypted?(name, through = [])
|
|
240
|
+
down = self.class.fold(name)
|
|
241
|
+
return true if @encrypted.include?(down)
|
|
242
|
+
|
|
243
|
+
!through.include?(down) && @generated.fetch(down, []).any? { |source| encrypted?(source, through + [ down ]) }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @return [String, nil] a secret column a generated column's expression reads, an `encrypts` one first
|
|
247
|
+
def generated_from(name)
|
|
248
|
+
sources = @generated.fetch(self.class.fold(name), [])
|
|
249
|
+
sources.find { |source| encrypted?(source) } || sources.first
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# [view, why] for the first refused view the text names, or nil. A view's definition renames a
|
|
253
|
+
# column outside the statement, where no check on the statement reads it.
|
|
254
|
+
def refused_view_in(text)
|
|
255
|
+
down = self.class.fold(text)
|
|
256
|
+
view, why = @refused_views.find { |name, _why| names?(down, name) }
|
|
257
|
+
[ spelled(view), why ] if view
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Why the view at exactly this place is refused, or nil. The plan names a relation as `schema.relation`, so a table
|
|
261
|
+
# is not taken for a view of its name in another schema; a view with no definition read is told by its bare name.
|
|
262
|
+
def placed_view_refusal(relation, name)
|
|
263
|
+
placed, bare = self.class.fold(relation), self.class.fold(name)
|
|
264
|
+
return @refused_views[placed] if @views&.key?(placed)
|
|
265
|
+
|
|
266
|
+
@refused_views[bare] if @views&.key?(bare) && @views[bare].nil?
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Whether the relation is a view whose rows the plan does not trace to their table columns.
|
|
270
|
+
def stored_view?(relation)
|
|
271
|
+
placed = self.class.fold(relation)
|
|
272
|
+
!@views.nil? && @views.key?(placed) && !@traced.include?(placed)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# [table, why] for the first table the text names that is never read, or nil: the file's pages, a shadow
|
|
276
|
+
# table, and a virtual table or its vocabulary when the virtual table holds a secret column.
|
|
277
|
+
def unread_table_in(text)
|
|
278
|
+
down = self.class.fold(text)
|
|
279
|
+
table, why = @unread_tables.find { |name, _why| names?(down, name) }
|
|
280
|
+
[ spelled(table), why ] if table
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The first relation holding a secret column that the statement's text names, or nil.
|
|
284
|
+
def relation_named_in(text)
|
|
285
|
+
down = self.class.fold(text)
|
|
286
|
+
relation = @by_relation.each_key.find { |name| names?(down, name) }
|
|
287
|
+
relation && spelled(relation)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The first secret name the statement's text names, or nil. Case-insensitive
|
|
291
|
+
# and word-bounded, so a table or alias that merely holds a secret word passes.
|
|
292
|
+
def named_in(text)
|
|
293
|
+
down = self.class.fold(text)
|
|
294
|
+
@named.each { |name, pattern| return name if down.match?(pattern) }
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The schema's real secret columns, read by the PostgreSQL plan checks.
|
|
299
|
+
attr_reader :in_schema
|
|
300
|
+
|
|
301
|
+
def of_relation(relation)
|
|
302
|
+
@by_relation.fetch(self.class.fold(relation), [])
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def relation_secret?(relation)
|
|
306
|
+
@by_relation.key?(self.class.fold(relation))
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
# Every name that stands before a dot, read backward from the dot, so a quote character inside a string before it
|
|
312
|
+
# changes nothing. A name in brackets is not read: SQLite reads every schema, and the gate refuses a bracket on MySQL.
|
|
313
|
+
def dotted_names(text)
|
|
314
|
+
scanner = StringScanner.new(text.reverse)
|
|
315
|
+
names = []
|
|
316
|
+
while scanner.skip_until(NAME_END)
|
|
317
|
+
scanner.skip(/\s+/)
|
|
318
|
+
if (bare = scanner.check(BARE_NAME)) then names << bare.reverse
|
|
319
|
+
elsif scanner.check(QUOTED_NAME) then names << (scanner[1]&.gsub("``", "`") || scanner[2].gsub('""', '"')).reverse
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
names
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# view => the view the text names that leads to it, for each view the text names and each one their
|
|
326
|
+
# definitions name. A view the plan reads through is no start: its relations are in the plan.
|
|
327
|
+
def views_reached(text)
|
|
328
|
+
down = self.class.fold(text)
|
|
329
|
+
reached = @views.to_h.keys.select { |view| !@traced.include?(view) && names?(down, view) }.to_h { |view| [ view, view ] }
|
|
330
|
+
waiting = reached.keys
|
|
331
|
+
while (view = waiting.shift)
|
|
332
|
+
definition = self.class.fold(@views[view])
|
|
333
|
+
(@views.keys - reached.keys).each do |other|
|
|
334
|
+
next unless names?(definition, other)
|
|
335
|
+
|
|
336
|
+
reached[other] = reached[view]
|
|
337
|
+
waiting << other
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
reached
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def too_deep(found, followed, family)
|
|
344
|
+
found = found.map { |schema| self.class.fold(schema) }
|
|
345
|
+
view = followed.find { |name, _start| (dotted_names(self.class.fold(@views[name])) & found).any? }&.last
|
|
346
|
+
Unreadable.new("The view `#{spelled(view)}` leads through more #{family.postgresql? ? "schemas" : "databases"} than rails_query follows, " \
|
|
347
|
+
"so the secret columns it could read cannot be told and no statement that names it runs.")
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# The name as the database spells it, for a sentence: the rule itself holds every name folded.
|
|
351
|
+
def spelled(name)
|
|
352
|
+
@spelled.fetch(name, name)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def learn(relations:, views: nil, traced_views: [], generated: {}, index_tables: {})
|
|
356
|
+
fold = self.class.method(:fold)
|
|
357
|
+
[ relations, views, index_tables ].each { |named| named&.each_key { |name| @spelled[fold.call(name)] ||= name.to_s } }
|
|
358
|
+
relations.each { |relation, names| @columns[fold.call(relation)].concat(Array(names).map(&fold)) }
|
|
359
|
+
generated.each do |relation, expressions|
|
|
360
|
+
@columns[fold.call(relation)].concat(expressions.keys.map(&fold))
|
|
361
|
+
(@expressions[relation] ||= {}).merge!(expressions) { |_column, known, read| "#{known} #{read}" }
|
|
362
|
+
end
|
|
363
|
+
views&.each do |view, definition|
|
|
364
|
+
view = fold.call(view)
|
|
365
|
+
@views[view] = @views.key?(view) ? [ @views[view], definition ].then { |both| both.all? ? both.join("\n") : nil } : definition
|
|
366
|
+
end
|
|
367
|
+
@traced.concat(traced_views.map(&fold))
|
|
368
|
+
@index_tables.merge!(index_tables)
|
|
369
|
+
|
|
370
|
+
@generated = {}
|
|
371
|
+
flag_secret_columns(@columns)
|
|
372
|
+
# A generated column can read another one, so this repeats until it finds no more.
|
|
373
|
+
flag_secret_columns(@columns) while trace_generated(@expressions)
|
|
374
|
+
@unread_tables = unread_tables(@index_tables)
|
|
375
|
+
# A virtual table can be built on a refused view, and a view on that virtual table.
|
|
376
|
+
loop do
|
|
377
|
+
@refused_views = @views ? refused_views(@views).except(*@traced) : {}
|
|
378
|
+
built = built_on_unread(@index_tables)
|
|
379
|
+
break if built.empty?
|
|
380
|
+
|
|
381
|
+
@unread_tables.merge!(built)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def flag_secret_columns(columns)
|
|
386
|
+
@by_relation = columns.each_with_object({}) do |(relation, names), acc|
|
|
387
|
+
flagged = names.uniq.select { |c| secret?(c) }
|
|
388
|
+
acc[relation] = flagged unless flagged.empty?
|
|
389
|
+
end
|
|
390
|
+
@in_schema = @by_relation.values.flatten.uniq.freeze
|
|
391
|
+
@named = ((@redacted.to_a - @allowed.to_a) | @encrypted.to_a | @in_schema).map { |c| [ c, self.class.identifier(c) ] }
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Whether another secret column was found that a generated column reads. Every source of a name is kept.
|
|
395
|
+
def trace_generated(generated)
|
|
396
|
+
found = false
|
|
397
|
+
generated.each_value do |expressions|
|
|
398
|
+
expressions.each do |column, expression|
|
|
399
|
+
column = self.class.fold(column)
|
|
400
|
+
down = self.class.fold(expression)
|
|
401
|
+
sources = @named.filter_map { |name, pattern| name if name != column && down.match?(pattern) } - @generated.fetch(column, [])
|
|
402
|
+
next if sources.empty?
|
|
403
|
+
|
|
404
|
+
(@generated[column] ||= []).concat(sources)
|
|
405
|
+
found = true
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
found
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# view name => why it is refused, followed through views built on refused views.
|
|
412
|
+
def refused_views(views)
|
|
413
|
+
views = views.to_h { |view, definition| [ self.class.fold(view), self.class.fold(definition) ] }
|
|
414
|
+
refused = views.transform_values { |definition| refusal_of(definition) }.compact
|
|
415
|
+
loop do
|
|
416
|
+
nested = views.filter_map do |view, definition|
|
|
417
|
+
inner = refused.keys.find { |other| names?(definition, other) } unless refused.key?(view)
|
|
418
|
+
[ view, inner ] if inner
|
|
419
|
+
end
|
|
420
|
+
return refused if nested.empty?
|
|
421
|
+
|
|
422
|
+
nested.each { |view, inner| refused[view] = "it reads the view `#{spelled(inner)}`: #{refused[inner]}" }
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def unread_tables(index_tables)
|
|
427
|
+
index_tables.each_with_object({}) do |(table, (kind, owner)), unread|
|
|
428
|
+
secret = of_relation(owner).first
|
|
429
|
+
why = case kind
|
|
430
|
+
when :pages then "it returns the database file's raw pages, and every stored value is in them"
|
|
431
|
+
when :shadow
|
|
432
|
+
"it is a shadow table, where SQLite keeps what the virtual table `#{owner}` stores under other column names" \
|
|
433
|
+
"#{". Query `#{owner}`" unless secret}"
|
|
434
|
+
when :vocabulary
|
|
435
|
+
"it lists the words of the full-text table `#{owner}`, which holds the secret column `#{secret}`" if secret
|
|
436
|
+
when :virtual
|
|
437
|
+
if secret
|
|
438
|
+
"it is a virtual table that holds the secret column `#{secret}`, and a search or a function over a virtual " \
|
|
439
|
+
"table reads a column by its position, with no name written"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
unread[self.class.fold(table)] = why if why
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# A name or a string in any of the four quotings SQLite reads.
|
|
447
|
+
SQLITE_QUOTED = /"(?:""|[^"])*"|'(?:''|[^'])*'|`(?:``|[^`])*`|\[[^\]]*\]/
|
|
448
|
+
|
|
449
|
+
# One argument of a virtual table's module: up to the next comma outside quotes, brackets and parentheses.
|
|
450
|
+
MODULE_ARGUMENT = /(?:#{SQLITE_QUOTED}|\([^()]*\)|[^,])+/
|
|
451
|
+
|
|
452
|
+
# virtual table => why it is not read, for each one built on a refused view or on a table that is not read:
|
|
453
|
+
# its rows, or its words, are theirs.
|
|
454
|
+
def built_on_unread(index_tables)
|
|
455
|
+
index_tables.each_with_object({}) do |(table, (kind, owner, definition)), built|
|
|
456
|
+
table = self.class.fold(table)
|
|
457
|
+
next if definition.nil? || @unread_tables.key?(table)
|
|
458
|
+
|
|
459
|
+
down = self.class.fold("#{owner if kind == :vocabulary} #{relation_words(table, definition)}")
|
|
460
|
+
view, why = @refused_views.find { |name, _why| name != table && names?(down, name) }
|
|
461
|
+
other = @unread_tables.keys.find { |name| name != table && names?(down, name) } unless view
|
|
462
|
+
built[table] = "its definition names the view `#{spelled(view)}`, which is not queried: #{why}" if view
|
|
463
|
+
built[table] = "its definition names `#{spelled(other)}`, which is not read" if other
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# The name an argument of a virtual table's module opens with: one quoted name whole, or its first word.
|
|
468
|
+
LEADING_NAME = /\A\s*(?:#{SQLITE_QUOTED}|[^\s"'`\[]+)/
|
|
469
|
+
|
|
470
|
+
# What a virtual table's definition can name a relation with: its module, and every argument but the columns a
|
|
471
|
+
# full-text table declares (a column named like a view is no view). An argument with `=` is an option, kept whole.
|
|
472
|
+
def relation_words(table, definition)
|
|
473
|
+
mod, arguments = definition.match(/\A\s*USING\s+([^\s(]+)\s*(?:\((.*)\))?\s*\z/mi)&.captures
|
|
474
|
+
return definition unless mod
|
|
475
|
+
|
|
476
|
+
mod_name = mod.delete(%q("'`[]))
|
|
477
|
+
columns = mod_name.match?(/\Afts[345]\z/i) ? @columns[table] : []
|
|
478
|
+
named = arguments.to_s.scan(MODULE_ARGUMENT).reject do |argument|
|
|
479
|
+
!argument.include?("=") && columns.include?(self.class.fold(argument[LEADING_NAME].to_s.delete(%q("'`[])).strip))
|
|
480
|
+
end
|
|
481
|
+
# A vocabulary's last argument is its type (row, col or instance), which SQLite reads as no relation.
|
|
482
|
+
named.pop if mod_name.casecmp?("fts5vocab") && named.size > 1
|
|
483
|
+
[ mod, *named ].join(" ")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def refusal_of(definition)
|
|
487
|
+
if definition.strip.empty?
|
|
488
|
+
"its definition could not be read, and this database holds secret columns" if @in_schema.any?
|
|
489
|
+
elsif (column = named_in(definition))
|
|
490
|
+
"its definition names the secret column `#{column}`"
|
|
491
|
+
elsif (table = @unread_tables.keys.find { |name| names?(definition, name) })
|
|
492
|
+
"its definition names `#{spelled(table)}`, which is not read"
|
|
493
|
+
elsif definition.match?(WILDCARD) && (relation = @by_relation.keys.find { |name| names?(definition, name) })
|
|
494
|
+
"its definition has a wildcard and names `#{spelled(relation)}`, which holds the secret column `#{@by_relation[relation].first}`"
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def names?(down, name)
|
|
499
|
+
down.match?(self.class.identifier(name))
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def heuristic?(down)
|
|
503
|
+
down.end_with?(*HEURISTIC_SUFFIXES) || down.match?(/password|secret|token|nonce/) || down == "iv" || down.end_with?("_iv")
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
end
|
|
@@ -90,11 +90,11 @@ module RailsAiContext
|
|
|
90
90
|
|
|
91
91
|
def database_heading_lines(name, tables, secondary)
|
|
92
92
|
relations = Introspectors::SchemaConventions.relations_phrase(tables)
|
|
93
|
-
return [ "##
|
|
93
|
+
return [ "## #{Payload.primary_label(context)} (#{SchemaAdapter.label(context)}, #{relations})", "" ] unless name
|
|
94
94
|
|
|
95
95
|
db = secondary[name]
|
|
96
96
|
adapter = SchemaAdapter.secondary_label(context, name, db)
|
|
97
|
-
lines = [ "## #{name} (#{[ adapter, relations ].compact.join(', ')})", "" ]
|
|
97
|
+
lines = [ "## #{name} (#{[ Payload.named_scope_words(context, name), adapter, relations ].compact.join(', ')})", "" ]
|
|
98
98
|
# Where the snapshot comes from: a booted run reads the primary live, this from its files.
|
|
99
99
|
lines.push("_#{db[:note]}._", "") if db[:note]
|
|
100
100
|
lines
|
|
@@ -75,16 +75,18 @@ module RailsAiContext
|
|
|
75
75
|
schema = Payload.section(context, :schema)
|
|
76
76
|
secondary = Payload.app_databases(schema)
|
|
77
77
|
|
|
78
|
-
sections = [ database_schema_text(schema[:tables], secondary.any? ?
|
|
79
|
-
secondary.each { |name, db| sections << database_schema_text(db[:tables], name, db[:note]) }
|
|
80
|
-
frameworks = Payload.framework_databases(schema).map
|
|
78
|
+
sections = [ database_schema_text(schema[:tables], secondary.any? ? Payload.primary_label(context) : nil) ]
|
|
79
|
+
secondary.each { |name, db| sections << database_schema_text(db[:tables], name, db[:note], Payload.named_scope_words(context, name)) }
|
|
80
|
+
frameworks = Payload.framework_databases(schema).map do |name, db|
|
|
81
|
+
SectionFacts.framework_database_text("#{name}#{Payload.named_database_mark(context, name)}", db)
|
|
82
|
+
end
|
|
81
83
|
sections << "Framework databases, their tables not listed: #{frameworks.join('; ')}" if frameworks.any?
|
|
82
84
|
sections.join("\n\n")
|
|
83
85
|
end
|
|
84
86
|
|
|
85
|
-
def database_schema_text(tables, database = nil, note = nil)
|
|
87
|
+
def database_schema_text(tables, database = nil, note = nil, scope = nil)
|
|
86
88
|
phrase = Introspectors::SchemaConventions.relations_phrase(tables)
|
|
87
|
-
lines = [ database ? "## Database Schema: #{database} (#{phrase})" : "## Database Schema (#{phrase})" ]
|
|
89
|
+
lines = [ database ? "## Database Schema: #{database} (#{[ scope, phrase ].compact.join(', ')})" : "## Database Schema (#{phrase})" ]
|
|
88
90
|
lines << "_#{note}._" if note
|
|
89
91
|
tables&.each do |name, data|
|
|
90
92
|
lines << "### #{escape_markdown(name)}"
|
|
@@ -490,16 +492,18 @@ module RailsAiContext
|
|
|
490
492
|
|
|
491
493
|
# The other databases' own migrations, which the primary's counts leave out.
|
|
492
494
|
secondary = data[:secondary_databases].is_a?(Hash) ? data[:secondary_databases] : {}
|
|
493
|
-
primary = " (
|
|
495
|
+
primary = " (#{Payload.primary_label(context)})" if secondary.any?
|
|
494
496
|
|
|
495
497
|
lines = [ "## Migrations" ]
|
|
496
498
|
lines << "- Total: #{data[:total]}#{primary}"
|
|
497
499
|
lines << "- Schema version: #{data[:schema_version]}#{primary}" if data[:schema_version]
|
|
498
500
|
secondary.each do |name, db|
|
|
499
|
-
|
|
501
|
+
paths = Array(db[:migrations_paths])
|
|
502
|
+
counts = SectionFacts.migration_counts(db[:total], db[:pending])
|
|
503
|
+
lines << "- #{name}#{Payload.named_database_mark(context, name)}: #{counts}#{" in #{paths.join(', ')}" if paths.any?}"
|
|
500
504
|
end
|
|
501
505
|
|
|
502
|
-
pending = {
|
|
506
|
+
pending = { Payload.primary_label(context) => Payload.pending_migrations(context) }.merge(secondary.transform_values { |db| Array(db[:pending]) })
|
|
503
507
|
pending.each do |name, migrations|
|
|
504
508
|
next if migrations.empty?
|
|
505
509
|
|
|
@@ -508,7 +512,7 @@ module RailsAiContext
|
|
|
508
512
|
end
|
|
509
513
|
|
|
510
514
|
if data[:recent]&.any?
|
|
511
|
-
lines << "### Recent Migrations#{":
|
|
515
|
+
lines << "### Recent Migrations#{": #{Payload.primary_label(context)}" if secondary.any?}"
|
|
512
516
|
data[:recent].each do |m|
|
|
513
517
|
actions = m[:actions]&.any? ? " (#{m[:actions].join(', ')})" : ""
|
|
514
518
|
lines << "- `#{m[:version]}` #{m[:name]}#{actions}"
|
|
@@ -623,7 +627,7 @@ module RailsAiContext
|
|
|
623
627
|
if data[:databases]&.any?
|
|
624
628
|
data[:databases].each do |db|
|
|
625
629
|
replica = db[:replica] ? " (replica)" : ""
|
|
626
|
-
lines << "- `#{db[:name]}` - #{SchemaAdapter.database_label(db) || "unknown"}#{replica}"
|
|
630
|
+
lines << "- `#{db[:name]}` - #{SchemaAdapter.database_label(db) || "unknown"}#{replica}#{Payload.database_mark(db)}"
|
|
627
631
|
end
|
|
628
632
|
end
|
|
629
633
|
|
|
@@ -41,13 +41,23 @@ module RailsAiContext
|
|
|
41
41
|
|
|
42
42
|
parts = [ "#{SchemaAdapter.label(ctx)} - #{CountPhrase.call(schema[:total_tables].to_i, "table")}" ]
|
|
43
43
|
Payload.secondary_databases(schema).each do |name, db|
|
|
44
|
+
label = "#{name}#{Payload.named_database_mark(ctx, name)}"
|
|
45
|
+
next parts << "#{label}: #{empty_database_text(db)}" if db[:tables].empty?
|
|
46
|
+
|
|
44
47
|
tables = CountPhrase.call(db[:total_tables].to_i, "table")
|
|
45
48
|
adapter = SchemaAdapter.secondary_label(ctx, name, db)
|
|
46
|
-
parts << (framework_database_text(
|
|
49
|
+
parts << (framework_database_text(label, db) || "#{label}: #{adapter ? "#{adapter} - #{tables}" : tables}")
|
|
47
50
|
end
|
|
48
51
|
"- Database: #{parts.join('; ')}"
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
# A dump that was read and holds no table says so, in the schema section's words.
|
|
55
|
+
def empty_database_text(db)
|
|
56
|
+
return "tables not read" if db[:unread]
|
|
57
|
+
|
|
58
|
+
db[:dump] ? "#{db[:dump]} #{Introspectors::SchemaIntrospector::NO_TABLES}" : "no tables"
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
# A database a Rails framework keeps to itself, as every file names it:
|
|
52
62
|
# "queue: Solid Queue (13 tables)". Its tables are the framework's, so
|
|
53
63
|
# no file lists them. nil for a database of the app's own.
|
|
@@ -65,7 +75,7 @@ module RailsAiContext
|
|
|
65
75
|
pending = Payload.pending_migrations(ctx) if migrations.key?(:pending)
|
|
66
76
|
parts = [ migration_counts(migrations[:total], pending) ]
|
|
67
77
|
secondary = migrations[:secondary_databases].is_a?(Hash) ? migrations[:secondary_databases] : {}
|
|
68
|
-
secondary.each { |name, db| parts << "#{name}: #{migration_counts(db[:total], db[:pending])}" }
|
|
78
|
+
secondary.each { |name, db| parts << "#{name}#{Payload.named_database_mark(ctx, name)}: #{migration_counts(db[:total], db[:pending])}" }
|
|
69
79
|
"- Migrations: #{parts.join('; ')}"
|
|
70
80
|
end
|
|
71
81
|
|
|
@@ -83,10 +83,13 @@ module RailsAiContext
|
|
|
83
83
|
raw_databases = Payload.section(ctx, :multi_database)&.dig(:databases)
|
|
84
84
|
db_list = raw_databases.is_a?(Hash) ? raw_databases.keys : Array(raw_databases)
|
|
85
85
|
if db_list.size > 1
|
|
86
|
-
db_names = db_list.map { |d| d.is_a?(Hash) ? d[:name] : d }
|
|
86
|
+
db_names = db_list.map { |d| d.is_a?(Hash) ? "#{d[:name]}#{Payload.database_mark(d)}" : d }
|
|
87
87
|
# The names it leaves out are counted, as the mounted apps' are.
|
|
88
88
|
shown = db_names.first(DATABASES_SHOWN)
|
|
89
|
-
|
|
89
|
+
if db_names.size > DATABASES_SHOWN
|
|
90
|
+
elsewhere = db_list.drop(DATABASES_SHOWN).count { |d| Payload.scope_words(d) }
|
|
91
|
+
shown << "...#{db_names.size - DATABASES_SHOWN} more#{", #{elsewhere} of them not configured in this environment" if elsewhere > 0}"
|
|
92
|
+
end
|
|
90
93
|
lines << "- Databases: #{db_list.size} (#{shown.join(', ')})"
|
|
91
94
|
end
|
|
92
95
|
|