dynamic-active-model-rails 0.13.0 → 0.15.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: a8515d28d9394768614fb8cd2f4cf49173ab1d5f97c905debbf3f9dc85d21f21
4
- data.tar.gz: 33388b8956cc6106da4d6d94d0255a15b66d6156be30a49cfa9c941f31f3ed4e
3
+ metadata.gz: c00e8c89d4f3d145711f0ec10472dda48b3dfd38ab11e921a2efa26641cf71e3
4
+ data.tar.gz: 13900a4926179764ed30e642f44fd19dca06cbbe5742226292aafc8c06537ebb
5
5
  SHA512:
6
- metadata.gz: ed7b1653b8ea5643e50b797a0cad76714958d204052ee97ba44cd10eed2e3f2103e20bcad4c16ee7277a12886ef85cceb6421a8ca41c485dec72dbac67b89fc4
7
- data.tar.gz: 42a9e8a11125f32d8e9ea403662e232d602595c776c7829a05f7759fd14f4c9fa88822100fd92dd52c2e2188fb39c1d95082eb62c0bef16f6f5c2c17ba13beed
6
+ metadata.gz: 1d0a57c73c5976963edec4c2d9b9c928907a95476219085cca20ab8d2a57b8399f329b29c67e91ae00ca885da623f8a36680e025199e21aaab032066e10c124b
7
+ data.tar.gz: 5dd9266a94e120c327f54a2677a42a88094bbb6caf54a66b1e129641352048609d9dd56bdcd43b44ecf74a9941597c9b08b5dee5a6e8e04de57cdccca6d0b709
data/README.md CHANGED
@@ -13,6 +13,22 @@ gem 'dynamic-active-model-rails'
13
13
 
14
14
  `dynamic-active-model-rails` is released in lockstep with `dynamic-active-model` and pins the same version.
15
15
 
16
+ ## Generators
17
+
18
+ ```bash
19
+ bin/rails generate dynamic_active_model:install # DB, app/models/db/
20
+ bin/rails generate dynamic_active_model:install grant_db # GrantDB, app/models/grant_db/
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
23
+ bin/rails generate dynamic_active_model:extension grant_db users # app/models/grant_db/users.ext.rb
24
+ ```
25
+
26
+ - **`install [NAME]`** creates `config/initializers/dynamic_active_model.rb`, declaring the first database (default `db`), and its models folder.
27
+ - **`database NAME`** adds another `add_database` line to the initializer and creates the folder. It refuses a namespace the app already declares.
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.
29
+
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.
31
+
16
32
  ## Configuration
17
33
 
18
34
  ```ruby
@@ -61,9 +77,41 @@ The `cars_db` → `CarsDB` mapping is registered with the Rails autoloader only,
61
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. |
62
78
  | `module_name:` | `"<Name>DB"` | Namespace override. |
63
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`. |
64
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. |
65
82
  | `extensions_suffix:` | `'.ext.rb'` | Suffix of extension files. The autoloader ignores files with this suffix. |
66
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
+
67
115
  ## Extending Models
68
116
 
69
117
  Add one `<table_name>.ext.rb` file per table to the database's folder:
@@ -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,6 +27,14 @@ 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?
@@ -60,6 +68,7 @@ module DynamicActiveModel
60
68
  # @return [DynamicActiveModel::Database] A database configured from the definition
61
69
  def new_database
62
70
  Database.new(namespace, definition.connection, parent_class: parent_class).tap do |database|
71
+ database.factory.base_class.connects_to(**definition.connects_to) if definition.connects_to
63
72
  definition.skipped_tables.each { |table| database.skip_table(Explorer.skip_table_matcher(table)) }
64
73
  definition.included_tables.each { |table| database.include_table(Explorer.skip_table_matcher(table)) }
65
74
  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]
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require_relative '../database_arguments'
5
+
6
+ module DynamicActiveModel
7
+ module Generators
8
+ # rails g dynamic_active_model:database NAME [--connection=NAME]
9
+ class DatabaseGenerator < ::Rails::Generators::Base
10
+ include DatabaseArguments
11
+
12
+ desc 'Declares another database in config/initializers/dynamic_active_model.rb ' \
13
+ '(e.g. cars => CarsDB in app/models/cars_db/).'
14
+
15
+ argument :name, type: :string, banner: 'NAME'
16
+
17
+ def check_options
18
+ check_replica_has_connection
19
+ end
20
+
21
+ def check_initializer
22
+ return if File.exist?(File.join(destination_root, INITIALIZER))
23
+
24
+ raise Thor::Error, "#{INITIALIZER} not found; run rails g dynamic_active_model:install first"
25
+ end
26
+
27
+ def check_not_declared
28
+ raise Thor::Error, "#{definition.module_name} is already declared" if declared?
29
+ end
30
+
31
+ def add_database
32
+ inject_into_file INITIALIZER, " #{add_database_line}\n", before: /^end\b/
33
+ end
34
+
35
+ def create_models_folder
36
+ create_extensions_folder
37
+ end
38
+
39
+ def check_connection
40
+ warn_about_missing_connection
41
+ end
42
+
43
+ private
44
+
45
+ # @return [Boolean] Whether the app already declares this namespace
46
+ def declared?
47
+ DynamicActiveModel::Rails.configuration.definitions.any? { |db| db.module_name == definition.module_name }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicActiveModel
4
+ module Generators
5
+ # Shared --connection/--replica options and helpers for generators that declare a database
6
+ module DatabaseArguments
7
+ INITIALIZER = 'config/initializers/dynamic_active_model.rb'
8
+
9
+ def self.included(base)
10
+ base.class_option :connection,
11
+ type: :string,
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)'
16
+ end
17
+
18
+ private
19
+
20
+ # @return [DynamicActiveModel::Rails::DatabaseDefinition] Naming for the NAME argument
21
+ def definition
22
+ @definition ||= DynamicActiveModel::Rails::DatabaseDefinition.new(name)
23
+ end
24
+
25
+ # @return [String] e.g. "config.add_database :cars, :cars"
26
+ # @raise [Thor::Error] If --replica is given without --connection
27
+ def add_database_line
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)'
46
+ end
47
+
48
+ # Creates the database's models folder for its extension files
49
+ # @return [void]
50
+ def create_extensions_folder
51
+ create_file File.join(definition.extensions_path(destination_root), '.keep')
52
+ end
53
+
54
+ # Warns when --connection or --replica names a database.yml entry the current environment lacks
55
+ # @return [void]
56
+ def warn_about_missing_connection
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
63
+
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?
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module DynamicActiveModel
6
+ module Generators
7
+ # rails g dynamic_active_model:extension DATABASE TABLE
8
+ class ExtensionGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+ desc 'Creates an extension file for a table of a declared database ' \
11
+ '(e.g. grant_db users => app/models/grant_db/users.ext.rb).'
12
+
13
+ argument :database, type: :string, banner: 'DATABASE'
14
+ argument :table, type: :string, banner: 'TABLE'
15
+
16
+ def create_extension
17
+ template 'extension.rb.tt', File.join(definition.extensions_path(destination_root), file_name)
18
+ end
19
+
20
+ private
21
+
22
+ # The declared database named by DATABASE, as a name (grant_db) or namespace (GrantDB)
23
+ # @return [DynamicActiveModel::Rails::DatabaseDefinition]
24
+ # @raise [Thor::Error] If no such database is declared
25
+ def definition
26
+ @definition ||= declared.find { |db| [database, default_module_name].include?(db.module_name) } ||
27
+ raise(Thor::Error, "no database #{database} is declared " \
28
+ "(declared: #{declared.map(&:module_name).join(', ')})")
29
+ end
30
+
31
+ # @return [Array<DynamicActiveModel::Rails::DatabaseDefinition>]
32
+ def declared
33
+ DynamicActiveModel::Rails.configuration.definitions
34
+ end
35
+
36
+ # @return [String] Namespace DATABASE maps to by default, e.g. "GrantDB"
37
+ def default_module_name
38
+ DynamicActiveModel::Rails::DatabaseDefinition.new(database).module_name
39
+ end
40
+
41
+ # @return [String] e.g. "users.ext.rb"
42
+ def file_name
43
+ "#{table}#{definition.extensions_suffix}"
44
+ end
45
+
46
+ # @return [String] e.g. "GrantDB::User"
47
+ def model_name
48
+ "#{definition.module_name}::#{definition.table_class_names[table] || table.classify}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extends <%= model_name %> (table <%= table %>). Columns and associations that
4
+ # follow foreign key conventions are detected from the schema; add the rest here:
5
+ # through/polymorphic associations, validations, scopes and methods.
6
+ update_model do
7
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require_relative '../database_arguments'
5
+
6
+ module DynamicActiveModel
7
+ module Generators
8
+ # rails g dynamic_active_model:install [NAME] [--connection=NAME]
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include DatabaseArguments
11
+
12
+ source_root File.expand_path('templates', __dir__)
13
+ desc 'Creates config/initializers/dynamic_active_model.rb declaring a first database ' \
14
+ '(default: db, i.e. DB in app/models/db/).'
15
+
16
+ argument :name, type: :string, default: 'db', banner: 'NAME'
17
+
18
+ def check_options
19
+ check_replica_has_connection
20
+ end
21
+
22
+ def create_initializer
23
+ template 'dynamic_active_model.rb.tt', INITIALIZER
24
+ end
25
+
26
+ def create_models_folder
27
+ create_extensions_folder
28
+ end
29
+
30
+ def check_connection
31
+ warn_about_missing_connection
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Each database's tables become ActiveRecord models in a <Name>DB namespace. Models
4
+ # are built on first use and rebuilt after code reloads and migrations. Per-table
5
+ # extensions go in app/models/<name>_db/<table_name>.ext.rb.
6
+ # https://github.com/dougyouch/dynamic-active-model/tree/master/dynamic-active-model-rails
7
+ #
8
+ # Add databases with bin/rails g dynamic_active_model:database NAME, or by hand:
9
+ # config.add_database :cars, :cars do |db| # CarsDB, "cars" entry in database.yml
10
+ # db.skip_tables 'legacy_*'
11
+ # end
12
+ DynamicActiveModel::Rails.configure do |config|
13
+ <%= add_database_line %>
14
+ end
15
+
16
+ # Setup that touches the models (e.g. has_paper_trail) belongs in a load hook,
17
+ # which runs after every build of <%= definition.module_name %>'s models:
18
+ # ActiveSupport.on_load(<%= definition.load_hook.inspect %>) do
19
+ # get_model!(:users).has_paper_trail
20
+ # 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.13.0
4
+ version: 0.15.0
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.13.0
32
+ version: 0.15.0
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.13.0
39
+ version: 0.15.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: railties
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -69,6 +69,12 @@ files:
69
69
  - lib/dynamic-active-model/rails/lazy_namespace.rb
70
70
  - lib/dynamic-active-model/rails/railtie.rb
71
71
  - lib/dynamic-active-model/rails/schema_change_hook.rb
72
+ - lib/generators/dynamic_active_model/database/database_generator.rb
73
+ - lib/generators/dynamic_active_model/database_arguments.rb
74
+ - lib/generators/dynamic_active_model/extension/extension_generator.rb
75
+ - lib/generators/dynamic_active_model/extension/templates/extension.rb.tt
76
+ - lib/generators/dynamic_active_model/install/install_generator.rb
77
+ - lib/generators/dynamic_active_model/install/templates/dynamic_active_model.rb.tt
72
78
  homepage: https://github.com/dougyouch/dynamic-active-model/tree/master/dynamic-active-model-rails
73
79
  licenses:
74
80
  - MIT