Class: Synthra::Export::Graphviz

Inherits:
Base
  • Object
show all
Defined in:
lib/synthra/export/graphviz.rb

Overview

Exports schemas as GraphViz DOT format

Examples:

Export a registry as DOT

exporter = Graphviz.new(registry: registry)
dot = exporter.export

Constant Summary collapse

COLORS =

Color schemes for different schema types

{
  default: "black",
  deprecated: "gray",
  main: "#2563eb",
  nested: "#059669"
}.freeze
EDGE_STYLES =

Edge styles for different relationship types

{
  embed: "",
  array: "style=dashed",
  map_by_field: "style=dotted",
  reference: "style=bold, color=\"#dc2626\""
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(schema = nil, registry: nil, **options) ⇒ Graphviz

Returns a new instance of Graphviz.



29
30
31
32
# File 'lib/synthra/export/graphviz.rb', line 29

def initialize(schema = nil, registry: nil, **options)
  super
  @registry = registry
end

Instance Method Details

#build_edge(source, target, label, type) ⇒ Object (private)



211
212
213
214
215
# File 'lib/synthra/export/graphviz.rb', line 211

def build_edge(source, target, label, type)
  style = EDGE_STYLES[type] || ""
  style_attr = style.empty? ? "" : ", #{style}"
  "  #{source} -> #{target} [label=\"#{escape_dot(label)}\"#{style_attr}];"
end

#build_grouped_nodesObject (private)



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/synthra/export/graphviz.rb', line 96

def build_grouped_nodes
  lines = []
  groups = Hash.new { |h, k| h[k] = [] }

  @registry.schemas.each do |name, schema|
    group = detect_group(name)
    groups[group] << schema
  end

  groups.each do |group, schemas|
    lines << "  subgraph cluster_#{group} {"
    lines << "    label=\"#{group.capitalize}\";"
    lines << "    style=dashed;"
    schemas.each { |s| lines << build_schema_node(s, indent: 4) }
    lines << "  }"
  end

  lines
end

#build_registry_graphObject (private)



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/synthra/export/graphviz.rb', line 74

def build_registry_graph
  lines = []

  # Group schemas by category if specified
  if options[:group]
    lines.concat(build_grouped_nodes)
  else
    @registry.schemas.each do |name, schema|
      lines << build_schema_node(schema)
    end
  end

  lines << ""

  # Add edges
  @registry.schemas.each do |name, schema|
    lines.concat(build_schema_edges(schema))
  end

  lines
end

#build_schema_edges(source_schema) ⇒ Object (private)



147
148
149
150
151
152
153
154
155
156
# File 'lib/synthra/export/graphviz.rb', line 147

def build_schema_edges(source_schema)
  lines = []

  source_schema.fields.each do |field|
    edges = field_to_edges(source_schema.name, field)
    lines.concat(edges)
  end

  lines
end

#build_schema_node(target_schema, indent: 2) ⇒ Object (private)



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/synthra/export/graphviz.rb', line 126

def build_schema_node(target_schema, indent: 2)
  prefix = " " * indent
  name = target_schema.name
  
  fields = target_schema.fields.map do |f|
    optional = f.optional? ? "?" : ""
    nullable = f.nullable? ? "?" : ""
    type_info = format_type(f)
    "#{f.name}#{optional}: #{type_info}#{nullable}"
  end

  # Escape special characters for DOT
  escaped_fields = fields.map { |f| escape_dot(f) }.join("\\l")
  label = "{#{name}|#{escaped_fields}\\l}"

  color = target_schema.deprecated? ? COLORS[:deprecated] : COLORS[:default]
  style = target_schema.deprecated? ? ", style=dashed" : ""

  "#{prefix}#{name} [label=\"#{label}\", color=\"#{color}\"#{style}];"
end

#detect_group(name) ⇒ Object (private)



116
117
118
119
120
121
122
123
124
# File 'lib/synthra/export/graphviz.rb', line 116

def detect_group(name)
  case name
  when /User|Profile|Account/i then "users"
  when /Order|Cart|Payment/i then "commerce"
  when /Message|Chat|DM/i then "messaging"
  when /API|Response|Request/i then "api"
  else "other"
  end
end

#escape_dot(str) ⇒ Object (private)



243
244
245
246
247
248
249
250
251
252
# File 'lib/synthra/export/graphviz.rb', line 243

def escape_dot(str)
  str.to_s
     .gsub("\\", "\\\\")
     .gsub("\"", "\\\"")
     .gsub("{", "\\{")
     .gsub("}", "\\}")
     .gsub("<", "\\<")
     .gsub(">", "\\>")
     .gsub("|", "\\|")
end

#exportObject



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/synthra/export/graphviz.rb', line 34

def export
  lines = []
  lines << "digraph Synthra {"
  lines << "  // Graph settings"
  lines << "  rankdir=#{options[:direction] || 'LR'};"
  lines << "  splines=ortho;" if options[:ortho]
  lines << "  node [shape=record, fontname=\"#{options[:font] || 'Helvetica'}\", fontsize=10];"
  lines << "  edge [fontname=\"#{options[:font] || 'Helvetica'}\", fontsize=9];"
  lines << ""

  if @registry
    lines << build_registry_graph
  elsif @schema
    lines << build_schema_node(@schema)
  end

  lines << "}"
  lines.join("\n")
end

#field_to_edges(source_name, field) ⇒ Object (private)



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/synthra/export/graphviz.rb', line 158

def field_to_edges(source_name, field)
  edges = []
  type_name = field.type_name
  type_args = field.type_args

  # Direct schema reference
  if schema_in_registry?(type_name)
    edges << build_edge(source_name, type_name, field.name, :embed)
  end

  # Array element type
  if type_name == "array"
    element = type_args[:element]
    if element && schema_in_registry?(element.to_s)
      edges << build_edge(source_name, element.to_s, "#{field.name}[]", :array)
    end
  end

  # map_by_field entries
  if type_name == "map_by_field"
    entries = type_args[:entries] || {}
    entries.each do |entry_name, |
      schema_name = .is_a?(Hash) ? ([:schema] || ["schema"]) : .to_s
      if schema_name && schema_in_registry?(schema_name)
        shared = .is_a?(Hash) && [:shared]
        label = shared ? "#{field.name}.#{entry_name} (shared)" : "#{field.name}.#{entry_name}"
        edges << build_edge(source_name, schema_name, label, :map_by_field)
      end
    end
  end

  # one_of schemas
  if type_name == "one_of"
    schemas = type_args[:schemas] || []
    schemas.each do |s|
      schema_name = s.is_a?(Hash) ? (s[:schema] || s["schema"]) : s.to_s
      if schema_name && schema_in_registry?(schema_name)
        edges << build_edge(source_name, schema_name, "#{field.name}|", :reference)
      end
    end
  end

  # Ref() references
  if type_name == "reference"
    ref = type_args[:ref]
    if ref.respond_to?(:schema_name) && schema_in_registry?(ref.schema_name)
      edges << build_edge(source_name, ref.schema_name, "Ref(#{ref.schema_name}.#{ref.field_path})", :reference)
    end
  end

  edges
end

#format_type(field) ⇒ Object (private)



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/synthra/export/graphviz.rb', line 217

def format_type(field)
  type_name = field.type_name
  type_args = field.type_args

  case type_name
  when "array"
    element = type_args[:element] || "?"
    "#{element}[]"
  when "enum"
    values = type_args[:values]&.map { |v| v.respond_to?(:value) ? v.value : v } || []
    values.length <= 3 ? values.join("|") : "enum(#{values.length})"
  when "const"
    "\"#{type_args[:value]}\""
  when "one_of"
    schemas = type_args[:schemas] || []
    names = schemas.map { |s| s.is_a?(Hash) ? s[:schema] : s.to_s }
    names.length <= 2 ? names.join("|") : "one_of(#{names.length})"
  else
    type_name
  end
end

#render(output_path, format: :png) ⇒ Boolean

Render DOT to image format using graphviz

Parameters:

  • output_path (String)

    output file path

  • format (Symbol) (defaults to: :png)

    output format (:png, :svg, :pdf)

Returns:

  • (Boolean)

    true if successful



60
61
62
63
64
65
66
67
68
69
70
# File 'lib/synthra/export/graphviz.rb', line 60

def render(output_path, format: :png)
  dot_content = export
  
  require "tempfile"
  Tempfile.create(["schema", ".dot"]) do |dot_file|
    dot_file.write(dot_content)
    dot_file.flush
    
    system("dot", "-T#{format}", dot_file.path, "-o", output_path)
  end
end

#schema_in_registry?(name) ⇒ Boolean (private)

Returns:

  • (Boolean)


239
240
241
# File 'lib/synthra/export/graphviz.rb', line 239

def schema_in_registry?(name)
  @registry&.schema?(name) || false
end