Module: Parse::Agent::RelationGraph
Overview
RelationGraph derives the class-relationship graph from Parse Stack's
existing belongs_to and has_many :through => :relation declarations,
with no extra model DSL required. Each edge is a hash:
{ from:, to:, via:, cardinality:, kind: }
from/to are Parse class names; via is the owning side's field path
(Post.author); cardinality is "1:N" for pointer edges and "N:M"
for relation columns; kind is :belongs_to or :relation.
Convention: pointer edges are emitted from the target ("the one") to the
source ("the many"), so Post.author → _User reads as
_User ─1:N→ Post (Post.author) — natural English.
Constant Summary collapse
- SAFE_IDENTIFIER =
Conservative identifier shape used to sanitize edge components before rendering them into LLM-facing text. Edges sourced from gem-internal introspection should already match; the filter is defense in depth against any future code path that lets remote input into class/field naming (would otherwise be a prompt-injection channel).
/\A[A-Za-z_][A-Za-z0-9_]{0,127}\z/.freeze
- SAFE_VIA =
%r{\A[A-Za-z_][A-Za-z0-9_]{0,127}\.[A-Za-z_][A-Za-z0-9_]{0,127}\z}.freeze
- ANALYTICS_RELEVANT_SYSTEM_CLASSES =
System classes that participate in normal analytics queries and should remain visible by default. Other
_-prefixed Parse internals are filtered out so the graph stays aligned with theexplore_databaseprompt that already tells the LLM to skip them. %w[_User _Role].freeze
Instance Method Summary collapse
-
#build(classes: nil) ⇒ Array<Hash>
Build edges across the currently-loaded Parse model classes.
-
#edges_for(class_name, edges = nil) ⇒ Hash
For a single Parse class, return its incoming and outgoing edges in a form suitable for embedding inside an enriched schema.
-
#to_ascii(edges) ⇒ String
Render edges as a compact ASCII diagram.
Instance Method Details
#build(classes: nil) ⇒ Array<Hash>
Build edges across the currently-loaded Parse model classes.
When classes: is provided, only edges whose from AND to are both
in the subset are returned (strict slice — keeps the diagram focused).
Pass nil for the full graph.
When MetadataRegistry has any agent_visible classes registered, only
those are walked; otherwise all Parse::Object descendants are walked.
Keeps the graph aligned with what the agent surfaces elsewhere.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/parse/agent/relation_graph.rb', line 60 def build(classes: nil) subset = classes && classes.map(&:to_s) edges = [] candidate_classes.each do |klass| next unless klass.respond_to?(:parse_class) parse_class = klass.parse_class if klass.respond_to?(:references) klass.references.each do |field, target| edges << { from: target.to_s, to: parse_class, via: "#{parse_class}.#{field}", cardinality: "1:N", kind: :belongs_to, } end end if klass.respond_to?(:relations) klass.relations.each do |key, target| # has_many :through => :relation stores the Ruby key in # `relations`, but `field_map` carries the on-the-wire camelCase # column name (respecting an explicit `field:` override). The # LLM needs the wire name to build `where:` / `include:` clauses # against the actual column. wire = klass.respond_to?(:field_map) ? (klass.field_map[key]&.to_s || key.to_s) : key.to_s edges << { from: parse_class, to: target.to_s, via: "#{parse_class}.#{wire}", cardinality: "N:M", kind: :relation, } end end end edges.uniq! { |e| [e[:from], e[:to], e[:via]] } return edges unless subset edges.select { |e| subset.include?(e[:from]) && subset.include?(e[:to]) } end |
#edges_for(class_name, edges = nil) ⇒ Hash
For a single Parse class, return its incoming and outgoing edges in a
form suitable for embedding inside an enriched schema. Pass a
pre-computed edges array to avoid re-walking the descendants on each
call when enriching many schemas at once.
135 136 137 138 139 140 141 |
# File 'lib/parse/agent/relation_graph.rb', line 135 def edges_for(class_name, edges = nil) edges ||= build { outgoing: edges.select { |e| e[:from] == class_name }, incoming: edges.select { |e| e[:to] == class_name }, } end |
#to_ascii(edges) ⇒ String
Render edges as a compact ASCII diagram. Empty graph returns a one-line placeholder. Edges with components that don't match the SAFE_IDENTIFIER / SAFE_VIA shapes are dropped before rendering so the resulting text is always alphanumeric/dot-only — closes a theoretical prompt-injection channel if any future code path admits attacker influence into class or field names.
113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/parse/agent/relation_graph.rb', line 113 def to_ascii(edges) safe = edges.select do |e| e[:from].to_s.match?(SAFE_IDENTIFIER) && e[:to].to_s.match?(SAFE_IDENTIFIER) && e[:via].to_s.match?(SAFE_VIA) end return "(no class relations defined)" if safe.empty? max_from = safe.map { |e| e[:from].length }.max max_to = safe.map { |e| e[:to].length }.max safe.map do |e| "#{e[:from].ljust(max_from)} ─#{e[:cardinality]}→ #{e[:to].ljust(max_to)} (#{e[:via]})" end.join("\n") end |