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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +101 -10
- data/docs/mongodb.md +13 -0
- data/lib/exwiw/cli.rb +190 -3
- data/lib/exwiw/db_introspector/mysql_introspector.rb +160 -0
- data/lib/exwiw/db_introspector/postgresql_introspector.rb +215 -0
- data/lib/exwiw/db_introspector.rb +166 -0
- data/lib/exwiw/db_schema_generator.rb +298 -0
- data/lib/exwiw/mongodb_collection_config.rb +21 -12
- data/lib/exwiw/mongoid_schema_generator.rb +368 -36
- data/lib/exwiw/schema_check.rb +48 -6
- data/lib/exwiw/table_config.rb +1 -1
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +4 -0
- data/lib/tasks/exwiw.rake +37 -0
- metadata +5 -1
|
@@ -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
|