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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90a440a039497562ccc9a4ab3045342953ba23faf0fdff48cfd633771a4688a4
4
- data.tar.gz: 5f8ee4853cbbe39dedba4176e5db61d91572ee3766d1d76e3eda6977ea373e6b
3
+ metadata.gz: 568620ee295b1822c2606a42be450bbe5601aabb874606b6773b72619261cbba
4
+ data.tar.gz: 46f7b6f97c6b77b7046dfb19117734286ecb5d88001214f9d6e1995560f0b50a
5
5
  SHA512:
6
- metadata.gz: aa34168f0c3b6137eca95bf8ebeea625ea106559a779bd8739c09e5ab8ffeae0a9a84f9ca188809e579ab3d1c3f8c77b68d57d006b156de30b1d01e100435ecb
7
- data.tar.gz: c7ad4547852032fcb4c356883232c8a34d6418baf498686ed6f25a80279d1cf61379b566ee29267f989d9de80f4b2490022d03695e686dedf5c506b2c01871f8
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
- subquery_sql = compile_ast(subquery_ast)
181
- sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql})"
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) }.join(', ')
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
- sql += " JOIN #{join.join_table_name} ON #{join.base_table_name}.#{join.foreign_key} = #{join.join_table_name}.#{join.primary_key}"
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
- "#{key} IN (#{compile_subquery(where_clause.value)})"
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
- # A SelectSubquery wraps a full Select (the referencing table's
257
- # extraction query, projected to a foreign key); compile it as-is.
258
- return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
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
- "SELECT #{subquery.table_name}.#{subquery.select_column} " \
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 { nil => build_tables_for(models, conn) }
150
+ return [[nil, models, conn]]
53
151
  end
54
152
 
55
- grouped.each_with_object({}) do |(db_name, group_models), result|
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
- dir = db_name.nil? ? @output_dir : File.join(@output_dir, db_name)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.3.8"
4
+ VERSION = "0.4.0"
5
5
  end
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"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.8
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia