mecha_encrypt 0.1.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 +7 -0
- data/LICENSE +19 -0
- data/README.md +123 -0
- data/Rakefile +11 -0
- data/lib/mecha_encrypt/auditor/prober.rb +307 -0
- data/lib/mecha_encrypt/auditor/static.rb +357 -0
- data/lib/mecha_encrypt/configuration.rb +71 -0
- data/lib/mecha_encrypt/errors.rb +11 -0
- data/lib/mecha_encrypt/matrix_test.rb +119 -0
- data/lib/mecha_encrypt/railtie.rb +11 -0
- data/lib/mecha_encrypt/registry.rb +195 -0
- data/lib/mecha_encrypt/version.rb +6 -0
- data/lib/mecha_encrypt.rb +39 -0
- data/lib/tasks/mecha_encrypt.rake +180 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9fc649c1928e2410fbe5fd4b6ab9cb7affc54c1341e23b7366d4bc9350114992
|
|
4
|
+
data.tar.gz: fddc7bd16987dcba62498085db982b695f68b0096e46478bc5ce7902721ef869
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6a694a96f6a20c6d041ea6e77cde060d842d3ad9ace9550308d54485326830f24268dad8a28a6e49910a0566c12796da85a082e85f89ca24ed58fb86c99604f0
|
|
7
|
+
data.tar.gz: d86de6527187ea372aa71aa038f9a10c3937ed40a16768d97ecb42900c58ed20d12d737c39dbf89c120a43d0f664fea477afb7e42e3951192f6e89ff82d99714
|
data/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright 2026 Jeff Lunt
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
4
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
5
|
+
the Software without restriction, including without limitation the rights to
|
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
7
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
8
|
+
so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# MechaEncrypt
|
|
2
|
+
|
|
3
|
+
`mecha_encrypt` provides a closed-by-default database column encryption registry and auditing toolkit for Rails applications using `ActiveRecord::Encryption`. Every database table and column must be explicitly registered in `config/mecha_encrypt.yml` with its encryption status and justification.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's `Gemfile`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "mecha_encrypt"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or if developing locally:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "mecha_encrypt", path: "~/code/mecha_encrypt"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
And then execute:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### 1. Define `config/mecha_encrypt.yml`
|
|
28
|
+
|
|
29
|
+
Register all database tables and columns in `config/mecha_encrypt.yml`. Unencrypted columns require a meaningful explanation in `comment:` (minimum length enforced, placeholder strings rejected).
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
debug_colorization:
|
|
33
|
+
encrypted: :green
|
|
34
|
+
deterministic: :light_cyan
|
|
35
|
+
unencrypted: :light_purple
|
|
36
|
+
ignored: :yellow
|
|
37
|
+
violation: :light_red
|
|
38
|
+
unmapped: :dark_grey
|
|
39
|
+
|
|
40
|
+
ignored_tables:
|
|
41
|
+
- pattern: "solid_queue_*"
|
|
42
|
+
comment: "SolidQueue internal background job coordination tables"
|
|
43
|
+
|
|
44
|
+
tables:
|
|
45
|
+
email_accounts:
|
|
46
|
+
id:
|
|
47
|
+
encrypted: false
|
|
48
|
+
comment: "Primary key UUID identifier required for foreign key joins"
|
|
49
|
+
email_address:
|
|
50
|
+
encrypted: true
|
|
51
|
+
deterministic: true
|
|
52
|
+
comment: "User email address encrypted deterministically for lookups"
|
|
53
|
+
oauth_token:
|
|
54
|
+
encrypted: true
|
|
55
|
+
deterministic: false
|
|
56
|
+
comment: "OAuth access token encrypted with non-deterministic IV"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Configure the Initializer
|
|
60
|
+
|
|
61
|
+
Create `config/initializers/mecha_encrypt.rb`:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# config/initializers/mecha_encrypt.rb
|
|
65
|
+
require "mecha_encrypt"
|
|
66
|
+
|
|
67
|
+
MechaEncrypt.configure do |config|
|
|
68
|
+
config.config_path = Rails.root.join("config/mecha_encrypt.yml")
|
|
69
|
+
config.base_model_class = "ApplicationRecord"
|
|
70
|
+
config.min_comment_length = 10
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. Rake Tasks & Auditing
|
|
75
|
+
|
|
76
|
+
`mecha_encrypt` automatically registers Rake tasks via its Railtie:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Print all database tables and columns in alphabetical order with encryption status
|
|
80
|
+
bin/rails mecha_encrypt
|
|
81
|
+
|
|
82
|
+
# Statically verify schema parity, ActiveRecord::Encryption declarations, and comments
|
|
83
|
+
bin/rails mecha_encrypt:verify:static
|
|
84
|
+
|
|
85
|
+
# Run behavioral ciphertext round-trip and index safety tests
|
|
86
|
+
bin/rails mecha_encrypt:verify:behavioral
|
|
87
|
+
|
|
88
|
+
# Run both static and behavioral verification
|
|
89
|
+
bin/rails mecha_encrypt:verify
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 4. Dynamic Parallel Matrix Testing in Consuming Apps
|
|
93
|
+
|
|
94
|
+
`mecha_encrypt` ships with [`MechaEncrypt::MatrixTest`](lib/mecha_encrypt/matrix_test.rb), which dynamically generates individual Minitest test methods for each encrypted column (type serialization, envelope format, and determinism) and each table with encrypted columns (raw SQL ciphertext verification), running in parallel across Minitest workers.
|
|
95
|
+
|
|
96
|
+
In your consuming Rails application's `test/test_helper.rb`:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
MechaEncrypt.configuration.configure_test_matrix(
|
|
100
|
+
setup: -> {
|
|
101
|
+
# Create persisted sample records for tables containing encrypted columns
|
|
102
|
+
@records_by_table = {
|
|
103
|
+
"email_accounts" => @account,
|
|
104
|
+
"email_messages" => @message
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
records: -> { @records_by_table }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
require "mecha_encrypt/matrix_test" unless ENV["MECHA_MATRIX_STANDALONE"] == "1"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Running Tests
|
|
114
|
+
|
|
115
|
+
To run the gem's standalone test suite:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bundle exec rake test
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
Copyright 2026 Jeff Lunt. Released under the [MIT License](LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module MechaEncrypt
|
|
6
|
+
module Auditor
|
|
7
|
+
class Prober
|
|
8
|
+
CipherFailure = Struct.new(:table, :column, :model, :message, keyword_init: true)
|
|
9
|
+
IndexFailure = Struct.new(:table, :column, :index_name, :message, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
Result = Struct.new(
|
|
12
|
+
:probed_columns_count,
|
|
13
|
+
:probed_records_count,
|
|
14
|
+
:cipher_failures,
|
|
15
|
+
:index_failures,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
) do
|
|
18
|
+
def success?
|
|
19
|
+
cipher_failures.empty? && index_failures.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def failures
|
|
23
|
+
cipher_failures + index_failures
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(registry: MechaEncrypt.registry, configuration: MechaEncrypt.configuration, connection: nil)
|
|
28
|
+
@registry = registry
|
|
29
|
+
@configuration = configuration
|
|
30
|
+
@connection = connection
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Verify that non-deterministic encrypted columns do not have broken B-tree / unique DB indexes
|
|
34
|
+
def probe_index_safety
|
|
35
|
+
index_failures = []
|
|
36
|
+
conn = db_connection
|
|
37
|
+
|
|
38
|
+
@registry.tables.each do |table_name, cols|
|
|
39
|
+
next unless conn.table_exists?(table_name)
|
|
40
|
+
|
|
41
|
+
indexes = conn.indexes(table_name)
|
|
42
|
+
cols.each_value do |entry|
|
|
43
|
+
next unless entry[:encrypted] && !entry[:deterministic]
|
|
44
|
+
|
|
45
|
+
col_name = entry[:column]
|
|
46
|
+
indexes.each do |idx|
|
|
47
|
+
covered = if idx.columns.is_a?(Array)
|
|
48
|
+
idx.columns.map(&:to_s).include?(col_name)
|
|
49
|
+
else
|
|
50
|
+
idx.columns.to_s.match?(/\b#{Regexp.escape(col_name)}\b/)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if covered
|
|
54
|
+
index_failures << IndexFailure.new(
|
|
55
|
+
table: table_name,
|
|
56
|
+
column: col_name,
|
|
57
|
+
index_name: idx.name,
|
|
58
|
+
message: "Non-deterministic encrypted column '#{table_name}.#{col_name}' is covered by DB index '#{idx.name}'. Non-deterministic encryption uses random IVs, breaking index lookups and uniqueness constraints."
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Result.new(
|
|
66
|
+
probed_columns_count: @registry.encrypted_columns.size,
|
|
67
|
+
probed_records_count: 0,
|
|
68
|
+
cipher_failures: [],
|
|
69
|
+
index_failures: index_failures
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Verify type serialization, ciphertext envelope, round-trip decryption, and deterministic vs non-deterministic IV behavior
|
|
74
|
+
def probe_encryption_types
|
|
75
|
+
ensure_models_loaded!
|
|
76
|
+
cipher_failures = []
|
|
77
|
+
models_by_table = discover_models_by_table
|
|
78
|
+
probed_count = 0
|
|
79
|
+
|
|
80
|
+
@registry.encrypted_columns.each do |entry|
|
|
81
|
+
res = probe_single_column_type(entry, models_by_table: models_by_table)
|
|
82
|
+
probed_count += res.probed_columns_count
|
|
83
|
+
cipher_failures.concat(res.cipher_failures)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Result.new(
|
|
87
|
+
probed_columns_count: probed_count,
|
|
88
|
+
probed_records_count: 0,
|
|
89
|
+
cipher_failures: cipher_failures,
|
|
90
|
+
index_failures: []
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def probe_single_column_type(entry, models_by_table: nil)
|
|
95
|
+
ensure_models_loaded!
|
|
96
|
+
models_map = models_by_table || discover_models_by_table
|
|
97
|
+
cipher_failures = []
|
|
98
|
+
|
|
99
|
+
table_name = entry[:table]
|
|
100
|
+
col_name = entry[:column]
|
|
101
|
+
model_klass = models_map[table_name]
|
|
102
|
+
|
|
103
|
+
if model_klass.nil?
|
|
104
|
+
cipher_failures << CipherFailure.new(
|
|
105
|
+
table: table_name,
|
|
106
|
+
column: col_name,
|
|
107
|
+
model: nil,
|
|
108
|
+
message: "No ActiveRecord model found for table '#{table_name}'"
|
|
109
|
+
)
|
|
110
|
+
else
|
|
111
|
+
attr_type = model_klass.type_for_attribute(col_name)
|
|
112
|
+
if !attr_type.respond_to?(:serialize) || !attr_type.respond_to?(:deserialize)
|
|
113
|
+
cipher_failures << CipherFailure.new(
|
|
114
|
+
table: table_name,
|
|
115
|
+
column: col_name,
|
|
116
|
+
model: model_klass.name,
|
|
117
|
+
message: "Attribute type for #{model_klass.name}##{col_name} does not support serialize/deserialize"
|
|
118
|
+
)
|
|
119
|
+
else
|
|
120
|
+
probe_plaintext = "mecha_secret_#{table_name}_#{col_name}_#{SecureRandom.hex(8)}"
|
|
121
|
+
cipher1 = attr_type.serialize(probe_plaintext)
|
|
122
|
+
cipher2 = attr_type.serialize(probe_plaintext)
|
|
123
|
+
|
|
124
|
+
if !cipher1.is_a?(String) || cipher1.include?(probe_plaintext) || !cipher1.start_with?("{\"p\":")
|
|
125
|
+
cipher_failures << CipherFailure.new(
|
|
126
|
+
table: table_name,
|
|
127
|
+
column: col_name,
|
|
128
|
+
model: model_klass.name,
|
|
129
|
+
message: "Serialized value for #{model_klass.name}##{col_name} is not valid ActiveRecord::Encryption ciphertext"
|
|
130
|
+
)
|
|
131
|
+
else
|
|
132
|
+
decrypted = attr_type.deserialize(cipher1)
|
|
133
|
+
if decrypted != probe_plaintext
|
|
134
|
+
cipher_failures << CipherFailure.new(
|
|
135
|
+
table: table_name,
|
|
136
|
+
column: col_name,
|
|
137
|
+
model: model_klass.name,
|
|
138
|
+
message: "Round-trip decryption failed for #{model_klass.name}##{col_name}: expected #{probe_plaintext.inspect}, got #{decrypted.inspect}"
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if entry[:deterministic] && cipher1 != cipher2
|
|
143
|
+
cipher_failures << CipherFailure.new(
|
|
144
|
+
table: table_name,
|
|
145
|
+
column: col_name,
|
|
146
|
+
model: model_klass.name,
|
|
147
|
+
message: "Deterministic encrypted column #{model_klass.name}##{col_name} produced different ciphertexts for identical plaintext"
|
|
148
|
+
)
|
|
149
|
+
elsif !entry[:deterministic] && cipher1 == cipher2
|
|
150
|
+
cipher_failures << CipherFailure.new(
|
|
151
|
+
table: table_name,
|
|
152
|
+
column: col_name,
|
|
153
|
+
model: model_klass.name,
|
|
154
|
+
message: "Non-deterministic encrypted column #{model_klass.name}##{col_name} produced identical ciphertexts for identical plaintext (missing random IV)"
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Result.new(
|
|
162
|
+
probed_columns_count: 1,
|
|
163
|
+
probed_records_count: 0,
|
|
164
|
+
cipher_failures: cipher_failures,
|
|
165
|
+
index_failures: []
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Verify raw SQL storage in PostgreSQL for persisted model records
|
|
170
|
+
def probe_persisted_records(records_by_table)
|
|
171
|
+
cipher_failures = []
|
|
172
|
+
probed_records = 0
|
|
173
|
+
normalized = normalize_records_map(records_by_table)
|
|
174
|
+
|
|
175
|
+
encrypted_by_table = @registry.encrypted_columns.group_by { |e| e[:table] }
|
|
176
|
+
|
|
177
|
+
encrypted_by_table.each_key do |table_name|
|
|
178
|
+
res = probe_single_table_persisted_record(table_name, normalized)
|
|
179
|
+
probed_records += res.probed_records_count
|
|
180
|
+
cipher_failures.concat(res.cipher_failures)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
Result.new(
|
|
184
|
+
probed_columns_count: @registry.encrypted_columns.size,
|
|
185
|
+
probed_records_count: probed_records,
|
|
186
|
+
cipher_failures: cipher_failures,
|
|
187
|
+
index_failures: []
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def probe_single_table_persisted_record(table_name, records_by_table)
|
|
192
|
+
cipher_failures = []
|
|
193
|
+
probed_records = 0
|
|
194
|
+
conn = db_connection
|
|
195
|
+
normalized = normalize_records_map(records_by_table)
|
|
196
|
+
|
|
197
|
+
entries = @registry.encrypted_columns.select { |e| e[:table] == table_name.to_s }
|
|
198
|
+
record = normalized[table_name.to_s]
|
|
199
|
+
|
|
200
|
+
if record.nil?
|
|
201
|
+
cipher_failures << CipherFailure.new(
|
|
202
|
+
table: table_name.to_s,
|
|
203
|
+
column: entries.map { |e| e[:column] }.join(", "),
|
|
204
|
+
model: nil,
|
|
205
|
+
message: "Missing test record for table '#{table_name}' with encrypted columns"
|
|
206
|
+
)
|
|
207
|
+
else
|
|
208
|
+
probed_records = 1
|
|
209
|
+
entries.each do |entry|
|
|
210
|
+
col_name = entry[:column]
|
|
211
|
+
plaintext_val = record.public_send(col_name).to_s
|
|
212
|
+
|
|
213
|
+
if plaintext_val.empty?
|
|
214
|
+
cipher_failures << CipherFailure.new(
|
|
215
|
+
table: table_name.to_s,
|
|
216
|
+
column: col_name,
|
|
217
|
+
model: record.class.name,
|
|
218
|
+
message: "Test record for #{record.class.name}##{col_name} has an empty plaintext value; cannot verify ciphertext storage"
|
|
219
|
+
)
|
|
220
|
+
next
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
sql = "SELECT #{conn.quote_column_name(col_name)} FROM #{conn.quote_table_name(table_name)} WHERE id = #{conn.quote(record.id)}"
|
|
224
|
+
raw_db_value = conn.select_value(sql).to_s
|
|
225
|
+
|
|
226
|
+
if raw_db_value.empty? || raw_db_value.include?(plaintext_val) || !raw_db_value.start_with?("{\"p\":")
|
|
227
|
+
cipher_failures << CipherFailure.new(
|
|
228
|
+
table: table_name.to_s,
|
|
229
|
+
column: col_name,
|
|
230
|
+
model: record.class.name,
|
|
231
|
+
message: "PLAINTEXT LEAK IN DB: Raw SQL value for #{table_name}.#{col_name} (id=#{record.id}) is not encrypted! Got: #{raw_db_value.inspect}"
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
reloaded_val = record.class.find(record.id).public_send(col_name).to_s
|
|
236
|
+
if reloaded_val != plaintext_val
|
|
237
|
+
cipher_failures << CipherFailure.new(
|
|
238
|
+
table: table_name.to_s,
|
|
239
|
+
column: col_name,
|
|
240
|
+
model: record.class.name,
|
|
241
|
+
message: "Reloaded value for #{table_name}.#{col_name} (id=#{record.id}) did not decrypt back to #{plaintext_val.inspect} (got #{reloaded_val.inspect})"
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
Result.new(
|
|
248
|
+
probed_columns_count: entries.size,
|
|
249
|
+
probed_records_count: probed_records,
|
|
250
|
+
cipher_failures: cipher_failures,
|
|
251
|
+
index_failures: []
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def probe_all(records_by_table = nil)
|
|
256
|
+
idx_res = probe_index_safety
|
|
257
|
+
type_res = probe_encryption_types
|
|
258
|
+
rec_res = records_by_table ? probe_persisted_records(records_by_table) : nil
|
|
259
|
+
|
|
260
|
+
Result.new(
|
|
261
|
+
probed_columns_count: type_res.probed_columns_count,
|
|
262
|
+
probed_records_count: rec_res ? rec_res.probed_records_count : 0,
|
|
263
|
+
cipher_failures: type_res.cipher_failures + (rec_res ? rec_res.cipher_failures : []),
|
|
264
|
+
index_failures: idx_res.index_failures
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def db_connection
|
|
271
|
+
@connection || ActiveRecord::Base.connection
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def ensure_models_loaded!
|
|
275
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
276
|
+
Rails.application.eager_load!
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def discover_models_by_table
|
|
281
|
+
base_class_name = @configuration.base_model_class.to_s
|
|
282
|
+
base_class = base_class_name.safe_constantize
|
|
283
|
+
return {} unless base_class
|
|
284
|
+
|
|
285
|
+
models = {}
|
|
286
|
+
base_class.descendants.reject(&:abstract_class?).each do |klass|
|
|
287
|
+
next unless klass.name.present? && !klass.name.include?("#<")
|
|
288
|
+
next unless klass.table_name.present?
|
|
289
|
+
|
|
290
|
+
models[klass.table_name] ||= klass
|
|
291
|
+
end
|
|
292
|
+
models
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def normalize_records_map(records_hash)
|
|
296
|
+
records_hash.each_with_object({}) do |(k, v), h|
|
|
297
|
+
key = if k.is_a?(Class) && k.respond_to?(:table_name)
|
|
298
|
+
k.table_name.to_s
|
|
299
|
+
else
|
|
300
|
+
k.to_s
|
|
301
|
+
end
|
|
302
|
+
h[key] = v
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|