railbow 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../calendar"
4
+ require_relative "../color_assigner"
5
+ require_relative "../config"
6
+ require_relative "../formatters/base"
7
+ require_relative "../params"
8
+ require_relative "../table"
9
+
10
+ module Railbow
11
+ module Status
12
+ # Turns collected sections into output. The only place db:migrate:status
13
+ # writes to stdout, which is what lets a multi-database run share column
14
+ # widths and print a single header.
15
+ #
16
+ # A run holding one database renders exactly as it did before multi-database
17
+ # support existed: no overview line, no section rule, nothing new.
18
+ class Printer
19
+ RULE = "─"
20
+ COLLAPSED = "⋯"
21
+ PURPLE = Table::Themes::PURPLE
22
+ RESET = Formatters::Base::RESET
23
+
24
+ DEFAULT_RULE_WIDTH = 60
25
+
26
+ def initialize(sections, skipped: [])
27
+ @sections = Array(sections)
28
+ @skipped = skipped
29
+ @formatter = Formatters::Base.new
30
+ end
31
+
32
+ def print
33
+ return print_solo(sections.first) if solo?
34
+
35
+ print_overview
36
+ Railbow::Params.db_inline? ? print_inline : print_sections
37
+ print_skipped
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :sections, :skipped, :formatter
43
+
44
+ def print_sections
45
+ widths = shared_widths
46
+ sections.each { |section| print_grouped(section, widths) }
47
+ end
48
+
49
+ # Tables open with a blank line of their own. A collapsed line only needs
50
+ # one when it follows a table, so a run of quiet databases reads as one
51
+ # block instead of a ladder of gaps.
52
+ def print_grouped(section, widths)
53
+ if collapse?(section)
54
+ puts if @last_was_table
55
+ puts collapsed_line(section)
56
+ @last_was_table = false
57
+ else
58
+ print_table_section(section, widths)
59
+ @last_was_table = true
60
+ end
61
+ end
62
+
63
+ # One database, one migrations path: the shape every single-database app
64
+ # has, and the one whose output must not move.
65
+ def solo?
66
+ sections.size == 1 && !sections.first.sharded? && skipped.empty?
67
+ end
68
+
69
+ def print_solo(section)
70
+ puts "\n#{formatter.emoji(:status)} Database: #{formatter.cyan(section.database)}"
71
+ puts
72
+
73
+ if section.state == :no_migrations
74
+ puts formatter.yellow(" No migrations found")
75
+ return
76
+ end
77
+
78
+ if (note = hidden_note(section))
79
+ puts note
80
+ puts
81
+ end
82
+
83
+ if section.rows.empty?
84
+ puts formatter.yellow(" No migrations in the selected period")
85
+ return
86
+ end
87
+
88
+ puts render_table(section)
89
+ end
90
+
91
+ # Says what the window hid, and admits when the floor overrode it: the
92
+ # table then holds rows older than the window it names, and a bare
93
+ # "hidden - SINCE=70d" would read as a contradiction next to them.
94
+ def hidden_note(section)
95
+ return nil unless section.hidden_count > 0
96
+
97
+ note = "(#{section.hidden_count} older migrations hidden - SINCE=#{section.since_value}"
98
+ note += ", showing the last #{section.rows.size}" if section.floor_applied?
99
+ formatter.dim(" #{note})")
100
+ end
101
+
102
+ def print_overview
103
+ names = sections.flat_map(&:db_names)
104
+ puts "\n#{formatter.emoji(:status)} #{count_label(names.size, "database")} " \
105
+ "#{formatter.dim("·")} #{formatter.cyan(names.join(", "))}"
106
+ end
107
+
108
+ def print_table_section(section, widths)
109
+ renderer = renderer_for(section, widths)
110
+ puts
111
+ puts section_header(section, renderer.fixed_width(widths))
112
+
113
+ # Reachable with RBW_DB=full, which asks for every section to be drawn
114
+ # in full, including the ones that would otherwise have collapsed.
115
+ if section.state == :no_migrations
116
+ puts formatter.yellow(" No migrations found")
117
+ return
118
+ elsif section.rows.empty?
119
+ puts formatter.yellow(" No migrations in the selected period")
120
+ return
121
+ end
122
+
123
+ puts render_table(section, renderer)
124
+ end
125
+
126
+ # RBW_DB=inline: every database in one time-ordered table, each row
127
+ # carrying a Db badge. The calendar then spans the whole application
128
+ # rather than restarting per database, which is the point of asking for
129
+ # it. Quiet databases still collapse below the table.
130
+ def print_inline
131
+ live = sections.reject { |s| collapse?(s) || s.rows.empty? }
132
+ collapsed = sections - live
133
+
134
+ if live.any?
135
+ entries = inline_entries(live)
136
+ calendar = inline_calendar(entries)
137
+ puts
138
+ puts inline_renderer(live.first).render(
139
+ entries.map { |e| e[:row] },
140
+ separators: calendar.separators,
141
+ highlight_rows: inline_marked(entries, :highlight_rows),
142
+ ghost_rows: inline_marked(entries, :ghost_rows),
143
+ dim_rows: inline_marked(entries, :down_rows),
144
+ tick_rows: calendar.tick_rows,
145
+ tick_col: live.first.tick_col + 1
146
+ )
147
+ end
148
+
149
+ collapsed.each { |section| puts collapsed_line(section) }
150
+ end
151
+
152
+ # Sorted by version, so the databases interleave by when each migration
153
+ # was written. Ties fall back to config order, which keeps two databases
154
+ # that share a timestamp in database.yml order rather than an arbitrary one.
155
+ def inline_entries(live)
156
+ assigner = Railbow::ColorAssigner.new(live.map { |s| inline_label(s) })
157
+
158
+ entries = live.each_with_index.flat_map do |section, order|
159
+ badge = "#{assigner.color_for(inline_label(section))}● #{inline_label(section)}#{RESET}"
160
+ section.rows.each_with_index.map do |row, index|
161
+ {version: row[1].to_s, order: order, section: section, index: index,
162
+ row: [row[0], badge, *row[1..]]}
163
+ end
164
+ end
165
+
166
+ entries.sort_by { |e| [e[:version], e[:order]] }
167
+ end
168
+
169
+ def inline_label(section)
170
+ section.db_names.join("+")
171
+ end
172
+
173
+ def inline_renderer(section)
174
+ columns = section.columns.dup
175
+ columns.insert(1, Table::Column.new(label: "Db", sticky: true))
176
+
177
+ Table::Renderer.new(
178
+ columns: columns,
179
+ theme: Table::Themes::WALLS,
180
+ compact: Railbow::Params.compact_options,
181
+ aliases: Railbow::Config.table_aliases
182
+ )
183
+ end
184
+
185
+ # Row markers are per-section indices; re-key them to the merged order.
186
+ def inline_marked(entries, kind)
187
+ marked = Set.new
188
+ entries.each_with_index do |entry, i|
189
+ marked << i if entry[:section].public_send(kind).include?(entry[:index])
190
+ end
191
+ marked
192
+ end
193
+
194
+ def inline_calendar(entries)
195
+ Railbow::Calendar.build(
196
+ entries.map { |e| e[:version] },
197
+ weeks: Railbow::Params.calendar_wdividers?,
198
+ ticks: Railbow::Params.calendar_wticks?,
199
+ counts: Railbow::Params.calendar_counts?,
200
+ month_label: Railbow::Params.calendar_label,
201
+ week_label: Railbow::Params.calendar_week_label
202
+ )
203
+ end
204
+
205
+ def print_skipped
206
+ return if skipped.empty?
207
+
208
+ puts
209
+ puts formatter.dim("#{COLLAPSED} #{count_label(skipped.size, "database")} hidden: #{skipped.join(", ")}")
210
+ end
211
+
212
+ # A quiet database says everything it has to say in one line. Nothing in
213
+ # the window means there is no table to draw either, so collapsing loses
214
+ # nothing - the counts below carry what the rows would have shown.
215
+ #
216
+ # RBW_DB=focus collapses the busy ones too, keeping the first database as
217
+ # the one you actually read. Pending migrations veto that: a database
218
+ # with work waiting is drawn in full however focused the run is.
219
+ def collapse?(section)
220
+ return false if Railbow::Params.db_full?
221
+ return true if section.quiet?
222
+ # Focus shapes the sectioned view. Applying it to a merged table would
223
+ # drop every database but the first out of the very thing that exists
224
+ # to hold all of them.
225
+ return false if Railbow::Params.db_inline?
226
+ return false unless Railbow::Params.db_focus?
227
+
228
+ !focused?(section) && !section.pending_in_view?
229
+ end
230
+
231
+ # The first database in database.yml, which is conventionally primary and
232
+ # is the one Rails migrates first.
233
+ def focused?(section)
234
+ section.equal?(sections.first)
235
+ end
236
+
237
+ def collapsed_line(section)
238
+ body = (section.state == :no_migrations) ? "no migrations" : section_summary(section)
239
+ formatter.dim("#{COLLAPSED} #{section.db_names.join(" + ")} · #{body}")
240
+ end
241
+
242
+ def section_summary(section)
243
+ parts = [count_label(section.total_count, "migration")]
244
+ parts << ((section.pending_count > 0) ? "#{section.pending_count} pending" : "all applied")
245
+ parts << count_label(section.ghost_count, "ghost") if section.ghost_count > 0
246
+
247
+ summary = parts.join(", ")
248
+ if section.pending_count > 0 || section.ghost_count > 0
249
+ "#{summary} outside the #{section.since_value} window - RBW_SINCE=all"
250
+ elsif (date = section.latest_applied_date)
251
+ "#{summary} · latest #{date.strftime("%b %d %Y")}"
252
+ else
253
+ summary
254
+ end
255
+ end
256
+
257
+ # "──── primary · westside-dev ──── 111 of 1489 ────", ruled out to where
258
+ # the last column starts so it frames the table rather than floating.
259
+ def section_header(section, rule_width)
260
+ label = section.db_names.join(" + ")
261
+ detail = section.sharded? ? shared_path(section) : section.database
262
+ head = "#{RULE * 4} #{label} · #{detail} "
263
+ head += "#{RULE * 4} #{shown_count(section)} of #{section.total_count} " if section.hidden_count > 0
264
+
265
+ width = (rule_width > 0) ? rule_width : DEFAULT_RULE_WIDTH
266
+ filler = [width - formatter.display_width(head), 4].max
267
+ "#{PURPLE}#{head}#{RULE * filler}#{RESET}"
268
+ end
269
+
270
+ # A bare "10 of 50" reads as "10 fell inside the window". When the floor
271
+ # overrode the window the selection is a tail rather than a window, so it
272
+ # says "last 10 of 50" - the same words the single-database note uses.
273
+ def shown_count(section)
274
+ shown = section.total_count - section.hidden_count
275
+ section.floor_applied? ? "last #{shown}" : shown.to_s
276
+ end
277
+
278
+ # Sharded databases have different database names but one migrations
279
+ # directory, so the directory is what identifies the section.
280
+ def shared_path(section)
281
+ paths = section.migrations_key
282
+ return paths.first.to_s if paths.size <= 1
283
+
284
+ paths.map { |p| File.basename(p) }.join(", ")
285
+ end
286
+
287
+ # Every section resolves its own widths, then all of them are raised to
288
+ # the per-column maximum so the tables line up with each other.
289
+ def shared_widths
290
+ per_section = sections.reject { |s| collapse?(s) }.map do |section|
291
+ renderer_for(section, nil).column_widths(section.rows)
292
+ end
293
+ return nil if per_section.empty?
294
+
295
+ size = per_section.map(&:size).max
296
+ Array.new(size) { |i| per_section.filter_map { |w| w[i] }.max }
297
+ end
298
+
299
+ def renderer_for(section, widths)
300
+ Table::Renderer.new(
301
+ columns: section.columns,
302
+ theme: Table::Themes::WALLS,
303
+ compact: Railbow::Params.compact_options,
304
+ aliases: Railbow::Config.table_aliases,
305
+ min_widths: widths
306
+ )
307
+ end
308
+
309
+ def render_table(section, renderer = renderer_for(section, nil))
310
+ renderer.render(section.rows,
311
+ separators: section.calendar.separators,
312
+ highlight_rows: section.highlight_rows,
313
+ ghost_rows: section.ghost_rows,
314
+ dim_rows: section.down_rows,
315
+ tick_rows: section.calendar.tick_rows,
316
+ tick_col: section.tick_col)
317
+ end
318
+
319
+ def count_label(count, noun)
320
+ "#{count} #{noun}#{"s" unless count == 1}"
321
+ end
322
+ end
323
+ end
324
+ end