exwiw 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -0
- data/CHANGELOG.md +22 -0
- data/README.md +23 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +145 -0
- data/lib/exwiw/adapter/mysql2_adapter.rb +5 -1
- data/lib/exwiw/adapter/postgresql_adapter.rb +5 -1
- data/lib/exwiw/adapter/sqlite3_adapter.rb +6 -2
- data/lib/exwiw/adapter.rb +27 -0
- data/lib/exwiw/cli.rb +25 -11
- data/lib/exwiw/mongo_query.rb +16 -0
- data/lib/exwiw/mongodb_collection_config.rb +21 -0
- data/lib/exwiw/mongodb_field.rb +18 -0
- data/lib/exwiw/query_ast.rb +1 -1
- data/lib/exwiw/query_ast_builder.rb +1 -1
- data/lib/exwiw/runner.rb +22 -18
- data/lib/exwiw/schema_generator.rb +90 -0
- data/lib/exwiw/table_config.rb +2 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +8 -3
- data/lib/tasks/exwiw.rake +3 -51
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3fe2fb0c2cff1c962899799f4cfe22981a7b730759d456dc01ab8d19254dbec
|
|
4
|
+
data.tar.gz: 7bf651780e74074b50e9b0345a7cdcb9ec0281cd3d60ff04764cf03c1804b32e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8ee7520812cb8a655cd4f9b8dffa3afdd622311c6b39c5b7942aae5743c3642cb44a7b6b2fdd56ffdf967a8225c220866691a8a001bf75606c70871d4d731ca
|
|
7
|
+
data.tar.gz: 10182f0c69f0d773e1a515f932148c7b8795a51ba64e46141d0630459c815594e470ac62e4d082a695426ec254430dcac6c3cdbaff74b47e34c49f0dfa9a901a
|
data/.rubocop.yml
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- 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))
|
|
8
|
+
- 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))
|
|
9
|
+
- Introduce `MongodbCollectionConfig` for the MongoDB adapter, with MongoDB-native naming (`fields` instead of `columns`). ([#10](https://github.com/riseshia/exwiw/pull/10))
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **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))
|
|
14
|
+
- 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).
|
|
15
|
+
- 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))
|
|
16
|
+
- 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))
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- 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))
|
|
21
|
+
- Expand `~` in path arguments and validate the existence of `--config-dir`. ([#6](https://github.com/riseshia/exwiw/pull/6))
|
|
22
|
+
- 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))
|
|
23
|
+
- 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))
|
|
24
|
+
- `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))
|
|
25
|
+
- `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))
|
|
26
|
+
|
|
5
27
|
## [0.1.4] - 2026-04-04
|
|
6
28
|
|
|
7
29
|
### Fixed
|
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
|
|
|
@@ -98,6 +99,7 @@ This is an example of the one table schema:
|
|
|
98
99
|
"name": "users",
|
|
99
100
|
"primary_key": "id",
|
|
100
101
|
"filter": "users.id > 0",
|
|
102
|
+
"bulk_insert_chunk_size": 1000,
|
|
101
103
|
"belongs_to": [{
|
|
102
104
|
"name": "companies",
|
|
103
105
|
"foreign_key": "company_id"
|
|
@@ -115,6 +117,12 @@ This is an example of the one table schema:
|
|
|
115
117
|
|
|
116
118
|
`--config-dir` will use all json files in the specified directory.
|
|
117
119
|
|
|
120
|
+
### Bulk insert chunk size
|
|
121
|
+
|
|
122
|
+
`bulk_insert_chunk_size` splits the generated `INSERT` statement into multiple statements, each containing at most the specified number of rows. This is useful when the number of records per table is large enough to hit limits like MySQL's `max_allowed_packet`.
|
|
123
|
+
|
|
124
|
+
If omitted, all records for a table are emitted as a single `INSERT` statement.
|
|
125
|
+
|
|
118
126
|
### Filter
|
|
119
127
|
|
|
120
128
|
Some case, you don't need full records related to target. e.g. dump user access logs only for the last year.
|
|
@@ -163,6 +171,21 @@ Notice this is the most powerful option, but you should be careful to use this o
|
|
|
163
171
|
Because this transformation occured on exwiw process, so much slower than other options.
|
|
164
172
|
Most of case, this option is not recommended.
|
|
165
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
|
+
|
|
166
189
|
## How it works
|
|
167
190
|
|
|
168
191
|
- Load the table information from the specified config file.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
# NOTE: This adapter consumes MongodbCollectionConfig (`fields` instead of
|
|
6
|
+
# the SQL adapters' `columns`). It assumes a "flat" document schema where
|
|
7
|
+
# references between collections are expressed as scalar foreign keys
|
|
8
|
+
# (e.g. `shop_id` on `users`); the forward fan-out strategy here cannot
|
|
9
|
+
# follow references that live inside embedded structures.
|
|
10
|
+
module Exwiw
|
|
11
|
+
module Adapter
|
|
12
|
+
class MongodbAdapter < Base
|
|
13
|
+
def self.table_config_class
|
|
14
|
+
Exwiw::MongodbCollectionConfig
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(connection_config, logger)
|
|
18
|
+
super
|
|
19
|
+
@state = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build_query(config, dump_target, _config_by_name)
|
|
23
|
+
reject_filter!(config)
|
|
24
|
+
|
|
25
|
+
filter =
|
|
26
|
+
if config.name == dump_target.table_name
|
|
27
|
+
{ config.primary_key => { "$in" => coerce_ids(dump_target.ids) } }
|
|
28
|
+
else
|
|
29
|
+
constrained = config.belongs_tos.select do |relation|
|
|
30
|
+
@state.key?(relation.table_name) && !@state[relation.table_name].empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if constrained.empty?
|
|
34
|
+
{}
|
|
35
|
+
else
|
|
36
|
+
constrained.each_with_object({}) do |relation, acc|
|
|
37
|
+
acc[relation.foreign_key] = { "$in" => @state[relation.table_name] }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Exwiw::MongoQuery::Find.new(
|
|
43
|
+
collection: config.name,
|
|
44
|
+
primary_key: config.primary_key,
|
|
45
|
+
filter: filter,
|
|
46
|
+
projection: build_projection(config),
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def execute(query)
|
|
51
|
+
@logger.debug(" Executing Mongo find on '#{query.collection}': filter=#{query.filter.inspect} projection=#{query.projection.inspect}")
|
|
52
|
+
|
|
53
|
+
docs = db[query.collection].find(query.filter).projection(query.projection).to_a
|
|
54
|
+
|
|
55
|
+
@state[query.collection] = docs.map { |doc| doc[query.primary_key] }
|
|
56
|
+
|
|
57
|
+
docs
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_bulk_insert(rows, config)
|
|
61
|
+
rows.map do |doc|
|
|
62
|
+
apply_replace_with!(doc, config)
|
|
63
|
+
JSON.generate(extended_json(doc))
|
|
64
|
+
end.join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_bulk_delete(_query, _config)
|
|
68
|
+
raise NotImplementedError, "MongodbAdapter does not support bulk delete"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def output_extension
|
|
72
|
+
'jsonl'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def supports_bulk_delete?
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `--ids` from the CLI arrives as Strings. Mongo compares types strictly,
|
|
80
|
+
# so integer-looking ids are coerced to Integer. Other strings (e.g. ObjectId
|
|
81
|
+
# hex) are left as-is.
|
|
82
|
+
private def coerce_ids(ids)
|
|
83
|
+
Array(ids).map do |id|
|
|
84
|
+
if id.is_a?(String) && id.match?(/\A-?\d+\z/)
|
|
85
|
+
id.to_i
|
|
86
|
+
else
|
|
87
|
+
id
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private def reject_filter!(config)
|
|
93
|
+
return if config.filter.nil? || config.filter.to_s.empty?
|
|
94
|
+
|
|
95
|
+
raise NotImplementedError,
|
|
96
|
+
"collection-level `filter` is not supported by MongodbAdapter (collection: #{config.name})"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private def build_projection(config)
|
|
100
|
+
projection = {}
|
|
101
|
+
# Always include primary key so masking templates referencing it work,
|
|
102
|
+
# even if it is not declared in fields.
|
|
103
|
+
projection[config.primary_key] = 1
|
|
104
|
+
config.fields.each do |field|
|
|
105
|
+
projection[field.name] = 1
|
|
106
|
+
end
|
|
107
|
+
projection
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private def apply_replace_with!(doc, config)
|
|
111
|
+
config.fields.each do |field|
|
|
112
|
+
next unless field.replace_with
|
|
113
|
+
|
|
114
|
+
doc[field.name] = field.replace_with.gsub(/\{([^{}]+)\}/) do
|
|
115
|
+
ref = Regexp.last_match(1)
|
|
116
|
+
(doc.key?(ref) ? doc[ref] : nil).to_s
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private def extended_json(doc)
|
|
122
|
+
if doc.respond_to?(:as_extended_json)
|
|
123
|
+
doc.as_extended_json(mode: :relaxed)
|
|
124
|
+
else
|
|
125
|
+
doc
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private def db
|
|
130
|
+
@db ||=
|
|
131
|
+
begin
|
|
132
|
+
require 'mongo'
|
|
133
|
+
address = "#{@connection_config.host}:#{@connection_config.port}"
|
|
134
|
+
options = { database: @connection_config.database_name }
|
|
135
|
+
if @connection_config.user && !@connection_config.user.to_s.empty?
|
|
136
|
+
options[:user] = @connection_config.user
|
|
137
|
+
options[:password] = @connection_config.password
|
|
138
|
+
end
|
|
139
|
+
Mongo::Logger.logger.level = ::Logger::WARN
|
|
140
|
+
Mongo::Client.new([address], **options)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
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
|
|
|
@@ -79,7 +83,7 @@ module Exwiw
|
|
|
79
83
|
sql += " FROM #{query_ast.from_table_name}"
|
|
80
84
|
|
|
81
85
|
query_ast.join_clauses.each do |join|
|
|
82
|
-
sql += " JOIN #{join.join_table_name} ON #{
|
|
86
|
+
sql += " JOIN #{join.join_table_name} ON #{join.base_table_name}.#{join.foreign_key} = #{join.join_table_name}.#{join.primary_key}"
|
|
83
87
|
|
|
84
88
|
join.where_clauses.each do |where|
|
|
85
89
|
compiled_where_condition = compile_where_condition(where, join.join_table_name)
|
|
@@ -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
|
|
|
@@ -79,7 +83,7 @@ module Exwiw
|
|
|
79
83
|
sql += " FROM #{query_ast.from_table_name}"
|
|
80
84
|
|
|
81
85
|
query_ast.join_clauses.each do |join|
|
|
82
|
-
sql += " JOIN #{join.join_table_name} ON #{
|
|
86
|
+
sql += " JOIN #{join.join_table_name} ON #{join.base_table_name}.#{join.foreign_key} = #{join.join_table_name}.#{join.primary_key}"
|
|
83
87
|
|
|
84
88
|
join.where_clauses.each do |where|
|
|
85
89
|
compiled_where_condition = compile_where_condition(where, join.join_table_name)
|
|
@@ -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
|
|
|
@@ -79,7 +83,7 @@ module Exwiw
|
|
|
79
83
|
sql += " FROM #{query_ast.from_table_name}"
|
|
80
84
|
|
|
81
85
|
query_ast.join_clauses.each do |join|
|
|
82
|
-
sql += " JOIN #{join.join_table_name} ON #{
|
|
86
|
+
sql += " JOIN #{join.join_table_name} ON #{join.base_table_name}.#{join.foreign_key} = #{join.join_table_name}.#{join.primary_key}"
|
|
83
87
|
|
|
84
88
|
join.where_clauses.each do |where|
|
|
85
89
|
compiled_where_condition = compile_where_condition(where, join.join_table_name)
|
|
@@ -157,7 +161,7 @@ module Exwiw
|
|
|
157
161
|
@connection ||=
|
|
158
162
|
begin
|
|
159
163
|
require 'sqlite3'
|
|
160
|
-
SQLite3::Database.new(@connection_config.database_name)
|
|
164
|
+
SQLite3::Database.new(File.expand_path(@connection_config.database_name))
|
|
161
165
|
end
|
|
162
166
|
end
|
|
163
167
|
end
|
data/lib/exwiw/adapter.rb
CHANGED
|
@@ -9,6 +9,31 @@ 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
|
|
12
37
|
end
|
|
13
38
|
|
|
14
39
|
# @params [Exwiw::QueryAst] query_ast
|
|
@@ -36,6 +61,8 @@ module Exwiw
|
|
|
36
61
|
Adapter::Mysql2Adapter.new(connection_config, logger)
|
|
37
62
|
when 'postgresql'
|
|
38
63
|
Adapter::PostgresqlAdapter.new(connection_config, logger)
|
|
64
|
+
when 'mongodb'
|
|
65
|
+
Adapter::MongodbAdapter.new(connection_config, logger)
|
|
39
66
|
else
|
|
40
67
|
raise 'Unsupported adapter'
|
|
41
68
|
end
|
data/lib/exwiw/cli.rb
CHANGED
|
@@ -67,34 +67,46 @@ 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
|
-
}
|
|
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 @
|
|
83
|
-
$stderr.puts "Config dir is required"
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
if @database_password.nil? || @database_password.empty?
|
|
83
|
+
if @database_adapter != "mongodb" && (@database_password.nil? || @database_password.empty?)
|
|
87
84
|
$stderr.puts "environment variable 'DATABASE_PASSWORD' is required"
|
|
88
85
|
exit 1
|
|
89
86
|
end
|
|
90
87
|
end
|
|
91
88
|
|
|
92
|
-
valid_adapters = ["mysql2", "postgresql", "sqlite3"]
|
|
89
|
+
valid_adapters = ["mysql2", "postgresql", "sqlite3", "mongodb"]
|
|
93
90
|
unless valid_adapters.include?(@database_adapter)
|
|
94
91
|
$stderr.puts "Invalid adapter. Available options are: #{valid_adapters.join(', ')}"
|
|
95
92
|
exit 1
|
|
96
93
|
end
|
|
97
94
|
|
|
95
|
+
if @config_dir.nil?
|
|
96
|
+
$stderr.puts "Config dir is required"
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
unless Dir.exist?(@config_dir)
|
|
101
|
+
$stderr.puts "Config dir does not exist: #{@config_dir}"
|
|
102
|
+
exit 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if Dir.glob(File.join(@config_dir, "*.json")).empty?
|
|
106
|
+
$stderr.puts "Config dir contains no .json files: #{@config_dir}"
|
|
107
|
+
exit 1
|
|
108
|
+
end
|
|
109
|
+
|
|
98
110
|
if @target_table_name.nil? || @target_table_name.empty?
|
|
99
111
|
$stderr.puts "Target table is required"
|
|
100
112
|
exit 1
|
|
@@ -130,10 +142,12 @@ module Exwiw
|
|
|
130
142
|
opts.on("-p", "--port=PORT", "Target database port") { |v| @database_port = v }
|
|
131
143
|
opts.on("-u", "--user=USERNAME", "Target database user") { |v| @database_user = v }
|
|
132
144
|
opts.on("-o", "--output-dir=[DUMP_DIR_PATH]", "Output file path. default is dump/") do |v|
|
|
133
|
-
|
|
145
|
+
v = v.end_with?("/") ? v[0..-2] : v
|
|
146
|
+
@output_dir = File.expand_path(v)
|
|
134
147
|
end
|
|
135
148
|
opts.on("-c", "--config-dir=CONFIG_DIR_PATH", "Config dir path.") do |v|
|
|
136
|
-
|
|
149
|
+
v = v.end_with?("/") ? v[0..-2] : v
|
|
150
|
+
@config_dir = File.expand_path(v)
|
|
137
151
|
end
|
|
138
152
|
opts.on("-a", "--adapter=ADAPTER", "Database adapter") { |v| @database_adapter = v }
|
|
139
153
|
opts.on("--database=DATABASE", "Target database name") { |v| @database_name = v }
|
|
@@ -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,21 @@
|
|
|
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
|
+
def self.from_symbol_keys(hash)
|
|
18
|
+
from(JSON.parse(hash.to_json))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
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
|
data/lib/exwiw/query_ast.rb
CHANGED
data/lib/exwiw/runner.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Exwiw
|
|
|
20
20
|
|
|
21
21
|
def run
|
|
22
22
|
adapter = Adapter.build(@connection_config, @logger)
|
|
23
|
-
tables = load_table_config
|
|
23
|
+
tables = load_table_config(adapter.class.table_config_class)
|
|
24
24
|
|
|
25
25
|
@logger.info("Determining table processing order...")
|
|
26
26
|
ordered_table_names = DetermineTableProcessingOrder.run(tables)
|
|
@@ -36,7 +36,7 @@ module Exwiw
|
|
|
36
36
|
@logger.info("Processing table '#{table_name}'... (#{idx + 1}/#{total_size})")
|
|
37
37
|
table = table_by_name.fetch(table_name)
|
|
38
38
|
|
|
39
|
-
query_ast =
|
|
39
|
+
query_ast = adapter.build_query(table, @dump_target, table_by_name)
|
|
40
40
|
results = adapter.execute(query_ast)
|
|
41
41
|
record_num = results.size
|
|
42
42
|
|
|
@@ -44,34 +44,38 @@ module Exwiw
|
|
|
44
44
|
@logger.info(" No records matched. skip this table.")
|
|
45
45
|
next
|
|
46
46
|
end
|
|
47
|
-
@logger.debug(" Generate INSERT
|
|
47
|
+
@logger.debug(" Generate INSERT statement...")
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
chunk_size = table.bulk_insert_chunk_size
|
|
50
|
+
chunks = chunk_size ? results.each_slice(chunk_size).to_a : [results]
|
|
51
|
+
insert_sql = chunks.map { |chunk_rows| adapter.to_bulk_insert(chunk_rows, table) }.join("\n")
|
|
50
52
|
|
|
51
|
-
@logger.info(" Generated INSERT
|
|
53
|
+
@logger.info(" Generated INSERT statement for #{record_num} records (#{chunks.size} statement(s)).")
|
|
52
54
|
insert_idx = (idx + 1).to_s.rjust(3, '0')
|
|
53
|
-
File.open(File.join(@output_dir, "insert-#{insert_idx}-#{table_name}.
|
|
55
|
+
File.open(File.join(@output_dir, "insert-#{insert_idx}-#{table_name}.#{adapter.output_extension}"), 'w') do |file|
|
|
54
56
|
file.puts(insert_sql)
|
|
55
57
|
end
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@logger.debug
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
if adapter.supports_bulk_delete?
|
|
60
|
+
@logger.debug(" Generate DELETE statement...")
|
|
61
|
+
delete_sql = adapter.to_bulk_delete(query_ast, table)
|
|
62
|
+
if @logger.debug?
|
|
63
|
+
@logger.debug(" Generated DELETE statement:\n#{delete_sql}")
|
|
64
|
+
else
|
|
65
|
+
@logger.info(" Generated DELETE statement.")
|
|
66
|
+
end
|
|
67
|
+
delete_idx = (total_size - idx).to_s.rjust(3, '0')
|
|
68
|
+
File.open(File.join(@output_dir, "delete-#{delete_idx}-#{table_name}.#{adapter.output_extension}"), 'w') do |file|
|
|
69
|
+
file.puts(delete_sql)
|
|
70
|
+
end
|
|
67
71
|
end
|
|
68
72
|
end
|
|
69
73
|
end
|
|
70
74
|
|
|
71
|
-
private def load_table_config
|
|
75
|
+
private def load_table_config(klass)
|
|
72
76
|
Dir[File.join(@config_dir, "*.json")].map do |file|
|
|
73
77
|
json = JSON.parse(File.read(file))
|
|
74
|
-
|
|
78
|
+
klass.from(json)
|
|
75
79
|
end
|
|
76
80
|
end
|
|
77
81
|
|
|
@@ -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/table_config.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Exwiw
|
|
|
9
9
|
attribute :filter, optional(String), skip_serializing_if_nil: true
|
|
10
10
|
attribute :belongs_tos, array(BelongsTo)
|
|
11
11
|
attribute :columns, array(TableColumn)
|
|
12
|
+
attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
|
|
12
13
|
|
|
13
14
|
def self.from_symbol_keys(hash)
|
|
14
15
|
from(JSON.parse(hash.to_json))
|
|
@@ -74,6 +75,7 @@ module Exwiw
|
|
|
74
75
|
merged_table.primary_key = passed_table.primary_key
|
|
75
76
|
merged_table.filter = filter
|
|
76
77
|
merged_table.belongs_tos = passed_table.belongs_tos
|
|
78
|
+
merged_table.bulk_insert_chunk_size = passed_table.bulk_insert_chunk_size
|
|
77
79
|
|
|
78
80
|
receiver_column_by_name = columns.each_with_object({}) { |column, hash| hash[column.name] = column }
|
|
79
81
|
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
|
@@ -5,17 +5,22 @@ 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/mongodb_field"
|
|
12
|
+
require_relative "exwiw/mongodb_collection_config"
|
|
8
13
|
require_relative "exwiw/adapter"
|
|
9
14
|
require_relative "exwiw/adapter/sqlite3_adapter"
|
|
10
15
|
require_relative "exwiw/adapter/mysql2_adapter"
|
|
11
16
|
require_relative "exwiw/adapter/postgresql_adapter"
|
|
17
|
+
require_relative "exwiw/adapter/mongodb_adapter"
|
|
12
18
|
require_relative "exwiw/determine_table_processing_order"
|
|
19
|
+
require_relative "exwiw/mongo_query"
|
|
13
20
|
require_relative "exwiw/query_ast"
|
|
14
21
|
require_relative "exwiw/query_ast_builder"
|
|
15
22
|
require_relative "exwiw/runner"
|
|
16
|
-
require_relative "exwiw/
|
|
17
|
-
require_relative "exwiw/table_column"
|
|
18
|
-
require_relative "exwiw/table_config"
|
|
23
|
+
require_relative "exwiw/schema_generator"
|
|
19
24
|
|
|
20
25
|
begin
|
|
21
26
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
4
|
+
version: 0.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -32,22 +32,28 @@ executables:
|
|
|
32
32
|
extensions: []
|
|
33
33
|
extra_rdoc_files: []
|
|
34
34
|
files:
|
|
35
|
+
- ".rubocop.yml"
|
|
35
36
|
- CHANGELOG.md
|
|
36
37
|
- LICENSE.txt
|
|
37
38
|
- README.md
|
|
38
39
|
- exe/exwiw
|
|
39
40
|
- lib/exwiw.rb
|
|
40
41
|
- lib/exwiw/adapter.rb
|
|
42
|
+
- lib/exwiw/adapter/mongodb_adapter.rb
|
|
41
43
|
- lib/exwiw/adapter/mysql2_adapter.rb
|
|
42
44
|
- lib/exwiw/adapter/postgresql_adapter.rb
|
|
43
45
|
- lib/exwiw/adapter/sqlite3_adapter.rb
|
|
44
46
|
- lib/exwiw/belongs_to.rb
|
|
45
47
|
- lib/exwiw/cli.rb
|
|
46
48
|
- lib/exwiw/determine_table_processing_order.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
|
|
@@ -66,14 +72,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
66
72
|
requirements:
|
|
67
73
|
- - ">="
|
|
68
74
|
- !ruby/object:Gem::Version
|
|
69
|
-
version: 3.
|
|
75
|
+
version: 3.3.0
|
|
70
76
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
77
|
requirements:
|
|
72
78
|
- - ">="
|
|
73
79
|
- !ruby/object:Gem::Version
|
|
74
80
|
version: '0'
|
|
75
81
|
requirements: []
|
|
76
|
-
rubygems_version:
|
|
82
|
+
rubygems_version: 3.6.9
|
|
77
83
|
specification_version: 4
|
|
78
84
|
summary: Ruby gem that allows you to export records from a database to a dump file.
|
|
79
85
|
test_files: []
|