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 the `explore_database` prompt 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 |