rails-erd 2.0.2 → 2.2.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/Rakefile CHANGED
@@ -9,7 +9,7 @@ Rake::TestTask.new do |test|
9
9
  end
10
10
 
11
11
  YARD::Rake::YardocTask.new do |yard|
12
- yard.files = ["lib/**/*.rb", "-", "LICENSE", "CHANGES.md"]
12
+ yard.files = ["lib/**/*.rb", "-", "LICENSE.md", "CHANGES.md"]
13
13
  end
14
14
 
15
15
  desc "Generate diagrams for bundled examples"
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
@@ -47,6 +47,11 @@ Choice.options do
47
47
  desc "Display polymorphic and abstract entities."
48
48
  end
49
49
 
50
+ option :no_recursive do
51
+ long "--no-recursive"
52
+ desc "Omit self-referential relationships (an entity related to itself)."
53
+ end
54
+
50
55
  option :no_indirect do
51
56
  long "--direct"
52
57
  desc "Omit indirect relationships (through other entities)."
@@ -72,6 +77,11 @@ Choice.options do
72
77
  desc "Filter to exclude listed models in diagram."
73
78
  end
74
79
 
80
+ option :only_attributes do
81
+ long "--only_attributes=MODEL.ATTRIBUTE,..."
82
+ desc "Show only the listed attributes for a model, e.g. Book.title,Book.isbn. Models that are not listed keep all their attributes."
83
+ end
84
+
75
85
  option :exclude_attributes do
76
86
  long "--exclude_attributes=MODEL[.ATTRIBUTE],..."
77
87
  desc "Hide attributes per model. Use Model to hide all its attributes or Model.attribute to hide one, e.g. BigTable,User.password_digest."
@@ -193,10 +203,11 @@ module RailsERD
193
203
  def initialize(path, options)
194
204
  @path, @options = path, options
195
205
  generator = options[:generator] || RailsERD.options[:generator]
196
- if generator == :mermaid
197
- require "rails_erd/diagram/mermaid"
198
- else
199
- require "rails_erd/diagram/graphviz"
206
+ case generator
207
+ when :mermaid then require "rails_erd/diagram/mermaid"
208
+ when :tbls then require "rails_erd/diagram/tbls"
209
+ when :graphviz then require "rails_erd/diagram/graphviz"
210
+ else require "rails_erd/diagram/mermaid"
200
211
  end
201
212
  end
202
213
 
@@ -236,10 +247,11 @@ module RailsERD
236
247
 
237
248
  def generator
238
249
  generator_type = options[:generator] || RailsERD.options[:generator]
239
- if generator_type == :mermaid
240
- RailsERD::Diagram::Mermaid
241
- else
242
- RailsERD::Diagram::Graphviz
250
+ case generator_type
251
+ when :mermaid then RailsERD::Diagram::Mermaid
252
+ when :tbls then RailsERD::Diagram::Tbls
253
+ when :graphviz then RailsERD::Diagram::Graphviz
254
+ else RailsERD::Diagram::Mermaid
243
255
  end
244
256
  end
245
257
 
@@ -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> => [<string>] }
77
+ when :only_attributes
78
+ require "rails_erd/diagram"
79
+ RailsERD::Diagram.normalize_only_attributes(value)
80
+
76
81
  # nil | { <string> => true | [<string>] }
77
82
  when :exclude_attributes
78
83
  require "rails_erd/diagram"
@@ -80,7 +85,7 @@ module RailsERD
80
85
 
81
86
  # true | false
82
87
  when :disconnected, :indirect, :inheritance, :markup, :polymorphism,
83
- :warn, :cluster
88
+ :recursive, :warn, :cluster
84
89
  !!value
85
90
 
86
91
  # nil | <string>
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rails_erd/diagram"
4
4
  require "erb"
5
+ require "set"
5
6
 
6
7
  module RailsERD
7
8
  class Diagram
@@ -11,13 +12,25 @@ module RailsERD
11
12
 
12
13
  setup do
13
14
  self.graph = [diagram_type]
15
+ @drawn_entity_names = Set.new
14
16
 
15
17
  # Respect orientation option: horizontal = TB (top-down), vertical = LR (left-right)
16
18
  direction = (options.orientation.to_s == "vertical") ? "LR" : "TB"
17
19
  self.graph << "\tdirection #{direction}"
20
+
21
+ # Initialize namespace grouping for clustering
22
+ @entities_by_namespace = Hash.new { |h, k| h[k] = [] }
23
+ @clustering_enabled = options[:cluster] && !er_diagram?
24
+ @clustered_entities_emitted = false
25
+
26
+ # Warn if clustering requested with erDiagram (not supported)
27
+ if options[:cluster] && er_diagram? && options[:warn]
28
+ warn "Clustering is not supported with erDiagram style. Use mermaid_style: classdiagram instead."
29
+ end
18
30
  end
19
31
 
20
32
  each_entity do |entity, attributes|
33
+ @drawn_entity_names << entity.name.to_s
21
34
  if er_diagram?
22
35
  # Build entity block as a single string to avoid uniq issues with closing braces
23
36
  quoted_entity = quote_entity_name(entity)
@@ -28,6 +41,12 @@ module RailsERD
28
41
  end
29
42
  entity_lines << "\t}"
30
43
  graph << entity_lines.join("\n")
44
+ elsif @clustering_enabled
45
+ # Collect entities by namespace for later emission
46
+ ns = entity.namespace || ""
47
+ short_name = entity.namespace ? entity.name.split("::").last : entity.name
48
+ entity_block = build_class_diagram_entity(short_name, entity.name, attributes)
49
+ @entities_by_namespace[ns] << entity_block
31
50
  else
32
51
  graph << "\tclass `#{entity}`"
33
52
  attributes.each do |attr|
@@ -37,6 +56,12 @@ module RailsERD
37
56
  end
38
57
 
39
58
  each_specialization do |specialization|
59
+ # Emit clustered entities before the first specialization (same as relationships)
60
+ if @clustering_enabled && !@clustered_entities_emitted
61
+ emit_clustered_entities
62
+ @clustered_entities_emitted = true
63
+ end
64
+
40
65
  from, to = specialization.generalized, specialization.specialized
41
66
  if er_diagram?
42
67
  # erDiagram doesn't have a direct polymorphic notation, use inheritance-like
@@ -48,35 +73,29 @@ module RailsERD
48
73
  end
49
74
 
50
75
  each_relationship do |relationship|
76
+ # Emit clustered entities before the first relationship
77
+ if @clustering_enabled && !@clustered_entities_emitted
78
+ emit_clustered_entities
79
+ @clustered_entities_emitted = true
80
+ end
81
+
51
82
  from, to = relationship.source, relationship.destination
52
83
  next unless from && to
53
84
 
54
- if er_diagram?
55
- graph << "\t#{quote_entity_name(from.name)} #{er_relation_notation(relationship)} #{quote_entity_name(to.name)} : \"\""
56
-
57
- from.children.each do |child|
58
- graph << "\t#{quote_entity_name(child.name)} #{er_relation_notation(relationship)} #{quote_entity_name(to.name)} : \"\""
59
- end
60
-
61
- to.children.each do |child|
62
- graph << "\t#{quote_entity_name(from.name)} #{er_relation_notation(relationship)} #{quote_entity_name(child.name)} : \"\""
63
- end
64
- else
65
- graph << "\t`#{from.name}` #{relation_arrow(relationship)} `#{to.name}`"
66
-
67
- from.children.each do |child|
68
- graph << "\t`#{child.name}` #{relation_arrow(relationship)} `#{to.name}`"
69
- end
70
-
71
- to.children.each do |child|
72
- graph << "\t`#{from.name}` #{relation_arrow(relationship)} `#{child.name}`"
73
- end
74
- end
85
+ draw_relation(from, to, relationship)
86
+ from.children.each { |child| draw_relation(child, to, relationship) }
87
+ to.children.each { |child| draw_relation(from, child, relationship) }
75
88
  end
76
89
 
77
90
  save do
78
91
  raise "Saving diagram failed!\nOutput directory '#{File.dirname(filename)}' does not exist." unless File.directory?(File.dirname(filename))
79
92
 
93
+ # Emit clustered entities if not already emitted (e.g., no relationships)
94
+ if @clustering_enabled && !@clustered_entities_emitted
95
+ emit_clustered_entities
96
+ @clustered_entities_emitted = true
97
+ end
98
+
80
99
  File.write(filename.gsub(/\s/,"_"), graph.uniq.join("\n"))
81
100
  filename
82
101
  end
@@ -93,6 +112,22 @@ module RailsERD
93
112
  options[:mermaid_style] == :erdiagram || options[:mermaid_style] == :er
94
113
  end
95
114
 
115
+ # Mermaid renders any entity a relationship names, so an edge to an entity that was
116
+ # filtered out would draw it back into the diagram. Skip those, as Graphviz already does.
117
+ def draw_relation(from, to, relationship)
118
+ return unless entity_drawn?(from.name) && entity_drawn?(to.name)
119
+
120
+ graph << if er_diagram?
121
+ "\t#{quote_entity_name(from.name)} #{er_relation_notation(relationship)} #{quote_entity_name(to.name)} : \"\""
122
+ else
123
+ "\t`#{from.name}` #{relation_arrow(relationship)} `#{to.name}`"
124
+ end
125
+ end
126
+
127
+ def entity_drawn?(name)
128
+ @drawn_entity_names.include?(name.to_s)
129
+ end
130
+
96
131
  # Quote entity names that contain special characters (like :: for namespaces)
97
132
  # Mermaid erDiagram requires double quotes for names with special characters
98
133
  def quote_entity_name(name)
@@ -159,6 +194,36 @@ module RailsERD
159
194
  relationship.many_to? ? "<" : ""
160
195
  end
161
196
 
197
+ # Build entity lines for classDiagram mode (used in clustering)
198
+ def build_class_diagram_entity(short_name, full_name, attributes)
199
+ lines = ["\t\tclass `#{short_name}`"]
200
+ attributes.each do |attr|
201
+ lines << "\t\t`#{short_name}` : +#{attr.type} #{attr.name}"
202
+ end
203
+ lines
204
+ end
205
+
206
+ # Emit entities grouped by namespace for clustering
207
+ def emit_clustered_entities
208
+ # Entities without namespace go first (outside any namespace block)
209
+ @entities_by_namespace[""].each do |entity_lines|
210
+ entity_lines.each { |line| graph << line.sub(/^\t\t/, "\t") }
211
+ end
212
+
213
+ # Then emit each namespace block
214
+ @entities_by_namespace.each do |ns, entity_blocks|
215
+ next if ns == ""
216
+
217
+ # Convert Ruby namespace (Admin::Users) to Mermaid format (Admin.Users)
218
+ mermaid_ns = ns.gsub("::", ".")
219
+ graph << "\tnamespace #{mermaid_ns} {"
220
+ entity_blocks.each do |entity_lines|
221
+ entity_lines.each { |line| graph << line }
222
+ end
223
+ graph << "\t}"
224
+ end
225
+ end
226
+
162
227
  end
163
228
  end
164
229
  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
+ # only_attributes:: Shows only the listed attributes for the given models,
59
+ # without affecting other models. Accepts a hash that maps
60
+ # model names to a list of attribute names to keep. From the
61
+ # command line it can also be given as a comma separated
62
+ # string of +Model.attribute+ entries, for example
63
+ # <tt>only_attributes="Book.title,Book.isbn"</tt>. Models
64
+ # that are not listed keep all of their attributes. Unlike
65
+ # +exclude_attributes+ it cannot be set to +true+.
58
66
  # exclude_attributes:: Hides attributes on a per-model basis, without affecting
59
67
  # other models. Accepts a hash that maps model names to
60
68
  # either +true+ (hide all attributes for that model) or a
@@ -63,6 +71,7 @@ module RailsERD
63
71
  # entry is either +Model+ (hide all attributes) or
64
72
  # +Model.attribute+ (hide a single attribute), for example
65
73
  # <tt>exclude_attributes="BigTable,User.password_digest"</tt>.
74
+ # Applied after +only_attributes+.
66
75
  # disconnected:: Set to +false+ to exclude entities that are not connected to other
67
76
  # entities. Defaults to +false+.
68
77
  # indirect:: Set to +false+ to exclude relationships that are indirect.
@@ -84,21 +93,26 @@ module RailsERD
84
93
  new(Domain.generate(options), options).create
85
94
  end
86
95
 
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:
96
+ # Canonicalises an attribute filter option (+only_attributes+ or
97
+ # +exclude_attributes+) into a hash that maps model names (as strings) to
98
+ # either +true+ (the whole model was named, without any attribute) or an
99
+ # array of attribute names. Accepts several input shapes:
90
100
  #
91
101
  # * 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);
102
+ # (values may be +true+/+"all"+ to name the whole model, +false+/+nil+
103
+ # to name no attributes, or a comma separated string / array of names);
94
104
  # * a string or array of <tt>Model</tt> / <tt>Model.attribute</tt>
95
105
  # entries, e.g. <tt>"BigTable,User.password_digest"</tt>.
96
- def normalize_exclude_attributes(value)
106
+ #
107
+ # How +true+ and an empty list are interpreted is up to the caller: for
108
+ # +exclude_attributes+ they mean "hide every attribute" and "hide none",
109
+ # for +only_attributes+ they both mean "keep every attribute".
110
+ def normalize_attribute_filter(value)
97
111
  return {} if value.nil? || value == false
98
112
 
99
113
  if value.is_a?(Hash)
100
114
  value.each_with_object({}) do |(model, attrs), result|
101
- result[model.to_s] = normalize_exclude_attribute_value(attrs)
115
+ result[model.to_s] = normalize_attribute_filter_value(attrs)
102
116
  end
103
117
  else
104
118
  entries = Array(value).flat_map { |entry| entry.to_s.split(",") }
@@ -116,6 +130,25 @@ module RailsERD
116
130
  end
117
131
  end
118
132
 
133
+ # Canonicalises the +only_attributes+ option. See #normalize_attribute_filter.
134
+ # Unlike +exclude_attributes+ it cannot be enabled with a bare +true+:
135
+ # "show only all attributes" has no meaning, so it is rejected rather than
136
+ # silently ignored.
137
+ def normalize_only_attributes(value)
138
+ if value == true
139
+ raise ArgumentError, "only_attributes cannot be true: it takes the attributes to keep, " \
140
+ "either as a hash of model names to attribute names, or as a list of Model.attribute " \
141
+ "entries. Use attributes: false or exclude_attributes to hide attributes."
142
+ end
143
+
144
+ normalize_attribute_filter(value)
145
+ end
146
+
147
+ # Canonicalises the +exclude_attributes+ option. See #normalize_attribute_filter.
148
+ def normalize_exclude_attributes(value)
149
+ normalize_attribute_filter(value)
150
+ end
151
+
119
152
  protected
120
153
 
121
154
  def setup(&block)
@@ -144,7 +177,7 @@ module RailsERD
144
177
  @callbacks ||= Hash.new { proc {} }
145
178
  end
146
179
 
147
- def normalize_exclude_attribute_value(attrs)
180
+ def normalize_attribute_filter_value(attrs)
148
181
  case attrs
149
182
  when true then true
150
183
  when false, nil then []
@@ -231,8 +264,7 @@ module RailsERD
231
264
 
232
265
  def filtered_entities
233
266
  @domain.entities.reject { |entity|
234
- options.exclude.present? && [options.exclude].flatten.map(&:to_sym).include?(entity.name.to_sym) or
235
- options[:only].present? && entity.model && ![options[:only]].flatten.map(&:to_sym).include?(entity.name.to_sym) or
267
+ excluded_by_filter?(entity) or
236
268
  !options.inheritance && entity.specialized? or
237
269
  !options.polymorphism && entity.generalized? or
238
270
  !options.disconnected && entity.disconnected?
@@ -243,14 +275,76 @@ module RailsERD
243
275
 
244
276
  def filtered_relationships
245
277
  @domain.relationships.reject { |relationship|
246
- !options.indirect && relationship.indirect?
278
+ (!options.indirect && relationship.indirect?) ||
279
+ (!options.recursive && relationship.recursive?) ||
280
+ # Drop relationships to a model removed by :only/:exclude, otherwise the filtered-out
281
+ # model leaks back in: Graphviz skips such edges implicitly (it only draws an edge when
282
+ # both nodes exist) but Mermaid emits every relationship and renders any named entity.
283
+ excluded_by_filter?(relationship.source) ||
284
+ excluded_by_filter?(relationship.destination)
247
285
  }
248
286
  end
249
287
 
288
+ # Whether the entity was removed from the diagram by the :only or :exclude option.
289
+ # Used to filter both entities and the relationships that touch them.
290
+ #
291
+ def excluded_by_filter?(entity)
292
+ name = entity.name.to_s
293
+
294
+ if options.exclude.present?
295
+ patterns = [options.exclude].flatten
296
+ return true if patterns.any? { |pattern| matches_pattern?(pattern, name) }
297
+ end
298
+
299
+ if options[:only].present? && entity.model
300
+ patterns = [options[:only]].flatten
301
+ return true unless patterns.any? { |pattern| matches_pattern?(pattern, name) }
302
+ end
303
+
304
+ false
305
+ end
306
+
307
+ # Matches a name against a pattern. Supports three pattern types:
308
+ #
309
+ # - Exact match: "Foo" matches only "Foo"
310
+ # - Glob pattern: "SolidQueue::*" matches "SolidQueue::Job", etc.
311
+ # - Regex pattern: "/^Active/" matches "ActiveRecord", "ActiveStorage::Blob"
312
+ #
313
+ def matches_pattern?(pattern, name)
314
+ pattern_str = pattern.to_s
315
+
316
+ # Regex pattern: /pattern/ or /pattern/flags
317
+ #
318
+ if pattern_str.start_with?("/") && pattern_str =~ %r{\A/(.+)/([imx]*)\z}
319
+ regex_body = Regexp.last_match(1)
320
+ flags_str = Regexp.last_match(2)
321
+ flags = 0
322
+ flags |= Regexp::IGNORECASE if flags_str.include?("i")
323
+ flags |= Regexp::MULTILINE if flags_str.include?("m")
324
+ flags |= Regexp::EXTENDED if flags_str.include?("x")
325
+ return Regexp.new(regex_body, flags).match?(name.to_s)
326
+ end
327
+
328
+ # Glob pattern: contains *, ?, or [
329
+ #
330
+ if pattern_str.include?("*") || pattern_str.include?("?") || pattern_str.include?("[")
331
+ return File.fnmatch?(pattern_str, name.to_s)
332
+ end
333
+
334
+ # Exact match (backward compatible)
335
+ pattern_str == name.to_s
336
+ end
337
+
250
338
  def filtered_specializations
251
339
  @domain.specializations.reject { |specialization|
252
340
  !options.inheritance && specialization.inheritance? or
253
- !options.polymorphism && specialization.polymorphic?
341
+ !options.polymorphism && specialization.polymorphic? or
342
+ # Drop specializations whose generalized or specialized entity is not
343
+ # part of the rendered domain (e.g. an abstract parent whose child model
344
+ # has no table). These resolve to a Null entity with a blank name and
345
+ # would otherwise produce an edge to a nameless entity (invalid output).
346
+ specialization.generalized.name.to_s.empty? or
347
+ specialization.specialized.name.to_s.empty?
254
348
  }
255
349
  end
256
350
 
@@ -258,15 +352,34 @@ module RailsERD
258
352
  excluded = excluded_attributes_for(entity)
259
353
  return [] if excluded == :all
260
354
 
261
- entity.attributes.reject { |attribute|
355
+ only = only_attributes_for(entity)
356
+
357
+ entity.attributes.select { |attribute|
358
+ # Keep only the attributes listed for this specific model, mirroring how
359
+ # :only is applied before :exclude when filtering entities.
360
+ next false if only != :all && !only.include?(attribute.name)
262
361
  # Hide attributes excluded for this specific model.
263
- excluded.include?(attribute.name) or
264
- # Select attributes that satisfy the conditions in the :attributes option.
265
- !options.attributes or entity.specialized? or
266
- [*options.attributes].none? { |type| attribute.send(:"#{type.to_s.chomp('s')}?") }
362
+ next false if excluded.include?(attribute.name)
363
+ # Hide every attribute when the :attributes option is off or the entity
364
+ # is specialized (its attributes are shown on the parent instead).
365
+ next false if !options.attributes || entity.specialized?
366
+ # Otherwise keep only attributes matching the requested :attributes types.
367
+ [*options.attributes].any? { |type| attribute.send(:"#{type.to_s.chomp('s')}?") }
267
368
  }
268
369
  end
269
370
 
371
+ # Returns the attributes the given entity is restricted to through the
372
+ # +only_attributes+ option. Returns +:all+ when no restriction applies,
373
+ # which is the case when the model is not listed at all, or is listed
374
+ # without naming any attribute.
375
+ def only_attributes_for(entity)
376
+ spec = normalized_only_attributes[entity.name]
377
+ return :all if spec.nil? || spec == true
378
+
379
+ names = Array(spec)
380
+ names.empty? ? :all : names
381
+ end
382
+
270
383
  # Returns the attribute exclusions configured for the given entity through
271
384
  # the +exclude_attributes+ option. Returns +:all+ when every attribute
272
385
  # should be hidden, or an array of attribute names otherwise.
@@ -276,6 +389,10 @@ module RailsERD
276
389
  Array(spec)
277
390
  end
278
391
 
392
+ def normalized_only_attributes
393
+ @normalized_only_attributes ||= self.class.normalize_only_attributes(options.only_attributes)
394
+ end
395
+
279
396
  def normalized_exclude_attributes
280
397
  @normalized_exclude_attributes ||= self.class.normalize_exclude_attributes(options.exclude_attributes)
281
398
  end