class GraphRecords
Spit out a Mermaid-style graph of records.
Usage:
graph = GraphRecords.new.graph(patients: [patient]) puts graph
Constants
- ALLOWED_TYPES
- BOX_STYLES
- DEFAULT_NODE_ORDER
- DEFAULT_TRAVERSALS
- DETAIL_WHITELIST
- DETAIL_WHITELIST_WITH_PII
- EXTRA_DETAIL_WHITELIST_WITH_PII
Public Class Methods
Source
# File app/lib/graph_records.rb, line 253 def initialize( focus_config: {}, node_order: DEFAULT_NODE_ORDER, traversals_config: {}, node_limit: 1000, primary_type: nil, clickable: false, show_pii: false ) @focus_config = focus_config @node_order = node_order @traversals_config = traversals_config @node_limit = node_limit @primary_type = primary_type @clickable = clickable @detail_whitelist = show_pii ? DETAIL_WHITELIST_WITH_PII : DETAIL_WHITELIST end
@param focus_config [Hash] Hash of model names to ids to focus on (make bold) @param node_order [Array] Array of model names in order to render nodes @param traversals_config [Hash] Hash of model names to arrays of associations to traverse @param node_limit [Integer] The maximum number of nodes which can be displayed
Public Instance Methods
Source
# File app/lib/graph_records.rb, line 409 def class_text_for_obj(obj) obj.class.name.underscore + (obj.in?(@focus) ? "_focused" : "") end
Source
# File app/lib/graph_records.rb, line 448 def escape_special_chars(text) text.to_s.gsub("@", "\\@") end
Source
# File app/lib/graph_records.rb, line 378 def get_associated_objects(obj, association_name) obj .send(association_name) .then { |associated_objects| load_association(associated_objects) } end
Source
# File app/lib/graph_records.rb, line 272 def graph(**objects) @nodes = Set.new @edges = Set.new @inspected = Set.new @focus = @focus_config.map { _1.to_s.classify.constantize.where(id: _2) } @primary_type ||= if objects.keys.size >= 1 objects.keys.first.to_s.singularize.to_sym else :patient end objects.map do |klass, ids| class_name = klass.to_s.singularize class_sym = class_name.to_sym # Skip objects whose type is not in the traversal configuration next unless traversals.key?(class_sym) associated_objects = load_association(class_name.classify.constantize.where(id: ids)) @focus += associated_objects associated_objects.each do |obj| @nodes << obj introspect(obj) end end ["flowchart TB"] + render_styles + render_nodes + render_edges + (@clickable ? render_clicks : []) rescue StandardError => e if e.message.include?("Recursion limit") # Create a Mermaid diagram with a red box containing the error message. [ "flowchart TB", " error[#{e.message}]", " style error fill:#f88,stroke:#f00,stroke-width:2px" ] else raise e end end
@param objects [Hash] Hash of model name to ids to be graphed
Source
# File app/lib/graph_records.rb, line 357 def introspect(obj) associations_list = traversals[obj.class.name.underscore.to_sym] return if associations_list.blank? return if @inspected.include?(obj) @inspected << obj associations_list.each do get_associated_objects(obj, it).each do @nodes << it @edges << order_nodes(obj, it) if @nodes.length > @node_limit raise "Recursion limit of #{@node_limit} nodes has been exceeded. Try restricting the graph." end introspect(it) end end end
Source
# File app/lib/graph_records.rb, line 384 def load_association(associated_objects) Array( if associated_objects.is_a?(ActiveRecord::Relation) associated_objects.strict_loading!(false) else associated_objects end ) end
Source
# File app/lib/graph_records.rb, line 413 def node_display_name(obj) klass = obj.class.name.underscore.humanize "#{klass} #{obj.id}" end
Source
# File app/lib/graph_records.rb, line 400 def node_link(obj) "/inspect/graph/#{obj.class.name.underscore.pluralize}/#{obj.id}" end
Source
# File app/lib/graph_records.rb, line 404 def node_name(obj) klass = obj.class.name.underscore "#{klass}-#{obj.id}" end
Source
# File app/lib/graph_records.rb, line 452 def node_text(obj) text = "\"#{node_display_name(obj)}" unless @clickable command = "#{obj.class.to_s.classify}.find(#{obj.id})" text += "<br><span style=\"font-size:10px\"><i>#{non_breaking_text(command)}</i></span>" command = "puts GraphRecords.new.graph(#{obj.class.name.underscore}: #{obj.id})" text += "<br><span style=\"font-size:10px\"><i>#{non_breaking_text(command)}</i></span>" end if @detail_whitelist.key?(obj.class.name.underscore.to_sym) @detail_whitelist[obj.class.name.underscore.to_sym].each do |detail| value = obj.send(detail) name = detail.to_s detail_text = "#{name}: #{escape_special_chars(value)}" text += "<br><span style=\"font-size:14px\">#{non_breaking_text(detail_text)}</span>" end end "#{text}\"" end
Source
# File app/lib/graph_records.rb, line 439 def node_types_in_graph @nodes.map { |node| node.class.name.underscore.to_sym }.uniq end
Source
# File app/lib/graph_records.rb, line 478 def node_with_class(obj) "#{node_name(obj)}[#{node_text(obj)}]:::#{class_text_for_obj(obj)}" end
Source
# File app/lib/graph_records.rb, line 443 def non_breaking_text(text) # Insert non-breaking spaces and hyphens to prevent Mermaid from breaking the line text.gsub(" ", " ").gsub("-", "#8209;") end
Source
# File app/lib/graph_records.rb, line 394 def order_nodes(*nodes) nodes.sort_by do |node| @node_order.index(node.class.name.underscore) || Float::INFINITY end end
Source
# File app/lib/graph_records.rb, line 418 def patients_with_pii_in_graph result = Set.new(@nodes.select { |node| node.is_a?(Patient) }) @nodes.each do |node| # Skip if not a type with potential PII node_type = node.class.name.underscore.to_sym next unless EXTRA_DETAIL_WHITELIST_WITH_PII.key?(node_type) next if node.is_a?(Patient) # Already handled these if node.respond_to?(:patient) && node.patient result << node.patient elsif node.respond_to?(:consent) && node.consent&.patient result << node.consent.patient elsif node.respond_to?(:patients) result.merge(node.patients.to_a) end end result.to_a end
Source
# File app/lib/graph_records.rb, line 353 def render_clicks @nodes.map { " click #{node_name(it)} \"#{node_link(it)}\"" } end
Source
# File app/lib/graph_records.rb, line 349 def render_edges @edges.map { |from, to| " #{node_name(from)} --> #{node_name(to)}" } end
Source
# File app/lib/graph_records.rb, line 345 def render_nodes @nodes.to_a.map { " #{node_with_class(it)}" } end
Source
# File app/lib/graph_records.rb, line 322 def render_styles object_types = @nodes.map { |node| node.class.name.underscore.to_sym }.uniq styles = object_types.each_with_object({}) do |type, hash| color_index = Digest::MD5.hexdigest(type.to_s).to_i(16) % BOX_STYLES.length hash[type] = "#{BOX_STYLES[color_index]},stroke:#000" end focused_styles = styles.each_with_object({}) do |(type, style), hash| hash["#{type}_focused"] = "#{style},stroke-width:3px" end styles.merge!(focused_styles) styles .with_indifferent_access .slice(*@nodes.map { class_text_for_obj(it) }) .map { |klass, style| " classDef #{klass} #{style}" } end
Source
# File app/lib/graph_records.rb, line 317 def traversals @traversals ||= (DEFAULT_TRAVERSALS[@primary_type] || {}).merge(@traversals_config) end