exwiw 0.3.8 → 0.4.0
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 +12 -0
- data/README.md +17 -0
- data/lib/exwiw/adapter/postgresql_adapter.rb +92 -11
- data/lib/exwiw/schema_generator.rb +109 -7
- data/lib/exwiw/version.rb +1 -1
- data/lib/tasks/exwiw.rake +20 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 568620ee295b1822c2606a42be450bbe5601aabb874606b6773b72619261cbba
|
|
4
|
+
data.tar.gz: 46f7b6f97c6b77b7046dfb19117734286ecb5d88001214f9d6e1995560f0b50a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8800c7adfa78ea01fb513eb3dbbe2b3fbfa98b13916d5b4e5a205232851b73e1bbef8cd9d0190abe5a9375d073c753d2f92be583c3a2a96d898cb757cd883a0a
|
|
7
|
+
data.tar.gz: 02f0ebd17ed423dfffbba3aefe2fbba01b533c23672d680ccce7c21edf2b36dbfc0c66ee72b8f4433af3af643aaea126861233a99d2222a2d347f407c8ab5c27
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-06-04
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- PostgreSQL: extraction no longer fails with `operator does not exist: character varying = uuid` when a `belongs_to` chain crosses a varchar foreign key and a uuid primary key. The adapter now introspects column types via `pg_attribute`/`pg_type` at query compile time and injects `::text` casts on both sides of the comparison when a uuid/varchar mismatch is detected. Covers JOIN ON conditions, WHERE IN subqueries, and bulk-delete IN clauses. ([#73](https://github.com/heyinc/exwiw/pull/73))
|
|
10
|
+
|
|
11
|
+
## [0.3.9] - 2026-06-03
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- New `exwiw:schema:tidy` rake task. It reconciles the existing schema config against the live database (read through the database connection, not the models) and removes only what no longer exists there: a config file whose table has been dropped is deleted, and columns recorded in a surviving table's config that the table no longer has are dropped from that file. Because it reads the database directly, a table that still exists in the database but has lost (or never had) a model is kept — only a genuinely-dropped table is removed. Unlike `schema:generate` it never adds or regenerates entries, so hand-edited `comment` / `ignore` / `replace_with` on surviving tables/columns are preserved. It honors `OUTPUT_DIR_PATH` and the per-database subdirectory layout, and prints the tables/columns it removed.
|
|
16
|
+
|
|
5
17
|
## [0.3.8] - 2026-06-02
|
|
6
18
|
|
|
7
19
|
### Added
|
data/README.md
CHANGED
|
@@ -144,6 +144,23 @@ By default, the schema files will be saved in the `exwiw` directory. You can spe
|
|
|
144
144
|
OUTPUT_DIR_PATH=custom_directory bundle exec rake exwiw:schema:generate
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
#### Tidying stale config (`schema:tidy`)
|
|
148
|
+
|
|
149
|
+
`schema:generate` adds and updates config files for the tables it finds, but it never deletes the config file of a table that has been dropped from the application. To reconcile the existing config against the current schema, run:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
bundle exec rake exwiw:schema:tidy
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`schema:tidy` compares the config files already on disk with the **live database** (read through the database connection, not the models) and removes only what no longer exists there:
|
|
156
|
+
|
|
157
|
+
- a config file whose table has been dropped from the database is **deleted**, and
|
|
158
|
+
- columns recorded in a surviving table's config that the table no longer has are **dropped** from that file.
|
|
159
|
+
|
|
160
|
+
Because it reads the database directly, a table that still exists in the database but has lost (or never had) an ActiveRecord model is **kept** — only a table that is genuinely gone is removed. (This is the deliberate counterpart to `generate`, which is model-driven and only ever adds what the models know about.)
|
|
161
|
+
|
|
162
|
+
It respects `OUTPUT_DIR_PATH` and the per-database subdirectory layout in the same way as `schema:generate`. Unlike `generate`, `tidy` never adds or regenerates entries — every surviving table/column (including hand-edited `comment` / `ignore` / `replace_with`) is left untouched, so it is safe to run on a customized config. The task prints which tables and columns it removed (or that the config was already tidy). Stale `belongs_tos` are not pruned by `tidy`; rerun `schema:generate` to refresh those.
|
|
163
|
+
|
|
147
164
|
#### Multiple databases
|
|
148
165
|
|
|
149
166
|
If the application uses Rails' multiple-database support (`connects_to`), `schema:generate` buckets models by the database they connect to and writes each database's config files into its own subdirectory of the output directory, named after the database config name (`primary`, `analytics`, ...):
|
|
@@ -177,8 +177,17 @@ module Exwiw
|
|
|
177
177
|
end
|
|
178
178
|
|
|
179
179
|
foreign_key = first_join.foreign_key
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
outer_table = select_query_ast.from_table_name
|
|
181
|
+
inner_table = first_join.join_table_name
|
|
182
|
+
inner_column = first_join.primary_key
|
|
183
|
+
cast_to = types_need_cast?(
|
|
184
|
+
column_pg_type(outer_table, foreign_key),
|
|
185
|
+
column_pg_type(inner_table, inner_column)
|
|
186
|
+
) ? 'text' : nil
|
|
187
|
+
subquery_sql = compile_ast(subquery_ast, select_cast_to: cast_to)
|
|
188
|
+
outer_expr = "#{outer_table}.#{foreign_key}"
|
|
189
|
+
outer_expr = "#{outer_expr}::text" if cast_to
|
|
190
|
+
sql += "\nWHERE #{outer_expr} IN (#{subquery_sql})"
|
|
182
191
|
|
|
183
192
|
# first_join.base_where_clauses holds conditions on the outer
|
|
184
193
|
# delete-target table (from_table_name), such as a polymorphic type
|
|
@@ -195,19 +204,30 @@ module Exwiw
|
|
|
195
204
|
sql
|
|
196
205
|
end
|
|
197
206
|
|
|
198
|
-
def compile_ast(query_ast)
|
|
207
|
+
def compile_ast(query_ast, select_cast_to: nil)
|
|
199
208
|
raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
200
209
|
|
|
201
210
|
sql = "SELECT "
|
|
202
211
|
sql += if query_ast.select_all
|
|
203
212
|
"*"
|
|
204
213
|
else
|
|
205
|
-
query_ast.columns.map { |col| compile_column_name(query_ast, col) }
|
|
214
|
+
cols = query_ast.columns.map { |col| compile_column_name(query_ast, col) }
|
|
215
|
+
cols = cols.map { |c| "#{c}::#{select_cast_to}" } if select_cast_to
|
|
216
|
+
cols.join(', ')
|
|
206
217
|
end
|
|
207
218
|
sql += " FROM #{query_ast.from_table_name}"
|
|
208
219
|
|
|
209
220
|
query_ast.join_clauses.each do |join|
|
|
210
|
-
|
|
221
|
+
fk_expr = "#{join.base_table_name}.#{join.foreign_key}"
|
|
222
|
+
pk_expr = "#{join.join_table_name}.#{join.primary_key}"
|
|
223
|
+
if types_need_cast?(
|
|
224
|
+
column_pg_type(join.base_table_name, join.foreign_key),
|
|
225
|
+
column_pg_type(join.join_table_name, join.primary_key)
|
|
226
|
+
)
|
|
227
|
+
fk_expr = "#{fk_expr}::text"
|
|
228
|
+
pk_expr = "#{pk_expr}::text"
|
|
229
|
+
end
|
|
230
|
+
sql += " JOIN #{join.join_table_name} ON #{fk_expr} = #{pk_expr}"
|
|
211
231
|
|
|
212
232
|
join.where_clauses.each do |where|
|
|
213
233
|
compiled_where_condition = compile_where_condition(where, join.join_table_name)
|
|
@@ -246,23 +266,54 @@ module Exwiw
|
|
|
246
266
|
"#{key} IN (#{values.join(', ')})"
|
|
247
267
|
end
|
|
248
268
|
elsif where_clause.operator == :in_subquery
|
|
249
|
-
|
|
269
|
+
subquery_sql = compile_subquery(where_clause.value, outer_table: table_name, outer_column: where_clause.column_name)
|
|
270
|
+
cast_to = subquery_cast_to(where_clause.value, table_name, where_clause.column_name)
|
|
271
|
+
outer_key = cast_to ? "#{key}::#{cast_to}" : key
|
|
272
|
+
"#{outer_key} IN (#{subquery_sql})"
|
|
250
273
|
else
|
|
251
274
|
raise "Unsupported operator: #{where_clause.operator}"
|
|
252
275
|
end
|
|
253
276
|
end
|
|
254
277
|
|
|
255
|
-
private def compile_subquery(subquery)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
278
|
+
private def compile_subquery(subquery, outer_table: nil, outer_column: nil)
|
|
279
|
+
cast_to = subquery_cast_to(subquery, outer_table, outer_column)
|
|
280
|
+
|
|
281
|
+
if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
|
|
282
|
+
return compile_ast(subquery.query, select_cast_to: cast_to)
|
|
283
|
+
end
|
|
259
284
|
|
|
260
285
|
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
261
|
-
"
|
|
286
|
+
select_expr = "#{subquery.table_name}.#{subquery.select_column}"
|
|
287
|
+
select_expr = "#{select_expr}::#{cast_to}" if cast_to
|
|
288
|
+
"SELECT #{select_expr} " \
|
|
262
289
|
"FROM #{subquery.table_name} " \
|
|
263
290
|
"WHERE #{subquery.table_name}.#{subquery.where_column} IN (#{inner_values.join(', ')})"
|
|
264
291
|
end
|
|
265
292
|
|
|
293
|
+
private def subquery_select_target(subquery)
|
|
294
|
+
case subquery
|
|
295
|
+
when Exwiw::QueryAst::SelectSubquery
|
|
296
|
+
q = subquery.query
|
|
297
|
+
col = q.columns.first
|
|
298
|
+
col ? [q.from_table_name, col.name] : [nil, nil]
|
|
299
|
+
when Exwiw::QueryAst::Subquery
|
|
300
|
+
[subquery.table_name, subquery.select_column]
|
|
301
|
+
else
|
|
302
|
+
[nil, nil]
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private def subquery_cast_to(subquery, outer_table, outer_column)
|
|
307
|
+
return nil if outer_table.nil? || outer_column.nil?
|
|
308
|
+
|
|
309
|
+
inner_table, inner_column = subquery_select_target(subquery)
|
|
310
|
+
return nil if inner_table.nil?
|
|
311
|
+
|
|
312
|
+
outer_type = column_pg_type(outer_table, outer_column)
|
|
313
|
+
inner_type = column_pg_type(inner_table, inner_column)
|
|
314
|
+
types_need_cast?(outer_type, inner_type) ? 'text' : nil
|
|
315
|
+
end
|
|
316
|
+
|
|
266
317
|
private def escape_value(value)
|
|
267
318
|
case value
|
|
268
319
|
when nil
|
|
@@ -351,6 +402,36 @@ module Exwiw
|
|
|
351
402
|
end
|
|
352
403
|
end
|
|
353
404
|
|
|
405
|
+
private def column_pg_type(table_name, column_name)
|
|
406
|
+
@column_type_cache ||= {}
|
|
407
|
+
cache_key = [table_name, column_name]
|
|
408
|
+
return @column_type_cache[cache_key] if @column_type_cache.key?(cache_key)
|
|
409
|
+
|
|
410
|
+
sql = <<~SQL
|
|
411
|
+
SELECT t.typname
|
|
412
|
+
FROM pg_attribute a
|
|
413
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
414
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
415
|
+
WHERE c.relname = $1
|
|
416
|
+
AND a.attname = $2
|
|
417
|
+
AND a.attnum > 0
|
|
418
|
+
AND NOT a.attisdropped
|
|
419
|
+
LIMIT 1
|
|
420
|
+
SQL
|
|
421
|
+
|
|
422
|
+
result = connection.exec_params(sql, [table_name, column_name])
|
|
423
|
+
@column_type_cache[cache_key] = result.ntuples > 0 ? result.getvalue(0, 0) : nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
private def types_need_cast?(type_a, type_b)
|
|
427
|
+
return false if type_a.nil? || type_b.nil?
|
|
428
|
+
return false if type_a == type_b
|
|
429
|
+
|
|
430
|
+
string_types = %w[varchar text bpchar name].freeze
|
|
431
|
+
(type_a == 'uuid' && string_types.include?(type_b)) ||
|
|
432
|
+
(type_b == 'uuid' && string_types.include?(type_a))
|
|
433
|
+
end
|
|
434
|
+
|
|
354
435
|
private def connection
|
|
355
436
|
@connection ||=
|
|
356
437
|
begin
|
|
@@ -5,6 +5,30 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
module Exwiw
|
|
7
7
|
class SchemaGenerator
|
|
8
|
+
# Summary of what `SchemaGenerator#tidy!` removed, returned so callers can
|
|
9
|
+
# report it. `removed_columns` maps a surviving table's name to the column
|
|
10
|
+
# names that were dropped from its config.
|
|
11
|
+
class TidyResult
|
|
12
|
+
attr_reader :removed_tables, :removed_columns
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@removed_tables = []
|
|
16
|
+
@removed_columns = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add_removed_table(table_name)
|
|
20
|
+
@removed_tables << table_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add_removed_column(table_name, column_name)
|
|
24
|
+
(@removed_columns[table_name] ||= []) << column_name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def empty?
|
|
28
|
+
@removed_tables.empty? && @removed_columns.empty?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
8
32
|
# ActiveStorage tracks generated image variants in this table. Its rows are
|
|
9
33
|
# derivative and regenerable — ActiveStorage lazily (re)creates a variant the
|
|
10
34
|
# next time it is requested — so there is little value in exporting them. More
|
|
@@ -35,6 +59,60 @@ module Exwiw
|
|
|
35
59
|
groups
|
|
36
60
|
end
|
|
37
61
|
|
|
62
|
+
# Reconcile the config files already on disk against the live database,
|
|
63
|
+
# removing only what no longer exists there:
|
|
64
|
+
#
|
|
65
|
+
# - a config file whose table is no longer present is deleted, and
|
|
66
|
+
# - columns recorded in a surviving table's config that the table no
|
|
67
|
+
# longer has are dropped from that file.
|
|
68
|
+
#
|
|
69
|
+
# The source of truth is the database connection (`data_sources` for table
|
|
70
|
+
# existence — which covers views too — and `columns` for the column list),
|
|
71
|
+
# NOT `build_table_groups`. `build_table_groups` only knows about tables
|
|
72
|
+
# that still have an ActiveRecord model, so reconciling against it would
|
|
73
|
+
# delete the config of a table that is still present in the database but
|
|
74
|
+
# has merely lost (or never had) a model. Reading the connection directly
|
|
75
|
+
# avoids that: only a table that is genuinely gone from the database is
|
|
76
|
+
# removed.
|
|
77
|
+
#
|
|
78
|
+
# Unlike `generate!`, tidy never adds or regenerates entries: every
|
|
79
|
+
# surviving table/column — including its hand-edited `comment` / `ignore` /
|
|
80
|
+
# `replace_with` — is left untouched, and only the stale entries are
|
|
81
|
+
# stripped. (Removing a deleted column is something `generate!` already does
|
|
82
|
+
# incidentally via #merge, but `generate!` can never delete the config file
|
|
83
|
+
# of a removed table, which is the gap this fills.) Returns a TidyResult
|
|
84
|
+
# describing the removals so callers (e.g. the rake task) can report them.
|
|
85
|
+
def tidy!
|
|
86
|
+
result = TidyResult.new
|
|
87
|
+
|
|
88
|
+
model_db_groups.each do |db_name, _group_models, conn|
|
|
89
|
+
dir = config_dir_for(db_name)
|
|
90
|
+
next unless Dir.exist?(dir)
|
|
91
|
+
|
|
92
|
+
existing_data_sources = conn.data_sources.to_set
|
|
93
|
+
|
|
94
|
+
Dir[File.join(dir, "*.json")].sort.each do |path|
|
|
95
|
+
existing = TableConfig.from(JSON.parse(File.read(path)))
|
|
96
|
+
|
|
97
|
+
unless existing_data_sources.include?(existing.name)
|
|
98
|
+
File.delete(path)
|
|
99
|
+
result.add_removed_table(existing.name)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
valid_column_names = conn.columns(existing.name).map(&:name).to_set
|
|
104
|
+
stale_columns = existing.columns.reject { |column| valid_column_names.include?(column.name) }
|
|
105
|
+
next if stale_columns.empty?
|
|
106
|
+
|
|
107
|
+
existing.columns = existing.columns.select { |column| valid_column_names.include?(column.name) }
|
|
108
|
+
File.write(path, JSON.pretty_generate(existing.to_hash) + "\n")
|
|
109
|
+
stale_columns.each { |column| result.add_removed_column(existing.name, column.name) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
38
116
|
# Returns a Hash keyed by the database name.
|
|
39
117
|
#
|
|
40
118
|
# - Single-database setup: the only key is `nil`, signalling that the table
|
|
@@ -44,18 +122,35 @@ module Exwiw
|
|
|
44
122
|
# mapping to that database's table configs. They are written into
|
|
45
123
|
# `output_dir/<db_name>/`.
|
|
46
124
|
def build_table_groups
|
|
125
|
+
model_db_groups.each_with_object({}) do |(db_name, group_models, conn), result|
|
|
126
|
+
result[db_name] = build_tables_for(group_models, conn)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The per-database grouping that both `build_table_groups` and `tidy!` work
|
|
131
|
+
# from: `[[db_name, models, connection], ...]`.
|
|
132
|
+
#
|
|
133
|
+
# - Single-database setup: one entry keyed by `nil` (configs written flat
|
|
134
|
+
# into `output_dir`). When there are no models at all, fall back to the
|
|
135
|
+
# default connection so callers still have a connection to inspect.
|
|
136
|
+
# - Multi-database setup (Rails `connects_to`): one entry per database,
|
|
137
|
+
# keyed by `connection_db_config.name` ("primary" / "analytics", ...).
|
|
138
|
+
#
|
|
139
|
+
# The db_name <-> connection mapping is necessarily model-derived: which
|
|
140
|
+
# databases the app talks to is only declared on the model side (via
|
|
141
|
+
# `connects_to`). What each consumer reads *through* that connection differs
|
|
142
|
+
# — `build_table_groups` builds configs from the models, while `tidy!`
|
|
143
|
+
# reads the live database's actual tables/columns.
|
|
144
|
+
private def model_db_groups
|
|
47
145
|
models = concrete_models
|
|
48
146
|
grouped = models.group_by { |model| database_name_for(model) }
|
|
49
147
|
|
|
50
148
|
if grouped.size <= 1
|
|
51
149
|
conn = models.empty? ? ActiveRecord::Base.connection : models.first.connection
|
|
52
|
-
return
|
|
150
|
+
return [[nil, models, conn]]
|
|
53
151
|
end
|
|
54
152
|
|
|
55
|
-
grouped.
|
|
56
|
-
conn = group_models.first.connection
|
|
57
|
-
result[db_name] = build_tables_for(group_models, conn)
|
|
58
|
-
end
|
|
153
|
+
grouped.map { |db_name, group_models| [db_name, group_models, group_models.first.connection] }
|
|
59
154
|
end
|
|
60
155
|
|
|
61
156
|
# Backwards-compatible flat list of all table configs. Only meaningful for
|
|
@@ -67,11 +162,18 @@ module Exwiw
|
|
|
67
162
|
|
|
68
163
|
def write_groups(groups)
|
|
69
164
|
groups.each do |db_name, tables|
|
|
70
|
-
|
|
71
|
-
write_files(dir, tables)
|
|
165
|
+
write_files(config_dir_for(db_name), tables)
|
|
72
166
|
end
|
|
73
167
|
end
|
|
74
168
|
|
|
169
|
+
# The directory a database group's config files live in. A single-database
|
|
170
|
+
# setup (`db_name` is nil) writes flat into `output_dir`; a multi-database
|
|
171
|
+
# setup writes into `output_dir/<db_name>/`. Shared by `write_groups` and
|
|
172
|
+
# `tidy!` so the two operations agree on file locations.
|
|
173
|
+
private def config_dir_for(db_name)
|
|
174
|
+
db_name.nil? ? @output_dir : File.join(@output_dir, db_name)
|
|
175
|
+
end
|
|
176
|
+
|
|
75
177
|
def write_files(dir, tables)
|
|
76
178
|
FileUtils.mkdir_p(dir)
|
|
77
179
|
|
data/lib/exwiw/version.rb
CHANGED
data/lib/tasks/exwiw.rake
CHANGED
|
@@ -11,6 +11,26 @@ namespace :exwiw do
|
|
|
11
11
|
).generate!
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
desc "Remove tables/columns from the schema config that no longer exist in the application"
|
|
15
|
+
task tidy: :environment do
|
|
16
|
+
require "exwiw"
|
|
17
|
+
|
|
18
|
+
result = Exwiw::SchemaGenerator.from_rails_application(
|
|
19
|
+
output_dir: ENV["OUTPUT_DIR_PATH"] || "exwiw",
|
|
20
|
+
).tidy!
|
|
21
|
+
|
|
22
|
+
if result.empty?
|
|
23
|
+
puts "exwiw: schema config is already tidy; nothing to remove."
|
|
24
|
+
else
|
|
25
|
+
result.removed_tables.each do |name|
|
|
26
|
+
puts "exwiw: removed config for table '#{name}' (no longer exists in the application)."
|
|
27
|
+
end
|
|
28
|
+
result.removed_columns.each do |table_name, columns|
|
|
29
|
+
puts "exwiw: removed column(s) #{columns.join(', ')} from '#{table_name}' (no longer in the table)."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
14
34
|
desc "Generate schema from a Mongoid application"
|
|
15
35
|
task generate_mongoid: :environment do
|
|
16
36
|
require "exwiw"
|