exwiw 0.9.21 → 0.9.22

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.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ module DbIntrospector
5
+ # Reads MySQL's `information_schema`, scoped to `DATABASE()` — the database
6
+ # the connection was opened against, which is the one the dump would run in.
7
+ #
8
+ # Connects through MysqlClient, the same wrapper the dump path uses, so the
9
+ # driver choice (mysql2 or trilogy) and the "install the gem" error message
10
+ # are shared rather than reimplemented here.
11
+ class MysqlIntrospector < Base
12
+ # MySQL data_type (the type without its length/precision) -> the
13
+ # ActiveRecord-ish symbol DefaultMask understands. Anything absent maps to
14
+ # nil, which leaves the column unmasked: binary/blob columns must not
15
+ # receive a text mask, and enum/set/geometry/bit have no constant that is
16
+ # valid for every table's declaration.
17
+ TYPE_MAP = {
18
+ "char" => :string,
19
+ "varchar" => :string,
20
+ "tinytext" => :text,
21
+ "text" => :text,
22
+ "mediumtext" => :text,
23
+ "longtext" => :text,
24
+ "tinyint" => :integer,
25
+ "smallint" => :integer,
26
+ "mediumint" => :integer,
27
+ "int" => :integer,
28
+ "integer" => :integer,
29
+ "bigint" => :integer,
30
+ "decimal" => :decimal,
31
+ "numeric" => :decimal,
32
+ "float" => :float,
33
+ "double" => :float,
34
+ "date" => :date,
35
+ "datetime" => :datetime,
36
+ "timestamp" => :datetime,
37
+ "time" => :time,
38
+ "json" => :json,
39
+ }.freeze
40
+
41
+ # MySQL has no boolean type: `BOOLEAN` is an alias for `TINYINT(1)`, and
42
+ # the display width is the only trace of the distinction left in the
43
+ # catalog. Mapping it to :boolean (as every MySQL ORM does) is what makes
44
+ # a flag column mask to false / to its own default rather than to 0.
45
+ BOOLEAN_COLUMN_TYPE = "tinyint(1)"
46
+
47
+ # A default MySQL evaluates per row rather than storing as a literal.
48
+ # `extra` carries DEFAULT_GENERATED for an expression default, but only on
49
+ # servers new enough to support them, so the text is screened as well: a
50
+ # function call, or a bare keyword such as CURRENT_TIMESTAMP, is not a
51
+ # value we can mask with. A literal string that happens to contain
52
+ # parentheses is rejected too — losing a usable default costs nothing more
53
+ # than falling back to the per-type constant, while accepting an
54
+ # expression would write a mask the database re-evaluates.
55
+ EXPRESSION_DEFAULT = /[()]|\Acurrent_(?:timestamp|date|time)\z|\Alocaltime(?:stamp)?\z/i
56
+
57
+ def table_names
58
+ rows(<<~SQL).map { |row| row[0] }.sort
59
+ SELECT table_name
60
+ FROM information_schema.tables
61
+ WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
62
+ SQL
63
+ end
64
+
65
+ def primary_key(table_name)
66
+ names = rows(<<~SQL).map { |row| row[0] }
67
+ SELECT column_name
68
+ FROM information_schema.key_column_usage
69
+ WHERE table_schema = DATABASE()
70
+ AND table_name = #{quote(table_name)}
71
+ AND constraint_name = 'PRIMARY'
72
+ ORDER BY ordinal_position
73
+ SQL
74
+
75
+ case names.size
76
+ when 0 then nil
77
+ when 1 then names.first
78
+ else names
79
+ end
80
+ end
81
+
82
+ def columns(table_name)
83
+ sql = <<~SQL
84
+ SELECT column_name, data_type, column_type, character_maximum_length, column_default, extra
85
+ FROM information_schema.columns
86
+ WHERE table_schema = DATABASE() AND table_name = #{quote(table_name)}
87
+ ORDER BY ordinal_position
88
+ SQL
89
+
90
+ rows(sql).map do |name, data_type, column_type, character_maximum_length, column_default, extra|
91
+ type = column_type == BOOLEAN_COLUMN_TYPE ? :boolean : TYPE_MAP[data_type]
92
+ Column.new(
93
+ name: name,
94
+ type: type,
95
+ limit: character_maximum_length&.to_i,
96
+ # MySQL has no array column type; a multi-valued column is JSON,
97
+ # which is masked as JSON rather than as an array.
98
+ array: false,
99
+ default: coerce_default(type, literal_default(column_default, extra)),
100
+ )
101
+ end
102
+ end
103
+
104
+ def unique_column_names(table_name)
105
+ rows(<<~SQL).map { |row| row[0] }.to_set
106
+ SELECT DISTINCT column_name
107
+ FROM information_schema.statistics
108
+ WHERE table_schema = DATABASE()
109
+ AND table_name = #{quote(table_name)}
110
+ AND non_unique = 0
111
+ SQL
112
+ rescue StandardError => e
113
+ warn_once(
114
+ :unique_column_names,
115
+ "exwiw: could not read the indexes of '#{table_name}' (#{e.class}); " \
116
+ "treating every column as unique-indexed so no constant mask is emitted.",
117
+ )
118
+ nil
119
+ end
120
+
121
+ def foreign_keys(table_name)
122
+ # `referenced_table_name IS NOT NULL` is what distinguishes a foreign
123
+ # key's rows from the primary/unique key rows sharing this view.
124
+ build_foreign_keys(table_name, rows(<<~SQL))
125
+ SELECT constraint_name, column_name, referenced_table_name
126
+ FROM information_schema.key_column_usage
127
+ WHERE table_schema = DATABASE()
128
+ AND table_name = #{quote(table_name)}
129
+ AND referenced_table_name IS NOT NULL
130
+ ORDER BY constraint_name, ordinal_position
131
+ SQL
132
+ end
133
+
134
+ # The catalog's `column_default` as a plain literal, or nil when it is
135
+ # absent or is an expression (see EXPRESSION_DEFAULT).
136
+ private def literal_default(column_default, extra)
137
+ return nil if column_default.nil?
138
+ return nil if extra.to_s.upcase.include?("DEFAULT_GENERATED")
139
+ return nil if column_default.match?(EXPRESSION_DEFAULT)
140
+
141
+ column_default
142
+ end
143
+
144
+ private def rows(sql)
145
+ connection.query(sql).rows
146
+ end
147
+
148
+ # Table names reaching this class come from `table_names` (the catalog
149
+ # itself), so they cannot carry an injection; quoting is defensive, for
150
+ # the day a caller passes a name from elsewhere.
151
+ private def quote(value)
152
+ "'#{value.to_s.gsub("\\", "\\\\\\\\").gsub("'", "''")}'"
153
+ end
154
+
155
+ private def connection
156
+ @connection ||= Adapter::MysqlClient.new(@connection_config)
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ module DbIntrospector
5
+ # Reads PostgreSQL's catalog, scoped to `current_schema()` — the first
6
+ # schema on the connection's search_path, which is the one an unqualified
7
+ # table name in the dump resolves to.
8
+ #
9
+ # `information_schema` is used where it is unambiguous (tables, columns,
10
+ # primary keys); unique indexes and foreign keys go through `pg_catalog`
11
+ # instead. information_schema only lists what a *constraint* declares, so a
12
+ # bare `CREATE UNIQUE INDEX` would be invisible there, and its
13
+ # `constraint_column_usage` join multiplies the rows of a composite foreign
14
+ # key into a cross product that cannot be grouped back.
15
+ class PostgresqlIntrospector < Base
16
+ # PostgreSQL data_type (information_schema's spelling) -> the
17
+ # ActiveRecord-ish symbol DefaultMask understands. An unmapped type stays
18
+ # nil so no mask is emitted: 'USER-DEFINED' covers every enum and
19
+ # extension type, where the set of valid values is per-column, and
20
+ # bytea/uuid/inet/interval have no constant that is safe to write back.
21
+ TYPE_MAP = {
22
+ "character" => :string,
23
+ "character varying" => :string,
24
+ "text" => :text,
25
+ "smallint" => :integer,
26
+ "integer" => :integer,
27
+ "bigint" => :integer,
28
+ "numeric" => :decimal,
29
+ "decimal" => :decimal,
30
+ "real" => :float,
31
+ "double precision" => :float,
32
+ "boolean" => :boolean,
33
+ "date" => :date,
34
+ "timestamp without time zone" => :datetime,
35
+ "timestamp with time zone" => :datetime,
36
+ "time without time zone" => :time,
37
+ "time with time zone" => :time,
38
+ "json" => :json,
39
+ "jsonb" => :jsonb,
40
+ }.freeze
41
+
42
+ # An ARRAY column reports data_type 'ARRAY' and carries the element type
43
+ # in udt_name, prefixed with an underscore (`_int4`). The element type is
44
+ # mapped so the column is still described accurately, even though
45
+ # DefaultMask emits no mask for an array either way.
46
+ ARRAY_DATA_TYPE = "ARRAY"
47
+ ELEMENT_TYPE_MAP = {
48
+ "bpchar" => :string,
49
+ "varchar" => :string,
50
+ "text" => :text,
51
+ "int2" => :integer,
52
+ "int4" => :integer,
53
+ "int8" => :integer,
54
+ "numeric" => :decimal,
55
+ "float4" => :float,
56
+ "float8" => :float,
57
+ "bool" => :boolean,
58
+ "date" => :date,
59
+ "timestamp" => :datetime,
60
+ "timestamptz" => :datetime,
61
+ "time" => :time,
62
+ "timetz" => :time,
63
+ "json" => :json,
64
+ "jsonb" => :jsonb,
65
+ }.freeze
66
+
67
+ # PostgreSQL renders a stored default back as the SQL text that produced
68
+ # it, so a plain literal arrives with its cast attached
69
+ # (`'member'::user_role`, `0`, `true`) and a computed one as the call that
70
+ # computes it (`now()`, `nextval('...')`). Only the literal forms are
71
+ # recognized, and the cast is stripped: matching what a mask may be built
72
+ # from, rather than trying to exclude every expression, keeps an
73
+ # unfamiliar expression on the safe side of the line.
74
+ QUOTED_LITERAL = /\A'((?:[^']|'')*)'(?:::[^']+)?\z/
75
+ NUMERIC_LITERAL = /\A-?\d+(?:\.\d+)?\z/
76
+ BOOLEAN_LITERAL = /\A(?:true|false)\z/i
77
+
78
+ def table_names
79
+ rows(<<~SQL).map { |row| row[0] }.sort
80
+ SELECT table_name
81
+ FROM information_schema.tables
82
+ WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'
83
+ SQL
84
+ end
85
+
86
+ def primary_key(table_name)
87
+ names = rows(<<~SQL, [table_name]).map { |row| row[0] }
88
+ SELECT kcu.column_name
89
+ FROM information_schema.table_constraints tc
90
+ JOIN information_schema.key_column_usage kcu
91
+ ON kcu.constraint_name = tc.constraint_name
92
+ AND kcu.constraint_schema = tc.constraint_schema
93
+ AND kcu.table_name = tc.table_name
94
+ WHERE tc.constraint_type = 'PRIMARY KEY'
95
+ AND tc.table_schema = current_schema()
96
+ AND tc.table_name = $1
97
+ ORDER BY kcu.ordinal_position
98
+ SQL
99
+
100
+ case names.size
101
+ when 0 then nil
102
+ when 1 then names.first
103
+ else names
104
+ end
105
+ end
106
+
107
+ def columns(table_name)
108
+ sql = <<~SQL
109
+ SELECT column_name, data_type, udt_name, character_maximum_length, column_default
110
+ FROM information_schema.columns
111
+ WHERE table_schema = current_schema() AND table_name = $1
112
+ ORDER BY ordinal_position
113
+ SQL
114
+
115
+ rows(sql, [table_name]).map do |name, data_type, udt_name, character_maximum_length, column_default|
116
+ array = data_type == ARRAY_DATA_TYPE
117
+ type = array ? ELEMENT_TYPE_MAP[udt_name.to_s.delete_prefix("_")] : TYPE_MAP[data_type]
118
+ Column.new(
119
+ name: name,
120
+ type: type,
121
+ limit: character_maximum_length&.to_i,
122
+ array: array,
123
+ default: coerce_default(type, literal_default(column_default)),
124
+ )
125
+ end
126
+ end
127
+
128
+ def unique_column_names(table_name)
129
+ # `attnum = ANY(indkey)` keeps an expression index out of the result on
130
+ # its own: its entries are recorded as attnum 0, which no real column
131
+ # has, so the index simply contributes nothing.
132
+ rows(<<~SQL, [table_name]).map { |row| row[0] }.to_set
133
+ SELECT att.attname
134
+ FROM pg_index i
135
+ JOIN pg_class rel ON rel.oid = i.indrelid
136
+ JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
137
+ JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = ANY(i.indkey)
138
+ WHERE i.indisunique
139
+ AND nsp.nspname = current_schema()
140
+ AND rel.relname = $1
141
+ SQL
142
+ rescue StandardError => e
143
+ warn_once(
144
+ :unique_column_names,
145
+ "exwiw: could not read the indexes of '#{table_name}' (#{e.class}); " \
146
+ "treating every column as unique-indexed so no constant mask is emitted.",
147
+ )
148
+ nil
149
+ end
150
+
151
+ def foreign_keys(table_name)
152
+ # `conkey` lists the constrained columns in key order; unnesting it WITH
153
+ # ORDINALITY yields the one-row-per-key-column shape build_foreign_keys
154
+ # groups, so a composite constraint stays recognizable as one.
155
+ build_foreign_keys(table_name, rows(<<~SQL, [table_name]))
156
+ SELECT con.conname, att.attname, ref.relname
157
+ FROM pg_constraint con
158
+ JOIN pg_class rel ON rel.oid = con.conrelid
159
+ JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
160
+ JOIN pg_class ref ON ref.oid = con.confrelid
161
+ JOIN unnest(con.conkey) WITH ORDINALITY AS u(attnum, ord) ON TRUE
162
+ JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = u.attnum
163
+ WHERE con.contype = 'f'
164
+ AND nsp.nspname = current_schema()
165
+ AND rel.relname = $1
166
+ ORDER BY con.conname, u.ord
167
+ SQL
168
+ end
169
+
170
+ # The catalog's `column_default` as a plain literal, or nil when it is
171
+ # absent or is an expression (see QUOTED_LITERAL and friends).
172
+ private def literal_default(column_default)
173
+ return nil if column_default.nil?
174
+
175
+ if (match = QUOTED_LITERAL.match(column_default))
176
+ # Inside a SQL string literal a quote is doubled; undouble it so the
177
+ # mask is the value the column actually defaults to.
178
+ return match[1].gsub("''", "'")
179
+ end
180
+ return column_default if column_default.match?(NUMERIC_LITERAL)
181
+ return column_default.downcase if column_default.match?(BOOLEAN_LITERAL)
182
+
183
+ nil
184
+ end
185
+
186
+ private def rows(sql, params = nil)
187
+ params.nil? ? connection.exec(sql).values : connection.exec_params(sql, params).values
188
+ end
189
+
190
+ private def connection
191
+ @connection ||= begin
192
+ require_driver!
193
+ PG.connect(
194
+ host: @connection_config.host,
195
+ port: @connection_config.port,
196
+ user: @connection_config.user,
197
+ password: @connection_config.password,
198
+ dbname: @connection_config.database_name,
199
+ )
200
+ end
201
+ end
202
+
203
+ # Soft-require the driver, like MysqlClient does, so a host that only ever
204
+ # runs the MySQL path is not forced to build the pg gem — and so the
205
+ # failure names the gem to install instead of surfacing a bare LoadError.
206
+ private def require_driver!
207
+ require "pg"
208
+ rescue LoadError
209
+ raise LoadError,
210
+ "exwiw needs the 'pg' gem to connect to PostgreSQL. " \
211
+ "Add `gem \"pg\"` to your Gemfile."
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Exwiw
6
+ # Reads a database's structure — tables, primary keys, columns, unique
7
+ # indexes, foreign keys — straight from its catalog, with no application code
8
+ # in the process.
9
+ #
10
+ # This is what lets a non-Ruby application keep an exwiw schema config: the
11
+ # ActiveRecord generator needs the app's models loaded in memory, which only
12
+ # its own runtime can do, while the database schema is a description every
13
+ # application shares regardless of the language it is written in. What is
14
+ # lost by reading the database instead of the models is relations that exist
15
+ # only in application code (no foreign-key constraint backs them) — which is
16
+ # why DbSchemaGenerator only ever *adds* belongs_tos and never rewrites the
17
+ # ones already in the config.
18
+ #
19
+ # Everything is scoped to the connection's current database/schema (MySQL:
20
+ # `DATABASE()`, PostgreSQL: `current_schema()`), i.e. exactly the objects the
21
+ # extraction itself would see through the same connection. There is no
22
+ # multi-database grouping equivalent to the ActiveRecord generator's: a
23
+ # connection points at one database, so one run generates one config
24
+ # directory, and a second database is a second run.
25
+ module DbIntrospector
26
+ # The adapters that can be introspected. sqlite is excluded because its
27
+ # catalog (PRAGMA-based) is a different shape entirely, and mongodb has no
28
+ # fixed schema to read — MongoidSchemaGenerator covers that case from the
29
+ # application side.
30
+ SUPPORTED_ADAPTERS = %w[mysql postgresql].freeze
31
+
32
+ # One column of a table, in the vocabulary DefaultMask.for speaks:
33
+ #
34
+ # - `type` is an ActiveRecord-ish symbol (:string, :integer, ...) or nil
35
+ # when the database type has no equivalent there. nil is deliberate and
36
+ # safe: DefaultMask emits no mask for a type it does not recognize, so an
37
+ # exotic column is exported unmasked-but-flagged rather than masked with a
38
+ # value it cannot hold.
39
+ # - `limit` is the character length for text-ish columns, else nil.
40
+ # - `array` marks a PostgreSQL ARRAY column, where no scalar mask fits.
41
+ # - `default` is the column's default as a Ruby scalar, or nil when it is
42
+ # absent or is an expression the database evaluates per row.
43
+ Column = Data.define(:name, :type, :limit, :array, :default)
44
+
45
+ # Build the introspector for a connection. Takes the same ConnectionConfig
46
+ # the adapters do, so the CLI can hand over exactly what it already parsed.
47
+ def self.build(connection_config)
48
+ case Adapter.normalize_name(connection_config.adapter)
49
+ when "mysql" then MysqlIntrospector.new(connection_config)
50
+ when "postgresql" then PostgresqlIntrospector.new(connection_config)
51
+ else
52
+ raise ArgumentError,
53
+ "Schema generation from a database connection supports " \
54
+ "#{SUPPORTED_ADAPTERS.join(' / ')} only, got #{connection_config.adapter.inspect}."
55
+ end
56
+ end
57
+
58
+ class Base
59
+ def initialize(connection_config)
60
+ @connection_config = connection_config
61
+ end
62
+
63
+ # Sorted names of the tables in the current database/schema. Views are
64
+ # excluded: they hold no rows of their own, so exporting one would
65
+ # duplicate data already covered by its underlying tables (and the
66
+ # restore would fail against a target where the same view exists).
67
+ def table_names
68
+ raise NotImplementedError
69
+ end
70
+
71
+ # The table's primary key as a String, an Array for a composite key, or
72
+ # nil when the table has none. The three cases are what the generator
73
+ # branches on, so they are kept distinct rather than normalized.
74
+ def primary_key(_table_name)
75
+ raise NotImplementedError
76
+ end
77
+
78
+ # The table's columns as Column structs, in ordinal_position order — the
79
+ # order the config lists them in, which mirrors what a `SELECT *` returns.
80
+ def columns(_table_name)
81
+ raise NotImplementedError
82
+ end
83
+
84
+ # The names of columns covered by any unique index or constraint, or nil
85
+ # when the catalog could not be read. Callers treat nil as "assume every
86
+ # column is unique", since masking a unique column with a constant makes
87
+ # every row collide on restore. The primary key is included; it is
88
+ # unique, and the generator never masks it anyway.
89
+ def unique_column_names(_table_name)
90
+ raise NotImplementedError
91
+ end
92
+
93
+ # The table's single-column foreign keys as sorted
94
+ # `{ table_name:, foreign_key: }` hashes — the belongs_to shape. Composite
95
+ # foreign keys are skipped with a warning: exwiw joins on one column, so a
96
+ # multi-column edge cannot be expressed, and silently emitting one of its
97
+ # columns would produce a join that is quietly wrong.
98
+ def foreign_keys(_table_name)
99
+ raise NotImplementedError
100
+ end
101
+
102
+ # Turn the plain default literal the catalog reported into the Ruby
103
+ # scalar DefaultMask can use as a mask value.
104
+ #
105
+ # Deliberately narrow. A default only earns its place as a mask because it
106
+ # is a value the column provably holds and the application treats as
107
+ # neutral; a value we had to guess at loses both properties. So a type
108
+ # whose text form we cannot map back with certainty yields nil and the
109
+ # per-type constant is used instead. JSON is excluded for that reason:
110
+ # DefaultMask re-serializes a JSON default with #to_json, which would turn
111
+ # the catalog's already-serialized text into a doubly-encoded string.
112
+ private def coerce_default(type, literal)
113
+ return nil if literal.nil?
114
+
115
+ case type
116
+ when :integer then Integer(literal, exception: false)
117
+ when :decimal, :float then Float(literal, exception: false)
118
+ when :boolean then BOOLEAN_DEFAULTS[literal.downcase]
119
+ when :string, :text, :date, :datetime, :time then literal
120
+ end
121
+ end
122
+
123
+ # How the two databases spell a boolean default in the catalog: MySQL
124
+ # stores a TINYINT(1) default as "1"/"0", PostgreSQL a boolean one as
125
+ # "true"/"false". Anything else is not a boolean literal and falls through
126
+ # to nil.
127
+ BOOLEAN_DEFAULTS = {
128
+ "1" => true, "true" => true,
129
+ "0" => false, "false" => false,
130
+ }.freeze
131
+
132
+ # Emit `message` to stderr the first time this introspector hits `key`.
133
+ # A catalog failure is systematic rather than per-table, so repeating it
134
+ # once per table would bury the rest of the run's output.
135
+ private def warn_once(key, message)
136
+ @warned ||= {}
137
+ return if @warned[key]
138
+
139
+ @warned[key] = true
140
+ warn(message)
141
+ end
142
+
143
+ # Group the catalog's one-row-per-key-column foreign-key listing into
144
+ # `{ table_name:, foreign_key: }` entries, dropping composite constraints.
145
+ # Both introspectors read their catalog in that shape (ordered by
146
+ # constraint name, then key position), so the grouping and the warning
147
+ # live here rather than being written twice.
148
+ private def build_foreign_keys(table_name, rows)
149
+ rows.group_by { |constraint_name, _column_name, _referenced_table| constraint_name }
150
+ .filter_map do |constraint_name, group|
151
+ if group.size > 1
152
+ warn "exwiw: skipping composite foreign key '#{constraint_name}' on '#{table_name}' " \
153
+ "(#{group.map { |_c, column_name, _r| column_name }.join(', ')}); " \
154
+ "exwiw joins a belongs_to on a single column."
155
+ next
156
+ end
157
+
158
+ _constraint_name, column_name, referenced_table = group.first
159
+ { table_name: referenced_table, foreign_key: column_name }
160
+ end
161
+ .uniq
162
+ .sort_by { |entry| [entry[:table_name], entry[:foreign_key]] }
163
+ end
164
+ end
165
+ end
166
+ end