dynamic-active-model 0.16.1 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71c68f3819a95964f682dd47f37363b3dd665a740b5e2f744ca6e9c1e845bfbc
4
- data.tar.gz: f7820abf7161c0e2a852b5a7c405dc871d94aa652be68381aaa5a32a3dbd60a8
3
+ metadata.gz: 4b97bc188a4c9b06a6cfa87bf1af11ef77bd1e80dbf795b61992d90a940202f8
4
+ data.tar.gz: add2284f1e7f247f47bb3146b145bb8fe8880baa95acc37b904c791d4077c24d
5
5
  SHA512:
6
- metadata.gz: 043fe2e34181d12e5b3d1678255026bd91353dfb72472d60250c9859fe9d7190e7194a097c9a61b12013166ffc508b27af049a8d460fbe0b841bedd19db4804d
7
- data.tar.gz: 2f19238a9d133a501daff55bd7f3e8c7a1c5478e4b70fd35624902deddc94a122a1a86b3b86e16729573c683b24190d994661fce697989df164216d15db2720b
6
+ metadata.gz: 9a185c609164a98c20d755c47d45db2ed059aebe42811d15c987ebb8f3e18d8dd6b7101e714252d046b85ef0f42cad1997d557ead86d0fac534a18314751bac5
7
+ data.tar.gz: 6dc01aefd21b1dce01a1b4bc9926bd4424a25da6fb0bbcaeb728b6692fff88de984567f2fa988e4dbde917d996b3a2166bfa6d19671fa1831438e745700f25f7
@@ -66,6 +66,10 @@ OptionParser.new do |opts|
66
66
  options[:id_suffix] = v
67
67
  end
68
68
 
69
+ opts.on('--foreign-key-constraints', 'Also relate columns through foreign key constraints') do
70
+ options[:foreign_key_constraints] = true
71
+ end
72
+
69
73
  opts.on('-c', '--config FILE', "config file, default #{DEFAULT_CONFIG_FILE}") do |v|
70
74
  options[:config_file] = v
71
75
  end
@@ -91,6 +95,7 @@ if options[:config_file]
91
95
  config = YAML.safe_load_file(options[:config_file], permitted_classes: [Symbol])[options[:config_section]]
92
96
  options[:skip_tables] = config.delete('skip_tables') || []
93
97
  options[:relationships] = config.delete('relationships') || {}
98
+ options[:foreign_key_constraints] ||= config.delete('foreign_key_constraints') || false
94
99
  if (module_name = config.delete('module'))
95
100
  raise ArgumentError, "Invalid module name: #{module_name}" unless module_name.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
96
101
 
@@ -104,7 +109,8 @@ DYNAMIC_DATABASE = DynamicActiveModel::Explorer.explore(
104
109
  options[:base_module],
105
110
  options[:connection_options],
106
111
  options[:skip_tables],
107
- options[:relationships]
112
+ options[:relationships],
113
+ foreign_key_constraints: options[:foreign_key_constraints] || false
108
114
  )
109
115
 
110
116
  if options[:class_files_dir]
@@ -53,6 +53,13 @@ module DynamicActiveModel
53
53
  .add(foreign_key, relationship_name)
54
54
  end
55
55
 
56
+ # Relates columns through the database's foreign key constraints as well as by
57
+ # naming convention. A column with a constraint follows the constraint.
58
+ # @return [void]
59
+ def use_foreign_key_constraints!
60
+ @constraints = ForeignKeyConstraints.new(@database)
61
+ end
62
+
56
63
  # Builds all relationships between models based on foreign keys and constraints
57
64
  # This method:
58
65
  # 1. Maps foreign keys to their corresponding models
@@ -64,15 +71,7 @@ module DynamicActiveModel
64
71
  foreign_key_to_models = create_foreign_key_to_model_map
65
72
 
66
73
  @database.models.each do |model|
67
- model.column_names.each do |column_name|
68
- next unless foreign_key_to_models[column_name.downcase]
69
-
70
- foreign_key_to_models[column_name.downcase].each do |foreign_model, relationship_name|
71
- next if foreign_model == model
72
-
73
- add_relationships(relationship_name, model, foreign_model, column_name)
74
- end
75
- end
74
+ model.column_names.each { |column_name| add_column_relationships(model, column_name, foreign_key_to_models) }
76
75
  end
77
76
 
78
77
  @join_tables.each do |join_table_model|
@@ -85,6 +84,30 @@ module DynamicActiveModel
85
84
 
86
85
  private
87
86
 
87
+ # Relates a column through its foreign key constraint when it has one (and
88
+ # constraints are in use), otherwise by naming convention
89
+ # @param model [Class] The model with the column
90
+ # @param column_name [String]
91
+ # @param foreign_key_to_models [Hash] See #create_foreign_key_to_model_map
92
+ def add_column_relationships(model, column_name, foreign_key_to_models)
93
+ constraint = @constraints&.find(model, column_name)
94
+ return add_constraint_relationships(constraint) if constraint
95
+
96
+ foreign_key_to_models.fetch(column_name.downcase, []).each do |foreign_model, relationship_name|
97
+ next if foreign_model == model
98
+
99
+ add_relationships(relationship_name, model, foreign_model, column_name)
100
+ end
101
+ end
102
+
103
+ # Adds the relationships a foreign key constraint describes; unlike the naming
104
+ # convention, these may reference the same model (employees.manager_id)
105
+ # @param constraint [ForeignKeyConstraints::Constraint]
106
+ def add_constraint_relationships(constraint)
107
+ add_relationships(constraint.relationship_name, constraint.model, constraint.referenced_model,
108
+ constraint.column, constraint.primary_key)
109
+ end
110
+
88
111
  # Adds has_and_belongs_to_many relationships between two models
89
112
  # @param join_table_model [Class] The join table model
90
113
  # @param models [Array<Class>] The two models to be related
@@ -101,12 +124,14 @@ module DynamicActiveModel
101
124
  # @param model [Class] The model with the foreign key
102
125
  # @param belongs_to_model [Class] The model being referenced
103
126
  # @param foreign_key [String] The foreign key column name
104
- def add_relationships(relationship_name, model, belongs_to_model, foreign_key)
105
- add_belongs_to(relationship_name, model, belongs_to_model, foreign_key)
127
+ # @param primary_key [String] The referenced column, normally the primary key
128
+ def add_relationships(relationship_name, model, belongs_to_model, foreign_key,
129
+ primary_key = belongs_to_model.primary_key)
130
+ add_belongs_to(relationship_name, model, belongs_to_model, foreign_key, primary_key)
106
131
  if unique_index?(model, foreign_key)
107
- add_has_one(relationship_name, belongs_to_model, model, foreign_key)
132
+ add_has_one(relationship_name, belongs_to_model, model, foreign_key, primary_key)
108
133
  else
109
- add_has_many(relationship_name, belongs_to_model, model, foreign_key)
134
+ add_has_many(relationship_name, belongs_to_model, model, foreign_key, primary_key)
110
135
  end
111
136
  end
112
137
 
@@ -115,12 +140,13 @@ module DynamicActiveModel
115
140
  # @param model [Class] The model with the foreign key
116
141
  # @param belongs_to_model [Class] The model being referenced
117
142
  # @param foreign_key [String] The foreign key column name
118
- def add_belongs_to(relationship_name, model, belongs_to_model, foreign_key)
143
+ # @param primary_key [String] The referenced column
144
+ def add_belongs_to(relationship_name, model, belongs_to_model, foreign_key, primary_key)
119
145
  model.belongs_to(
120
146
  relationship_name.singularize.to_sym,
121
147
  class_name: belongs_to_model.name,
122
148
  foreign_key: foreign_key,
123
- primary_key: belongs_to_model.primary_key
149
+ primary_key: primary_key
124
150
  )
125
151
  end
126
152
 
@@ -129,12 +155,13 @@ module DynamicActiveModel
129
155
  # @param model [Class] The referenced (parent) model that gets the association
130
156
  # @param has_many_model [Class] The model with the foreign key
131
157
  # @param foreign_key [String] The foreign key column name
132
- def add_has_many(relationship_name, model, has_many_model, foreign_key)
158
+ # @param primary_key [String] The referenced column on the parent model
159
+ def add_has_many(relationship_name, model, has_many_model, foreign_key, primary_key)
133
160
  model.has_many(
134
161
  generate_has_many_association_name(relationship_name, model, has_many_model),
135
162
  class_name: has_many_model.name,
136
163
  foreign_key: foreign_key,
137
- primary_key: model.primary_key
164
+ primary_key: primary_key
138
165
  )
139
166
  end
140
167
 
@@ -143,12 +170,13 @@ module DynamicActiveModel
143
170
  # @param model [Class] The referenced (parent) model that gets the association
144
171
  # @param has_one_model [Class] The model with the foreign key
145
172
  # @param foreign_key [String] The foreign key column name
146
- def add_has_one(relationship_name, model, has_one_model, foreign_key)
173
+ # @param primary_key [String] The referenced column on the parent model
174
+ def add_has_one(relationship_name, model, has_one_model, foreign_key, primary_key)
147
175
  model.has_one(
148
176
  generate_has_one_association_name(relationship_name, model, has_one_model),
149
177
  class_name: has_one_model.name,
150
178
  foreign_key: foreign_key,
151
- primary_key: model.primary_key
179
+ primary_key: primary_key
152
180
  )
153
181
  end
154
182
 
@@ -14,6 +14,9 @@ module DynamicActiveModel
14
14
  # skip_tables = ['temporary_data', 'audit_logs']
15
15
  # DynamicActiveModel::Explorer.explore(DB, database_config, skip_tables)
16
16
  #
17
+ # @example With Foreign Key Constraints
18
+ # DynamicActiveModel::Explorer.explore(DB, database_config, foreign_key_constraints: true)
19
+ #
17
20
  # @example With Custom Relationships
18
21
  # relationships = {
19
22
  # 'users' => {
@@ -30,13 +33,15 @@ module DynamicActiveModel
30
33
  # @param relationships [Hash] Custom foreign key relationships to add
31
34
  # @param table_class_names [Hash] Custom class names by table name
32
35
  # @param parent_class [Class, nil] Optional superclass for the base class (see Factory)
36
+ # @param foreign_key_constraints [Boolean] Also relate columns through the database's
37
+ # foreign key constraints (see ForeignKeyConstraints)
33
38
  # @return [Database] The configured database instance
34
39
  # @raise [ClassNameConflict] If two tables map to the same class name
35
40
  def self.explore(base_module, connection_options, skip_tables = [], relationships = {}, table_class_names = {},
36
- parent_class: nil)
41
+ parent_class: nil, foreign_key_constraints: false)
37
42
  database = create_models!(base_module, connection_options, skip_tables, table_class_names,
38
43
  parent_class: parent_class)
39
- build_relationships!(database, relationships)
44
+ build_relationships!(database, relationships, foreign_key_constraints: foreign_key_constraints)
40
45
  database
41
46
  end
42
47
 
@@ -67,9 +72,11 @@ module DynamicActiveModel
67
72
  # Sets up relationships between created models
68
73
  # @param database [Database] The database instance containing the models
69
74
  # @param relationships [Hash] Custom foreign key relationships to add
75
+ # @param foreign_key_constraints [Boolean] Also relate columns through foreign key constraints
70
76
  # @return [void]
71
- def self.build_relationships!(database, relationships)
77
+ def self.build_relationships!(database, relationships, foreign_key_constraints: false)
72
78
  relations = Associations.new(database)
79
+ relations.use_foreign_key_constraints! if foreign_key_constraints
73
80
  relationships.each do |table_name, foreign_keys|
74
81
  foreign_keys.each do |foreign_key, relationship_name|
75
82
  relations.add_foreign_key(table_name, foreign_key, relationship_name)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicActiveModel
4
+ # Reads the database's foreign key constraints and resolves them to models, so
5
+ # Associations can relate columns that don't follow the <table>_id naming
6
+ # convention (posts.author_id => users) and self-references (employees.manager_id).
7
+ #
8
+ # Skipped: composite constraints, columns without the id suffix (a belongs_to
9
+ # named after the column itself would hide it), and constraints whose table or
10
+ # referenced table has no model.
11
+ #
12
+ # @example
13
+ # constraints = DynamicActiveModel::ForeignKeyConstraints.new(database)
14
+ # constraints.find(DB::Post, 'author_id') # => #<struct referenced_model=DB::User, ...>
15
+ class ForeignKeyConstraints
16
+ # A constraint resolved to models
17
+ Constraint = Struct.new(:model, :column, :referenced_model, :primary_key, :relationship_name)
18
+
19
+ # @param database [Database] The database whose models' constraints to read
20
+ def initialize(database)
21
+ @database = database
22
+ @constraints = database.models.flat_map { |model| constraints_for(model) }
23
+ .to_h { |constraint| [[constraint.model, constraint.column], constraint] }
24
+ end
25
+
26
+ # @param model [Class] The model with the column
27
+ # @param column [String] The column name
28
+ # @return [Constraint, nil] The column's constraint, if it has one
29
+ def find(model, column)
30
+ @constraints[[model, column]]
31
+ end
32
+
33
+ # @return [Integer] Number of usable constraints
34
+ def size
35
+ @constraints.size
36
+ end
37
+
38
+ private
39
+
40
+ # @param model [Class]
41
+ # @return [Array<Constraint>] The model's usable constraints
42
+ def constraints_for(model)
43
+ model.connection.foreign_keys(model.table_name).filter_map do |foreign_key|
44
+ column = foreign_key.column
45
+ next unless column.is_a?(String) && column.end_with?(ForeignKey.id_suffix)
46
+
47
+ referenced_model = @database.get_model(foreign_key.to_table)
48
+ next unless referenced_model
49
+
50
+ Constraint.new(model, column, referenced_model, foreign_key.primary_key,
51
+ relationship_name(column, referenced_model))
52
+ end
53
+ end
54
+
55
+ # Names the relationship like the naming convention does: author_id => "author",
56
+ # but user_id referencing users => "users", so its has_many stays :posts
57
+ # @param column [String]
58
+ # @param referenced_model [Class]
59
+ # @return [String]
60
+ def relationship_name(column, referenced_model)
61
+ name = column.delete_suffix(ForeignKey.id_suffix)
62
+ table_name = referenced_model.table_name.underscore
63
+ name == table_name.singularize ? table_name : name
64
+ end
65
+ end
66
+ end
@@ -53,6 +53,7 @@ module DynamicActiveModel
53
53
  {
54
54
  connection_options: nil,
55
55
  parent_class: nil,
56
+ foreign_key_constraints: false,
56
57
  skip_tables: [],
57
58
  relationships: {},
58
59
  table_class_names: {},
@@ -63,11 +64,12 @@ module DynamicActiveModel
63
64
 
64
65
  # Sets or gets the database connection options
65
66
  # @param options [Hash, Symbol, String, nil] Anything establish_connection accepts:
66
- # a config hash, or a Symbol naming a database.yml entry for the current
67
- # environment. A String database.yml name is deprecated.
68
- # @return [Hash, Symbol] The current connection options
67
+ # a config hash, a URL, or a Symbol naming a database.yml entry for the
68
+ # current environment
69
+ # @return [Hash, Symbol, String] The current connection options
70
+ # @raise [ArgumentError] For a database.yml name given as a String
69
71
  def connection_options(options = nil)
70
- options = deprecated_named_configuration(options) if options.is_a?(String)
72
+ reject_database_yml_name!(options) if options.is_a?(String)
71
73
 
72
74
  update_config(:connection_options, options) if options
73
75
 
@@ -83,6 +85,15 @@ module DynamicActiveModel
83
85
  dynamic_active_model_config[:parent_class]
84
86
  end
85
87
 
88
+ # Sets or gets whether columns are also related through the database's foreign
89
+ # key constraints (see ForeignKeyConstraints)
90
+ # @param enabled [Boolean, nil]
91
+ # @return [Boolean] The current setting
92
+ def foreign_key_constraints(enabled = nil)
93
+ update_config(:foreign_key_constraints, enabled) unless enabled.nil?
94
+ dynamic_active_model_config[:foreign_key_constraints]
95
+ end
96
+
86
97
  # Sets or gets the list of tables to skip
87
98
  # @param tables [Array<String>, nil] Tables to skip
88
99
  # @return [Array<String>] The current list of skipped tables
@@ -164,7 +175,8 @@ module DynamicActiveModel
164
175
  skip_tables,
165
176
  relationships,
166
177
  table_class_names,
167
- parent_class: parent_class
178
+ parent_class: parent_class,
179
+ foreign_key_constraints: foreign_key_constraints
168
180
  )
169
181
  )
170
182
  database.update_all_models(extensions_path, extensions_suffix) if extensions_path
@@ -173,17 +185,16 @@ module DynamicActiveModel
173
185
 
174
186
  private
175
187
 
176
- # Resolves a database.yml name given as a String, warning that it's deprecated
177
- # @param name [String]
178
- # @return [Hash] The named configuration for the current Rails environment
179
- def deprecated_named_configuration(name)
180
- DynamicActiveModel.deprecator.warn(
181
- "connection_options with a database.yml name as a String (#{name.inspect}) is deprecated; " \
182
- "pass a Symbol instead (connection_options #{name.to_sym.inspect}), " \
183
- 'or use dynamic-active-model-rails in a Rails app',
184
- caller_locations(2) # point at the caller of connection_options
185
- )
186
- ActiveRecord::Base.configurations.configs_for(env_name: ::Rails.env, name: name).configuration_hash
188
+ # A String database.yml name was deprecated in 0.16 and removed in 1.0. Without
189
+ # this check it would reach establish_connection as a URL and fail confusingly.
190
+ # @param options [String]
191
+ # @raise [ArgumentError] Unless the String is a URL
192
+ def reject_database_yml_name!(options)
193
+ return if options.include?('://')
194
+
195
+ raise ArgumentError,
196
+ "connection_options no longer accepts a database.yml name as a String (#{options.inspect}); " \
197
+ "pass a Symbol instead (connection_options #{options.to_sym.inspect})"
187
198
  end
188
199
 
189
200
  # Stores a single configuration value
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DynamicActiveModel
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.16.1'
5
+ VERSION = '1.1.0'
6
6
  end
@@ -37,6 +37,9 @@ module DynamicActiveModel
37
37
  # Handles foreign key relationships and constraints
38
38
  autoload :ForeignKey, 'dynamic-active-model/foreign_key'
39
39
 
40
+ # Resolves the database's foreign key constraints to models
41
+ autoload :ForeignKeyConstraints, 'dynamic-active-model/foreign_key_constraints'
42
+
40
43
  # Manages automatic discovery and setup of model relationships
41
44
  autoload :Associations, 'dynamic-active-model/associations'
42
45
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dynamic-active-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '4'
18
+ version: '7.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '4'
25
+ version: '7.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: inheritance-helper
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -56,6 +56,7 @@ files:
56
56
  - lib/dynamic-active-model/explorer.rb
57
57
  - lib/dynamic-active-model/factory.rb
58
58
  - lib/dynamic-active-model/foreign_key.rb
59
+ - lib/dynamic-active-model/foreign_key_constraints.rb
59
60
  - lib/dynamic-active-model/setup.rb
60
61
  - lib/dynamic-active-model/template_class_file.rb
61
62
  - lib/dynamic-active-model/version.rb
@@ -71,7 +72,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
71
72
  requirements:
72
73
  - - ">="
73
74
  - !ruby/object:Gem::Version
74
- version: '3.0'
75
+ version: '3.2'
75
76
  required_rubygems_version: !ruby/object:Gem::Requirement
76
77
  requirements:
77
78
  - - ">="