exwiw 0.3.7 → 0.3.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91f070d56a04f45680e1c858109fd6828d69220373ad725d07c248d4c8ed2f10
4
- data.tar.gz: a2dc34318e0c0a15ec8af84d75330b92ce5278c14a5b88d6e824e26bc5b1f6a7
3
+ metadata.gz: ea6a0b4fda446d66766027139136320937a59319bfe28d83ee595cf062a4023a
4
+ data.tar.gz: 1ce3d4a7601ae6b3f1fe6ca8ed22e977389ac7ab46973c33113206911893326e
5
5
  SHA512:
6
- metadata.gz: 5700d2fd7287c365ef6c9759fd7f5e85188db6e568b32f8c29a4a0f9f3479d967014a1f77bbf64e1d0e20fdfd75004d05e86e5152d781d6fcae8a5cbf82bca1b
7
- data.tar.gz: '09d1f99b70a0f7d0a8e12fbb0f69de776b9932db9fdc386b71fb3822ee118eda786be79737e3a2e85c340d8c6ce15e5bca472e61d4f4f6517f0bc34d133db9d1'
6
+ metadata.gz: '08ffcb1713110629652b4047b159613ab3d13e5fffcd092433282f28564328d1609572d8f159b1c0e7fddfab1be4fc92651b8f819ddfa44a913e18da04e4aeef'
7
+ data.tar.gz: 77529a1897ded0943898f60d8a55d54bfdf35f80fc512b57be7300adad9fc8c99767199de8e6d9fb9649dc2644fbcfc2252e445de2f5c38bc20aece5ae4a56d5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.9] - 2026-06-03
6
+
7
+ ### Added
8
+
9
+ - New `exwiw:schema:tidy` rake task. It reconciles the existing schema config against the live database (read through the database connection, not the models) and removes only what no longer exists there: a config file whose table has been dropped is deleted, and columns recorded in a surviving table's config that the table no longer has are dropped from that file. Because it reads the database directly, a table that still exists in the database but has lost (or never had) a model is kept — only a genuinely-dropped table is removed. Unlike `schema:generate` it never adds or regenerates entries, so hand-edited `comment` / `ignore` / `replace_with` on surviving tables/columns are preserved. It honors `OUTPUT_DIR_PATH` and the per-database subdirectory layout, and prints the tables/columns it removed.
10
+
11
+ ## [0.3.8] - 2026-06-02
12
+
13
+ ### Added
14
+
15
+ - ActiveStorage support is now complete. `schema:generate` follows `has_one_attached` / `has_many_attached` macros so `active_storage_attachments` is extracted correctly via its polymorphic `belongs_to`. `active_storage_blobs` is no longer dumped wholesale: a reverse "referenced_by" extraction (a `SELECT` subquery) narrows it to only the blobs referenced by the extracted attachments. `active_storage_variant_records` (derivative, regenerable variant-tracking rows) is now emitted with `ignore: true` instead of being dumped in full — it has no path to a dump target and its `blob_id` could otherwise reference blobs outside the narrowed set, causing a foreign-key violation on import. It is also dropped from the attachments `record` polymorphic expansion so the non-ignored attachments table carries no dangling belongs_to to it. ([#69](https://github.com/heyinc/exwiw/pull/69))
16
+
5
17
  ## [0.3.7] - 2026-06-01
6
18
 
7
19
  - bug fix https://github.com/heyinc/exwiw/pull/67
data/README.md CHANGED
@@ -144,6 +144,23 @@ By default, the schema files will be saved in the `exwiw` directory. You can spe
144
144
  OUTPUT_DIR_PATH=custom_directory bundle exec rake exwiw:schema:generate
145
145
  ```
146
146
 
147
+ #### Tidying stale config (`schema:tidy`)
148
+
149
+ `schema:generate` adds and updates config files for the tables it finds, but it never deletes the config file of a table that has been dropped from the application. To reconcile the existing config against the current schema, run:
150
+
151
+ ```bash
152
+ bundle exec rake exwiw:schema:tidy
153
+ ```
154
+
155
+ `schema:tidy` compares the config files already on disk with the **live database** (read through the database connection, not the models) and removes only what no longer exists there:
156
+
157
+ - a config file whose table has been dropped from the database is **deleted**, and
158
+ - columns recorded in a surviving table's config that the table no longer has are **dropped** from that file.
159
+
160
+ Because it reads the database directly, a table that still exists in the database but has lost (or never had) an ActiveRecord model is **kept** — only a table that is genuinely gone is removed. (This is the deliberate counterpart to `generate`, which is model-driven and only ever adds what the models know about.)
161
+
162
+ It respects `OUTPUT_DIR_PATH` and the per-database subdirectory layout in the same way as `schema:generate`. Unlike `generate`, `tidy` never adds or regenerates entries — every surviving table/column (including hand-edited `comment` / `ignore` / `replace_with`) is left untouched, so it is safe to run on a customized config. The task prints which tables and columns it removed (or that the config was already tidy). Stale `belongs_tos` are not pruned by `tidy`; rerun `schema:generate` to refresh those.
163
+
147
164
  #### Multiple databases
148
165
 
149
166
  If the application uses Rails' multiple-database support (`connects_to`), `schema:generate` buckets models by the database they connect to and writes each database's config files into its own subdirectory of the output directory, named after the database config name (`primary`, `analytics`, ...):
@@ -341,6 +358,25 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
341
358
 
342
359
  The same type filter is applied on the join path — and in the matching `delete-*.sql` bulk-delete subquery — when the polymorphic table is an intermediate hop rather than the directly-dumped table.
343
360
 
361
+ ### ActiveStorage (`has_one_attached` / `has_many_attached`)
362
+
363
+ ActiveStorage is handled automatically — no ActiveStorage-specific configuration is required. The `has_one_attached` / `has_many_attached` macros don't add a column to the owning model; they generate ordinary associations that exwiw already understands:
364
+
365
+ - **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted.
366
+ - **`active_storage_blobs`** has no `belongs_to` of its own (attachments point *at* it), so it has no path to the dump target. exwiw narrows it via **reverse / "referenced_by" extraction**: a parent table referenced by exactly one constrained, non-polymorphic child is constrained to just the referenced ids instead of dumping every row:
367
+
368
+ ```sql
369
+ SELECT active_storage_blobs.* FROM active_storage_blobs
370
+ WHERE active_storage_blobs.id IN (
371
+ SELECT active_storage_attachments.blob_id FROM active_storage_attachments
372
+ WHERE active_storage_attachments.record_id IN (/* owner subquery */)
373
+ AND active_storage_attachments.record_type = '...'
374
+ )
375
+ ```
376
+
377
+ `active_storage_variant_records` also references blobs, but since it has no path of its own to the dump target it doesn't constrain anything and is ignored as a referencer — blobs stays narrowed to the attachment-referenced ids. (A parent referenced by *multiple* constrained children currently falls back to dumping all of its rows.)
378
+ - **`active_storage_variant_records`** holds derivative variant-tracking rows that ActiveStorage regenerates lazily, and it too has no path to the dump target — left alone it would land in the "no relation → dump all" branch and, worse, its `blob_id` could point at blobs outside the narrowed set above (a foreign-key violation on import). `exwiw:schema:generate` therefore emits it with **`ignore: true`** (and drops it from the attachments `record` polymorphic expansion so nothing carries a dangling reference to it), so its data is skipped while the DDL is still written. Remove `ignore` from the generated config if you really need to export it.
379
+
344
380
  ### Rails-managed tables (special `type` values)
345
381
 
346
382
  Some tables are owned by Rails itself rather than the application — they have no ActiveRecord model and Rails reserves the right to evolve their column shape between versions (e.g. `schema_migrations`, `ar_internal_metadata`). exwiw treats them as a distinct category via the `type` field on a table config:
@@ -226,6 +226,10 @@ module Exwiw
226
226
  end
227
227
 
228
228
  private def compile_subquery(subquery)
229
+ # A SelectSubquery wraps a full Select (the referencing table's
230
+ # extraction query, projected to a foreign key); compile it as-is.
231
+ return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
232
+
229
233
  inner_values = subquery.where_values.map { |v| escape_value(v) }
230
234
  "SELECT #{subquery.table_name}.#{subquery.select_column} " \
231
235
  "FROM #{subquery.table_name} " \
@@ -253,6 +253,10 @@ module Exwiw
253
253
  end
254
254
 
255
255
  private def compile_subquery(subquery)
256
+ # A SelectSubquery wraps a full Select (the referencing table's
257
+ # extraction query, projected to a foreign key); compile it as-is.
258
+ return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
259
+
256
260
  inner_values = subquery.where_values.map { |v| escape_value(v) }
257
261
  "SELECT #{subquery.table_name}.#{subquery.select_column} " \
258
262
  "FROM #{subquery.table_name} " \
@@ -198,6 +198,10 @@ module Exwiw
198
198
  end
199
199
 
200
200
  private def compile_subquery(subquery)
201
+ # A SelectSubquery wraps a full Select (the referencing table's
202
+ # extraction query, projected to a foreign key); compile it as-is.
203
+ return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
204
+
201
205
  inner_values = subquery.where_values.map { |v| escape_value(v) }
202
206
  "SELECT #{subquery.table_name}.#{subquery.select_column} " \
203
207
  "FROM #{subquery.table_name} " \
@@ -41,7 +41,7 @@ module Exwiw
41
41
  {
42
42
  column_name: column_name,
43
43
  operator: operator,
44
- value: value.is_a?(Subquery) ? value.to_h : value,
44
+ value: value.is_a?(Subquery) || value.is_a?(SelectSubquery) ? value.to_h : value,
45
45
  }
46
46
  end
47
47
  end
@@ -65,6 +65,24 @@ module Exwiw
65
65
  end
66
66
  end
67
67
 
68
+ # A subquery whose body is a full `Select`, projected down to a single
69
+ # column. Unlike the flat `Subquery` above (one column = one IN-list), this
70
+ # carries the referencing table's complete extraction query — joins,
71
+ # multiple where conditions, polymorphic type filters and all. Used by the
72
+ # reverse / "referenced_by" extraction so a parent table with no belongs_to
73
+ # path to the dump target (e.g. active_storage_blobs) is constrained to only
74
+ # the rows referenced by an extractable child table:
75
+ #
76
+ # <parent>.<pk> IN (SELECT <child>.<fk> FROM <child> WHERE <child filters>)
77
+ #
78
+ # `query` is the child's `Select` already projected to the foreign-key
79
+ # column that points at the parent.
80
+ SelectSubquery = Struct.new(:query, keyword_init: true) do
81
+ def to_h
82
+ { query: query.to_h }
83
+ end
84
+ end
85
+
68
86
  module ColumnValue
69
87
  Base = Struct.new(:name, :value, keyword_init: true)
70
88
  Plain = Class.new(Base)
@@ -103,6 +121,15 @@ module Exwiw
103
121
  @join_clauses << join_clause
104
122
  end
105
123
 
124
+ def to_h
125
+ {
126
+ from: from_table_name,
127
+ columns: select_all ? "*" : columns.map { |c| { name: c.name, value: c.value } },
128
+ joins: join_clauses.map(&:to_h),
129
+ where: where_clauses.map { |w| w.is_a?(String) ? w : w.to_h },
130
+ }
131
+ end
132
+
106
133
  private def map_column_value(columns)
107
134
  columns.map do |c|
108
135
  if c.raw_sql
@@ -2,17 +2,18 @@
2
2
 
3
3
  module Exwiw
4
4
  class QueryAstBuilder
5
- def self.run(table_name, table_by_name, dump_target, logger)
6
- new(table_name, table_by_name, dump_target, logger).run
5
+ def self.run(table_name, table_by_name, dump_target, logger, allow_reverse: true)
6
+ new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse).run
7
7
  end
8
8
 
9
9
  attr_reader :table_name, :table_by_name, :dump_target
10
10
 
11
- def initialize(table_name, table_by_name, dump_target, logger)
11
+ def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true)
12
12
  @table_name = table_name
13
13
  @table_by_name = table_by_name
14
14
  @dump_target = dump_target
15
15
  @logger = logger
16
+ @allow_reverse = allow_reverse
16
17
  end
17
18
 
18
19
  def run
@@ -21,6 +22,19 @@ module Exwiw
21
22
  where_clauses = build_where_clauses(table, dump_target)
22
23
  join_clauses = build_join_clauses(table, table_by_name, dump_target)
23
24
 
25
+ # Reverse / "referenced_by" extraction. A table with no belongs_to path to
26
+ # the dump target produces no where/join clauses and would otherwise dump
27
+ # every row (see the "no relation -> dump all" case). If an extractable
28
+ # child table references it via a foreign key (e.g. active_storage_blobs is
29
+ # referenced by active_storage_attachments.blob_id), constrain it to just
30
+ # the referenced ids instead. Disabled (@allow_reverse=false) while building
31
+ # a child's subquery, so this never recurses.
32
+ if @allow_reverse && table.name != dump_target.table_name &&
33
+ where_clauses.empty? && join_clauses.empty?
34
+ reverse_clause = build_referenced_by_clause(table)
35
+ where_clauses.push(reverse_clause) if reverse_clause
36
+ end
37
+
24
38
  QueryAst::Select.new.tap do |ast|
25
39
  ast.from(table.name)
26
40
  if table.rails_managed?
@@ -100,6 +114,61 @@ module Exwiw
100
114
  join_clauses
101
115
  end
102
116
 
117
+ # Builds a `pk IN (SELECT child.fk FROM <child extraction query>)` clause
118
+ # for a table that is referenced by an extractable child table but has no
119
+ # belongs_to of its own toward the dump target. Returns nil when there is no
120
+ # such (single, unambiguous) referencer, leaving the caller to fall back to
121
+ # the dump-all behavior.
122
+ private def build_referenced_by_clause(table)
123
+ candidates = table_by_name.each_value.filter_map do |other|
124
+ next if other.name == table.name
125
+
126
+ relation = other.belongs_to(table.name)
127
+ # A polymorphic foreign key stores ids of several models in one column,
128
+ # so projecting it would pull in unrelated parents. Skip it here; the
129
+ # non-polymorphic blob_id on active_storage_attachments is what we want.
130
+ next if relation.nil? || relation.polymorphic?
131
+
132
+ # Build the child's own extraction query. allow_reverse:false stops a
133
+ # chain of FK-less tables from recursing back into each other.
134
+ child_query = self.class.run(other.name, table_by_name, dump_target, @logger, allow_reverse: false)
135
+
136
+ # Only an *already constrained* child narrows anything; an unconstrained
137
+ # child would select every fk value (i.e. dump all) and not help.
138
+ next unless child_query.where_clauses.any? || child_query.join_clauses.any?
139
+
140
+ [relation, child_query]
141
+ end
142
+
143
+ # Scope: only the unambiguous single-referencer case. Multiple referencers
144
+ # would need their subqueries OR'd together (not yet supported); falling
145
+ # back to dump-all preserves today's behavior for those.
146
+ if candidates.size != 1
147
+ if candidates.size > 1
148
+ @logger.debug(" #{table.name} has multiple referencing tables; skipping reverse extraction (dump all).")
149
+ end
150
+ return nil
151
+ end
152
+
153
+ relation, child_query = candidates.first
154
+
155
+ # Project the child's extraction query down to just the foreign key that
156
+ # points at `table`. Force a plain column so any masking/raw_sql configured
157
+ # on that column does not corrupt the id comparison.
158
+ fk_column = TableColumn.from_symbol_keys(name: relation.foreign_key)
159
+ projected = QueryAst::Select.new
160
+ projected.from(child_query.from_table_name)
161
+ projected.select([fk_column])
162
+ child_query.join_clauses.each { |j| projected.join(j) }
163
+ child_query.where_clauses.each { |w| projected.where(w) }
164
+
165
+ QueryAst::WhereClause.new(
166
+ column_name: table.primary_key,
167
+ operator: :in_subquery,
168
+ value: QueryAst::SelectSubquery.new(query: projected)
169
+ )
170
+ end
171
+
103
172
  private def build_where_clauses(table, dump_target)
104
173
  clauses = []
105
174
 
@@ -5,6 +5,44 @@ require "json"
5
5
 
6
6
  module Exwiw
7
7
  class SchemaGenerator
8
+ # Summary of what `SchemaGenerator#tidy!` removed, returned so callers can
9
+ # report it. `removed_columns` maps a surviving table's name to the column
10
+ # names that were dropped from its config.
11
+ class TidyResult
12
+ attr_reader :removed_tables, :removed_columns
13
+
14
+ def initialize
15
+ @removed_tables = []
16
+ @removed_columns = {}
17
+ end
18
+
19
+ def add_removed_table(table_name)
20
+ @removed_tables << table_name
21
+ end
22
+
23
+ def add_removed_column(table_name, column_name)
24
+ (@removed_columns[table_name] ||= []) << column_name
25
+ end
26
+
27
+ def empty?
28
+ @removed_tables.empty? && @removed_columns.empty?
29
+ end
30
+ end
31
+
32
+ # ActiveStorage tracks generated image variants in this table. Its rows are
33
+ # derivative and regenerable — ActiveStorage lazily (re)creates a variant the
34
+ # next time it is requested — so there is little value in exporting them. More
35
+ # importantly, the table has no belongs_to path to any dump target, which
36
+ # would land it in QueryAstBuilder's "no relation -> dump all" branch, while
37
+ # its `blob_id` references active_storage_blobs, which the reverse
38
+ # "referenced_by" extraction narrows to only the attachment-referenced blobs.
39
+ # A full variant_records dump can therefore reference blobs that were never
40
+ # exported (a foreign-key violation on import). So the table is emitted with
41
+ # `ignore: true` (data extraction skipped) and excluded as a polymorphic
42
+ # `record` target so the non-ignored attachments table carries no dangling
43
+ # belongs_to to it.
44
+ ACTIVE_STORAGE_VARIANT_RECORDS_TABLE = "active_storage_variant_records"
45
+
8
46
  def self.from_rails_application(output_dir:)
9
47
  Rails.application.eager_load!
10
48
  new(models: ActiveRecord::Base.descendants, output_dir: output_dir)
@@ -21,6 +59,60 @@ module Exwiw
21
59
  groups
22
60
  end
23
61
 
62
+ # Reconcile the config files already on disk against the live database,
63
+ # removing only what no longer exists there:
64
+ #
65
+ # - a config file whose table is no longer present is deleted, and
66
+ # - columns recorded in a surviving table's config that the table no
67
+ # longer has are dropped from that file.
68
+ #
69
+ # The source of truth is the database connection (`data_sources` for table
70
+ # existence — which covers views too — and `columns` for the column list),
71
+ # NOT `build_table_groups`. `build_table_groups` only knows about tables
72
+ # that still have an ActiveRecord model, so reconciling against it would
73
+ # delete the config of a table that is still present in the database but
74
+ # has merely lost (or never had) a model. Reading the connection directly
75
+ # avoids that: only a table that is genuinely gone from the database is
76
+ # removed.
77
+ #
78
+ # Unlike `generate!`, tidy never adds or regenerates entries: every
79
+ # surviving table/column — including its hand-edited `comment` / `ignore` /
80
+ # `replace_with` — is left untouched, and only the stale entries are
81
+ # stripped. (Removing a deleted column is something `generate!` already does
82
+ # incidentally via #merge, but `generate!` can never delete the config file
83
+ # of a removed table, which is the gap this fills.) Returns a TidyResult
84
+ # describing the removals so callers (e.g. the rake task) can report them.
85
+ def tidy!
86
+ result = TidyResult.new
87
+
88
+ model_db_groups.each do |db_name, _group_models, conn|
89
+ dir = config_dir_for(db_name)
90
+ next unless Dir.exist?(dir)
91
+
92
+ existing_data_sources = conn.data_sources.to_set
93
+
94
+ Dir[File.join(dir, "*.json")].sort.each do |path|
95
+ existing = TableConfig.from(JSON.parse(File.read(path)))
96
+
97
+ unless existing_data_sources.include?(existing.name)
98
+ File.delete(path)
99
+ result.add_removed_table(existing.name)
100
+ next
101
+ end
102
+
103
+ valid_column_names = conn.columns(existing.name).map(&:name).to_set
104
+ stale_columns = existing.columns.reject { |column| valid_column_names.include?(column.name) }
105
+ next if stale_columns.empty?
106
+
107
+ existing.columns = existing.columns.select { |column| valid_column_names.include?(column.name) }
108
+ File.write(path, JSON.pretty_generate(existing.to_hash) + "\n")
109
+ stale_columns.each { |column| result.add_removed_column(existing.name, column.name) }
110
+ end
111
+ end
112
+
113
+ result
114
+ end
115
+
24
116
  # Returns a Hash keyed by the database name.
25
117
  #
26
118
  # - Single-database setup: the only key is `nil`, signalling that the table
@@ -30,18 +122,35 @@ module Exwiw
30
122
  # mapping to that database's table configs. They are written into
31
123
  # `output_dir/<db_name>/`.
32
124
  def build_table_groups
125
+ model_db_groups.each_with_object({}) do |(db_name, group_models, conn), result|
126
+ result[db_name] = build_tables_for(group_models, conn)
127
+ end
128
+ end
129
+
130
+ # The per-database grouping that both `build_table_groups` and `tidy!` work
131
+ # from: `[[db_name, models, connection], ...]`.
132
+ #
133
+ # - Single-database setup: one entry keyed by `nil` (configs written flat
134
+ # into `output_dir`). When there are no models at all, fall back to the
135
+ # default connection so callers still have a connection to inspect.
136
+ # - Multi-database setup (Rails `connects_to`): one entry per database,
137
+ # keyed by `connection_db_config.name` ("primary" / "analytics", ...).
138
+ #
139
+ # The db_name <-> connection mapping is necessarily model-derived: which
140
+ # databases the app talks to is only declared on the model side (via
141
+ # `connects_to`). What each consumer reads *through* that connection differs
142
+ # — `build_table_groups` builds configs from the models, while `tidy!`
143
+ # reads the live database's actual tables/columns.
144
+ private def model_db_groups
33
145
  models = concrete_models
34
146
  grouped = models.group_by { |model| database_name_for(model) }
35
147
 
36
148
  if grouped.size <= 1
37
149
  conn = models.empty? ? ActiveRecord::Base.connection : models.first.connection
38
- return { nil => build_tables_for(models, conn) }
150
+ return [[nil, models, conn]]
39
151
  end
40
152
 
41
- grouped.each_with_object({}) do |(db_name, group_models), result|
42
- conn = group_models.first.connection
43
- result[db_name] = build_tables_for(group_models, conn)
44
- end
153
+ grouped.map { |db_name, group_models| [db_name, group_models, group_models.first.connection] }
45
154
  end
46
155
 
47
156
  # Backwards-compatible flat list of all table configs. Only meaningful for
@@ -53,11 +162,18 @@ module Exwiw
53
162
 
54
163
  def write_groups(groups)
55
164
  groups.each do |db_name, tables|
56
- dir = db_name.nil? ? @output_dir : File.join(@output_dir, db_name)
57
- write_files(dir, tables)
165
+ write_files(config_dir_for(db_name), tables)
58
166
  end
59
167
  end
60
168
 
169
+ # The directory a database group's config files live in. A single-database
170
+ # setup (`db_name` is nil) writes flat into `output_dir`; a multi-database
171
+ # setup writes into `output_dir/<db_name>/`. Shared by `write_groups` and
172
+ # `tidy!` so the two operations agree on file locations.
173
+ private def config_dir_for(db_name)
174
+ db_name.nil? ? @output_dir : File.join(@output_dir, db_name)
175
+ end
176
+
61
177
  def write_files(dir, tables)
62
178
  FileUtils.mkdir_p(dir)
63
179
 
@@ -94,6 +210,20 @@ module Exwiw
94
210
  belongs_tos: aggregate_belongs_tos(model_group),
95
211
  columns: representative.column_names.map { |name| { name: name } },
96
212
  )
213
+ elsif table_name == ACTIVE_STORAGE_VARIANT_RECORDS_TABLE
214
+ # See ACTIVE_STORAGE_VARIANT_RECORDS_TABLE. Emitted with ignore:true so
215
+ # the derivative variant rows are not dumped; primary_key/columns are
216
+ # kept so a user can remove `ignore` to opt back in if they really want
217
+ # to export them.
218
+ TableConfig.from_symbol_keys(
219
+ name: table_name,
220
+ primary_key: primary_key,
221
+ ignore: true,
222
+ comment: "ActiveStorage variant tracking records are derivative and " \
223
+ "regenerable; data extraction is skipped. Remove `ignore` to export them.",
224
+ belongs_tos: aggregate_belongs_tos(model_group),
225
+ columns: representative.column_names.map { |name| { name: name } },
226
+ )
97
227
  else
98
228
  TableConfig.from_symbol_keys(
99
229
  name: table_name,
@@ -203,6 +333,12 @@ module Exwiw
203
333
  # belongs_to ordering stable.
204
334
  private def polymorphic_target_models(association_name)
205
335
  concrete_models.select do |model|
336
+ # active_storage_variant_records is ignored (see the constant), so it must
337
+ # not be expanded as a polymorphic target — otherwise the non-ignored
338
+ # attachments table would carry a dangling belongs_to to an ignored table,
339
+ # which is rejected at load time.
340
+ next false if model.table_name == ACTIVE_STORAGE_VARIANT_RECORDS_TABLE
341
+
206
342
  (model.reflect_on_all_associations(:has_many) +
207
343
  model.reflect_on_all_associations(:has_one))
208
344
  .any? { |reflection| reflection.options[:as] == association_name }
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.3.7"
4
+ VERSION = "0.3.9"
5
5
  end
data/lib/tasks/exwiw.rake CHANGED
@@ -11,6 +11,26 @@ namespace :exwiw do
11
11
  ).generate!
12
12
  end
13
13
 
14
+ desc "Remove tables/columns from the schema config that no longer exist in the application"
15
+ task tidy: :environment do
16
+ require "exwiw"
17
+
18
+ result = Exwiw::SchemaGenerator.from_rails_application(
19
+ output_dir: ENV["OUTPUT_DIR_PATH"] || "exwiw",
20
+ ).tidy!
21
+
22
+ if result.empty?
23
+ puts "exwiw: schema config is already tidy; nothing to remove."
24
+ else
25
+ result.removed_tables.each do |name|
26
+ puts "exwiw: removed config for table '#{name}' (no longer exists in the application)."
27
+ end
28
+ result.removed_columns.each do |table_name, columns|
29
+ puts "exwiw: removed column(s) #{columns.join(', ')} from '#{table_name}' (no longer in the table)."
30
+ end
31
+ end
32
+ end
33
+
14
34
  desc "Generate schema from a Mongoid application"
15
35
  task generate_mongoid: :environment do
16
36
  require "exwiw"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.7
4
+ version: 0.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia