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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +79 -1
- data/lib/railbow/config.rb +3 -1
- data/lib/railbow/init.rb +16 -0
- data/lib/railbow/multi_db.rb +87 -0
- data/lib/railbow/params.rb +54 -0
- data/lib/railbow/status/ghosts.rb +144 -0
- data/lib/railbow/status/git_data.rb +327 -0
- data/lib/railbow/status/help.rb +121 -0
- data/lib/railbow/status/printer.rb +324 -0
- data/lib/railbow/status/section.rb +551 -0
- data/lib/railbow/table/column.rb +7 -2
- data/lib/railbow/table/renderer.rb +47 -18
- data/lib/railbow/tasks/migrate_status.rake +30 -756
- data/lib/railbow/version.rb +1 -1
- metadata +7 -1
|
@@ -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
|