rails-erd 2.0.1 → 2.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.
data/lib/rails_erd/cli.rb CHANGED
@@ -9,7 +9,7 @@ Choice.options do
9
9
 
10
10
  option :generator do
11
11
  long "--generator=Generator"
12
- desc "Generator to use (mermaid or graphviz). Defaults to mermaid."
12
+ desc "Generator to use (mermaid, graphviz, or tbls). Defaults to mermaid."
13
13
  end
14
14
 
15
15
  option :mermaid_style do
@@ -72,6 +72,11 @@ Choice.options do
72
72
  desc "Filter to exclude listed models in diagram."
73
73
  end
74
74
 
75
+ option :exclude_attributes do
76
+ long "--exclude_attributes=MODEL[.ATTRIBUTE],..."
77
+ desc "Hide attributes per model. Use Model to hide all its attributes or Model.attribute to hide one, e.g. BigTable,User.password_digest."
78
+ end
79
+
75
80
  option :sort do
76
81
  long "--sort=BOOLEAN"
77
82
  desc "Sort attribute list alphabetically"
@@ -188,10 +193,11 @@ module RailsERD
188
193
  def initialize(path, options)
189
194
  @path, @options = path, options
190
195
  generator = options[:generator] || RailsERD.options[:generator]
191
- if generator == :mermaid
192
- require "rails_erd/diagram/mermaid"
193
- else
194
- require "rails_erd/diagram/graphviz"
196
+ case generator
197
+ when :mermaid then require "rails_erd/diagram/mermaid"
198
+ when :tbls then require "rails_erd/diagram/tbls"
199
+ when :graphviz then require "rails_erd/diagram/graphviz"
200
+ else require "rails_erd/diagram/mermaid"
195
201
  end
196
202
  end
197
203
 
@@ -231,10 +237,11 @@ module RailsERD
231
237
 
232
238
  def generator
233
239
  generator_type = options[:generator] || RailsERD.options[:generator]
234
- if generator_type == :mermaid
235
- RailsERD::Diagram::Mermaid
236
- else
237
- RailsERD::Diagram::Graphviz
240
+ case generator_type
241
+ when :mermaid then RailsERD::Diagram::Mermaid
242
+ when :tbls then RailsERD::Diagram::Tbls
243
+ when :graphviz then RailsERD::Diagram::Graphviz
244
+ else RailsERD::Diagram::Mermaid
238
245
  end
239
246
  end
240
247
 
@@ -73,6 +73,11 @@ module RailsERD
73
73
  when :only, :exclude
74
74
  Array(value).join(",").split(",").map { |v| v.strip }
75
75
 
76
+ # nil | { <string> => true | [<string>] }
77
+ when :exclude_attributes
78
+ require "rails_erd/diagram"
79
+ RailsERD::Diagram.normalize_exclude_attributes(value)
80
+
76
81
  # true | false
77
82
  when :disconnected, :indirect, :inheritance, :markup, :polymorphism,
78
83
  :warn, :cluster
@@ -15,6 +15,16 @@ module RailsERD
15
15
  # Respect orientation option: horizontal = TB (top-down), vertical = LR (left-right)
16
16
  direction = (options.orientation.to_s == "vertical") ? "LR" : "TB"
17
17
  self.graph << "\tdirection #{direction}"
18
+
19
+ # Initialize namespace grouping for clustering
20
+ @entities_by_namespace = Hash.new { |h, k| h[k] = [] }
21
+ @clustering_enabled = options[:cluster] && !er_diagram?
22
+ @clustered_entities_emitted = false
23
+
24
+ # Warn if clustering requested with erDiagram (not supported)
25
+ if options[:cluster] && er_diagram? && options[:warn]
26
+ warn "Clustering is not supported with erDiagram style. Use mermaid_style: classdiagram instead."
27
+ end
18
28
  end
19
29
 
20
30
  each_entity do |entity, attributes|
@@ -28,6 +38,12 @@ module RailsERD
28
38
  end
29
39
  entity_lines << "\t}"
30
40
  graph << entity_lines.join("\n")
41
+ elsif @clustering_enabled
42
+ # Collect entities by namespace for later emission
43
+ ns = entity.namespace || ""
44
+ short_name = entity.namespace ? entity.name.split("::").last : entity.name
45
+ entity_block = build_class_diagram_entity(short_name, entity.name, attributes)
46
+ @entities_by_namespace[ns] << entity_block
31
47
  else
32
48
  graph << "\tclass `#{entity}`"
33
49
  attributes.each do |attr|
@@ -37,6 +53,12 @@ module RailsERD
37
53
  end
38
54
 
39
55
  each_specialization do |specialization|
56
+ # Emit clustered entities before the first specialization (same as relationships)
57
+ if @clustering_enabled && !@clustered_entities_emitted
58
+ emit_clustered_entities
59
+ @clustered_entities_emitted = true
60
+ end
61
+
40
62
  from, to = specialization.generalized, specialization.specialized
41
63
  if er_diagram?
42
64
  # erDiagram doesn't have a direct polymorphic notation, use inheritance-like
@@ -48,6 +70,12 @@ module RailsERD
48
70
  end
49
71
 
50
72
  each_relationship do |relationship|
73
+ # Emit clustered entities before the first relationship
74
+ if @clustering_enabled && !@clustered_entities_emitted
75
+ emit_clustered_entities
76
+ @clustered_entities_emitted = true
77
+ end
78
+
51
79
  from, to = relationship.source, relationship.destination
52
80
  next unless from && to
53
81
 
@@ -77,6 +105,12 @@ module RailsERD
77
105
  save do
78
106
  raise "Saving diagram failed!\nOutput directory '#{File.dirname(filename)}' does not exist." unless File.directory?(File.dirname(filename))
79
107
 
108
+ # Emit clustered entities if not already emitted (e.g., no relationships)
109
+ if @clustering_enabled && !@clustered_entities_emitted
110
+ emit_clustered_entities
111
+ @clustered_entities_emitted = true
112
+ end
113
+
80
114
  File.write(filename.gsub(/\s/,"_"), graph.uniq.join("\n"))
81
115
  filename
82
116
  end
@@ -159,6 +193,36 @@ module RailsERD
159
193
  relationship.many_to? ? "<" : ""
160
194
  end
161
195
 
196
+ # Build entity lines for classDiagram mode (used in clustering)
197
+ def build_class_diagram_entity(short_name, full_name, attributes)
198
+ lines = ["\t\tclass `#{short_name}`"]
199
+ attributes.each do |attr|
200
+ lines << "\t\t`#{short_name}` : +#{attr.type} #{attr.name}"
201
+ end
202
+ lines
203
+ end
204
+
205
+ # Emit entities grouped by namespace for clustering
206
+ def emit_clustered_entities
207
+ # Entities without namespace go first (outside any namespace block)
208
+ @entities_by_namespace[""].each do |entity_lines|
209
+ entity_lines.each { |line| graph << line.sub(/^\t\t/, "\t") }
210
+ end
211
+
212
+ # Then emit each namespace block
213
+ @entities_by_namespace.each do |ns, entity_blocks|
214
+ next if ns == ""
215
+
216
+ # Convert Ruby namespace (Admin::Users) to Mermaid format (Admin.Users)
217
+ mermaid_ns = ns.gsub("::", ".")
218
+ graph << "\tnamespace #{mermaid_ns} {"
219
+ entity_blocks.each do |entity_lines|
220
+ entity_lines.each { |line| graph << line }
221
+ end
222
+ graph << "\t}"
223
+ end
224
+ end
225
+
162
226
  end
163
227
  end
164
228
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_erd/diagram"
4
+ require "json"
5
+
6
+ module RailsERD
7
+ class Diagram
8
+ # Emits a JSON description of the domain in the tbls schema format
9
+ # (https://github.com/k1LoW/tbls). Consumable by any tbls-compatible tool —
10
+ # for example Liam ERD: `liam erd build --input erd.json --format tbls`.
11
+ #
12
+ # Unlike a schema.rb dump, this reflects the relationships rails-erd derives
13
+ # from ActiveRecord — including FKs that exist only as `belongs_to`
14
+ # associations and not as DB-level constraints.
15
+ class Tbls < Diagram
16
+ attr_accessor :tables_by_name
17
+
18
+ setup do
19
+ self.tables_by_name = {}
20
+ end
21
+
22
+ each_entity do |entity, _attributes|
23
+ next if entity.generalized?
24
+ next unless entity.model
25
+
26
+ table_name = entity.model.table_name
27
+ next if tables_by_name.key?(table_name)
28
+
29
+ tables_by_name[table_name] = build_table(entity)
30
+ end
31
+
32
+ each_relationship do |relationship|
33
+ next if relationship.indirect?
34
+ next unless relationship.source && relationship.destination
35
+ next if relationship.source.generalized? || relationship.destination.generalized?
36
+
37
+ add_foreign_keys(relationship)
38
+ end
39
+
40
+ save do
41
+ dir = File.dirname(filename)
42
+ raise "Saving diagram failed!\nOutput directory '#{dir}' does not exist." unless File.directory?(dir)
43
+
44
+ File.write(filename, JSON.pretty_generate(schema_payload))
45
+ filename
46
+ end
47
+
48
+ def filename
49
+ "#{options.filename}.json"
50
+ end
51
+
52
+ private
53
+
54
+ def schema_payload
55
+ {
56
+ name: domain.name || "rails-erd",
57
+ tables: tables_by_name.values,
58
+ }
59
+ end
60
+
61
+ def build_table(entity)
62
+ model = entity.model
63
+ comment = model.respond_to?(:table_comment) ? model.table_comment : nil
64
+ payload = {
65
+ name: model.table_name,
66
+ type: "BASE TABLE",
67
+ columns: entity.attributes.map { |attr| column_payload(attr) },
68
+ indexes: indexes_payload(model),
69
+ constraints: primary_key_constraints(model),
70
+ }
71
+ payload[:comment] = comment if comment && !comment.empty?
72
+ payload
73
+ end
74
+
75
+ def column_payload(attr)
76
+ column = attr.column
77
+ payload = {
78
+ name: column.name,
79
+ type: column.sql_type.to_s,
80
+ nullable: column.null,
81
+ }
82
+ payload[:default] = column.default.to_s unless column.default.nil?
83
+ comment = column.comment if column.respond_to?(:comment)
84
+ payload[:comment] = comment if comment && !comment.empty?
85
+ payload
86
+ end
87
+
88
+ def indexes_payload(model)
89
+ return [] unless model.connection.respond_to?(:indexes)
90
+
91
+ model.connection.indexes(model.table_name).map do |idx|
92
+ columns = Array(idx.columns).map(&:to_s)
93
+ {
94
+ name: idx.name,
95
+ def: index_def(model.table_name, idx, columns),
96
+ table: model.table_name,
97
+ columns: columns,
98
+ }
99
+ end
100
+ rescue StandardError
101
+ []
102
+ end
103
+
104
+ def index_def(table_name, idx, columns)
105
+ unique = idx.unique ? "UNIQUE " : ""
106
+ "CREATE #{unique}INDEX #{idx.name} ON #{table_name} (#{columns.join(", ")})"
107
+ end
108
+
109
+ def primary_key_constraints(model)
110
+ pk = Array(model.primary_key).compact.map(&:to_s)
111
+ return [] if pk.empty?
112
+
113
+ [{
114
+ name: "#{model.table_name}_pkey",
115
+ type: "PRIMARY KEY",
116
+ def: "PRIMARY KEY (#{pk.join(", ")})",
117
+ table: model.table_name,
118
+ referenced_table: "",
119
+ columns: pk,
120
+ }]
121
+ end
122
+
123
+ def add_foreign_keys(relationship)
124
+ relationship.associations.each do |assoc|
125
+ next if assoc.options[:polymorphic]
126
+ next unless assoc.belongs_to?
127
+
128
+ fk_columns = Array(assoc.send(Domain.foreign_key_method_name)).map(&:to_s).reject(&:empty?)
129
+ next if fk_columns.empty?
130
+
131
+ target_model = safe_klass(assoc)
132
+ next unless target_model
133
+
134
+ target_pk = Array(target_model.primary_key).compact.map(&:to_s)
135
+ next if target_pk.empty?
136
+
137
+ owning_table = assoc.active_record.table_name
138
+ target_table = target_model.table_name
139
+ name = "fk_#{owning_table}_#{fk_columns.join("_")}"
140
+
141
+ add_constraint(owning_table, {
142
+ name: name,
143
+ type: "FOREIGN KEY",
144
+ def: "FOREIGN KEY (#{fk_columns.join(", ")}) REFERENCES #{target_table}(#{target_pk.join(", ")})",
145
+ table: owning_table,
146
+ referenced_table: target_table,
147
+ columns: fk_columns,
148
+ referenced_columns: target_pk,
149
+ })
150
+ end
151
+ end
152
+
153
+ def safe_klass(association)
154
+ association.klass
155
+ rescue NameError
156
+ nil
157
+ end
158
+
159
+ def add_constraint(table_name, constraint)
160
+ table = tables_by_name[table_name]
161
+ return unless table
162
+ return if table[:constraints].any? { |c| c[:name] == constraint[:name] }
163
+
164
+ table[:constraints] << constraint
165
+ end
166
+ end
167
+ end
168
+ end
@@ -55,6 +55,14 @@ module RailsERD
55
55
  # attributes:: Selects which attributes to display. Can be any combination of
56
56
  # +:content+, +:primary_keys+, +:foreign_keys+, +:timestamps+, or
57
57
  # +:inheritance+.
58
+ # exclude_attributes:: Hides attributes on a per-model basis, without affecting
59
+ # other models. Accepts a hash that maps model names to
60
+ # either +true+ (hide all attributes for that model) or a
61
+ # list of attribute names to hide. From the command line it
62
+ # can also be given as a comma separated string where each
63
+ # entry is either +Model+ (hide all attributes) or
64
+ # +Model.attribute+ (hide a single attribute), for example
65
+ # <tt>exclude_attributes="BigTable,User.password_digest"</tt>.
58
66
  # disconnected:: Set to +false+ to exclude entities that are not connected to other
59
67
  # entities. Defaults to +false+.
60
68
  # indirect:: Set to +false+ to exclude relationships that are indirect.
@@ -76,6 +84,38 @@ module RailsERD
76
84
  new(Domain.generate(options), options).create
77
85
  end
78
86
 
87
+ # Canonicalises the +exclude_attributes+ option into a hash that maps
88
+ # model names (as strings) to either +true+ (hide all attributes) or an
89
+ # array of attribute names to hide. Accepts several input shapes:
90
+ #
91
+ # * a hash, e.g. <tt>{ "BigTable" => true, "User" => ["password_digest"] }</tt>
92
+ # (values may be +true+/+"all"+ to hide every attribute, +false+/+nil+
93
+ # to hide none, or a comma separated string / array of names);
94
+ # * a string or array of <tt>Model</tt> / <tt>Model.attribute</tt>
95
+ # entries, e.g. <tt>"BigTable,User.password_digest"</tt>.
96
+ def normalize_exclude_attributes(value)
97
+ return {} if value.nil? || value == false
98
+
99
+ if value.is_a?(Hash)
100
+ value.each_with_object({}) do |(model, attrs), result|
101
+ result[model.to_s] = normalize_exclude_attribute_value(attrs)
102
+ end
103
+ else
104
+ entries = Array(value).flat_map { |entry| entry.to_s.split(",") }
105
+ entries.each_with_object({}) do |entry, result|
106
+ model, attribute = entry.split(".", 2)
107
+ model = model.to_s.strip
108
+ next if model.empty?
109
+
110
+ if attribute.nil?
111
+ result[model] = true
112
+ elsif result[model] != true
113
+ (result[model] ||= []) << attribute.strip
114
+ end
115
+ end
116
+ end
117
+ end
118
+
79
119
  protected
80
120
 
81
121
  def setup(&block)
@@ -103,6 +143,16 @@ module RailsERD
103
143
  def callbacks
104
144
  @callbacks ||= Hash.new { proc {} }
105
145
  end
146
+
147
+ def normalize_exclude_attribute_value(attrs)
148
+ case attrs
149
+ when true then true
150
+ when false, nil then []
151
+ when "all", :all, "true" then true
152
+ else
153
+ Array(attrs).flat_map { |attr| attr.to_s.split(",") }.map(&:strip).reject(&:empty?)
154
+ end
155
+ end
106
156
  end
107
157
 
108
158
  # The options that are used to create this diagram.
@@ -181,8 +231,7 @@ module RailsERD
181
231
 
182
232
  def filtered_entities
183
233
  @domain.entities.reject { |entity|
184
- options.exclude.present? && [options.exclude].flatten.map(&:to_sym).include?(entity.name.to_sym) or
185
- options[:only].present? && entity.model && ![options[:only]].flatten.map(&:to_sym).include?(entity.name.to_sym) or
234
+ excluded_by_filter?(entity) or
186
235
  !options.inheritance && entity.specialized? or
187
236
  !options.polymorphism && entity.generalized? or
188
237
  !options.disconnected && entity.disconnected?
@@ -193,25 +242,106 @@ module RailsERD
193
242
 
194
243
  def filtered_relationships
195
244
  @domain.relationships.reject { |relationship|
196
- !options.indirect && relationship.indirect?
245
+ (!options.indirect && relationship.indirect?) ||
246
+ # Drop relationships to a model removed by :only/:exclude, otherwise the filtered-out
247
+ # model leaks back in: Graphviz skips such edges implicitly (it only draws an edge when
248
+ # both nodes exist) but Mermaid emits every relationship and renders any named entity.
249
+ excluded_by_filter?(relationship.source) ||
250
+ excluded_by_filter?(relationship.destination)
197
251
  }
198
252
  end
199
253
 
254
+ # Whether the entity was removed from the diagram by the :only or :exclude option.
255
+ # Used to filter both entities and the relationships that touch them.
256
+ #
257
+ def excluded_by_filter?(entity)
258
+ name = entity.name.to_s
259
+
260
+ if options.exclude.present?
261
+ patterns = [options.exclude].flatten
262
+ return true if patterns.any? { |pattern| matches_pattern?(pattern, name) }
263
+ end
264
+
265
+ if options[:only].present? && entity.model
266
+ patterns = [options[:only]].flatten
267
+ return true unless patterns.any? { |pattern| matches_pattern?(pattern, name) }
268
+ end
269
+
270
+ false
271
+ end
272
+
273
+ # Matches a name against a pattern. Supports three pattern types:
274
+ #
275
+ # - Exact match: "Foo" matches only "Foo"
276
+ # - Glob pattern: "SolidQueue::*" matches "SolidQueue::Job", etc.
277
+ # - Regex pattern: "/^Active/" matches "ActiveRecord", "ActiveStorage::Blob"
278
+ #
279
+ def matches_pattern?(pattern, name)
280
+ pattern_str = pattern.to_s
281
+
282
+ # Regex pattern: /pattern/ or /pattern/flags
283
+ #
284
+ if pattern_str.start_with?("/") && pattern_str =~ %r{\A/(.+)/([imx]*)\z}
285
+ regex_body = Regexp.last_match(1)
286
+ flags_str = Regexp.last_match(2)
287
+ flags = 0
288
+ flags |= Regexp::IGNORECASE if flags_str.include?("i")
289
+ flags |= Regexp::MULTILINE if flags_str.include?("m")
290
+ flags |= Regexp::EXTENDED if flags_str.include?("x")
291
+ return Regexp.new(regex_body, flags).match?(name.to_s)
292
+ end
293
+
294
+ # Glob pattern: contains *, ?, or [
295
+ #
296
+ if pattern_str.include?("*") || pattern_str.include?("?") || pattern_str.include?("[")
297
+ return File.fnmatch?(pattern_str, name.to_s)
298
+ end
299
+
300
+ # Exact match (backward compatible)
301
+ pattern_str == name.to_s
302
+ end
303
+
200
304
  def filtered_specializations
201
305
  @domain.specializations.reject { |specialization|
202
306
  !options.inheritance && specialization.inheritance? or
203
- !options.polymorphism && specialization.polymorphic?
307
+ !options.polymorphism && specialization.polymorphic? or
308
+ # Drop specializations whose generalized or specialized entity is not
309
+ # part of the rendered domain (e.g. an abstract parent whose child model
310
+ # has no table). These resolve to a Null entity with a blank name and
311
+ # would otherwise produce an edge to a nameless entity (invalid output).
312
+ specialization.generalized.name.to_s.empty? or
313
+ specialization.specialized.name.to_s.empty?
204
314
  }
205
315
  end
206
316
 
207
317
  def filtered_attributes(entity)
208
- entity.attributes.reject { |attribute|
209
- # Select attributes that satisfy the conditions in the :attributes option.
210
- !options.attributes or entity.specialized? or
211
- [*options.attributes].none? { |type| attribute.send(:"#{type.to_s.chomp('s')}?") }
318
+ excluded = excluded_attributes_for(entity)
319
+ return [] if excluded == :all
320
+
321
+ entity.attributes.select { |attribute|
322
+ # Hide attributes excluded for this specific model.
323
+ next false if excluded.include?(attribute.name)
324
+ # Hide every attribute when the :attributes option is off or the entity
325
+ # is specialized (its attributes are shown on the parent instead).
326
+ next false if !options.attributes || entity.specialized?
327
+ # Otherwise keep only attributes matching the requested :attributes types.
328
+ [*options.attributes].any? { |type| attribute.send(:"#{type.to_s.chomp('s')}?") }
212
329
  }
213
330
  end
214
331
 
332
+ # Returns the attribute exclusions configured for the given entity through
333
+ # the +exclude_attributes+ option. Returns +:all+ when every attribute
334
+ # should be hidden, or an array of attribute names otherwise.
335
+ def excluded_attributes_for(entity)
336
+ spec = normalized_exclude_attributes[entity.name]
337
+ return :all if spec == true
338
+ Array(spec)
339
+ end
340
+
341
+ def normalized_exclude_attributes
342
+ @normalized_exclude_attributes ||= self.class.normalize_exclude_attributes(options.exclude_attributes)
343
+ end
344
+
215
345
  def warn(message)
216
346
  puts "Warning: #{message}" if options.warn
217
347
  end
@@ -128,6 +128,7 @@ module RailsERD
128
128
  .reject { |model| tableless_rails_models.include?(model) }
129
129
  .select { |model| check_model_validity(model) }
130
130
  .reject { |model| check_habtm_model(model) }
131
+ .sort_by { |model| model.name.to_s }
131
132
  end
132
133
 
133
134
  # Returns Rails model classes defined in the app
@@ -187,7 +188,39 @@ module RailsERD
187
188
  def excluded_model?(model)
188
189
  return false unless options.exclude.present?
189
190
 
190
- [options.exclude].flatten.map(&:to_sym).include?(model.name.to_sym)
191
+ patterns = [options.exclude].flatten
192
+ patterns.any? { |pattern| matches_pattern?(pattern, model.name) }
193
+ end
194
+
195
+ # Matches a name against a pattern. Supports three pattern types:
196
+ #
197
+ # - Exact match: "Foo" matches only "Foo"
198
+ # - Glob pattern: "SolidQueue::*" matches "SolidQueue::Job", etc.
199
+ # - Regex pattern: "/^Active/" matches "ActiveRecord", "ActiveStorage::Blob"
200
+ #
201
+ def matches_pattern?(pattern, name)
202
+ pattern_str = pattern.to_s
203
+
204
+ # Regex pattern: /pattern/ or /pattern/flags
205
+ #
206
+ if pattern_str.start_with?("/") && pattern_str =~ %r{\A/(.+)/([imx]*)\z}
207
+ regex_body = Regexp.last_match(1)
208
+ flags_str = Regexp.last_match(2)
209
+ flags = 0
210
+ flags |= Regexp::IGNORECASE if flags_str.include?("i")
211
+ flags |= Regexp::MULTILINE if flags_str.include?("m")
212
+ flags |= Regexp::EXTENDED if flags_str.include?("x")
213
+ return Regexp.new(regex_body, flags).match?(name.to_s)
214
+ end
215
+
216
+ # Glob pattern: contains *, ?, or [
217
+ #
218
+ if pattern_str.include?("*") || pattern_str.include?("?") || pattern_str.include?("[")
219
+ return File.fnmatch?(pattern_str, name.to_s)
220
+ end
221
+
222
+ # Exact match (backward compatible)
223
+ pattern_str == name.to_s
191
224
  end
192
225
 
193
226
  def check_association_validity(association)
@@ -207,14 +240,17 @@ module RailsERD
207
240
  def excluded_association?(association)
208
241
  return false unless options.exclude.present?
209
242
 
210
- excluded_names = [options.exclude].flatten.map(&:to_sym)
243
+ patterns = [options.exclude].flatten
211
244
 
212
245
  # Suppress warning if either the source model or target model is excluded
213
- excluded_names.include?(association.active_record.name.to_sym) ||
214
- (association.klass.name && excluded_names.include?(association.klass.name.to_sym))
246
+ source_name = association.active_record.name
247
+ return true if patterns.any? { |pattern| matches_pattern?(pattern, source_name) }
248
+
249
+ target_name = association.options[:polymorphic] ? association.class_name : association.klass.name
250
+ target_name && patterns.any? { |pattern| matches_pattern?(pattern, target_name) }
215
251
  rescue NameError
216
- # If we can't determine the target class, check only the source model
217
- excluded_names.include?(association.active_record.name.to_sym)
252
+ # If we can't determine the target class, the source model was already checked above
253
+ false
218
254
  end
219
255
 
220
256
  def check_polymorphic_association_validity(association)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsERD
4
- VERSION = "2.0.1"
4
+ VERSION = "2.1.0"
5
5
  BANNER = "RailsERD #{VERSION}"
6
6
  end
data/lib/rails_erd.rb CHANGED
@@ -55,6 +55,7 @@ module RailsERD
55
55
  :warn, true,
56
56
  :title, true,
57
57
  :exclude, nil,
58
+ :exclude_attributes, nil,
58
59
  :only, nil,
59
60
  :only_recursion_depth, nil,
60
61
  :prepend_primary, false,
@@ -122,6 +122,17 @@ class ConfigTest < ActiveSupport::TestCase
122
122
  assert_equal [:content, :primary_keys], normalize_value(:attributes, ["content", "primary_keys"])
123
123
  end
124
124
 
125
+ test "normalize_value should canonicalize a hash when key is :exclude_attributes." do
126
+ value = { "BigTable" => true, "User" => ["password_digest"] }
127
+ expected = { "BigTable" => true, "User" => ["password_digest"] }
128
+ assert_equal expected, normalize_value(:exclude_attributes, value)
129
+ end
130
+
131
+ test "normalize_value should parse a string when key is :exclude_attributes." do
132
+ expected = { "BigTable" => true, "User" => ["password_digest"] }
133
+ assert_equal expected, normalize_value(:exclude_attributes, "BigTable,User.password_digest")
134
+ end
135
+
125
136
  test "normalize_value should return hash with symbol keys when key is :fonts and value is a hash." do
126
137
  fonts_value = { "normal" => "Arial", "bold" => "Arial Bold", "italic" => "Arial Italic" }
127
138
  expected = {:normal => "Arial", :bold => "Arial Bold", :italic => "Arial Italic"}