exwiw 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +31 -0
- data/docs/plans/2026-05-29-rails-managed-tables.md +344 -0
- data/lib/exwiw/adapter/mysql2_adapter.rb +11 -3
- data/lib/exwiw/adapter/postgresql_adapter.rb +18 -5
- data/lib/exwiw/adapter/sqlite3_adapter.rb +11 -3
- data/lib/exwiw/query_ast.rb +6 -1
- data/lib/exwiw/query_ast_builder.rb +5 -1
- data/lib/exwiw/runner.rb +13 -1
- data/lib/exwiw/schema_generator.rb +40 -1
- data/lib/exwiw/table_config.rb +54 -3
- data/lib/exwiw/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4b63b89a56ddce55ea6dc835bde4052b9140cff4b24da6cb9c05d5cac7d0f59
|
|
4
|
+
data.tar.gz: a567c52917014798129638281c90d66f9f7102db624f8b601dd9e025d4351677
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d782a227fb06e75ac28d35a636f28ccb224dd1c1eb1aad7015863bd8365082be6f51c948f3e7beafbca12d10ac1162af9170e1e064f07a7ca7b64e1433a71d4
|
|
7
|
+
data.tar.gz: 994f8795368c96bc9f773526da33cf8f3d024eac6fcb0665e028f3fc8bd2f67fc5424d4e2e3ada7e91d7a3eb4e2906bd2464d18c4da01d134ba051426e49b9e0
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.5] - 2026-05-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `schema:generate` now auto-emits config entries for Rails-managed tables (`schema_migrations` and `ar_internal_metadata`), tagged `type: "rails_managed_schema_migrations"` / `"rails_managed_internal_metadata"`. Extraction uses `SELECT *`, INSERT omits the column list, and DELETE is not generated. Defining `primary_key`, `columns`, or `belongs_tos` on such an entry is rejected at load time, and these tables cannot be used as `--target-table`.
|
|
10
|
+
- Added optional `type` and `comment` fields to `TableConfig`. `primary_key` is now optional in JSON (still required for non-rails-managed tables, enforced at load time).
|
|
11
|
+
|
|
5
12
|
## [0.2.4] - 2026-05-28
|
|
6
13
|
|
|
7
14
|
### Added
|
data/README.md
CHANGED
|
@@ -221,6 +221,37 @@ Constraints:
|
|
|
221
221
|
- Specifying a skipped table as `--target-table` raises `ArgumentError`.
|
|
222
222
|
- `skip: true` is preserved by `exwiw:schema:generate` regenerations (the receiver value wins over the auto-generated config).
|
|
223
223
|
|
|
224
|
+
### Rails-managed tables (special `type` values)
|
|
225
|
+
|
|
226
|
+
Some tables are owned by Rails itself rather than the application — they have no ActiveRecord model and Rails reserves the right to evolve their column shape between versions (e.g. `schema_migrations`, `ar_internal_metadata`). exwiw treats them as a distinct category via the `type` field on a table config:
|
|
227
|
+
|
|
228
|
+
- `type: "rails_managed_schema_migrations"` — Rails' migration history table (`ActiveRecord::Base.schema_migrations_table_name`).
|
|
229
|
+
- `type: "rails_managed_internal_metadata"` — Rails' internal metadata table (`ActiveRecord::Base.internal_metadata_table_name`).
|
|
230
|
+
|
|
231
|
+
`exwiw:schema:generate` emits these entries automatically when the corresponding tables exist on the connection — they are NOT pulled from `ActiveRecord::Base.descendants` because they have no model class.
|
|
232
|
+
|
|
233
|
+
A rails-managed entry has a minimal shape (no `primary_key`, no `belongs_tos`, no `columns`):
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"name": "schema_migrations",
|
|
238
|
+
"type": "rails_managed_schema_migrations",
|
|
239
|
+
"comment": "Managed internally by Rails. Tracks applied schema migrations."
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Behavior at dump time:
|
|
244
|
+
|
|
245
|
+
- Extraction uses `SELECT *` so the dump is robust against Rails-side column additions.
|
|
246
|
+
- `INSERT` statements omit the column list (`INSERT INTO schema_migrations VALUES (...)`). For PostgreSQL `--output-format=copy`, the `COPY` header similarly omits the column list (`COPY schema_migrations FROM stdin;`).
|
|
247
|
+
- No `delete-*.sql` file is generated for rails-managed tables, to avoid wiping migration history on the import target.
|
|
248
|
+
|
|
249
|
+
Constraints:
|
|
250
|
+
|
|
251
|
+
- Defining `primary_key`, `columns`, or `belongs_tos` on a rails-managed entry is rejected with `ArgumentError` on load.
|
|
252
|
+
- A rails-managed table cannot be used as `--target-table`.
|
|
253
|
+
- Multi-database setups are not yet supported — the table name is read from the global `ActiveRecord::Base` accessor.
|
|
254
|
+
|
|
224
255
|
### Bulk insert chunk size
|
|
225
256
|
|
|
226
257
|
`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`.
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# Plan: `schema:generate` で Rails 管理テーブル(`schema_migrations` / `ar_internal_metadata`)を出力する
|
|
2
|
+
|
|
3
|
+
## Context(背景)
|
|
4
|
+
|
|
5
|
+
`Exwiw::SchemaGenerator` は現状 `ActiveRecord::Base.descendants` を辿って dumpable なテーブルを列挙している。`schema_migrations` や `ar_internal_metadata` のような Rails が内部で管理するテーブルは **AR モデルが存在しない** ため、生成される config に出てこず、結果として dump 出力にも含まれない。マイグレーション履歴や Rails のメタ情報を含めたい利用者は JSON を手書きする必要がある。
|
|
6
|
+
|
|
7
|
+
本変更では、Rails の標準アクセサ(`ActiveRecord::Base.schema_migrations_table_name` と `ActiveRecord::Base.internal_metadata_table_name`)からテーブル名を取得して、それぞれ `schema_migrations.json` / `ar_internal_metadata.json` を自動生成する。新しい `type` フィールドに `rails_managed_schema_migrations` / `rails_managed_internal_metadata` を付与し、この **type フィールドを唯一のトリガ**として `SELECT *` 形式の extract を行う。利用者はこれらのエントリに対して `columns` / `belongs_tos` / `primary_key` を定義できず、generator とロード時バリデーションでそれを保証する。
|
|
8
|
+
|
|
9
|
+
## Decisions(設計判断)
|
|
10
|
+
|
|
11
|
+
- **取得経路**:
|
|
12
|
+
- `schema_migrations` → `ActiveRecord::Base.schema_migrations_table_name`(= `config.active_record.schema_migrations_table_name`)
|
|
13
|
+
- `ar_internal_metadata` → `ActiveRecord::Base.internal_metadata_table_name`
|
|
14
|
+
- どちらも Rails 6.1〜8.x で安定。
|
|
15
|
+
- **`type` と `comment` は `TableConfig` の汎用 optional フィールド**として追加する。
|
|
16
|
+
- **`type` の値は rails_managed_xxx 系で 2 種類**:
|
|
17
|
+
- `rails_managed_schema_migrations`
|
|
18
|
+
- `rails_managed_internal_metadata`
|
|
19
|
+
両者の挙動は同じだが、テーブルの役割が違うので type 値を分け、`TableConfig#rails_managed?` ヘルパで一括判定する。
|
|
20
|
+
- **rails_managed_xxx を唯一のトリガ**として以下を発行する:
|
|
21
|
+
- extract 時は `SELECT *`(カラム列挙なし)
|
|
22
|
+
- insert 時は `INSERT INTO <t> VALUES ...`(カラム列挙なし)
|
|
23
|
+
- DELETE は生成しない
|
|
24
|
+
通常テーブルで `columns: []` でも `SELECT *` にはしない(既存挙動を維持)。
|
|
25
|
+
- **rails_managed_xxx のときは `columns` / `belongs_tos` / `primary_key` を定義できない**。`TableConfig` ロード時に `ArgumentError` を上げる。
|
|
26
|
+
- **rails_managed_xxx の JSON 出力では `columns` / `belongs_tos` / `primary_key` フィールドを出さない**。
|
|
27
|
+
- `primary_key` は optional 化することで `skip_serializing_if_nil` が効く → 自動的に消える。
|
|
28
|
+
- `belongs_tos` / `columns` は required な配列なので Serdes 標準では空配列が出てしまう。`TableConfig#to_hash` を override して `rails_managed?` のときだけ両キーを strip する。ラウンドトリップのため属性宣言に `default: []` を付け、欠落 JSON も `[]` でロードできるようにする。
|
|
29
|
+
- **`primary_key` を optional 化**する(rails_managed_xxx で省略可)。非 rails_managed では presence をロード時にバリデーション(既存挙動を壊さない)。
|
|
30
|
+
- **`merge` の所有関係**: `type` は generator 由来。`comment` は receiver 由来(`filter` と対称)。
|
|
31
|
+
|
|
32
|
+
## Critical files(主に変更するファイル)
|
|
33
|
+
|
|
34
|
+
| File | 変更内容 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `lib/exwiw/table_config.rb` | `type` / `comment` 属性追加、`primary_key` を optional 化、`rails_managed?` 追加、ロード時バリデーション、`merge` 更新 |
|
|
37
|
+
| `lib/exwiw/schema_generator.rb` | descendants ループの後に rails-managed エントリ群を append |
|
|
38
|
+
| `lib/exwiw/query_ast.rb` | `QueryAst::Select` に `select_all` モードを追加 |
|
|
39
|
+
| `lib/exwiw/query_ast_builder.rb` | rails-managed テーブルを `select_all` モードに振り分け |
|
|
40
|
+
| `lib/exwiw/runner.rb` | `table.rails_managed?` のとき DELETE 生成をスキップ |
|
|
41
|
+
| `lib/exwiw/adapter/mysql2_adapter.rb` | `compile_ast` が `select_all` を尊重、`to_bulk_insert` は `table.rails_managed?` で分岐 |
|
|
42
|
+
| `lib/exwiw/adapter/postgresql_adapter.rb` | 同上 |
|
|
43
|
+
| `lib/exwiw/adapter/sqlite3_adapter.rb` | 同上 |
|
|
44
|
+
| `spec/schema_generator_spec.rb` | スナップショット列挙に 2 つ追加 |
|
|
45
|
+
| `spec/schema_output_snapshots/schema_migrations.json` | 新規 |
|
|
46
|
+
| `spec/schema_output_snapshots/ar_internal_metadata.json` | 新規 |
|
|
47
|
+
| `spec/table_config_spec.rb` | `type` / `comment` / `primary_key` optional / merge / バリデーションをカバー |
|
|
48
|
+
| `CHANGELOG.md` | Unreleased エントリ |
|
|
49
|
+
|
|
50
|
+
## Implementation(実装方針)
|
|
51
|
+
|
|
52
|
+
### 1. `lib/exwiw/table_config.rb`
|
|
53
|
+
|
|
54
|
+
属性宣言を更新。`primary_key` を optional 化し、`type` / `comment` を追加。`belongs_tos` / `columns` には `default: []` を付与してラウンドトリップを担保:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
attribute :name, String
|
|
58
|
+
attribute :primary_key, optional(String), skip_serializing_if_nil: true # ← optional化
|
|
59
|
+
attribute :type, optional(String), skip_serializing_if_nil: true # 新規
|
|
60
|
+
attribute :comment, optional(String), skip_serializing_if_nil: true # 新規
|
|
61
|
+
attribute :filter, optional(String), skip_serializing_if_nil: true
|
|
62
|
+
attribute :belongs_tos, array(BelongsTo), default: [] # ← default追加
|
|
63
|
+
attribute :columns, array(TableColumn), default: [] # ← default追加
|
|
64
|
+
attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
|
|
65
|
+
attribute :skip, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`to_hash` を override して、rails_managed のときは `belongs_tos` / `columns` を JSON 出力から取り除く(`primary_key` は optional 化により自動で省略される):
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
def to_hash
|
|
72
|
+
hash = super
|
|
73
|
+
if rails_managed?
|
|
74
|
+
hash.delete("belongs_tos")
|
|
75
|
+
hash.delete("columns")
|
|
76
|
+
end
|
|
77
|
+
hash
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
定数と判定ヘルパ:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
RAILS_MANAGED_SCHEMA_MIGRATIONS = "rails_managed_schema_migrations"
|
|
85
|
+
RAILS_MANAGED_INTERNAL_METADATA = "rails_managed_internal_metadata"
|
|
86
|
+
RAILS_MANAGED_TYPES = [
|
|
87
|
+
RAILS_MANAGED_SCHEMA_MIGRATIONS,
|
|
88
|
+
RAILS_MANAGED_INTERNAL_METADATA,
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
def rails_managed?
|
|
92
|
+
RAILS_MANAGED_TYPES.include?(type)
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
ロード時バリデーション。`from` を override して全構築経路を通す:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
def self.from(hash)
|
|
100
|
+
config = super
|
|
101
|
+
config.send(:validate_after_load!)
|
|
102
|
+
config
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private def validate_after_load!
|
|
106
|
+
if rails_managed?
|
|
107
|
+
raise ArgumentError, "Table '#{name}' has type=#{type}; primary_key must not be defined." if primary_key
|
|
108
|
+
raise ArgumentError, "Table '#{name}' has type=#{type}; belongs_tos must not be defined." if !belongs_tos.empty?
|
|
109
|
+
raise ArgumentError, "Table '#{name}' has type=#{type}; columns must not be defined." if !columns.empty?
|
|
110
|
+
else
|
|
111
|
+
raise ArgumentError, "Table '#{name}' requires primary_key." if primary_key.nil?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`merge`(lines 70–94)を更新:
|
|
117
|
+
- `merged_table.type = passed_table.type`(generator 由来)
|
|
118
|
+
- `merged_table.comment = comment`(receiver 由来、`filter` と対称)
|
|
119
|
+
|
|
120
|
+
`build_extract_query`(lines 27–68)はそのまま放置(dead code、別件で削除)。
|
|
121
|
+
|
|
122
|
+
### 2. `lib/exwiw/schema_generator.rb`
|
|
123
|
+
|
|
124
|
+
ヘルパーを切り出して `build_tables` で concat:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
def build_tables
|
|
128
|
+
models = concrete_models
|
|
129
|
+
validate_single_database!(models)
|
|
130
|
+
|
|
131
|
+
tables_from_models = models.group_by(&:table_name).map do |table_name, model_group|
|
|
132
|
+
representative = model_group.first
|
|
133
|
+
TableConfig.from_symbol_keys(
|
|
134
|
+
name: table_name,
|
|
135
|
+
primary_key: representative.primary_key,
|
|
136
|
+
belongs_tos: aggregate_belongs_tos(model_group),
|
|
137
|
+
columns: representative.column_names.map { |name| { name: name } },
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
tables_from_models + build_rails_managed_tables
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private def build_rails_managed_tables
|
|
145
|
+
conn = ActiveRecord::Base.connection
|
|
146
|
+
result = []
|
|
147
|
+
|
|
148
|
+
schema_migrations_name = ActiveRecord::Base.schema_migrations_table_name
|
|
149
|
+
if conn.table_exists?(schema_migrations_name)
|
|
150
|
+
result << TableConfig.from_symbol_keys(
|
|
151
|
+
name: schema_migrations_name,
|
|
152
|
+
type: TableConfig::RAILS_MANAGED_SCHEMA_MIGRATIONS,
|
|
153
|
+
comment: "Managed internally by Rails. Tracks applied schema migrations.",
|
|
154
|
+
belongs_tos: [],
|
|
155
|
+
columns: [],
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
internal_metadata_name = ActiveRecord::Base.internal_metadata_table_name
|
|
160
|
+
if conn.table_exists?(internal_metadata_name)
|
|
161
|
+
result << TableConfig.from_symbol_keys(
|
|
162
|
+
name: internal_metadata_name,
|
|
163
|
+
type: TableConfig::RAILS_MANAGED_INTERNAL_METADATA,
|
|
164
|
+
comment: "Managed internally by Rails. Stores environment and schema metadata.",
|
|
165
|
+
belongs_tos: [],
|
|
166
|
+
columns: [],
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
result
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
メモ:
|
|
175
|
+
- `table_exists?` で防御。マイグレーションを無効化したアプリでも壊れない。
|
|
176
|
+
- `primary_key` は省略(rails_managed_xxx の規約)。
|
|
177
|
+
- belongs_tos / columns は明示的に `[]` を渡す(Serdes の required な配列属性なので)。
|
|
178
|
+
|
|
179
|
+
### 3. `lib/exwiw/query_ast.rb`
|
|
180
|
+
|
|
181
|
+
`QueryAst::Select` に `select_all` フラグを追加:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class Select
|
|
185
|
+
attr_reader :from_table_name, :columns, :where_clauses, :join_clauses, :select_all
|
|
186
|
+
|
|
187
|
+
def initialize
|
|
188
|
+
@from_table_name = nil
|
|
189
|
+
@columns = []
|
|
190
|
+
@where_clauses = []
|
|
191
|
+
@join_clauses = []
|
|
192
|
+
@select_all = false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def select_all!
|
|
196
|
+
@select_all = true
|
|
197
|
+
end
|
|
198
|
+
# ... 他のメソッドはそのまま ...
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
opt-in なフラグ。default false なので既存挙動には影響しない。
|
|
203
|
+
|
|
204
|
+
### 4. `lib/exwiw/query_ast_builder.rb`
|
|
205
|
+
|
|
206
|
+
line 24–29 で rails-managed テーブルのときにフラグを立てる:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
QueryAst::Select.new.tap do |ast|
|
|
210
|
+
ast.from(table.name)
|
|
211
|
+
if table.rails_managed?
|
|
212
|
+
ast.select_all!
|
|
213
|
+
else
|
|
214
|
+
ast.select(table.columns)
|
|
215
|
+
end
|
|
216
|
+
join_clauses.each { |join_clause| ast.join(join_clause) }
|
|
217
|
+
where_clauses.each { |where_clause| ast.where(where_clause) }
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
rails-managed テーブルは `belongs_tos` が空なので、`build_where_clauses` も `build_join_clauses` も自然に空配列を返す。`build_where_clauses` の `table.primary_key` 参照(line 80)は `table.name == dump_target.table_name` のときだけ動くが、rails-managed テーブルは dump_target にできない(後述の防御)。
|
|
222
|
+
|
|
223
|
+
### 5. `lib/exwiw/runner.rb`
|
|
224
|
+
|
|
225
|
+
DELETE 生成をスキップ(line 97):
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
if adapter.supports_bulk_delete? && !@insert_only && !table.rails_managed?
|
|
229
|
+
# ... 既存の DELETE ブロック ...
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`validate_skipped`(line 132)と `dumpable?` フィルタ(line 41)は変更不要。rails-managed テーブルは `belongs_tos` が空で、何にも参照されないし、`ordered_tables` に残しておかないと `dump_schema` で DDL が出力されないため。
|
|
234
|
+
|
|
235
|
+
`@dump_target` の防御として、line 149 付近の `skip` チェックと同様に rails-managed テーブルを target に指定された場合のチェックを追加:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
if @dump_target.table_name && (target = configs.find { |c| c.name == @dump_target.table_name }) && target.rails_managed?
|
|
239
|
+
raise ArgumentError,
|
|
240
|
+
"--target-table '#{@dump_target.table_name}' is a Rails-managed table and cannot be used as a dump target."
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 6. Adapter 変更(mysql2 / postgresql / sqlite3)
|
|
245
|
+
|
|
246
|
+
3 つの SQL アダプタで同じパターン。
|
|
247
|
+
|
|
248
|
+
**`compile_ast`** — SELECT のカラムリスト部分(mysql2:144, postgresql:181, sqlite3:131):
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
sql += if query_ast.select_all
|
|
252
|
+
"*"
|
|
253
|
+
else
|
|
254
|
+
query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
|
|
255
|
+
end
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**`to_bulk_insert`** — カラム名 join 部分(mysql2:90, postgresql:87/92, sqlite3:77)。`table.rails_managed?` で分岐:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
if table.rails_managed?
|
|
262
|
+
"INSERT INTO #{table_name} VALUES\n#{values};"
|
|
263
|
+
else
|
|
264
|
+
column_names = table.columns.map(&:name).join(', ')
|
|
265
|
+
"INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`to_bulk_delete` は Runner 側でガードしているので rails-managed では到達しない。変更不要。
|
|
270
|
+
|
|
271
|
+
PostgreSQL の `post_insert_sql`(postgresql_adapter.rb:114–129)は安全。`pg_get_serial_sequence` の引数に渡しても nil を返して no-op。
|
|
272
|
+
|
|
273
|
+
### 7. Tests
|
|
274
|
+
|
|
275
|
+
**`spec/schema_output_snapshots/schema_migrations.json`**(新規):
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"name": "schema_migrations",
|
|
280
|
+
"type": "rails_managed_schema_migrations",
|
|
281
|
+
"comment": "Managed internally by Rails. Tracks applied schema migrations."
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**`spec/schema_output_snapshots/ar_internal_metadata.json`**(新規):
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{
|
|
289
|
+
"name": "ar_internal_metadata",
|
|
290
|
+
"type": "rails_managed_internal_metadata",
|
|
291
|
+
"comment": "Managed internally by Rails. Stores environment and schema metadata."
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`primary_key` / `belongs_tos` / `columns` キーは JSON から完全に省略される。
|
|
296
|
+
|
|
297
|
+
key の順序は `TableConfig` の attribute 宣言順に揃える(`spec/schema_generator_spec.rb:223` の snapshot 一致テストで落ちないように)。
|
|
298
|
+
|
|
299
|
+
**`spec/schema_generator_spec.rb`** — line 188–191 の `contain_exactly` に `"schema_migrations.json"` と `"ar_internal_metadata.json"` を追加。line 211–225 の snapshot テストは新 fixture を自動的に拾う。
|
|
300
|
+
|
|
301
|
+
**`spec/table_config_spec.rb`** — 以下を追加:
|
|
302
|
+
- merge が user 設定の `comment` を保持する
|
|
303
|
+
- merge が `type` を常に generator 値で上書きする
|
|
304
|
+
- `type: rails_managed_*` で `columns` 非空のロード → `ArgumentError`
|
|
305
|
+
- `type: rails_managed_*` で `belongs_tos` 非空のロード → `ArgumentError`
|
|
306
|
+
- `type: rails_managed_*` で `primary_key` 指定あり → `ArgumentError`
|
|
307
|
+
- 非 rails_managed で `primary_key` 欠落 → `ArgumentError`(既存挙動の維持確認)
|
|
308
|
+
- rails_managed の `to_hash` 出力に `belongs_tos` / `columns` / `primary_key` キーが含まれないこと
|
|
309
|
+
- rails_managed の JSON(これらキー欠落)が round-trip でロードできること
|
|
310
|
+
|
|
311
|
+
**Adapter / query specs** — rails-managed 経路:
|
|
312
|
+
- `query_ast.select_all` true → `SELECT *`
|
|
313
|
+
- `table.rails_managed?` true → `INSERT INTO t VALUES (...)`(カラムリストなし)
|
|
314
|
+
- **通常テーブル**で `columns: []` のとき `SELECT *` には**ならない**(negative test)
|
|
315
|
+
|
|
316
|
+
### 8. `CHANGELOG.md`
|
|
317
|
+
|
|
318
|
+
Unreleased エントリ:
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
- schema:generate が Rails 管理テーブル(`schema_migrations` と `ar_internal_metadata`)の config を自動生成するようになった。それぞれ `type: "rails_managed_schema_migrations"` / `"rails_managed_internal_metadata"` を付与し、extract は `SELECT *`、INSERT はカラムリスト省略、DELETE は生成しない。これらのエントリでは `primary_key` / `columns` / `belongs_tos` を定義するとロード時に拒否される。
|
|
322
|
+
- TableConfig に optional な `type` / `comment` フィールドを追加。`primary_key` を optional に変更(非 rails_managed では依然必須)。
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Verification(確認手順)
|
|
326
|
+
|
|
327
|
+
1. **Unit spec**: `bundle exec rspec spec/schema_generator_spec.rb spec/table_config_spec.rb spec/query_ast_builder_spec.rb`
|
|
328
|
+
2. **全体テスト**: `bundle exec rspec` — adapter の rails-managed SQL 生成と通常テーブルの挙動非破壊を確認。
|
|
329
|
+
3. **dummy アプリでの end-to-end**:
|
|
330
|
+
- `cd spec/dummy && bin/rails db:migrate`
|
|
331
|
+
- リポジトリルートで `rake exwiw:schema:generate OUTPUT_DIR_PATH=tmp/schema`
|
|
332
|
+
- `tmp/schema/schema_migrations.json` と `tmp/schema/ar_internal_metadata.json` が想定の形(primary_key/columns/belongs_tos なし or 空)で出力されることを確認。
|
|
333
|
+
- `tmp/schema/schema_migrations.json` に手動で `primary_key` を足して dump 系コマンドを叩くとバリデーションエラーが出ることを確認。
|
|
334
|
+
- 適当な target テーブル指定で dump を実行:
|
|
335
|
+
- `insert-NNN-schema_migrations.sql` / `insert-NNN-ar_internal_metadata.sql` が `INSERT INTO t VALUES (...);` 形式(カラムリストなし)になる
|
|
336
|
+
- 対応する `delete-NNN-*.sql` が生成されないことを確認
|
|
337
|
+
4. **Snapshot diff**: `git diff spec/schema_output_snapshots/` — 新規 2 ファイルのみ差分として出る。
|
|
338
|
+
|
|
339
|
+
## Out of scope(やらないこと)
|
|
340
|
+
|
|
341
|
+
- INSERT の重複ハンドリング(`ON CONFLICT DO NOTHING` 等)— 別件。
|
|
342
|
+
- dead code `TableConfig#build_extract_query` の削除 — 別件。
|
|
343
|
+
- アダプタ側の per-table フック(`supports_bulk_delete_for?` 等)— 必要になったら導入。
|
|
344
|
+
- 非 rails_managed テーブルからの `columns: []` / `belongs_tos: []` 省略 — 既存スナップショット(`shops.json` でも `"belongs_tos": []` が出ている)と整合させるため、空配列はそのまま出す。省略は rails_managed のみ。
|
|
@@ -87,8 +87,12 @@ module Exwiw
|
|
|
87
87
|
end
|
|
88
88
|
values = value_list.join(",\n")
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
if table.rails_managed?
|
|
91
|
+
"INSERT INTO #{table_name} VALUES\n#{values};"
|
|
92
|
+
else
|
|
93
|
+
column_names = table.columns.map(&:name).join(', ')
|
|
94
|
+
"INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
|
|
95
|
+
end
|
|
92
96
|
end
|
|
93
97
|
|
|
94
98
|
def to_bulk_delete(select_query_ast, table)
|
|
@@ -141,7 +145,11 @@ module Exwiw
|
|
|
141
145
|
raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
142
146
|
|
|
143
147
|
sql = "SELECT "
|
|
144
|
-
sql +=
|
|
148
|
+
sql += if query_ast.select_all
|
|
149
|
+
"*"
|
|
150
|
+
else
|
|
151
|
+
query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
|
|
152
|
+
end
|
|
145
153
|
sql += " FROM #{query_ast.from_table_name}"
|
|
146
154
|
|
|
147
155
|
query_ast.join_clauses.each do |join|
|
|
@@ -84,13 +84,22 @@ module Exwiw
|
|
|
84
84
|
end
|
|
85
85
|
values = value_list.join(",\n")
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
if table.rails_managed?
|
|
88
|
+
"INSERT INTO #{table_name} VALUES\n#{values};"
|
|
89
|
+
else
|
|
90
|
+
column_names = table.columns.map(&:name).join(', ')
|
|
91
|
+
"INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
|
|
92
|
+
end
|
|
89
93
|
end
|
|
90
94
|
|
|
91
95
|
def to_copy_from_stdin(results, table)
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
header = if table.rails_managed?
|
|
97
|
+
"COPY #{table.name} FROM stdin;"
|
|
98
|
+
else
|
|
99
|
+
column_names = table.columns.map(&:name).join(', ')
|
|
100
|
+
"COPY #{table.name} (#{column_names}) FROM stdin;"
|
|
101
|
+
end
|
|
102
|
+
lines = [header]
|
|
94
103
|
results.each do |row|
|
|
95
104
|
lines << row.map { |v| escape_copy_value(v) }.join("\t")
|
|
96
105
|
end
|
|
@@ -178,7 +187,11 @@ module Exwiw
|
|
|
178
187
|
raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
179
188
|
|
|
180
189
|
sql = "SELECT "
|
|
181
|
-
sql +=
|
|
190
|
+
sql += if query_ast.select_all
|
|
191
|
+
"*"
|
|
192
|
+
else
|
|
193
|
+
query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
|
|
194
|
+
end
|
|
182
195
|
sql += " FROM #{query_ast.from_table_name}"
|
|
183
196
|
|
|
184
197
|
query_ast.join_clauses.each do |join|
|
|
@@ -74,8 +74,12 @@ module Exwiw
|
|
|
74
74
|
end
|
|
75
75
|
values = value_list.join(",\n")
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
if table.rails_managed?
|
|
78
|
+
"INSERT INTO #{table_name} VALUES\n#{values};"
|
|
79
|
+
else
|
|
80
|
+
column_names = table.columns.map(&:name).join(', ')
|
|
81
|
+
"INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
|
|
82
|
+
end
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
def to_bulk_delete(select_query_ast, table)
|
|
@@ -128,7 +132,11 @@ module Exwiw
|
|
|
128
132
|
raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
129
133
|
|
|
130
134
|
sql = "SELECT "
|
|
131
|
-
sql +=
|
|
135
|
+
sql += if query_ast.select_all
|
|
136
|
+
"*"
|
|
137
|
+
else
|
|
138
|
+
query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
|
|
139
|
+
end
|
|
132
140
|
sql += " FROM #{query_ast.from_table_name}"
|
|
133
141
|
|
|
134
142
|
query_ast.join_clauses.each do |join|
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -45,13 +45,14 @@ module Exwiw
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
class Select
|
|
48
|
-
attr_reader :from_table_name, :columns, :where_clauses, :join_clauses
|
|
48
|
+
attr_reader :from_table_name, :columns, :where_clauses, :join_clauses, :select_all
|
|
49
49
|
|
|
50
50
|
def initialize
|
|
51
51
|
@from_table_name = nil
|
|
52
52
|
@columns = []
|
|
53
53
|
@where_clauses = []
|
|
54
54
|
@join_clauses = []
|
|
55
|
+
@select_all = false
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def from(table)
|
|
@@ -62,6 +63,10 @@ module Exwiw
|
|
|
62
63
|
@columns = map_column_value(columns)
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
def select_all!
|
|
67
|
+
@select_all = true
|
|
68
|
+
end
|
|
69
|
+
|
|
65
70
|
def where(where_clause)
|
|
66
71
|
@where_clauses << where_clause
|
|
67
72
|
end
|
|
@@ -23,7 +23,11 @@ module Exwiw
|
|
|
23
23
|
|
|
24
24
|
QueryAst::Select.new.tap do |ast|
|
|
25
25
|
ast.from(table.name)
|
|
26
|
-
|
|
26
|
+
if table.rails_managed?
|
|
27
|
+
ast.select_all!
|
|
28
|
+
else
|
|
29
|
+
ast.select(table.columns)
|
|
30
|
+
end
|
|
27
31
|
join_clauses.each { |join_clause| ast.join(join_clause) }
|
|
28
32
|
where_clauses.each { |where_clause| ast.where(where_clause) }
|
|
29
33
|
end
|
data/lib/exwiw/runner.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Exwiw
|
|
|
31
31
|
configs = load_table_config(adapter.class.table_config_class)
|
|
32
32
|
|
|
33
33
|
validate_skipped(configs)
|
|
34
|
+
validate_rails_managed_target!(configs)
|
|
34
35
|
|
|
35
36
|
table_by_name = configs.each_with_object({}) { |config, hash| hash[config.name] = config }
|
|
36
37
|
|
|
@@ -94,7 +95,7 @@ module Exwiw
|
|
|
94
95
|
end
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
if adapter.supports_bulk_delete? && !@insert_only
|
|
98
|
+
if adapter.supports_bulk_delete? && !@insert_only && !(table.respond_to?(:rails_managed?) && table.rails_managed?)
|
|
98
99
|
@logger.debug(" Generate DELETE statement...")
|
|
99
100
|
delete_sql = adapter.to_bulk_delete(query_ast, table)
|
|
100
101
|
if @logger.debug?
|
|
@@ -153,5 +154,16 @@ module Exwiw
|
|
|
153
154
|
|
|
154
155
|
skipped_names.each { |n| @logger.info("Table '#{n}' is marked skip:true (schema will be included, data extraction skipped)") }
|
|
155
156
|
end
|
|
157
|
+
|
|
158
|
+
private def validate_rails_managed_target!(configs)
|
|
159
|
+
return if @dump_target.table_name.nil?
|
|
160
|
+
|
|
161
|
+
target = configs.find { |c| c.name == @dump_target.table_name }
|
|
162
|
+
return if target.nil?
|
|
163
|
+
return unless target.respond_to?(:rails_managed?) && target.rails_managed?
|
|
164
|
+
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"--target-table '#{@dump_target.table_name}' is a Rails-managed table (type=#{target.type}) and cannot be used as a dump target."
|
|
167
|
+
end
|
|
156
168
|
end
|
|
157
169
|
end
|
|
@@ -27,7 +27,7 @@ module Exwiw
|
|
|
27
27
|
models = concrete_models
|
|
28
28
|
validate_single_database!(models)
|
|
29
29
|
|
|
30
|
-
models.group_by(&:table_name).map do |table_name, model_group|
|
|
30
|
+
tables_from_models = models.group_by(&:table_name).map do |table_name, model_group|
|
|
31
31
|
representative = model_group.first
|
|
32
32
|
TableConfig.from_symbol_keys(
|
|
33
33
|
name: table_name,
|
|
@@ -36,6 +36,8 @@ module Exwiw
|
|
|
36
36
|
columns: representative.column_names.map { |name| { name: name } },
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
tables_from_models + build_rails_managed_tables
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def write_files(tables)
|
|
@@ -57,6 +59,43 @@ module Exwiw
|
|
|
57
59
|
@models.reject(&:abstract_class?).select(&:table_exists?)
|
|
58
60
|
end
|
|
59
61
|
|
|
62
|
+
# NOTE: multi-database setup には未対応。`ActiveRecord::Base.schema_migrations_table_name`
|
|
63
|
+
# と `internal_metadata_table_name` はクラスレベルのグローバル設定を返すため、
|
|
64
|
+
# connection 毎にテーブル名が違うケース (`connects_to` で別 DB を扱う場合や
|
|
65
|
+
# `ActiveRecord::Base` 以外で `connection.schema_migration.table_name` を上書きしている場合)
|
|
66
|
+
# を拾えない。現状は `validate_single_database!` で multi-DB を弾いているので
|
|
67
|
+
# ここに到達するのは単一 DB 構成のみという前提で動いている。
|
|
68
|
+
# multi-DB 対応する際は、対象の connection に紐づく schema_migration から
|
|
69
|
+
# テーブル名を取り、connection 毎にエントリを生成する必要がある。
|
|
70
|
+
private def build_rails_managed_tables
|
|
71
|
+
conn = ActiveRecord::Base.connection
|
|
72
|
+
result = []
|
|
73
|
+
|
|
74
|
+
schema_migrations_name = ActiveRecord::Base.schema_migrations_table_name
|
|
75
|
+
if conn.table_exists?(schema_migrations_name)
|
|
76
|
+
result << TableConfig.from_symbol_keys(
|
|
77
|
+
name: schema_migrations_name,
|
|
78
|
+
type: TableConfig::RAILS_MANAGED_SCHEMA_MIGRATIONS,
|
|
79
|
+
comment: "Managed internally by Rails. Tracks applied schema migrations.",
|
|
80
|
+
belongs_tos: [],
|
|
81
|
+
columns: [],
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
internal_metadata_name = ActiveRecord::Base.internal_metadata_table_name
|
|
86
|
+
if conn.table_exists?(internal_metadata_name)
|
|
87
|
+
result << TableConfig.from_symbol_keys(
|
|
88
|
+
name: internal_metadata_name,
|
|
89
|
+
type: TableConfig::RAILS_MANAGED_INTERNAL_METADATA,
|
|
90
|
+
comment: "Managed internally by Rails. Stores environment and schema metadata.",
|
|
91
|
+
belongs_tos: [],
|
|
92
|
+
columns: [],
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
60
99
|
private def aggregate_belongs_tos(models)
|
|
61
100
|
pairs = models
|
|
62
101
|
.flat_map { |m| m.reflect_on_all_associations(:belongs_to) }
|
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -4,18 +4,46 @@ module Exwiw
|
|
|
4
4
|
class TableConfig
|
|
5
5
|
include Serdes
|
|
6
6
|
|
|
7
|
+
RAILS_MANAGED_SCHEMA_MIGRATIONS = "rails_managed_schema_migrations"
|
|
8
|
+
RAILS_MANAGED_INTERNAL_METADATA = "rails_managed_internal_metadata"
|
|
9
|
+
RAILS_MANAGED_TYPES = [
|
|
10
|
+
RAILS_MANAGED_SCHEMA_MIGRATIONS,
|
|
11
|
+
RAILS_MANAGED_INTERNAL_METADATA,
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
7
14
|
attribute :name, String
|
|
8
|
-
attribute :primary_key, String
|
|
15
|
+
attribute :primary_key, optional(String), skip_serializing_if_nil: true
|
|
16
|
+
attribute :type, optional(String), skip_serializing_if_nil: true
|
|
17
|
+
attribute :comment, optional(String), skip_serializing_if_nil: true
|
|
9
18
|
attribute :filter, optional(String), skip_serializing_if_nil: true
|
|
10
|
-
attribute :belongs_tos, array(BelongsTo)
|
|
11
|
-
attribute :columns, array(TableColumn)
|
|
19
|
+
attribute :belongs_tos, array(BelongsTo), default: []
|
|
20
|
+
attribute :columns, array(TableColumn), default: []
|
|
12
21
|
attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
|
|
13
22
|
attribute :skip, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
|
|
14
23
|
|
|
24
|
+
def self.from(hash)
|
|
25
|
+
config = super
|
|
26
|
+
config.send(:validate_after_load!)
|
|
27
|
+
config
|
|
28
|
+
end
|
|
29
|
+
|
|
15
30
|
def self.from_symbol_keys(hash)
|
|
16
31
|
from(JSON.parse(hash.to_json))
|
|
17
32
|
end
|
|
18
33
|
|
|
34
|
+
def rails_managed?
|
|
35
|
+
RAILS_MANAGED_TYPES.include?(type)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_hash
|
|
39
|
+
hash = super
|
|
40
|
+
if rails_managed?
|
|
41
|
+
hash.delete("belongs_tos")
|
|
42
|
+
hash.delete("columns")
|
|
43
|
+
end
|
|
44
|
+
hash
|
|
45
|
+
end
|
|
46
|
+
|
|
19
47
|
def column_names
|
|
20
48
|
columns.map(&:name)
|
|
21
49
|
end
|
|
@@ -74,6 +102,8 @@ module Exwiw
|
|
|
74
102
|
TableConfig.new.tap do |merged_table|
|
|
75
103
|
merged_table.name = name
|
|
76
104
|
merged_table.primary_key = passed_table.primary_key
|
|
105
|
+
merged_table.type = passed_table.type
|
|
106
|
+
merged_table.comment = comment
|
|
77
107
|
merged_table.filter = filter
|
|
78
108
|
merged_table.belongs_tos = passed_table.belongs_tos
|
|
79
109
|
merged_table.bulk_insert_chunk_size = passed_table.bulk_insert_chunk_size
|
|
@@ -93,6 +123,27 @@ module Exwiw
|
|
|
93
123
|
end
|
|
94
124
|
end
|
|
95
125
|
|
|
126
|
+
private def validate_after_load!
|
|
127
|
+
if rails_managed?
|
|
128
|
+
if primary_key
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"Table '#{name}' has type=#{type}; primary_key must not be defined."
|
|
131
|
+
end
|
|
132
|
+
if !belongs_tos.empty?
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"Table '#{name}' has type=#{type}; belongs_tos must not be defined."
|
|
135
|
+
end
|
|
136
|
+
if !columns.empty?
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Table '#{name}' has type=#{type}; columns must not be defined."
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
if primary_key.nil?
|
|
142
|
+
raise ArgumentError, "Table '#{name}' requires primary_key."
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
96
147
|
private def compute_dependency_to_table(target_table_name, tables_by_name)
|
|
97
148
|
return [] if belongs_tos.empty?
|
|
98
149
|
|
data/lib/exwiw/version.rb
CHANGED
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.2.
|
|
4
|
+
version: 0.2.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- docs/plans/2026-05-16-mongodb-from-clean-scenario.md
|
|
40
40
|
- docs/plans/2026-05-22-after-insert-hook.md
|
|
41
41
|
- docs/plans/2026-05-22-postgres-copy-mode-scenario-test.md
|
|
42
|
+
- docs/plans/2026-05-29-rails-managed-tables.md
|
|
42
43
|
- exe/exwiw
|
|
43
44
|
- lib/exwiw.rb
|
|
44
45
|
- lib/exwiw/adapter.rb
|