exwiw 0.1.5 → 0.1.7

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: 2f9fb9e57ed24338ac642aa9f0aa200980d8b57fd2d73851249467f9ec9e251b
4
- data.tar.gz: 2cb68bd84f67b9a683116d83daacab6a96b65e4802f8b67afb81ef97ebccf3c7
3
+ metadata.gz: 4d94b3d27454accfa118d2ee8196f8df53ad026de2cce65c23d783b80ff9320d
4
+ data.tar.gz: 4292c5dca37b34d9a40892440603df62e77e8de62b68375e2990102da83c08f6
5
5
  SHA512:
6
- metadata.gz: 59bc123c523bf1f97dd97b6c18aa93972926366f121dda9dad9105cf651a783a77b38d7547fad99b7ab1c9509452123bf1c8423203cb0757cf3161d64d296c84
7
- data.tar.gz: 6ea7278185d5f92bea6b43d3fb164bb23ae6557ce5d1f3d27e2a86a5147e665a7228ae848e2340c41fd1c5d43ccbdb81decf71804c4d68283de3c61b17be701c
6
+ metadata.gz: 0cae5f397aff3258f7115625e2828d17579b754982287117384257d9858c5867d063b200954d978e54a95e86c5edf919203084f0913fdfdc2a156fde3f71d1cc
7
+ data.tar.gz: 41705e1dbcb3a9664e4fdeeaacd1da6b49f35131b1d03191adfdcc8c101bd348bc22d96fc4ebc6daf68fa67b4b70ce68a8f29eecdd986021b0ef8e9685191331
data/CHANGELOG.md CHANGED
@@ -2,19 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.7] - 2026-05-14
6
+
7
+ ### Added
8
+
9
+ - Add embedded document support to the MongoDB adapter via `embedded_in: { collection_name, path }`. Embedded configs are not dumped as their own jsonl; their `replace_with` rules apply to subdocuments (Array or Hash, with multi-level nesting) inside the parent collection.
10
+
11
+ ## [0.1.6] - 2026-03-14
12
+
5
13
  ### Added
6
14
 
7
15
  - Add `bulk_insert_chunk_size` table config to split the generated `INSERT` statement into chunks of the specified size. ([#8](https://github.com/riseshia/exwiw/pull/8))
16
+ - Add experimental MongoDB adapter (`--adapter=mongodb`) that exports collections as JSONL (`insert-{idx}-{collection}.jsonl`). Embedded documents and collection-level `filter` are not supported. ([#10](https://github.com/riseshia/exwiw/pull/10))
17
+ - Introduce `MongodbCollectionConfig` for the MongoDB adapter, with MongoDB-native naming (`fields` instead of `columns`). ([#10](https://github.com/riseshia/exwiw/pull/10))
8
18
 
9
19
  ### Changed
10
20
 
21
+ - **Breaking (MongoDB only):** scenario JSON for the MongoDB adapter must use `fields` instead of `columns`. SQL adapters (`mysql2`, `postgresql`, `sqlite3`) are unaffected. ([#10](https://github.com/riseshia/exwiw/pull/10))
11
22
  - Bump minimum required Ruby version to 3.3.0 and drop Ruby 3.2 from the CI matrix (3.2 reached EOL on 2026-03-31).
23
+ - Refactor `Adapter` contract to support non-SQL backends: introduce `Adapter#build_query`, `Adapter#output_extension`, and `Adapter#supports_bulk_delete?` hooks. SQL adapters retain existing behavior. ([#9](https://github.com/riseshia/exwiw/pull/9))
24
+ - Extract `exwiw:schema:generate` logic into `Exwiw::SchemaGenerator` so it can be exercised under RSpec without the Rake harness. ([#11](https://github.com/riseshia/exwiw/pull/11))
12
25
 
13
26
  ### Fixed
14
27
 
15
28
  - Fix MySQL host access for local rspec runs and switch local dev scripts to inject the password via `MYSQL_PWD` env on `docker compose exec` instead of the `-p` CLI flag. ([#5](https://github.com/riseshia/exwiw/pull/5))
16
29
  - Expand `~` in path arguments and validate the existence of `--config-dir`. ([#6](https://github.com/riseshia/exwiw/pull/6))
17
30
  - Fix incorrect left-side table in `JOIN ... ON` clause for join chains with 3+ hops, which caused `no such column` / `column does not exist` errors at execute time. ([#7](https://github.com/riseshia/exwiw/pull/7))
31
+ - Fix hard-coded `id` primary key in `QueryAstBuilder` so non-`id` primary keys are honored when expanding `dump_target.ids` into `WHERE`. ([#9](https://github.com/riseshia/exwiw/pull/9))
32
+ - `exwiw:schema:generate` now aggregates `belongs_to` reflections across STI subclasses sharing one table; previously the first-seen class won and subclass associations could be silently dropped. ([#11](https://github.com/riseshia/exwiw/pull/11))
33
+ - `exwiw:schema:generate` now fails fast with `Exwiw::SchemaGenerator::MultipleDatabasesNotSupportedError` when models span multiple `connects_to` databases instead of silently producing a partial schema bound to a single connection. ([#11](https://github.com/riseshia/exwiw/pull/11))
18
34
 
19
35
  ## [0.1.4] - 2026-04-04
20
36
 
data/README.md CHANGED
@@ -40,6 +40,7 @@ gem install exwiw
40
40
  - mysql2
41
41
  - postgresql
42
42
  - sqlite3
43
+ - mongodb (experimental, see [MongoDB notes](#mongodb-notes))
43
44
 
44
45
  ## Usage
45
46
 
@@ -170,6 +171,59 @@ Notice this is the most powerful option, but you should be careful to use this o
170
171
  Because this transformation occured on exwiw process, so much slower than other options.
171
172
  Most of case, this option is not recommended.
172
173
 
174
+ ### MongoDB notes
175
+
176
+ The MongoDB adapter is experimental. To use it:
177
+
178
+ - Add `gem "mongo"` to your Gemfile in addition to `exwiw` (it is not declared as a runtime dependency of the gem).
179
+ - Set `--adapter=mongodb`. `--user` / `DATABASE_PASSWORD` are optional and only needed when your MongoDB requires authentication.
180
+ - The MongoDB adapter consumes a separate config type, `MongodbCollectionConfig`, with MongoDB-native naming. Use `fields` (instead of the SQL adapters' `columns`), and set `"primary_key": "_id"`. Foreign keys (`shop_id`, `user_id`, ...) stay as ordinary fields.
181
+ - Output is JSON Lines (`insert-{idx}-{collection}.jsonl`) using MongoDB Extended JSON (relaxed mode). Import with `mongoimport`:
182
+ ```bash
183
+ mongoimport --db app_dev --collection users --file dump/insert-002-users.jsonl
184
+ ```
185
+ - Unlike SQL adapters, the MongoDB adapter does not emit `delete-*.jsonl` files (drop the database / collection yourself before importing if needed).
186
+ - `raw_sql` is not supported (the `MongodbField` schema does not declare it; any `raw_sql` keys in scenario JSON are silently dropped on load). Use `replace_with` for masking.
187
+ - The MongoDB adapter does not support the collection-level `filter` field (it raises `NotImplementedError` if set, since the SQL-string filter cannot be applied to MongoDB).
188
+
189
+ #### Embedded documents
190
+
191
+ MongoDB models often store one-to-many relationships as embedded subdocument arrays (e.g. `users` documents with a `posts: [...]` field). To mask fields inside embedded subdocuments, declare a separate config with `embedded_in`:
192
+
193
+ ```jsonc
194
+ // scenario/users.json — top-level collection
195
+ {
196
+ "name": "users",
197
+ "primary_key": "_id",
198
+ "belongs_tos": [{ "table_name": "shops", "foreign_key": "shop_id" }],
199
+ "fields": [
200
+ { "name": "_id" },
201
+ { "name": "name", "replace_with": "masked{_id}" },
202
+ { "name": "shop_id" }
203
+ ]
204
+ }
205
+
206
+ // scenario/posts.json — embedded under users.posts
207
+ {
208
+ "name": "posts",
209
+ "primary_key": "_id",
210
+ "embedded_in": { "collection_name": "users", "path": "posts" },
211
+ "belongs_tos": [],
212
+ "fields": [
213
+ { "name": "_id" },
214
+ { "name": "title", "replace_with": "masked-{_id}" }
215
+ ]
216
+ }
217
+ ```
218
+
219
+ At runtime:
220
+
221
+ - `posts` is **not** dumped as its own jsonl file. Its `replace_with` rules are applied to the subdocuments inside the parent `users` document at the path `posts`.
222
+ - `path` accepts dot-separated paths for nested fields (e.g. `"profile.contacts"`).
223
+ - Both arrays of subdocuments and a single Hash subdocument at `path` are supported. Multiple levels of nesting work via embedded chains.
224
+ - Cross-collection references from inside an embedded subdocument (`belongs_tos` on an embedded config) are not supported and raise `ArgumentError` on load.
225
+ - Specifying an embedded config as `--target-table` raises `NotImplementedError`; pass the top-level collection name instead.
226
+
173
227
  ## How it works
174
228
 
175
229
  - Load the table information from the specified config file.
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ # NOTE: This adapter consumes MongodbCollectionConfig (`fields` instead of
6
+ # `columns`, plus `embedded_in`). Top-level collections are dumped as one
7
+ # jsonl per collection; configs marked `embedded_in` are not dumped on their
8
+ # own — their masking rules apply to subdocuments inside the parent.
9
+ module Exwiw
10
+ module Adapter
11
+ class MongodbAdapter < Base
12
+ def self.table_config_class
13
+ Exwiw::MongodbCollectionConfig
14
+ end
15
+
16
+ def initialize(connection_config, logger)
17
+ super
18
+ @state = {}
19
+ end
20
+
21
+ def dumpable?(config)
22
+ !config.embedded?
23
+ end
24
+
25
+ def validate_as_dump_target!(config)
26
+ return unless config.embedded?
27
+
28
+ raise NotImplementedError,
29
+ "dump_target '#{config.name}' is an embedded MongodbCollectionConfig; " \
30
+ "specify a top-level collection instead."
31
+ end
32
+
33
+ def build_query(config, dump_target, config_by_name)
34
+ if config.embedded?
35
+ raise NotImplementedError,
36
+ "MongodbAdapter#build_query was called with embedded config '#{config.name}'. " \
37
+ "Embedded configs are masked through the parent collection."
38
+ end
39
+
40
+ reject_filter!(config)
41
+ # Stash the embedded-children index for the matching to_bulk_insert call
42
+ # below. The Adapter contract does not pass config_by_name to
43
+ # to_bulk_insert (SQL adapters don't need it), so we rely on the Runner
44
+ # invariant that build_query is always called before to_bulk_insert for
45
+ # the same config.
46
+ @embedded_children_by_parent = index_embedded_children(config_by_name)
47
+
48
+ filter =
49
+ if config.name == dump_target.table_name
50
+ { config.primary_key => { "$in" => coerce_ids(dump_target.ids) } }
51
+ else
52
+ constrained = config.belongs_tos.select do |relation|
53
+ @state.key?(relation.table_name) && !@state[relation.table_name].empty?
54
+ end
55
+
56
+ if constrained.empty?
57
+ {}
58
+ else
59
+ constrained.each_with_object({}) do |relation, acc|
60
+ acc[relation.foreign_key] = { "$in" => @state[relation.table_name] }
61
+ end
62
+ end
63
+ end
64
+
65
+ Exwiw::MongoQuery::Find.new(
66
+ collection: config.name,
67
+ primary_key: config.primary_key,
68
+ filter: filter,
69
+ projection: build_projection(config),
70
+ )
71
+ end
72
+
73
+ def execute(query)
74
+ @logger.debug(" Executing Mongo find on '#{query.collection}': filter=#{query.filter.inspect} projection=#{query.projection.inspect}")
75
+
76
+ docs = db[query.collection].find(query.filter).projection(query.projection).to_a
77
+
78
+ @state[query.collection] = docs.map { |doc| doc[query.primary_key] }
79
+
80
+ docs
81
+ end
82
+
83
+ # NOTE: relies on @embedded_children_by_parent set by a prior build_query
84
+ # call for the same config. This implicit ordering exists because the
85
+ # Adapter contract intentionally does not thread config_by_name through
86
+ # to_bulk_insert (SQL adapters don't need it). Safe in Runner, fragile in
87
+ # tests — call build_query first.
88
+ def to_bulk_insert(rows, config)
89
+ rows.map do |doc|
90
+ apply_replace_with!(doc, config)
91
+ apply_embedded_masking!(doc, config)
92
+ JSON.generate(extended_json(doc))
93
+ end.join("\n")
94
+ end
95
+
96
+ def to_bulk_delete(_query, _config)
97
+ raise NotImplementedError, "MongodbAdapter does not support bulk delete"
98
+ end
99
+
100
+ def output_extension
101
+ 'jsonl'
102
+ end
103
+
104
+ def supports_bulk_delete?
105
+ false
106
+ end
107
+
108
+ # `--ids` from the CLI arrives as Strings. Mongo compares types strictly,
109
+ # so integer-looking ids are coerced to Integer. Other strings (e.g. ObjectId
110
+ # hex) are left as-is.
111
+ private def coerce_ids(ids)
112
+ Array(ids).map do |id|
113
+ if id.is_a?(String) && id.match?(/\A-?\d+\z/)
114
+ id.to_i
115
+ else
116
+ id
117
+ end
118
+ end
119
+ end
120
+
121
+ private def reject_filter!(config)
122
+ return if config.filter.nil? || config.filter.to_s.empty?
123
+
124
+ raise NotImplementedError,
125
+ "collection-level `filter` is not supported by MongodbAdapter (collection: #{config.name})"
126
+ end
127
+
128
+ private def index_embedded_children(config_by_name)
129
+ config_by_name.each_value.with_object({}) do |child, acc|
130
+ next unless child.embedded?
131
+
132
+ (acc[child.embedded_in.collection_name] ||= []) << child
133
+ end
134
+ end
135
+
136
+ private def build_projection(config)
137
+ projection = {}
138
+ # Always include primary key so masking templates referencing it work,
139
+ # even if it is not declared in fields.
140
+ projection[config.primary_key] = 1
141
+ config.fields.each do |field|
142
+ projection[field.name] = 1
143
+ end
144
+ # Pull in paths owned by configs that mark themselves embedded in this
145
+ # collection, so the masker sees the subdocuments.
146
+ embedded_children_of(config).each do |child|
147
+ projection[child.embedded_in.path] = 1
148
+ end
149
+ projection
150
+ end
151
+
152
+ private def apply_replace_with!(doc, config)
153
+ config.fields.each do |field|
154
+ next unless field.replace_with
155
+
156
+ doc[field.name] = field.replace_with.gsub(/\{([^{}]+)\}/) do
157
+ ref = Regexp.last_match(1)
158
+ (doc.key?(ref) ? doc[ref] : nil).to_s
159
+ end
160
+ end
161
+ end
162
+
163
+ private def apply_embedded_masking!(doc, parent_config)
164
+ embedded_children_of(parent_config).each do |child|
165
+ walk(doc, child.embedded_in.path) do |subdoc|
166
+ apply_replace_with!(subdoc, child)
167
+ apply_embedded_masking!(subdoc, child)
168
+ end
169
+ end
170
+ end
171
+
172
+ private def embedded_children_of(parent_config)
173
+ @embedded_children_by_parent.fetch(parent_config.name, [])
174
+ end
175
+
176
+ private def walk(doc, dotted_path)
177
+ segments = dotted_path.split(".")
178
+ *prefix, last = segments
179
+ container = prefix.reduce(doc) { |acc, seg| acc.is_a?(Hash) ? acc[seg] : nil }
180
+ return unless container.is_a?(Hash)
181
+
182
+ value = container[last]
183
+ case value
184
+ when Array then value.each { |sub| yield sub if sub.is_a?(Hash) }
185
+ when Hash then yield value
186
+ end
187
+ end
188
+
189
+ private def extended_json(doc)
190
+ if doc.respond_to?(:as_extended_json)
191
+ doc.as_extended_json(mode: :relaxed)
192
+ else
193
+ doc
194
+ end
195
+ end
196
+
197
+ private def db
198
+ @db ||=
199
+ begin
200
+ require 'mongo'
201
+ address = "#{@connection_config.host}:#{@connection_config.port}"
202
+ options = { database: @connection_config.database_name }
203
+ if @connection_config.user && !@connection_config.user.to_s.empty?
204
+ options[:user] = @connection_config.user
205
+ options[:password] = @connection_config.password
206
+ end
207
+ Mongo::Logger.logger.level = ::Logger::WARN
208
+ Mongo::Client.new([address], **options)
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -3,6 +3,10 @@
3
3
  module Exwiw
4
4
  module Adapter
5
5
  class Mysql2Adapter < Base
6
+ def build_query(table, dump_target, table_by_name)
7
+ Exwiw::QueryAstBuilder.run(table.name, table_by_name, dump_target, @logger)
8
+ end
9
+
6
10
  def execute(query_ast)
7
11
  sql = compile_ast(query_ast)
8
12
 
@@ -3,6 +3,10 @@
3
3
  module Exwiw
4
4
  module Adapter
5
5
  class PostgresqlAdapter < Base
6
+ def build_query(table, dump_target, table_by_name)
7
+ Exwiw::QueryAstBuilder.run(table.name, table_by_name, dump_target, @logger)
8
+ end
9
+
6
10
  def execute(query_ast)
7
11
  sql = compile_ast(query_ast)
8
12
 
@@ -3,6 +3,10 @@
3
3
  module Exwiw
4
4
  module Adapter
5
5
  class Sqlite3Adapter < Base
6
+ def build_query(table, dump_target, table_by_name)
7
+ Exwiw::QueryAstBuilder.run(table.name, table_by_name, dump_target, @logger)
8
+ end
9
+
6
10
  def execute(query_ast)
7
11
  sql = compile_ast(query_ast)
8
12
 
data/lib/exwiw/adapter.rb CHANGED
@@ -9,6 +9,43 @@ module Exwiw
9
9
  @connection_config = connection_config
10
10
  @logger = logger
11
11
  end
12
+
13
+ # The config class that this adapter consumes. Runner uses this to
14
+ # decide which Serdes type to load scenario JSON into. SQL adapters
15
+ # share the SQL-shaped TableConfig; non-SQL adapters override.
16
+ def self.table_config_class
17
+ TableConfig
18
+ end
19
+
20
+ # @params [Exwiw::TableConfig] table
21
+ # @params [Exwiw::DumpTarget] dump_target
22
+ # @params [Hash{String => Exwiw::TableConfig}] table_by_name
23
+ # @return [Object] adapter-specific query object (e.g. Exwiw::QueryAst::Select for SQL)
24
+ def build_query(table, dump_target, table_by_name)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ # File extension used for dump output (e.g. 'sql' for SQL, 'jsonl' for MongoDB).
29
+ def output_extension
30
+ 'sql'
31
+ end
32
+
33
+ # Whether this adapter emits delete-NNN-*.sql files.
34
+ def supports_bulk_delete?
35
+ true
36
+ end
37
+
38
+ # Whether the given config produces its own dump output and needs an
39
+ # independent processing pass. SQL adapters always do; non-SQL adapters
40
+ # may exclude e.g. embedded subdocument configs.
41
+ def dumpable?(_config)
42
+ true
43
+ end
44
+
45
+ # Hook for adapter-specific validation when this config is used as the
46
+ # dump_target. Default: nothing to validate.
47
+ def validate_as_dump_target!(_config)
48
+ end
12
49
  end
13
50
 
14
51
  # @params [Exwiw::QueryAst] query_ast
@@ -36,6 +73,8 @@ module Exwiw
36
73
  Adapter::Mysql2Adapter.new(connection_config, logger)
37
74
  when 'postgresql'
38
75
  Adapter::PostgresqlAdapter.new(connection_config, logger)
76
+ when 'mongodb'
77
+ Adapter::MongodbAdapter.new(connection_config, logger)
39
78
  else
40
79
  raise 'Unsupported adapter'
41
80
  end
data/lib/exwiw/cli.rb CHANGED
@@ -67,25 +67,26 @@ module Exwiw
67
67
 
68
68
  private def validate_options!
69
69
  if @database_adapter != "sqlite3"
70
- {
70
+ required_options = {
71
71
  "Target database host" => @database_host,
72
72
  "Target database port" => @database_port,
73
- "Database user" => @database_user,
74
73
  "Target database name" => @database_name,
75
- }.each do |k, v|
74
+ }
75
+ required_options["Database user"] = @database_user unless @database_adapter == "mongodb"
76
+ required_options.each do |k, v|
76
77
  if v.nil?
77
78
  $stderr.puts "#{k} is required"
78
79
  exit 1
79
80
  end
80
81
  end
81
82
 
82
- if @database_password.nil? || @database_password.empty?
83
+ if @database_adapter != "mongodb" && (@database_password.nil? || @database_password.empty?)
83
84
  $stderr.puts "environment variable 'DATABASE_PASSWORD' is required"
84
85
  exit 1
85
86
  end
86
87
  end
87
88
 
88
- valid_adapters = ["mysql2", "postgresql", "sqlite3"]
89
+ valid_adapters = ["mysql2", "postgresql", "sqlite3", "mongodb"]
89
90
  unless valid_adapters.include?(@database_adapter)
90
91
  $stderr.puts "Invalid adapter. Available options are: #{valid_adapters.join(', ')}"
91
92
  exit 1
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ class EmbeddedIn
5
+ include Serdes
6
+
7
+ attribute :collection_name, String
8
+ attribute :path, String
9
+
10
+ def self.from_symbol_keys(hash)
11
+ from(hash.transform_keys(&:to_s))
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ module MongoQuery
5
+ Find = Struct.new(:collection, :primary_key, :filter, :projection, keyword_init: true) do
6
+ def to_h
7
+ {
8
+ collection: collection,
9
+ primary_key: primary_key,
10
+ filter: filter,
11
+ projection: projection,
12
+ }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ class MongodbCollectionConfig
5
+ include Serdes
6
+
7
+ # MongoDB-native names. Intentionally re-declared instead of inheriting
8
+ # from TableConfig — Serdes does not propagate attribute declarations
9
+ # across class boundaries.
10
+ attribute :name, String
11
+ attribute :primary_key, String
12
+ attribute :filter, optional(String), skip_serializing_if_nil: true
13
+ attribute :belongs_tos, array(BelongsTo)
14
+ attribute :fields, array(MongodbField)
15
+ attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
16
+
17
+ # Marks this config as physically embedded inside another collection's
18
+ # documents. When set, this config is not processed as a standalone dump
19
+ # unit; its masking rules are applied to the parent's subdocuments at
20
+ # `path`.
21
+ attribute :embedded_in, optional(EmbeddedIn), skip_serializing_if_nil: true
22
+
23
+ def self.from(obj)
24
+ instance = super
25
+ instance.__send__(:validate_embedded!)
26
+ instance
27
+ end
28
+
29
+ def self.from_symbol_keys(hash)
30
+ from(JSON.parse(hash.to_json))
31
+ end
32
+
33
+ def embedded?
34
+ !embedded_in.nil?
35
+ end
36
+
37
+ private def validate_embedded!
38
+ return unless embedded?
39
+ return if belongs_tos.empty?
40
+
41
+ raise ArgumentError,
42
+ "MongodbCollectionConfig '#{name}' is embedded_in '#{embedded_in.collection_name}'; " \
43
+ "belongs_tos must be empty (cross-collection refs from inside embedded arrays " \
44
+ "are not supported)."
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ class MongodbField
5
+ include Serdes
6
+
7
+ attribute :name, String
8
+ attribute :replace_with, optional(String), skip_serializing_if_nil: true
9
+
10
+ def self.from_symbol_keys(hash)
11
+ from(hash.transform_keys(&:to_s))
12
+ end
13
+
14
+ def to_hash
15
+ super.compact
16
+ end
17
+ end
18
+ end
@@ -32,7 +32,7 @@ module Exwiw
32
32
  {
33
33
  column_name: column_name,
34
34
  operator: operator,
35
- value: value
35
+ value: value,
36
36
  }
37
37
  end
38
38
  end
@@ -77,7 +77,7 @@ module Exwiw
77
77
 
78
78
  if table.name == dump_target.table_name
79
79
  clauses.push Exwiw::QueryAst::WhereClause.new(
80
- column_name: 'id',
80
+ column_name: table.primary_key,
81
81
  operator: :eq,
82
82
  value: dump_target.ids
83
83
  )
data/lib/exwiw/runner.rb CHANGED
@@ -20,23 +20,26 @@ module Exwiw
20
20
 
21
21
  def run
22
22
  adapter = Adapter.build(@connection_config, @logger)
23
- tables = load_table_config
23
+ configs = load_table_config(adapter.class.table_config_class)
24
+
25
+ table_by_name = configs.each_with_object({}) { |config, hash| hash[config.name] = config }
26
+
27
+ target = table_by_name[@dump_target.table_name]
28
+ adapter.validate_as_dump_target!(target) if target
24
29
 
25
30
  @logger.info("Determining table processing order...")
26
- ordered_table_names = DetermineTableProcessingOrder.run(tables)
31
+ ordered_table_names = DetermineTableProcessingOrder.run(configs.select { |c| adapter.dumpable?(c) })
27
32
 
28
33
  if !Dir.exist?(@output_dir)
29
34
  FileUtils.mkdir_p(@output_dir)
30
35
  end
31
36
 
32
- table_by_name = tables.each_with_object({}) { |table, hash| hash[table.name] = table }
33
-
34
37
  total_size = ordered_table_names.size
35
38
  ordered_table_names.each_with_index do |table_name, idx|
36
39
  @logger.info("Processing table '#{table_name}'... (#{idx + 1}/#{total_size})")
37
40
  table = table_by_name.fetch(table_name)
38
41
 
39
- query_ast = QueryAstBuilder.run(table.name, table_by_name, @dump_target, @logger)
42
+ query_ast = adapter.build_query(table, @dump_target, table_by_name)
40
43
  results = adapter.execute(query_ast)
41
44
  record_num = results.size
42
45
 
@@ -44,36 +47,38 @@ module Exwiw
44
47
  @logger.info(" No records matched. skip this table.")
45
48
  next
46
49
  end
47
- @logger.debug(" Generate INSERT SQL...")
50
+ @logger.debug(" Generate INSERT statement...")
48
51
 
49
52
  chunk_size = table.bulk_insert_chunk_size
50
53
  chunks = chunk_size ? results.each_slice(chunk_size).to_a : [results]
51
54
  insert_sql = chunks.map { |chunk_rows| adapter.to_bulk_insert(chunk_rows, table) }.join("\n")
52
55
 
53
- @logger.info(" Generated INSERT SQL for #{record_num} records (#{chunks.size} statement(s)).")
56
+ @logger.info(" Generated INSERT statement for #{record_num} records (#{chunks.size} statement(s)).")
54
57
  insert_idx = (idx + 1).to_s.rjust(3, '0')
55
- File.open(File.join(@output_dir, "insert-#{insert_idx}-#{table_name}.sql"), 'w') do |file|
58
+ File.open(File.join(@output_dir, "insert-#{insert_idx}-#{table_name}.#{adapter.output_extension}"), 'w') do |file|
56
59
  file.puts(insert_sql)
57
60
  end
58
61
 
59
- @logger.debug(" Generate DELETE SQL...")
60
- delete_sql = adapter.to_bulk_delete(query_ast, table)
61
- if @logger.debug?
62
- @logger.debug(" Generated DELETE SQL:\n#{delete_sql}")
63
- else
64
- @logger.info(" Generated DELETE SQL.")
65
- end
66
- delete_idx = (total_size - idx).to_s.rjust(3, '0')
67
- File.open(File.join(@output_dir, "delete-#{delete_idx}-#{table_name}.sql"), 'w') do |file|
68
- file.puts(delete_sql)
62
+ if adapter.supports_bulk_delete?
63
+ @logger.debug(" Generate DELETE statement...")
64
+ delete_sql = adapter.to_bulk_delete(query_ast, table)
65
+ if @logger.debug?
66
+ @logger.debug(" Generated DELETE statement:\n#{delete_sql}")
67
+ else
68
+ @logger.info(" Generated DELETE statement.")
69
+ end
70
+ delete_idx = (total_size - idx).to_s.rjust(3, '0')
71
+ File.open(File.join(@output_dir, "delete-#{delete_idx}-#{table_name}.#{adapter.output_extension}"), 'w') do |file|
72
+ file.puts(delete_sql)
73
+ end
69
74
  end
70
75
  end
71
76
  end
72
77
 
73
- private def load_table_config
78
+ private def load_table_config(klass)
74
79
  Dir[File.join(@config_dir, "*.json")].map do |file|
75
80
  json = JSON.parse(File.read(file))
76
- TableConfig.from(json)
81
+ klass.from(json)
77
82
  end
78
83
  end
79
84
 
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Exwiw
7
+ class SchemaGenerator
8
+ class MultipleDatabasesNotSupportedError < StandardError; end
9
+
10
+ def self.from_rails_application(output_dir:)
11
+ Rails.application.eager_load!
12
+ new(models: ActiveRecord::Base.descendants, output_dir: output_dir)
13
+ end
14
+
15
+ def initialize(models:, output_dir:)
16
+ @models = models
17
+ @output_dir = output_dir
18
+ end
19
+
20
+ def generate!
21
+ tables = build_tables
22
+ write_files(tables)
23
+ tables
24
+ end
25
+
26
+ def build_tables
27
+ models = concrete_models
28
+ validate_single_database!(models)
29
+
30
+ models.group_by(&:table_name).map do |table_name, model_group|
31
+ representative = model_group.first
32
+ TableConfig.from_symbol_keys(
33
+ name: table_name,
34
+ primary_key: representative.primary_key,
35
+ belongs_tos: aggregate_belongs_tos(model_group),
36
+ columns: representative.column_names.map { |name| { name: name } },
37
+ )
38
+ end
39
+ end
40
+
41
+ def write_files(tables)
42
+ FileUtils.mkdir_p(@output_dir)
43
+
44
+ tables.each do |table|
45
+ path = File.join(@output_dir, "#{table.name}.json")
46
+ config_to_write =
47
+ if File.exist?(path)
48
+ TableConfig.from(JSON.parse(File.read(path))).merge(table)
49
+ else
50
+ table
51
+ end
52
+ File.write(path, JSON.pretty_generate(config_to_write.to_hash) + "\n")
53
+ end
54
+ end
55
+
56
+ private def concrete_models
57
+ @models.reject(&:abstract_class?).select(&:table_exists?)
58
+ end
59
+
60
+ private def aggregate_belongs_tos(models)
61
+ pairs = models
62
+ .flat_map { |m| m.reflect_on_all_associations(:belongs_to) }
63
+ .reject(&:polymorphic?) # XXX: Support polymorphic
64
+ .map { |assoc| [assoc.table_name, assoc.foreign_key] }
65
+ .uniq
66
+
67
+ pairs.map do |table_name, foreign_key|
68
+ { table_name: table_name, foreign_key: foreign_key }
69
+ end
70
+ end
71
+
72
+ # `connection_specification_name` is a quasi-private API but has been stable
73
+ # across Rails 6.1 - 8.x. With Rails multi-DB (`connects_to`), every
74
+ # descendant of the same abstract base shares one spec name regardless of
75
+ # role/shard, so distinct values across concrete models indicate genuinely
76
+ # separate databases.
77
+ private def validate_single_database!(models)
78
+ return if models.empty?
79
+
80
+ specs = models.map(&:connection_specification_name).uniq
81
+ return if specs.size <= 1
82
+
83
+ raise MultipleDatabasesNotSupportedError, <<~MSG
84
+ exwiw does not yet support Rails multiple-database setup.
85
+ Detected connection specifications: #{specs.inspect}
86
+ Track progress at https://github.com/riseshia/exwiw/issues
87
+ MSG
88
+ end
89
+ end
90
+ 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.1.5"
4
+ VERSION = "0.1.7"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -5,17 +5,23 @@ require_relative "exwiw/version"
5
5
  require "json"
6
6
  require "serdes"
7
7
 
8
+ require_relative "exwiw/belongs_to"
9
+ require_relative "exwiw/table_column"
10
+ require_relative "exwiw/table_config"
11
+ require_relative "exwiw/embedded_in"
12
+ require_relative "exwiw/mongodb_field"
13
+ require_relative "exwiw/mongodb_collection_config"
8
14
  require_relative "exwiw/adapter"
9
15
  require_relative "exwiw/adapter/sqlite3_adapter"
10
16
  require_relative "exwiw/adapter/mysql2_adapter"
11
17
  require_relative "exwiw/adapter/postgresql_adapter"
18
+ require_relative "exwiw/adapter/mongodb_adapter"
12
19
  require_relative "exwiw/determine_table_processing_order"
20
+ require_relative "exwiw/mongo_query"
13
21
  require_relative "exwiw/query_ast"
14
22
  require_relative "exwiw/query_ast_builder"
15
23
  require_relative "exwiw/runner"
16
- require_relative "exwiw/belongs_to"
17
- require_relative "exwiw/table_column"
18
- require_relative "exwiw/table_config"
24
+ require_relative "exwiw/schema_generator"
19
25
 
20
26
  begin
21
27
  require 'rails'
data/lib/tasks/exwiw.rake CHANGED
@@ -4,59 +4,11 @@ namespace :exwiw do
4
4
  namespace :schema do
5
5
  desc "Generate schema from application"
6
6
  task generate: :environment do
7
- require "json"
8
7
  require "exwiw"
9
- require "fileutils"
10
8
 
11
- Rails.application.eager_load!
12
-
13
- table_by_name = {}
14
-
15
- ActiveRecord::Base.descendants.each do |model|
16
- next if model.abstract_class?
17
- next unless model.table_exists?
18
- next if table_by_name[model.table_name]
19
-
20
- belongs_tos = model.reflect_on_all_associations(:belongs_to).map do |assoc|
21
- if assoc.polymorphic?
22
- # XXX: Support polymorphic
23
- next
24
- else
25
- Exwiw::BelongsTo.from_symbol_keys({
26
- table_name: assoc.table_name,
27
- foreign_key: assoc.foreign_key,
28
- })
29
- end
30
- end
31
-
32
- columns = model.column_names.map do |name|
33
- Exwiw::TableColumn.from_symbol_keys({ name: name })
34
- end
35
-
36
- table = Exwiw::TableConfig.from_symbol_keys({
37
- name: model.table_name,
38
- primary_key: model.primary_key,
39
- belongs_tos: belongs_tos.compact,
40
- columns: columns,
41
- })
42
- table_by_name[table.name] = table
43
- end
44
-
45
- tables = table_by_name.values
46
-
47
- output_dir = ENV['OUTPUT_DIR_PATH'] || "exwiw"
48
- FileUtils.mkdir_p(output_dir)
49
-
50
- tables.each do |table|
51
- path = File.join(output_dir, "#{table.name}.json")
52
- if File.exist?(path)
53
- current_config = Exwiw::TableConfig.from(JSON.parse(File.read(path)))
54
- merged_config = current_config.merge(table)
55
- File.write(path, JSON.pretty_generate(merged_config.to_hash) + "\n")
56
- else
57
- File.write(path, JSON.pretty_generate(table.to_hash) + "\n")
58
- end
59
- end
9
+ Exwiw::SchemaGenerator.from_rails_application(
10
+ output_dir: ENV["OUTPUT_DIR_PATH"] || "exwiw",
11
+ ).generate!
60
12
  end
61
13
  end
62
14
  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.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -38,16 +38,22 @@ files:
38
38
  - exe/exwiw
39
39
  - lib/exwiw.rb
40
40
  - lib/exwiw/adapter.rb
41
+ - lib/exwiw/adapter/mongodb_adapter.rb
41
42
  - lib/exwiw/adapter/mysql2_adapter.rb
42
43
  - lib/exwiw/adapter/postgresql_adapter.rb
43
44
  - lib/exwiw/adapter/sqlite3_adapter.rb
44
45
  - lib/exwiw/belongs_to.rb
45
46
  - lib/exwiw/cli.rb
46
47
  - lib/exwiw/determine_table_processing_order.rb
48
+ - lib/exwiw/embedded_in.rb
49
+ - lib/exwiw/mongo_query.rb
50
+ - lib/exwiw/mongodb_collection_config.rb
51
+ - lib/exwiw/mongodb_field.rb
47
52
  - lib/exwiw/query_ast.rb
48
53
  - lib/exwiw/query_ast_builder.rb
49
54
  - lib/exwiw/railtie.rb
50
55
  - lib/exwiw/runner.rb
56
+ - lib/exwiw/schema_generator.rb
51
57
  - lib/exwiw/table_column.rb
52
58
  - lib/exwiw/table_config.rb
53
59
  - lib/exwiw/version.rb