dynamic-active-model 1.1.0 → 1.3.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: 4b97bc188a4c9b06a6cfa87bf1af11ef77bd1e80dbf795b61992d90a940202f8
4
- data.tar.gz: add2284f1e7f247f47bb3146b145bb8fe8880baa95acc37b904c791d4077c24d
3
+ metadata.gz: 4920851d69dd665975d96a0b59ebda7ec91471146ab5d36709481a4455063228
4
+ data.tar.gz: 7a1cc5e58f49a2ae6cb70f49284e2851e9d994a1d7ee2fddcdbbd6695f961c7d
5
5
  SHA512:
6
- metadata.gz: 9a185c609164a98c20d755c47d45db2ed059aebe42811d15c987ebb8f3e18d8dd6b7101e714252d046b85ef0f42cad1997d557ead86d0fac534a18314751bac5
7
- data.tar.gz: 6dc01aefd21b1dce01a1b4bc9926bd4424a25da6fb0bbcaeb728b6692fff88de984567f2fa988e4dbde917d996b3a2166bfa6d19671fa1831438e745700f25f7
6
+ metadata.gz: a98e981455474c76e03ee30603d41e0f3df9c5c10fb133ddb76a45cc9901639c52f2a090d416a9d7f9a6dc1ee4251dc197616a6aa1207a5be894777abc25783b
7
+ data.tar.gz: 5b28167c10c68c548efedb755b09c4dd96c24eff8e653587f0b12d539fe5c87e4feb44c777290f0abfde9f4512ba58889d93e15873085cbff50669be37d9774e
@@ -70,6 +70,10 @@ OptionParser.new do |opts|
70
70
  options[:foreign_key_constraints] = true
71
71
  end
72
72
 
73
+ opts.on('--has-many-through', 'Also add has_many :through across join models') do
74
+ options[:has_many_through] = true
75
+ end
76
+
73
77
  opts.on('-c', '--config FILE', "config file, default #{DEFAULT_CONFIG_FILE}") do |v|
74
78
  options[:config_file] = v
75
79
  end
@@ -96,6 +100,7 @@ if options[:config_file]
96
100
  options[:skip_tables] = config.delete('skip_tables') || []
97
101
  options[:relationships] = config.delete('relationships') || {}
98
102
  options[:foreign_key_constraints] ||= config.delete('foreign_key_constraints') || false
103
+ options[:has_many_through] ||= config.delete('has_many_through') || false
99
104
  if (module_name = config.delete('module'))
100
105
  raise ArgumentError, "Invalid module name: #{module_name}" unless module_name.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
101
106
 
@@ -110,7 +115,8 @@ DYNAMIC_DATABASE = DynamicActiveModel::Explorer.explore(
110
115
  options[:connection_options],
111
116
  options[:skip_tables],
112
117
  options[:relationships],
113
- foreign_key_constraints: options[:foreign_key_constraints] || false
118
+ foreign_key_constraints: options[:foreign_key_constraints] || false,
119
+ has_many_through: options[:has_many_through] || false
114
120
  )
115
121
 
116
122
  if options[:class_files_dir]
@@ -60,6 +60,12 @@ module DynamicActiveModel
60
60
  @constraints = ForeignKeyConstraints.new(@database)
61
61
  end
62
62
 
63
+ # Also adds has_many :through associations across join models (see HasManyThrough)
64
+ # @return [void]
65
+ def use_has_many_through!
66
+ @has_many_through = true
67
+ end
68
+
63
69
  # Builds all relationships between models based on foreign keys and constraints
64
70
  # This method:
65
71
  # 1. Maps foreign keys to their corresponding models
@@ -80,6 +86,8 @@ module DynamicActiveModel
80
86
  end.compact
81
87
  add_has_and_belongs_to_many(join_table_model, models) if models.size == 2
82
88
  end
89
+
90
+ HasManyThrough.new(@database.models, table_indexes).build! if @has_many_through
83
91
  end
84
92
 
85
93
  private
@@ -14,8 +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)
17
+ # @example With Foreign Key Constraints and has_many :through
18
+ # DynamicActiveModel::Explorer.explore(DB, database_config, foreign_key_constraints: true,
19
+ # has_many_through: true)
19
20
  #
20
21
  # @example With Custom Relationships
21
22
  # relationships = {
@@ -35,13 +36,16 @@ module DynamicActiveModel
35
36
  # @param parent_class [Class, nil] Optional superclass for the base class (see Factory)
36
37
  # @param foreign_key_constraints [Boolean] Also relate columns through the database's
37
38
  # foreign key constraints (see ForeignKeyConstraints)
39
+ # @param has_many_through [Boolean] Also add has_many :through across join models
40
+ # (see HasManyThrough)
38
41
  # @return [Database] The configured database instance
39
42
  # @raise [ClassNameConflict] If two tables map to the same class name
40
43
  def self.explore(base_module, connection_options, skip_tables = [], relationships = {}, table_class_names = {},
41
- parent_class: nil, foreign_key_constraints: false)
44
+ parent_class: nil, foreign_key_constraints: false, has_many_through: false)
42
45
  database = create_models!(base_module, connection_options, skip_tables, table_class_names,
43
46
  parent_class: parent_class)
44
- build_relationships!(database, relationships, foreign_key_constraints: foreign_key_constraints)
47
+ build_relationships!(database, relationships, foreign_key_constraints: foreign_key_constraints,
48
+ has_many_through: has_many_through)
45
49
  database
46
50
  end
47
51
 
@@ -73,10 +77,12 @@ module DynamicActiveModel
73
77
  # @param database [Database] The database instance containing the models
74
78
  # @param relationships [Hash] Custom foreign key relationships to add
75
79
  # @param foreign_key_constraints [Boolean] Also relate columns through foreign key constraints
80
+ # @param has_many_through [Boolean] Also add has_many :through across join models
76
81
  # @return [void]
77
- def self.build_relationships!(database, relationships, foreign_key_constraints: false)
82
+ def self.build_relationships!(database, relationships, foreign_key_constraints: false, has_many_through: false)
78
83
  relations = Associations.new(database)
79
84
  relations.use_foreign_key_constraints! if foreign_key_constraints
85
+ relations.use_has_many_through! if has_many_through
80
86
  relationships.each do |table_name, foreign_keys|
81
87
  foreign_keys.each do |foreign_key, relationship_name|
82
88
  relations.add_foreign_key(table_name, foreign_key, relationship_name)
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicActiveModel
4
+ # Adds has_many :through associations across join models. A join model is a
5
+ # table with a primary key and a unique index on exactly two columns, each a
6
+ # belongs_to to a different model; other columns (timestamps, audit columns,
7
+ # extra data) don't matter. For user_roles with a unique (user_id, role_id):
8
+ #
9
+ # User has_many :roles, through: :user_roles, source: :role
10
+ # Role has_many :users, through: :user_roles, source: :user
11
+ #
12
+ # A name already taken on the model (association, method or column) is skipped.
13
+ # Tables without a primary key are has_and_belongs_to_many join tables instead.
14
+ class HasManyThrough
15
+ # @param models [Array<Class>] Models with their belongs_to/has_many already built
16
+ # @param table_indexes [Hash] Indexes by table name (see Associations#table_indexes)
17
+ def initialize(models, table_indexes)
18
+ @models = models
19
+ @table_indexes = table_indexes
20
+ end
21
+
22
+ # @return [void]
23
+ def build!
24
+ @models.each { |model| add_through_associations(model) }
25
+ end
26
+
27
+ private
28
+
29
+ # @param join_model [Class]
30
+ def add_through_associations(join_model)
31
+ pair = join_belongs_to(join_model)
32
+ return unless pair
33
+
34
+ first, second = pair
35
+ add_through(join_model, first, second)
36
+ add_through(join_model, second, first)
37
+ end
38
+
39
+ # @param model [Class]
40
+ # @return [Array<ActiveRecord::Reflection::BelongsToReflection>, nil] The two
41
+ # belongs_to a unique two-column index covers, if the model is a join model
42
+ def join_belongs_to(model)
43
+ return unless model.primary_key.is_a?(String)
44
+
45
+ @table_indexes.fetch(model.table_name, []).each do |index|
46
+ next unless index.unique && index.columns.is_a?(Array) && index.columns.size == 2
47
+
48
+ reflections = index.columns.map { |column| belongs_to_for(model, column) }
49
+ return reflections if reflections.all? && reflections.map(&:klass).uniq.size == 2
50
+ end
51
+ nil
52
+ end
53
+
54
+ # @return [ActiveRecord::Reflection::BelongsToReflection, nil]
55
+ def belongs_to_for(model, column)
56
+ model.reflect_on_all_associations(:belongs_to).find { |reflection| reflection.foreign_key.to_s == column }
57
+ end
58
+
59
+ # Gives the owner side of the join a has_many :through to the target side
60
+ # @param join_model [Class]
61
+ # @param owner [ActiveRecord::Reflection::BelongsToReflection] e.g. user_roles.user
62
+ # @param target [ActiveRecord::Reflection::BelongsToReflection] e.g. user_roles.role
63
+ def add_through(join_model, owner, target)
64
+ through = join_association(owner.klass, join_model, owner.foreign_key.to_s)
65
+ name = target.name.to_s.pluralize.to_sym
66
+ return if through.nil? || taken?(owner.klass, name)
67
+
68
+ owner.klass.has_many(name, through: through.name, source: target.name)
69
+ end
70
+
71
+ # @return [ActiveRecord::Reflection::HasManyReflection, nil] The owner's has_many
72
+ # to the join model over that foreign key, e.g. User has_many :user_roles
73
+ def join_association(owner_model, join_model, foreign_key)
74
+ owner_model.reflect_on_all_associations(:has_many).find do |reflection|
75
+ reflection.options[:class_name] == join_model.name && reflection.options[:foreign_key].to_s == foreign_key
76
+ end
77
+ end
78
+
79
+ # @return [Boolean] Whether the name is already an association, method or column
80
+ def taken?(model, name)
81
+ !model.reflect_on_association(name).nil? || model.method_defined?(name) ||
82
+ model.column_names.include?(name.to_s)
83
+ end
84
+ end
85
+ end
@@ -54,6 +54,7 @@ module DynamicActiveModel
54
54
  connection_options: nil,
55
55
  parent_class: nil,
56
56
  foreign_key_constraints: false,
57
+ has_many_through: false,
57
58
  skip_tables: [],
58
59
  relationships: {},
59
60
  table_class_names: {},
@@ -94,6 +95,15 @@ module DynamicActiveModel
94
95
  dynamic_active_model_config[:foreign_key_constraints]
95
96
  end
96
97
 
98
+ # Sets or gets whether has_many :through associations are added across join
99
+ # models (see HasManyThrough)
100
+ # @param enabled [Boolean, nil]
101
+ # @return [Boolean] The current setting
102
+ def has_many_through(enabled = nil) # rubocop:disable Naming/PredicatePrefix -- DSL setter/getter
103
+ update_config(:has_many_through, enabled) unless enabled.nil?
104
+ dynamic_active_model_config[:has_many_through]
105
+ end
106
+
97
107
  # Sets or gets the list of tables to skip
98
108
  # @param tables [Array<String>, nil] Tables to skip
99
109
  # @return [Array<String>] The current list of skipped tables
@@ -176,7 +186,8 @@ module DynamicActiveModel
176
186
  relationships,
177
187
  table_class_names,
178
188
  parent_class: parent_class,
179
- foreign_key_constraints: foreign_key_constraints
189
+ foreign_key_constraints: foreign_key_constraints,
190
+ has_many_through: has_many_through
180
191
  )
181
192
  )
182
193
  database.update_all_models(extensions_path, extensions_suffix) if extensions_path
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+
3
5
  module DynamicActiveModel
4
6
  # The TemplateClassFile class generates Ruby source files for ActiveRecord models.
5
7
  # It creates properly formatted class definitions that include:
@@ -26,11 +28,13 @@ module DynamicActiveModel
26
28
  @model = model
27
29
  end
28
30
 
29
- # Creates a Ruby source file for the model
31
+ # Creates a Ruby source file for the model, at dir/<namespace>/<model>.rb
30
32
  # @param dir [String] Directory to create the file in
31
33
  # @return [void]
32
34
  def create_template!(dir)
33
35
  file = "#{dir}/#{@model.name.underscore}.rb"
36
+ # the namespace directory (e.g. db/ for DB::User) may not exist yet
37
+ FileUtils.mkdir_p(File.dirname(file))
34
38
  File.binwrite(file, to_s)
35
39
  end
36
40
 
@@ -44,6 +48,7 @@ module DynamicActiveModel
44
48
  all_has_many_relationships.each do |assoc|
45
49
  append_association!(str, 'has_many', assoc, has_many_association_options(assoc))
46
50
  end
51
+ all_has_many_through_relationships.each { |assoc| append_through_association!(str, assoc) }
47
52
  all_belongs_to_relationships.each do |assoc|
48
53
  append_association!(str, 'belongs_to', assoc, belongs_to_association_options(assoc))
49
54
  end
@@ -66,6 +71,12 @@ module DynamicActiveModel
66
71
  @model.reflect_on_all_associations.grep(ActiveRecord::Reflection::HasManyReflection)
67
72
  end
68
73
 
74
+ # Gets all has_many :through relationships for the model
75
+ # @return [Array] List of has_many :through associations
76
+ def all_has_many_through_relationships
77
+ @model.reflect_on_all_associations(:has_many).grep(ActiveRecord::Reflection::ThroughReflection)
78
+ end
79
+
69
80
  # Gets all belongs_to relationships for the model
70
81
  # @return [Array<ActiveRecord::Reflection::BelongsToReflection>]
71
82
  def all_belongs_to_relationships
@@ -84,6 +95,14 @@ module DynamicActiveModel
84
95
  @model.reflect_on_all_associations.grep(ActiveRecord::Reflection::HasAndBelongsToManyReflection)
85
96
  end
86
97
 
98
+ # Appends a has_many :through declaration; through and source are symbols
99
+ # @param str [String] The source string being built
100
+ # @param assoc [ActiveRecord::Reflection::ThroughReflection] The association to add
101
+ def append_through_association!(str, assoc)
102
+ str << " has_many #{assoc.name.inspect}, through: #{assoc.options[:through].to_sym.inspect}, " \
103
+ "source: #{assoc.options[:source].to_sym.inspect}\n"
104
+ end
105
+
87
106
  # Appends an association definition to the source string
88
107
  # @param str [String] The source string being built
89
108
  # @param assoc_type [String] The association macro, e.g. 'has_many'
@@ -2,5 +2,5 @@
2
2
 
3
3
  module DynamicActiveModel
4
4
  # Gem version, bumped by release-please
5
- VERSION = '1.1.0'
5
+ VERSION = '1.3.0'
6
6
  end
@@ -40,6 +40,9 @@ module DynamicActiveModel
40
40
  # Resolves the database's foreign key constraints to models
41
41
  autoload :ForeignKeyConstraints, 'dynamic-active-model/foreign_key_constraints'
42
42
 
43
+ # Adds has_many :through associations across join models
44
+ autoload :HasManyThrough, 'dynamic-active-model/has_many_through'
45
+
43
46
  # Manages automatic discovery and setup of model relationships
44
47
  autoload :Associations, 'dynamic-active-model/associations'
45
48
 
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: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -57,6 +57,7 @@ files:
57
57
  - lib/dynamic-active-model/factory.rb
58
58
  - lib/dynamic-active-model/foreign_key.rb
59
59
  - lib/dynamic-active-model/foreign_key_constraints.rb
60
+ - lib/dynamic-active-model/has_many_through.rb
60
61
  - lib/dynamic-active-model/setup.rb
61
62
  - lib/dynamic-active-model/template_class_file.rb
62
63
  - lib/dynamic-active-model/version.rb