exwiw 0.3.3 → 0.3.4

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: 4b10da211ee91b4daa986b71b18fce7ac9a782da81ef92a1146f552368b84d66
4
- data.tar.gz: 3893a581e2dac519021d6c86756333b980b3c587efcc5d13337e0fe98d9aaa42
3
+ metadata.gz: 3b68b4eed496cf67cbac408c7f1c233a881b9778b46f0f5a67d0ff50b9321e03
4
+ data.tar.gz: 521a8cf7d5af0dee4538e407d06d9605d37cb059f5023ee922e11807ff6d1b17
5
5
  SHA512:
6
- metadata.gz: 395b24342f9a752d5cda207a959ba905a9fc57c8b693faa838bf2978b7b1b08654e0c5a9901b8359d9af96afc70b36f5b199cfec04165ef9e6ad9b25df0f3ea6
7
- data.tar.gz: fb5514dd6d9c724e1d136cf54abe31616f8cec9944522dd7385fbbb04ddf82f9c290c2935d9c7a55949c86b9c433df02fc43f63aa3cff6d93e3f995a07b38f76
6
+ metadata.gz: bdd88c3f13879d23a4ecc7c239f22db2c5c5ddbd3bb06b838ae570d48309ba4f2460a6260d9f848da71717dcf43cae0de3b593e3e6e99f43406d700b39e79a37
7
+ data.tar.gz: ac2e73630034663ef1ef299fd09925843c086776d10e5998d30758e079a4066f388bdb76672867acacbb57024a5a990336c46e5d8f21d42548862125d1004748
data/CHANGELOG.md CHANGED
@@ -2,8 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.4] - 2026-05-31
6
+
7
+ ### Changed
8
+
9
+ - **Breaking:** the table/collection-level config attribute `skip` is renamed to `ignore`. There is no alias — config files using `"skip": true` must be updated to `"ignore": true` (`exwiw:schema:generate` / `exwiw:mongoid:schema:generate` now emit `ignore`, e.g. for composite-primary-key tables). The library accessors are renamed accordingly (`TableConfig#skip` → `#ignore`, `MongodbCollectionConfig#skip` → `#ignore`).
10
+
11
+ ### Added
12
+
13
+ - `columns` / `fields` and `belongs_tos` entries now accept optional `comment` (a free-form note) and `ignore: true`. An ignored column/field is excluded from the `SELECT` and generated `INSERT`; an ignored `belongs_to` is removed from dependency ordering and query building. These user-owned values are preserved across `exwiw:schema:generate` / `exwiw:mongoid:schema:generate` regenerations (the hand-edited value wins over the auto-generated config), like `replace_with`. The ignored entries are dropped at runtime right after the config is loaded from file, so the JSON on disk keeps them. Applies to both the SQL `TableConfig` and the MongoDB `MongodbCollectionConfig`.
14
+
15
+ ### Fixed
16
+
17
+ fixed HABTM relationship bug https://github.com/heyinc/exwiw/pull/56
18
+
5
19
  ## [0.3.3] - 2026-05-31
6
20
 
21
+ ### Changed
22
+
23
+ - `export` now empties `--output-dir` before writing, so a run never mixes files from a previous export. When running interactively (stdin is a tty) and the dir already has contents, exwiw asks for confirmation before removing them; in non-interactive contexts (CI, pipes) it proceeds without prompting.
24
+
7
25
  ## [0.3.2] - 2026-05-31
8
26
 
9
27
  ### Changed
data/README.md CHANGED
@@ -97,6 +97,8 @@ exwiw \
97
97
 
98
98
  This command will generate sql files in the `dump` directory.
99
99
 
100
+ The output dir is emptied before each export so it never mixes files from a previous run (defaulting to `dump/` when `--output-dir` is omitted). When run interactively (stdin is a tty) and the dir already contains files, exwiw asks for confirmation before removing them; in non-interactive contexts (CI, pipes) it proceeds without prompting.
101
+
100
102
  - `dump/insert-000-schema.sql` — idempotent `CREATE TABLE IF NOT EXISTS ...` for every table in scope. Apply this first to provision an empty database.
101
103
  - `dump/insert-{idx}-{table_name}.sql`
102
104
  - `dump/delete-{idx}-{table_name}.sql`
@@ -179,7 +181,7 @@ It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the Activ
179
181
 
180
182
  Models in an inheritance hierarchy whose subclasses share the base's collection (Mongoid STI, distinguished by the auto-added `_type` discriminator) collapse into a single config: the generator discovers the subclasses via `descendants` (Mongoid registers only the base class in `Mongoid.models`) and unions every class's `fields` and `belongs_tos` into the collection config, so subclass-only fields and associations are not lost.
181
183
 
182
- Regeneration preserves hand-edited `replace_with`, `filter`, `skip`, and `bulk_insert_chunk_size` values, like the ActiveRecord generator. Indexes are not written to the config — they are introspected from the live database at dump time (see [MongoDB notes](#mongodb-notes)). Polymorphic `belongs_to` is not yet expanded by this task.
184
+ Regeneration preserves hand-edited `replace_with`, `filter`, `ignore`, and `bulk_insert_chunk_size` values, like the ActiveRecord generator. Indexes are not written to the config — they are introspected from the live database at dump time (see [MongoDB notes](#mongodb-notes)). Polymorphic `belongs_to` is not yet expanded by this task.
183
185
 
184
186
  ### Configuration
185
187
 
@@ -254,15 +256,15 @@ A non-zero exit code from the shell hook aborts exwiw.
254
256
 
255
257
  Note: Ruby hooks are evaluated via `instance_eval` inside the exwiw process — only pass paths you trust.
256
258
 
257
- ### Skip a table
259
+ ### Ignore a table
258
260
 
259
- Set `"skip": true` on a table's config JSON to exclude it from data extraction. The table's DDL is still emitted into `insert-000-schema.{sql,js}` so the schema stays consistent, but no `insert-*` / `delete-*` files are generated for it and the table is never queried.
261
+ Set `"ignore": true` on a table's config JSON to exclude it from data extraction. The table's DDL is still emitted into `insert-000-schema.{sql,js}` so the schema stays consistent, but no `insert-*` / `delete-*` files are generated for it and the table is never queried.
260
262
 
261
263
  ```json
262
264
  {
263
265
  "name": "audit_logs",
264
266
  "primary_key": "id",
265
- "skip": true,
267
+ "ignore": true,
266
268
  "belongs_tos": [],
267
269
  "columns": [{ "name": "id" }]
268
270
  }
@@ -270,9 +272,33 @@ Set `"skip": true` on a table's config JSON to exclude it from data extraction.
270
272
 
271
273
  Constraints:
272
274
 
273
- - If another non-skipped table has a `belongs_to` entry pointing at a skipped table, exwiw raises `ArgumentError` on load. Remove the `belongs_to` entry on the referencing table, or unset `skip` on the referenced table.
274
- - Specifying a skipped table as `--target-table` raises `ArgumentError`.
275
- - `skip: true` is preserved by `exwiw:schema:generate` regenerations (the receiver value wins over the auto-generated config).
275
+ - If another non-ignored table has a `belongs_to` entry pointing at an ignored table, exwiw raises `ArgumentError` on load. Remove the `belongs_to` entry on the referencing table, or unset `ignore` on the referenced table.
276
+ - Specifying an ignored table as `--target-table` raises `ArgumentError`.
277
+ - `ignore: true` is preserved by `exwiw:schema:generate` regenerations (the receiver value wins over the auto-generated config).
278
+
279
+ ### Ignore / annotate a column or `belongs_to`
280
+
281
+ Individual `columns` (SQL) / `fields` (MongoDB) and `belongs_tos` entries accept two optional, **user-owned** keys:
282
+
283
+ - `comment` — a free-form note. Purely informational; exwiw never reads it.
284
+ - `ignore: true` — drops that entry from extraction. An ignored column/field is excluded from the `SELECT` and the generated `INSERT` (the column still exists in the target schema, since the DDL comes from the source database — exwiw just does not copy its data). An ignored `belongs_to` is removed from dependency ordering and query building, so the relation is not traversed.
285
+
286
+ ```json
287
+ {
288
+ "name": "users",
289
+ "primary_key": "id",
290
+ "belongs_tos": [
291
+ { "table_name": "companies", "foreign_key": "company_id" },
292
+ { "table_name": "audit_logs", "foreign_key": "log_id", "ignore": true, "comment": "huge table, not needed for this export" }
293
+ ],
294
+ "columns": [
295
+ { "name": "id" },
296
+ { "name": "secret_token", "ignore": true, "comment": "do not copy credentials" }
297
+ ]
298
+ }
299
+ ```
300
+
301
+ The ignored entries are removed only at runtime, right after the config is loaded from file; the JSON on disk keeps them. Both `comment` and `ignore` are **preserved across `exwiw:schema:generate` / `exwiw:mongoid:schema:generate` regenerations** (the hand-edited value wins over the auto-generated config), just like `replace_with`. This applies to the MongoDB `MongodbCollectionConfig` (`fields` / `belongs_tos`) as well.
276
302
 
277
303
  ### Polymorphic `belongs_to`
278
304
 
@@ -348,20 +374,20 @@ Constraints:
348
374
 
349
375
  ### Composite primary keys (unsupported)
350
376
 
351
- exwiw does not yet support tables with a composite primary key. When `exwiw:schema:generate` encounters a model whose `primary_key` is an array, it still emits a config entry so the table is not silently dropped, but marks it `skip: true`, tags it `type: "unsupported_composite_primary_key"`, and records the key columns in a `comment`:
377
+ exwiw does not yet support tables with a composite primary key. When `exwiw:schema:generate` encounters a model whose `primary_key` is an array, it still emits a config entry so the table is not silently dropped, but marks it `ignore: true`, tags it `type: "unsupported_composite_primary_key"`, and records the key columns in a `comment`:
352
378
 
353
379
  ```json
354
380
  {
355
381
  "name": "composite_pk_records",
356
382
  "type": "unsupported_composite_primary_key",
357
- "skip": true,
383
+ "ignore": true,
358
384
  "comment": "exwiw does not support composite primary keys (organization_id, location_id); data extraction is skipped.",
359
385
  "belongs_tos": [],
360
386
  "columns": [{ "name": "organization_id" }, { "name": "location_id" }, { "name": "name" }]
361
387
  }
362
388
  ```
363
389
 
364
- Unlike rails-managed entries, `columns` and `belongs_tos` are retained so the entry is ready to wire up once composite-key support lands. The `type` is purely a marker — `skip: true` is what actually excludes the table from extraction, so removing `skip` (and supplying a workable `primary_key`) lets you opt the table back in manually.
390
+ Unlike rails-managed entries, `columns` and `belongs_tos` are retained so the entry is ready to wire up once composite-key support lands. The `type` is purely a marker — `ignore: true` is what actually excludes the table from extraction, so removing `ignore` (and supplying a workable `primary_key`) lets you opt the table back in manually.
365
391
 
366
392
  ### Bulk insert chunk size
367
393
 
@@ -12,6 +12,12 @@ module Exwiw
12
12
  # non-polymorphic belongs_to.
13
13
  attribute :foreign_type, optional(String), skip_serializing_if_nil: true
14
14
  attribute :type_value, optional(String), skip_serializing_if_nil: true
15
+ # User-owned fields. The schema generators never emit them, but a user can
16
+ # add them by hand and they survive regeneration (see TableConfig#merge /
17
+ # MongodbCollectionConfig#merge). `ignore:true` drops the relation from
18
+ # extraction once the config is loaded (see #reject_ignored_members!).
19
+ attribute :comment, optional(String), skip_serializing_if_nil: true
20
+ attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
15
21
 
16
22
  def self.from_symbol_keys(hash)
17
23
  from(hash.transform_keys(&:to_s))
@@ -21,6 +27,13 @@ module Exwiw
21
27
  !foreign_type.nil?
22
28
  end
23
29
 
30
+ # Structural identity used to match a freshly generated belongs_to against a
31
+ # user-maintained one during merge. `comment`/`ignore` are user-owned and so
32
+ # are intentionally excluded.
33
+ def identity
34
+ [table_name, foreign_key, foreign_type, type_value]
35
+ end
36
+
24
37
  def to_hash
25
38
  super.compact
26
39
  end
data/lib/exwiw/cli.rb CHANGED
@@ -76,6 +76,7 @@ module Exwiw
76
76
 
77
77
  case @subcommand
78
78
  when "export"
79
+ confirm_output_dir_clear!
79
80
  Runner.new(
80
81
  connection_config: connection_config,
81
82
  output_dir: @output_dir,
@@ -270,6 +271,28 @@ module Exwiw
270
271
  end
271
272
  end
272
273
 
274
+ # The export clears @output_dir before writing (see Runner#clean_output_dir!).
275
+ # That is destructive, so when running interactively (stdin is a tty) ask for
276
+ # confirmation first. In non-interactive contexts (CI, pipes) we proceed
277
+ # without prompting. Only prompt when there is actually something to delete.
278
+ private def confirm_output_dir_clear!
279
+ return unless $stdin.tty?
280
+ return unless Dir.exist?(@output_dir)
281
+
282
+ entries = Dir.each_child(@output_dir).to_a
283
+ return if entries.empty?
284
+
285
+ $stderr.puts "All contents of the output dir will be removed before export:"
286
+ $stderr.puts " #{@output_dir} (#{entries.size} entr#{entries.size == 1 ? 'y' : 'ies'})"
287
+ $stderr.print "Continue? [y/N]: "
288
+
289
+ answer = $stdin.gets&.strip&.downcase
290
+ unless answer == "y" || answer == "yes"
291
+ $stderr.puts "Aborted."
292
+ exit 1
293
+ end
294
+ end
295
+
273
296
  private def build_cli_options_hash
274
297
  {
275
298
  database_host: @database_host,
@@ -322,7 +345,7 @@ module Exwiw
322
345
  opts.on("-h", "--host=HOST", "Target database host") { |v| @database_host = v }
323
346
  opts.on("-p", "--port=PORT", "Target database port") { |v| @database_port = v }
324
347
  opts.on("-u", "--user=USERNAME", "Target database user") { |v| @database_user = v }
325
- opts.on("-o", "--output-dir=[DUMP_DIR_PATH]", "Output file path. default is dump/ (export subcommand only)") do |v|
348
+ opts.on("-o", "--output-dir=[DUMP_DIR_PATH]", "Output file path. default is dump/. Its contents are emptied before each export (export subcommand only)") do |v|
326
349
  v = v.end_with?("/") ? v[0..-2] : v
327
350
  @output_dir = File.expand_path(v)
328
351
  end
@@ -19,7 +19,7 @@ module Exwiw
19
19
  def run
20
20
  adapter = Adapter.build(@connection_config, @logger)
21
21
  configs = load_table_config(adapter.class.table_config_class)
22
- validate_skipped(configs)
22
+ validate_ignored(configs)
23
23
 
24
24
  table_by_name = configs.each_with_object({}) { |config, hash| hash[config.name] = config }
25
25
 
@@ -32,8 +32,8 @@ module Exwiw
32
32
  total_size = ordered_table_names.size
33
33
  ordered_table_names.each_with_index do |table_name, idx|
34
34
  table = table_by_name.fetch(table_name)
35
- if table.skip
36
- @logger.debug("Skipping explain for '#{table_name}' (skip:true)")
35
+ if table.ignore
36
+ @logger.debug("Skipping explain for '#{table_name}' (ignore:true)")
37
37
  next
38
38
  end
39
39
 
@@ -55,30 +55,30 @@ module Exwiw
55
55
  private def load_table_config(klass)
56
56
  Dir[File.join(@config_dir, "*.json")].map do |file|
57
57
  json = JSON.parse(File.read(file))
58
- klass.from(json)
58
+ klass.from(json).reject_ignored_members!
59
59
  end
60
60
  end
61
61
 
62
- private def validate_skipped(configs)
63
- skipped_names = configs.select { |c| c.skip }.map(&:name).to_set
64
- return if skipped_names.empty?
62
+ private def validate_ignored(configs)
63
+ ignored_names = configs.select { |c| c.ignore }.map(&:name).to_set
64
+ return if ignored_names.empty?
65
65
 
66
66
  configs.each do |config|
67
- next if config.skip
67
+ next if config.ignore
68
68
  next unless config.respond_to?(:belongs_tos)
69
69
 
70
- dangling = config.belongs_tos.select { |rel| skipped_names.include?(rel.table_name) }
70
+ dangling = config.belongs_tos.select { |rel| ignored_names.include?(rel.table_name) }
71
71
  next if dangling.empty?
72
72
 
73
73
  raise ArgumentError,
74
- "Table '#{config.name}' has belongs_to references to skipped table(s): " \
74
+ "Table '#{config.name}' has belongs_to references to ignored table(s): " \
75
75
  "#{dangling.map(&:table_name).join(', ')}. " \
76
- "Remove the belongs_to entries or unset `skip` on the referenced table."
76
+ "Remove the belongs_to entries or unset `ignore` on the referenced table."
77
77
  end
78
78
 
79
- if @dump_target.table_name && skipped_names.include?(@dump_target.table_name)
79
+ if @dump_target.table_name && ignored_names.include?(@dump_target.table_name)
80
80
  raise ArgumentError,
81
- "--target-table '#{@dump_target.table_name}' is marked skip:true and cannot be used as a dump target."
81
+ "--target-table '#{@dump_target.table_name}' is marked ignore:true and cannot be used as a dump target."
82
82
  end
83
83
  end
84
84
  end
@@ -13,7 +13,7 @@ module Exwiw
13
13
  attribute :belongs_tos, array(BelongsTo)
14
14
  attribute :fields, array(MongodbField)
15
15
  attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
16
- attribute :skip, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
16
+ attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
17
17
 
18
18
  # Marks this config as physically embedded inside another collection's
19
19
  # documents. When set, this config is not processed as a standalone dump
@@ -35,12 +35,21 @@ module Exwiw
35
35
  !embedded_in.nil?
36
36
  end
37
37
 
38
+ # Drop the belongs_tos/fields flagged `ignore:true` so they are excluded from
39
+ # extraction. The config files on disk keep these entries; this is applied to
40
+ # the runtime config right after it is loaded (see Runner#load_table_config).
41
+ def reject_ignored_members!
42
+ self.belongs_tos = belongs_tos.reject(&:ignore)
43
+ self.fields = fields.reject(&:ignore)
44
+ self
45
+ end
46
+
38
47
  # Merge an auto-generated config (`passed`) into this user-maintained one so
39
48
  # that `MongoidSchemaGenerator` regenerations preserve hand-edited values.
40
49
  #
41
50
  # - structural facts come from the freshly generated config: primary_key,
42
51
  # belongs_tos, embedded_in.
43
- # - user customizations are kept from the receiver: filter, skip,
52
+ # - user customizations are kept from the receiver: filter, ignore,
44
53
  # bulk_insert_chunk_size, and each field's `replace_with` masking rule.
45
54
  # - generated fields drive the field list (so added/removed fields track the
46
55
  # model), but a matching receiver field wins to retain its masking.
@@ -51,18 +60,34 @@ module Exwiw
51
60
  merged.name = name
52
61
  merged.primary_key = passed.primary_key
53
62
  merged.filter = filter
54
- merged.belongs_tos = passed.belongs_tos
55
63
  merged.bulk_insert_chunk_size = bulk_insert_chunk_size
56
- merged.skip = skip
64
+ merged.ignore = ignore
57
65
  merged.embedded_in = passed.embedded_in
58
66
 
67
+ # Structural facts of each belongs_to come from the freshly generated
68
+ # config, but the user-owned `comment`/`ignore` carry over when the same
69
+ # relation still exists.
70
+ receiver_belongs_to_by_identity = belongs_tos.each_with_object({}) { |bt, h| h[bt.identity] = bt }
71
+ merged.belongs_tos = passed.belongs_tos.map do |pbt|
72
+ receiver_bt = receiver_belongs_to_by_identity[pbt.identity]
73
+ if receiver_bt
74
+ pbt.comment = receiver_bt.comment if receiver_bt.comment
75
+ pbt.ignore = receiver_bt.ignore unless receiver_bt.ignore.nil?
76
+ end
77
+ pbt
78
+ end
79
+
59
80
  # Take each field from the freshly generated config (so structural facts
60
81
  # like `mongoid_field_name` track the model) but carry over the user's
61
- # hand-edited `replace_with` masking when the field still exists.
82
+ # hand-edited `replace_with`/`comment`/`ignore` when the field still exists.
62
83
  receiver_field_by_name = fields.each_with_object({}) { |f, h| h[f.name] = f }
63
84
  merged.fields = passed.fields.map do |pf|
64
85
  receiver = receiver_field_by_name[pf.name]
65
- pf.replace_with = receiver.replace_with if receiver&.replace_with
86
+ if receiver
87
+ pf.replace_with = receiver.replace_with if receiver.replace_with
88
+ pf.comment = receiver.comment if receiver.comment
89
+ pf.ignore = receiver.ignore unless receiver.ignore.nil?
90
+ end
66
91
  pf
67
92
  end
68
93
  end
@@ -11,6 +11,11 @@ module Exwiw
11
11
  # masks/projects by `name` (the storage key) — but surfacing the accessor
12
12
  # keeps an otherwise cryptic short key understandable in the config.
13
13
  attribute :mongoid_field_name, optional(String), skip_serializing_if_nil: true
14
+ # User-owned fields preserved across schema regeneration (see
15
+ # MongodbCollectionConfig#merge). `ignore:true` drops the field from extraction
16
+ # once the config is loaded (see MongodbCollectionConfig#reject_ignored_members!).
17
+ attribute :comment, optional(String), skip_serializing_if_nil: true
18
+ attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
14
19
 
15
20
  def self.from_symbol_keys(hash)
16
21
  from(hash.transform_keys(&:to_s))
data/lib/exwiw/runner.rb CHANGED
@@ -30,7 +30,7 @@ module Exwiw
30
30
  adapter = Adapter.build(@connection_config, @logger)
31
31
  configs = load_table_config(adapter.class.table_config_class)
32
32
 
33
- validate_skipped(configs)
33
+ validate_ignored(configs)
34
34
  validate_rails_managed_target!(configs)
35
35
 
36
36
  table_by_name = configs.each_with_object({}) { |config, hash| hash[config.name] = config }
@@ -41,9 +41,7 @@ module Exwiw
41
41
  @logger.info("Determining table processing order...")
42
42
  ordered_table_names = DetermineTableProcessingOrder.run(configs.select { |c| adapter.dumpable?(c) })
43
43
 
44
- if !Dir.exist?(@output_dir)
45
- FileUtils.mkdir_p(@output_dir)
46
- end
44
+ clean_output_dir!
47
45
 
48
46
  ordered_tables = ordered_table_names.map { |n| table_by_name.fetch(n) }
49
47
  schema_path = File.join(@output_dir, "insert-000-schema.#{adapter.schema_output_extension}")
@@ -54,8 +52,8 @@ module Exwiw
54
52
  ordered_table_names.each_with_index do |table_name, idx|
55
53
  table = table_by_name.fetch(table_name)
56
54
 
57
- if table.skip
58
- @logger.info("Skipping data extraction for '#{table_name}' (skip:true)")
55
+ if table.ignore
56
+ @logger.info("Skipping data extraction for '#{table_name}' (ignore:true)")
59
57
  next
60
58
  end
61
59
 
@@ -123,36 +121,57 @@ module Exwiw
123
121
  end
124
122
  end
125
123
 
124
+ # Empty the output dir before writing so each export starts from a clean
125
+ # slate and never mixes files from a previous run. Remove the contents
126
+ # (including dotfiles) rather than the dir itself, preserving the dir's own
127
+ # permissions/inode. The CLI is responsible for confirming this with the
128
+ # user when running interactively.
129
+ private def clean_output_dir!
130
+ if Dir.exist?(@output_dir)
131
+ entries = Dir.each_child(@output_dir).to_a
132
+ unless entries.empty?
133
+ @logger.info("Cleaning output dir #{@output_dir} (#{entries.size} entr#{entries.size == 1 ? 'y' : 'ies'})...")
134
+ entries.each { |entry| FileUtils.rm_rf(File.join(@output_dir, entry)) }
135
+ end
136
+ else
137
+ FileUtils.mkdir_p(@output_dir)
138
+ end
139
+ end
140
+
126
141
  private def load_table_config(klass)
127
142
  Dir[File.join(@config_dir, "*.json")].map do |file|
128
143
  json = JSON.parse(File.read(file))
129
- klass.from(json)
144
+ # Drop belongs_tos/columns(fields) flagged ignore:true so they are not
145
+ # considered during extraction. Done here (after loading from file)
146
+ # rather than in `.from` so the schema generators keep the full config
147
+ # and can preserve the ignored entries on regeneration.
148
+ klass.from(json).reject_ignored_members!
130
149
  end
131
150
  end
132
151
 
133
- private def validate_skipped(configs)
134
- skipped_names = configs.select { |c| c.skip }.map(&:name).to_set
135
- return if skipped_names.empty?
152
+ private def validate_ignored(configs)
153
+ ignored_names = configs.select { |c| c.ignore }.map(&:name).to_set
154
+ return if ignored_names.empty?
136
155
 
137
156
  configs.each do |config|
138
- next if config.skip
157
+ next if config.ignore
139
158
  next unless config.respond_to?(:belongs_tos)
140
159
 
141
- dangling = config.belongs_tos.select { |rel| skipped_names.include?(rel.table_name) }
160
+ dangling = config.belongs_tos.select { |rel| ignored_names.include?(rel.table_name) }
142
161
  next if dangling.empty?
143
162
 
144
163
  raise ArgumentError,
145
- "Table '#{config.name}' has belongs_to references to skipped table(s): " \
164
+ "Table '#{config.name}' has belongs_to references to ignored table(s): " \
146
165
  "#{dangling.map(&:table_name).join(', ')}. " \
147
- "Remove the belongs_to entries or unset `skip` on the referenced table."
166
+ "Remove the belongs_to entries or unset `ignore` on the referenced table."
148
167
  end
149
168
 
150
- if @dump_target.table_name && skipped_names.include?(@dump_target.table_name)
169
+ if @dump_target.table_name && ignored_names.include?(@dump_target.table_name)
151
170
  raise ArgumentError,
152
- "--target-table '#{@dump_target.table_name}' is marked skip:true and cannot be used as a dump target."
171
+ "--target-table '#{@dump_target.table_name}' is marked ignore:true and cannot be used as a dump target."
153
172
  end
154
173
 
155
- skipped_names.each { |n| @logger.info("Table '#{n}' is marked skip:true (schema will be included, data extraction skipped)") }
174
+ ignored_names.each { |n| @logger.info("Table '#{n}' is marked ignore:true (schema will be included, data extraction skipped)") }
156
175
  end
157
176
 
158
177
  private def validate_rails_managed_target!(configs)
@@ -80,15 +80,15 @@ module Exwiw
80
80
 
81
81
  # Tables with a composite primary key (`representative.primary_key` is an
82
82
  # Array) are not supported yet. Emit them with `primary_key` omitted,
83
- # `skip: true`, and a `type` that marks them as unsupported — the `type`
83
+ # `ignore: true`, and a `type` that marks them as unsupported — the `type`
84
84
  # acts as a signpost for adding support later. The config file itself is
85
- # still generated so a user can manually remove `skip` and wire it up when
86
- # needed.
85
+ # still generated so a user can manually remove `ignore` and wire it up
86
+ # when needed.
87
87
  if primary_key.is_a?(Array)
88
88
  TableConfig.from_symbol_keys(
89
89
  name: table_name,
90
90
  type: TableConfig::UNSUPPORTED_COMPOSITE_PRIMARY_KEY,
91
- skip: true,
91
+ ignore: true,
92
92
  comment: "exwiw does not support composite primary keys " \
93
93
  "(#{primary_key.join(', ')}); data extraction is skipped.",
94
94
  belongs_tos: aggregate_belongs_tos(model_group),
@@ -147,7 +147,7 @@ module Exwiw
147
147
  end
148
148
 
149
149
  private def aggregate_belongs_tos(models)
150
- belongs_to_assocs = models.flat_map { |m| m.reflect_on_all_associations(:belongs_to) }
150
+ belongs_to_assocs = models.flat_map { |m| belongs_to_associations_for(m) }
151
151
 
152
152
  non_polymorphic = belongs_to_assocs
153
153
  .reject(&:polymorphic?)
@@ -174,6 +174,27 @@ module Exwiw
174
174
  (non_polymorphic + polymorphic).uniq
175
175
  end
176
176
 
177
+ # `belongs_to` reflections for a model, with the synthetic HABTM left-side
178
+ # association removed.
179
+ #
180
+ # Rails backs every `has_and_belongs_to_many` with an anonymous join model
181
+ # (`HABTM_*`, a concrete `ActiveRecord::Base` descendant whose table is the
182
+ # join table). That join model declares two belongs_tos: the "right side"
183
+ # (named after the association, e.g. `belongs_to :tags` -> `tag_id`), which
184
+ # is correct, and a synthetic `belongs_to :left_side`. The left-side
185
+ # association is built with `anonymous_class:` and no `foreign_key:`, so AR
186
+ # derives its foreign key from the reflection name -> `left_side_id`, a
187
+ # column that does not exist in the join table. Dropping it leaves the join
188
+ # table with only its genuine foreign keys; the right-side reflections of
189
+ # the two HABTM_* models together still supply both (`post_id` + `tag_id`).
190
+ private def belongs_to_associations_for(model)
191
+ assocs = model.reflect_on_all_associations(:belongs_to)
192
+ return assocs unless model.respond_to?(:left_reflection)
193
+
194
+ left = model.left_reflection
195
+ assocs.reject { |assoc| assoc.equal?(left) }
196
+ end
197
+
177
198
  # Enumerate the concrete models that can be targets of the polymorphic
178
199
  # association `association_name`, by looking them up from every model's
179
200
  # `has_many` / `has_one` `as:` option. The order of `concrete_models` depends
@@ -7,6 +7,11 @@ module Exwiw
7
7
  attribute :name, String
8
8
  attribute :replace_with, optional(String), skip_serializing_if_nil: true
9
9
  attribute :raw_sql, optional(String), skip_serializing_if_nil: true
10
+ # User-owned fields preserved across schema regeneration (see
11
+ # TableConfig#merge). `ignore:true` drops the column from extraction (SELECT /
12
+ # INSERT) once the config is loaded (see TableConfig#reject_ignored_members!).
13
+ attribute :comment, optional(String), skip_serializing_if_nil: true
14
+ attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
10
15
 
11
16
  def self.from_symbol_keys(hash)
12
17
  from(hash.transform_keys(&:to_s))
@@ -12,7 +12,7 @@ module Exwiw
12
12
  ].freeze
13
13
 
14
14
  # type marking a table with a composite primary key, which exwiw does not
15
- # support yet. schema:generate attaches it together with skip:true. Unlike
15
+ # support yet. schema:generate attaches it together with ignore:true. Unlike
16
16
  # rails-managed tables, columns/belongs_tos are retained so it can serve as a
17
17
  # signpost for adding support later.
18
18
  UNSUPPORTED_COMPOSITE_PRIMARY_KEY = "unsupported_composite_primary_key"
@@ -25,7 +25,7 @@ module Exwiw
25
25
  attribute :belongs_tos, array(BelongsTo), default: []
26
26
  attribute :columns, array(TableColumn), default: []
27
27
  attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
28
- attribute :skip, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
28
+ attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
29
29
 
30
30
  def self.from(hash)
31
31
  config = super
@@ -54,6 +54,16 @@ module Exwiw
54
54
  columns.map(&:name)
55
55
  end
56
56
 
57
+ # Drop the belongs_tos/columns flagged `ignore:true` so they are excluded
58
+ # from extraction (dependency ordering, SELECT projection, INSERT). The
59
+ # config files on disk keep these entries; this is applied to the runtime
60
+ # config right after it is loaded from a file (see Runner#load_table_config).
61
+ def reject_ignored_members!
62
+ self.belongs_tos = belongs_tos.reject(&:ignore)
63
+ self.columns = columns.reject(&:ignore)
64
+ self
65
+ end
66
+
57
67
  def belongs_to(table_name)
58
68
  belongs_tos.find { |relation| relation.table_name == table_name }
59
69
  end
@@ -111,9 +121,22 @@ module Exwiw
111
121
  merged_table.type = passed_table.type
112
122
  merged_table.comment = comment
113
123
  merged_table.filter = filter
114
- merged_table.belongs_tos = passed_table.belongs_tos
115
124
  merged_table.bulk_insert_chunk_size = passed_table.bulk_insert_chunk_size
116
- merged_table.skip = skip
125
+ merged_table.ignore = ignore
126
+
127
+ # Structural facts of each belongs_to come from the freshly generated
128
+ # config, but the user-owned `comment`/`ignore` carry over when the same
129
+ # relation still exists.
130
+ receiver_belongs_to_by_identity = belongs_tos.each_with_object({}) { |bt, hash| hash[bt.identity] = bt }
131
+ merged_table.belongs_tos =
132
+ passed_table.belongs_tos.map do |passed_belongs_to|
133
+ receiver_belongs_to = receiver_belongs_to_by_identity[passed_belongs_to.identity]
134
+ if receiver_belongs_to
135
+ passed_belongs_to.comment = receiver_belongs_to.comment if receiver_belongs_to.comment
136
+ passed_belongs_to.ignore = receiver_belongs_to.ignore unless receiver_belongs_to.ignore.nil?
137
+ end
138
+ passed_belongs_to
139
+ end
117
140
 
118
141
  receiver_column_by_name = columns.each_with_object({}) { |column, hash| hash[column.name] = column }
119
142
 
@@ -144,9 +167,9 @@ module Exwiw
144
167
  "Table '#{name}' has type=#{type}; columns must not be defined."
145
168
  end
146
169
  else
147
- # A skip:true table is not extracted, so primary_key is not required
170
+ # An ignore:true table is not extracted, so primary_key is not required
148
171
  # (e.g. a composite-primary-key table that exwiw does not support).
149
- if primary_key.nil? && !skip
172
+ if primary_key.nil? && !ignore
150
173
  raise ArgumentError, "Table '#{name}' requires primary_key."
151
174
  end
152
175
  end
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.3"
4
+ VERSION = "0.3.4"
5
5
  end
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.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia