dynamic-active-model-rails 0.14.0 → 0.15.1
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/README.md +54 -1
- data/lib/dynamic-active-model/rails/database_definition.rb +23 -1
- data/lib/dynamic-active-model/rails/database_loader.rb +22 -2
- data/lib/dynamic-active-model/rails/lazy_namespace.rb +7 -0
- data/lib/generators/dynamic_active_model/database/database_generator.rb +4 -0
- data/lib/generators/dynamic_active_model/database_arguments.rb +35 -8
- data/lib/generators/dynamic_active_model/install/install_generator.rb +4 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72bf2f4e81912203cc8fa972baec98b8e25fbca4565272a9e5bd7a915bb5bbd3
|
|
4
|
+
data.tar.gz: be83fdc8eaa6cb83c83d6298d54be005779ecd4dc4931a2c3216d3a5c5aa2342
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40a0d18557415731182cd67634f5dfac07e9394ce6a8715d437ab1c0a28f85f1dd0177d41b043bb3610f76aa1688dd58b49e9f5a9546508d8a0412c0b5466638
|
|
7
|
+
data.tar.gz: 3dc87829e2442bceb70c63127cc3a7ef00a43c3741166c721e4e8100a6468e6ad881dfc6b87dd4718dc30f2c984cadf76393d128d732cdb359e44f86e656a3ef
|
data/README.md
CHANGED
|
@@ -19,6 +19,7 @@ gem 'dynamic-active-model-rails'
|
|
|
19
19
|
bin/rails generate dynamic_active_model:install # DB, app/models/db/
|
|
20
20
|
bin/rails generate dynamic_active_model:install grant_db # GrantDB, app/models/grant_db/
|
|
21
21
|
bin/rails generate dynamic_active_model:database cars --connection cars
|
|
22
|
+
bin/rails generate dynamic_active_model:database cars --connection cars --replica cars_replica
|
|
22
23
|
bin/rails generate dynamic_active_model:extension grant_db users # app/models/grant_db/users.ext.rb
|
|
23
24
|
```
|
|
24
25
|
|
|
@@ -26,7 +27,7 @@ bin/rails generate dynamic_active_model:extension grant_db users # app/mod
|
|
|
26
27
|
- **`database NAME`** adds another `add_database` line to the initializer and creates the folder. It refuses a namespace the app already declares.
|
|
27
28
|
- **`extension DATABASE TABLE`** creates an extension file. `DATABASE` can be a name (`grant_db`) or a namespace (`GrantDB`). The generator uses the app's configuration, so a custom `extensions_path:` or `extensions_suffix:` is respected.
|
|
28
29
|
|
|
29
|
-
`--connection NAME` points the database at a `database.yml` entry. The generators warn if the current environment has no such entry. Without `--connection`, the database shares `ApplicationRecord`'s connection.
|
|
30
|
+
`--connection NAME` points the database at a `database.yml` entry. `--replica NAME` adds a reading role through `connects_to` and requires `--connection`. The generators warn if the current environment has no such entry. Without `--connection`, the database shares `ApplicationRecord`'s connection.
|
|
30
31
|
|
|
31
32
|
## Configuration
|
|
32
33
|
|
|
@@ -76,9 +77,41 @@ The `cars_db` → `CarsDB` mapping is registered with the Rails autoloader only,
|
|
|
76
77
|
| `connection` (2nd argument) | `nil` | `nil` shares the parent class's connection. Otherwise it's passed to `establish_connection`: a database.yml entry name (Symbol), URL or hash. |
|
|
77
78
|
| `module_name:` | `"<Name>DB"` | Namespace override. |
|
|
78
79
|
| `parent_class:` | `'ApplicationRecord'` | Superclass of the generated abstract base class, given as a name so it can be reloaded. |
|
|
80
|
+
| `connects_to:` | `nil` | Roles for Rails multi-database support, such as `{ writing: :cars, reading: :cars_replica }`, or `connects_to`'s own arguments (`{ database: ..., shards: ... }`). Can't be combined with a `connection`. |
|
|
79
81
|
| `extensions_path:` | `app/models/<folder>` | Directory of extension files, absolute or relative to `Rails.root`. The default folder may be absent; a configured path must exist. |
|
|
80
82
|
| `extensions_suffix:` | `'.ext.rb'` | Suffix of extension files. The autoloader ignores files with this suffix. |
|
|
81
83
|
|
|
84
|
+
## Read Replicas
|
|
85
|
+
|
|
86
|
+
`connects_to:` registers the database's roles with Rails' multi-database support:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
config.add_database :cars, connects_to: { writing: :cars, reading: :cars_replica }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
# config/database.yml
|
|
94
|
+
production:
|
|
95
|
+
cars:
|
|
96
|
+
<<: *default
|
|
97
|
+
database: cars
|
|
98
|
+
cars_replica:
|
|
99
|
+
<<: *default
|
|
100
|
+
database: cars
|
|
101
|
+
replica: true
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Switch roles for one database through its namespace, or for every database with ActiveRecord:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
CarsDB.connected_to(role: :reading) { CarsDB::Car.count } # only CarsDB
|
|
108
|
+
ActiveRecord::Base.connected_to(role: :reading) { ... } # all databases with roles
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Writes inside the reading role raise `ActiveRecord::ReadOnlyError`. Rails' automatic role switching (`config.active_record.database_selector`) works too. Shards pass through as well: `connects_to: { shards: { one: { writing: :cars_one } } }`.
|
|
112
|
+
|
|
113
|
+
A database that shares `ApplicationRecord`'s connection follows `ApplicationRecord`'s roles. `GrantDB.connected_to(...)` switches `ApplicationRecord`.
|
|
114
|
+
|
|
82
115
|
## Extending Models
|
|
83
116
|
|
|
84
117
|
Add one `<table_name>.ext.rb` file per table to the database's folder:
|
|
@@ -125,6 +158,26 @@ Avoid `Rails.application.config.after_initialize { GrantDB.database... }`. It ru
|
|
|
125
158
|
- **Migrations.** After migrations run or a schema is loaded (`db:migrate`, `db:rollback`, `db:prepare`, `db:schema:load`, `maintain_test_schema!`), models are reset and rebuild on next use. That means `bin/rails db:prepare` followed by seeding works in one process.
|
|
126
159
|
- **Reloading.** In development, models are rebuilt whenever the app reloads, including after edits to `.ext.rb` files or to anything under `db/`, such as a migration rewriting `db/schema.rb`.
|
|
127
160
|
|
|
161
|
+
## Schema Cache
|
|
162
|
+
|
|
163
|
+
Models read columns, primary keys and indexes through Rails' schema cache. Dump it at deploy time, and building models no longer queries every table:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
bin/rails db:schema:cache:dump # db/schema_cache.yml, db/<name>_schema_cache.yml
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| | Queries to build 5 tables (SQLite) |
|
|
170
|
+
|---|---|
|
|
171
|
+
| No dump | 28 |
|
|
172
|
+
| Dump, Rails defaults | 4 |
|
|
173
|
+
| Dump, `config.active_record.check_schema_cache_dump_version = false` | 2 |
|
|
174
|
+
|
|
175
|
+
The table list is always one live query. The dump holds each table's indexes (name, uniqueness, columns), so associations come out the same as from live queries, including `has_one` from unique indexes and join tables.
|
|
176
|
+
|
|
177
|
+
A dump is only as fresh as the last `db:schema:cache:dump`. By default, Rails compares the dump's schema version with the database, and ignores the dump after a migration. That version only tracks migrations, though. For a database whose schema changes outside Rails migrations, such as a legacy or externally managed database, regenerate the dump on every deploy or don't use one. Otherwise new columns, indexes and associations are missed. With `check_schema_cache_dump_version = false`, any stale dump is used.
|
|
178
|
+
|
|
179
|
+
When models are reset after a code reload or schema change, the gem clears the database's schema cache too. That matters because Rails only clears the primary database's cache on reload.
|
|
180
|
+
|
|
128
181
|
## Migrations
|
|
129
182
|
|
|
130
183
|
Migrations stay standard Rails. The gem reads the schema; it never owns it. For a secondary database, give its database.yml entry a `migrations_paths`:
|
|
@@ -26,6 +26,9 @@ module DynamicActiveModel
|
|
|
26
26
|
# @return [String] File suffix of extension files
|
|
27
27
|
attr_reader :extensions_suffix
|
|
28
28
|
|
|
29
|
+
# @return [Hash, nil] Keyword arguments for the base class's connects_to
|
|
30
|
+
attr_reader :connects_to
|
|
31
|
+
|
|
29
32
|
# @return [Hash] Custom relationship names by table and foreign key
|
|
30
33
|
attr_reader :relationships
|
|
31
34
|
|
|
@@ -39,9 +42,15 @@ module DynamicActiveModel
|
|
|
39
42
|
# @param extensions_path [String, nil] Extensions directory, absolute or relative to
|
|
40
43
|
# the app root; defaults to app/models/<folder>, which may be absent
|
|
41
44
|
# @param extensions_suffix [String] File suffix of extension files
|
|
45
|
+
# @param connects_to [Hash, nil] Roles ({ writing: :cars, reading: :cars_replica }) or
|
|
46
|
+
# connects_to's own arguments ({ database: ..., shards: ... })
|
|
47
|
+
# @raise [ArgumentError] If both a connection and connects_to are given
|
|
42
48
|
def initialize(name, connection = nil, module_name: nil, parent_class: 'ApplicationRecord',
|
|
43
|
-
extensions_path: nil, extensions_suffix: '.ext.rb')
|
|
49
|
+
extensions_path: nil, extensions_suffix: '.ext.rb', connects_to: nil)
|
|
50
|
+
raise ArgumentError, 'pass either a connection or connects_to:, not both' if connection && connects_to
|
|
51
|
+
|
|
44
52
|
@connection = connection
|
|
53
|
+
@connects_to = connects_to && normalize_connects_to(connects_to)
|
|
45
54
|
@module_name = module_name || default_module_name(name)
|
|
46
55
|
@parent_class_name = parent_class.to_s
|
|
47
56
|
@extensions_path = extensions_path&.to_s
|
|
@@ -68,6 +77,12 @@ module DynamicActiveModel
|
|
|
68
77
|
File.expand_path(@extensions_path || File.join('app', 'models', folder), root.to_s)
|
|
69
78
|
end
|
|
70
79
|
|
|
80
|
+
# @return [Boolean] Whether the generated base class owns its connection, rather
|
|
81
|
+
# than sharing the parent class's
|
|
82
|
+
def own_connection?
|
|
83
|
+
!(connection.nil? && connects_to.nil?)
|
|
84
|
+
end
|
|
85
|
+
|
|
71
86
|
# @return [Boolean] Whether extensions_path was configured, so it must exist
|
|
72
87
|
def custom_extensions_path?
|
|
73
88
|
!@extensions_path.nil?
|
|
@@ -107,6 +122,13 @@ module DynamicActiveModel
|
|
|
107
122
|
|
|
108
123
|
private
|
|
109
124
|
|
|
125
|
+
# Treats a hash of roles as connects_to's database: argument
|
|
126
|
+
# @param options [Hash]
|
|
127
|
+
# @return [Hash]
|
|
128
|
+
def normalize_connects_to(options)
|
|
129
|
+
options.key?(:database) || options.key?(:shards) ? options : { database: options }
|
|
130
|
+
end
|
|
131
|
+
|
|
110
132
|
# :cars => "CarsDB", :grant_db => "GrantDB", :db => "DB"
|
|
111
133
|
# @param name [Symbol, String]
|
|
112
134
|
# @return [String]
|
|
@@ -27,16 +27,28 @@ module DynamicActiveModel
|
|
|
27
27
|
@database
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# The class that owns the database's connection, for role and shard switching:
|
|
31
|
+
# the generated base class, or the parent class when sharing its connection
|
|
32
|
+
# @return [Class]
|
|
33
|
+
def connection_class
|
|
34
|
+
base_class = load!.factory.base_class
|
|
35
|
+
definition.own_connection? ? base_class : base_class.superclass
|
|
36
|
+
end
|
|
37
|
+
|
|
30
38
|
# @return [Boolean] Whether models are built (or being built)
|
|
31
39
|
def loaded?
|
|
32
40
|
!@database.nil?
|
|
33
41
|
end
|
|
34
42
|
|
|
35
|
-
# Removes the built models
|
|
43
|
+
# Removes the built models, and the schema cache their columns and indexes
|
|
44
|
+
# came from, so the next #load! rebuilds them against the current schema
|
|
36
45
|
# @return [void]
|
|
37
46
|
def reset!
|
|
38
47
|
@monitor.synchronize do
|
|
39
|
-
@database
|
|
48
|
+
if @database
|
|
49
|
+
clear_schema_cache
|
|
50
|
+
@database.reset!
|
|
51
|
+
end
|
|
40
52
|
@database = nil
|
|
41
53
|
end
|
|
42
54
|
end
|
|
@@ -57,9 +69,17 @@ module DynamicActiveModel
|
|
|
57
69
|
raise
|
|
58
70
|
end
|
|
59
71
|
|
|
72
|
+
# Clears the schema cache of the pool the models use. Rails clears only the
|
|
73
|
+
# primary pool on code reload, and nothing on schema changes outside migrations.
|
|
74
|
+
# @return [void]
|
|
75
|
+
def clear_schema_cache
|
|
76
|
+
@database.models.map(&:connection_pool).uniq.each { |pool| pool.schema_cache.clear! }
|
|
77
|
+
end
|
|
78
|
+
|
|
60
79
|
# @return [DynamicActiveModel::Database] A database configured from the definition
|
|
61
80
|
def new_database
|
|
62
81
|
Database.new(namespace, definition.connection, parent_class: parent_class).tap do |database|
|
|
82
|
+
database.factory.base_class.connects_to(**definition.connects_to) if definition.connects_to
|
|
63
83
|
definition.skipped_tables.each { |table| database.skip_table(Explorer.skip_table_matcher(table)) }
|
|
64
84
|
definition.included_tables.each { |table| database.include_table(Explorer.skip_table_matcher(table)) }
|
|
65
85
|
definition.table_class_names.each { |table, class_name| database.table_class_name(table, class_name) }
|
|
@@ -34,6 +34,13 @@ module DynamicActiveModel
|
|
|
34
34
|
database.models
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Switches role or shard for this database's models, like ActiveRecord's
|
|
38
|
+
# connected_to, e.g. CarsDB.connected_to(role: :reading) { CarsDB::Car.count }
|
|
39
|
+
# @return [Object] The block's result
|
|
40
|
+
def connected_to(**, &)
|
|
41
|
+
dynamic_active_model_loader.connection_class.connected_to(**, &)
|
|
42
|
+
end
|
|
43
|
+
|
|
37
44
|
# Builds the models on first reference, then retries the lookup
|
|
38
45
|
# @param name [Symbol]
|
|
39
46
|
# @return [Object]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module DynamicActiveModel
|
|
4
4
|
module Generators
|
|
5
|
-
# Shared --connection
|
|
5
|
+
# Shared --connection/--replica options and helpers for generators that declare a database
|
|
6
6
|
module DatabaseArguments
|
|
7
7
|
INITIALIZER = 'config/initializers/dynamic_active_model.rb'
|
|
8
8
|
|
|
@@ -10,6 +10,9 @@ module DynamicActiveModel
|
|
|
10
10
|
base.class_option :connection,
|
|
11
11
|
type: :string,
|
|
12
12
|
desc: "database.yml entry to connect to (default: share ApplicationRecord's connection)"
|
|
13
|
+
base.class_option :replica,
|
|
14
|
+
type: :string,
|
|
15
|
+
desc: 'database.yml entry for the reading role (requires --connection)'
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
private
|
|
@@ -20,10 +23,26 @@ module DynamicActiveModel
|
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
# @return [String] e.g. "config.add_database :cars, :cars"
|
|
26
|
+
# @raise [Thor::Error] If --replica is given without --connection
|
|
23
27
|
def add_database_line
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
"config.add_database #{[name.underscore.to_sym.inspect, *connection_args].join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<String>] Connection arguments for add_database
|
|
32
|
+
def connection_args
|
|
33
|
+
return [] unless options[:connection]
|
|
34
|
+
return [options[:connection].to_sym.inspect] unless options[:replica]
|
|
35
|
+
|
|
36
|
+
roles = { writing: options[:connection], reading: options[:replica] }
|
|
37
|
+
["connects_to: { #{roles.map { |role, entry| "#{role}: #{entry.to_sym.inspect}" }.join(', ')} }"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Fails before anything is generated when --replica lacks --connection
|
|
41
|
+
# @return [void]
|
|
42
|
+
def check_replica_has_connection
|
|
43
|
+
return unless options[:replica] && !options[:connection]
|
|
44
|
+
|
|
45
|
+
raise Thor::Error, '--replica requires --connection (the writing database)'
|
|
27
46
|
end
|
|
28
47
|
|
|
29
48
|
# Creates the database's models folder for its extension files
|
|
@@ -32,13 +51,21 @@ module DynamicActiveModel
|
|
|
32
51
|
create_file File.join(definition.extensions_path(destination_root), '.keep')
|
|
33
52
|
end
|
|
34
53
|
|
|
35
|
-
# Warns when --connection names a database.yml entry the current environment lacks
|
|
54
|
+
# Warns when --connection or --replica names a database.yml entry the current environment lacks
|
|
36
55
|
# @return [void]
|
|
37
56
|
def warn_about_missing_connection
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
options.values_at(:connection, :replica).compact.each do |entry|
|
|
58
|
+
next if configured?(entry)
|
|
59
|
+
|
|
60
|
+
say_status :warning, "config/database.yml has no #{entry} entry for #{::Rails.env}", :yellow
|
|
61
|
+
end
|
|
62
|
+
end
|
|
40
63
|
|
|
41
|
-
|
|
64
|
+
# @param entry [String] database.yml entry name
|
|
65
|
+
# @return [Boolean] Whether the current environment has it; replica: true
|
|
66
|
+
# entries are hidden unless asked for
|
|
67
|
+
def configured?(entry)
|
|
68
|
+
!ActiveRecord::Base.configurations.configs_for(env_name: ::Rails.env, name: entry, include_hidden: true).nil?
|
|
42
69
|
end
|
|
43
70
|
end
|
|
44
71
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dynamic-active-model-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - '='
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 0.
|
|
32
|
+
version: 0.15.1
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - '='
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 0.
|
|
39
|
+
version: 0.15.1
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: railties
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|