voormedia/rails-erd

View on GitHub
lib/rails_erd/diagram/graphviz.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# encoding: utf-8
require "rails_erd/diagram"
require "graphviz"
require "erb"

# Fix bad RegEx test in Ruby-Graphviz.
GraphViz::Types::LblString.class_eval do
  def output # @private :nodoc:
    if /^<.*>$/m =~ @data
      @data
    else
      @data.to_s.inspect.gsub("\\\\", "\\")
    end
  end
  alias_method :to_gv, :output
  alias_method :to_s, :output
end

module RailsERD
  class Diagram
    # Create Graphviz-based diagrams based on the domain model. For easy
    # command line graph generation, you can use:
    #
    #   % rake erd
    #
    # === Options
    #
    # The following options are supported:
    #
    # filename:: The file basename of the generated diagram. Defaults to +ERD+,
    #            or any other extension based on the file type.
    # filetype:: The file type of the generated diagram. Defaults to +pdf+, which
    #            is the recommended format. Other formats may render significantly
    #            worse than a PDF file. The available formats depend on your installation
    #            of Graphviz.
    # notation:: The cardinality notation to be used. Can be +:simple+ or
    #            +:bachman+. Refer to README.rdoc or to the examples on the project
    #            homepage for more information and examples.
    # orientation:: The direction of the hierarchy of entities. Either +:horizontal+
    #               or +:vertical+. Defaults to +horizontal+. The orientation of the
    #               PDF that is generated depends on the amount of hierarchy
    #               in your models.
    # title:: The title to add at the top of the diagram. Defaults to
    #         <tt>"YourApplication domain model"</tt>.
    class Graphviz < Diagram
      NODE_LABEL_TEMPLATES = {
        html:   "node.html.erb",
        record: "node.record.erb"
      } # @private :nodoc:

      NODE_WIDTH = 130 # @private :nodoc:

      FONTS = Config.font_names_based_on_os.merge(RailsERD.options[:fonts])

      # Default graph attributes.
      GRAPH_ATTRIBUTES = {
        rankdir:     :LR,
        ranksep:     0.5,
        nodesep:     0.4,
        pad:         "0.4,0.4",
        margin:      "0,0",
        concentrate: true,
        labelloc:    :t,
        fontsize:    13,
        fontname:    FONTS[:bold],
        splines:     'spline'
      }

      # Default node attributes.
      NODE_ATTRIBUTES = {
        shape:    "Mrecord",
        fontsize: 10,
        fontname: FONTS[:normal],
        margin:   "0.07,0.05",
        penwidth: 1.0
      }

      # Default edge attributes.
      EDGE_ATTRIBUTES = {
        fontname:      FONTS[:normal],
        fontsize:      7,
        dir:           :both,
        arrowsize:     0.9,
        penwidth:      1.0,
        labelangle:    32,
        labeldistance: 1.8,
      }

      # Default cluster attributes.
      CLUSTER_ATTRIBUTES = {
        margin: "10,10"
      }

      module Simple
        def entity_style(entity, attributes)
          {}.tap do |options|
            options[:fontcolor] = options[:color] = :grey60 if entity.virtual?
          end
        end

        def relationship_style(relationship)
          {}.tap do |options|
            options[:style] = :dotted if relationship.indirect?

            # Closed arrows for to/from many.
            options[:arrowhead] = relationship.to_many? ? "normal" : "none"
            options[:arrowtail] = relationship.many_to? ? "normal" : "none"
          end
        end

        def specialization_style(specialization)
          { color:     :grey60,
            arrowtail: :onormal,
            arrowhead: :none,
            arrowsize: 1.2 }
        end
      end

      module Crowsfoot
        include Simple
        def relationship_style(relationship)
          {}.tap do |options|
            options[:style] = :dotted if relationship.indirect?

            # Cardinality is "look-across".
            dst = relationship.to_many? ? "crow" : "tee"
            src = relationship.many_to? ? "crow" : "tee"

            # Participation is "look-across".
            dst << (relationship.destination_optional? ? "odot" : "tee")
            src << (relationship.source_optional? ? "odot" : "tee")

            options[:arrowsize] = 0.6
            options[:arrowhead], options[:arrowtail] = dst, src
          end
        end
      end

      module Bachman
        include Simple
        def relationship_style(relationship)
          {}.tap do |options|
            options[:style] = :dotted if relationship.indirect?

            # Participation is "look-here".
            dst = relationship.source_optional? ? "odot" : "dot"
            src = relationship.destination_optional? ? "odot" : "dot"

            # Cardinality is "look-across".
            dst << "normal" if relationship.to_many?
            src << "normal" if relationship.many_to?

            options[:arrowsize] = 0.6
            options[:arrowhead], options[:arrowtail] = dst, src
          end
        end
      end

      module Uml
        include Simple
        def relationship_style(relationship)
          {}.tap do |options|
            options[:style] = :dotted if relationship.indirect?

            options[:arrowsize] = 0.7
            options[:arrowhead] = relationship.to_many? ? "vee" : "none"
            options[:arrowtail] = relationship.many_to? ? "vee" : "none"

            ranges = [relationship.cardinality.destination_range, relationship.cardinality.source_range].map do |range|
              if range.min == range.max
                "#{range.min}"
              else
                "#{range.min}..#{range.max == Domain::Relationship::N ? "∗" : range.max}"
              end
            end
            options[:headlabel], options[:taillabel] = *ranges
          end
        end
      end

      attr_accessor :graph

      setup do
        self.graph = GraphViz.digraph(domain.name)

        # Set all default attributes.
        GRAPH_ATTRIBUTES.each { |attribute, value| graph[attribute] = value }
        NODE_ATTRIBUTES.each  { |attribute, value| graph.node[attribute] = value }
        EDGE_ATTRIBUTES.each  { |attribute, value| graph.edge[attribute] = value }

        # Switch rank direction if we're creating a vertically oriented graph.
        graph[:rankdir] = (options.orientation == "vertical") ? :LR : :TB

        # Title of the graph itself.
        graph[:label] = "#{title}\\n\\n" if title

        # Style of splines
        graph[:splines] = options.splines unless options.splines.nil?

        # Setup notation options.
        extend self.class.const_get(options.notation.to_s.capitalize.to_sym)
      end

      save do
        raise "Saving diagram failed!\nOutput directory '#{File.dirname(filename)}' does not exist." unless File.directory?(File.dirname(filename))

        begin
          # GraphViz doesn't like spaces in the filename
          graph.output(filetype => filename.gsub(/\s/,"_"))
          filename
        rescue RuntimeError => e
          raise "Saving diagram failed!\nGraphviz produced errors. Verify it " +
                  "has support for filetype=#{options.filetype}, or use " +
                  "filetype=dot.\nOriginal error: #{e.message.split("\n").last}"
        rescue StandardError => e
          raise "Saving diagram failed!\nVerify that Graphviz is installed " +
                  "and in your path, or use filetype=dot."
        end
      end

      each_entity do |entity, attributes|
        if options[:cluster] && entity.namespace
          cluster_name = "cluster_#{entity.namespace}"
          cluster_options = CLUSTER_ATTRIBUTES.merge(label: entity.namespace)
          cluster = graph.get_graph(cluster_name) ||
                    graph.add_graph(cluster_name, cluster_options)

          draw_cluster_node cluster, entity.name, entity_options(entity, attributes)
        else
          draw_node entity.name, entity_options(entity, attributes)
        end
      end

      each_specialization do |specialization|
        from, to = specialization.generalized, specialization.specialized
        draw_edge from.name, to.name, specialization_options(specialization)
      end

      each_relationship do |relationship|
        from, to = relationship.source, relationship.destination
        unless draw_edge from.name, to.name, relationship_options(relationship)
          from.children.each do |child|
            draw_edge child.name, to.name, relationship_options(relationship)
          end
          to.children.each do |child|
            draw_edge from.name, child.name, relationship_options(relationship)
          end
        end
      end

      private

      def node_exists?(name)
        !!graph.search_node(escape_name(name))
      end

      def draw_node(name, options)
        graph.add_nodes escape_name(name), options
      end

      def draw_cluster_node(cluster, name, options)
        cluster.add_nodes escape_name(name), options
      end

      def draw_edge(from, to, options)
        graph.add_edges graph.search_node(escape_name(from)), graph.search_node(escape_name(to)), options if node_exists?(from) and node_exists?(to)
      end

      def escape_name(name)
        "m_#{name}"
      end

      # Returns the title to be used for the graph.
      def title
        case options.title
        when false then nil
        when true
          if domain.name then "#{domain.name} domain model" else "Domain model" end
        else options.title
        end
      end

      # Returns the file name that will be used when saving the diagram.
      def filename
        "#{options.filename}.#{options.filetype}"
      end

      # Returns the default file extension to be used when saving the diagram.
      def filetype
        if options.filetype.to_sym == :dot then :none else options.filetype.to_sym end
      end

      def entity_options(entity, attributes)
        label = options[:markup] ? "<#{read_template(:html).result(binding)}>" : "#{read_template(:record).result(binding)}"
        entity_style(entity, attributes).merge :label => label
      end

      def relationship_options(relationship)
        relationship_style(relationship).tap do |options|
          # Edges with a higher weight are optimized to be shorter and straighter.
          options[:weight] = relationship.strength

          # Indirect relationships should not influence node ranks.
          options[:constraint] = false if relationship.indirect?
        end
      end

      def specialization_options(specialization)
        specialization_style(specialization)
      end

      def read_template(type)
        template_text = File.read(File.expand_path("templates/#{NODE_LABEL_TEMPLATES[type]}", File.dirname(__FILE__)))
        if ERB.instance_method(:initialize).parameters.assoc(:key) # Ruby 2.6+
          ERB.new(template_text, trim_mode: "<>")
        else
          ERB.new(template_text, nil, "<>")
        end
      end
    end
  end
end