Class: Synthra::Generator::Resolver

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/generator/resolver.rb

Overview

Resolves cross-schema references

Instance Method Summary collapse

Constructor Details

#initialize(registry) ⇒ Resolver

Returns a new instance of Resolver.



8
9
10
11
12
13
# File 'lib/synthra/generator/resolver.rb', line 8

def initialize(registry)
  @registry = registry
  @cache = {}
  @generating = Set.new
  @mutex = Mutex.new
end

Instance Method Details

#build_dependency_graphHash (private)

Build dependency graph from schema references

Analyzes all schemas to build a graph of cross-schema dependencies.

Returns:

  • (Hash)

    dependency graph { schema_name => [dependent_schemas] }



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/synthra/generator/resolver.rb', line 160

def build_dependency_graph
  graph = Hash.new { |h, k| h[k] = [] }

  @registry.each do |name, schema|
    schema.fields.each do |field|
      if field.type_name == "reference"
        ref = field.type_args[:ref]
        ref_schema = ref.respond_to?(:schema_name) ? ref.schema_name : ref&.dig(:schema_name)
        graph[name] << ref_schema if ref_schema
      end
    end
  end

  graph
end

#clear_cachevoid

This method returns an undefined value.

Clear the resolution cache



116
117
118
119
120
121
# File 'lib/synthra/generator/resolver.rb', line 116

def clear_cache
  @mutex.synchronize do
    @cache.clear
    @generating.clear
  end
end

#dependency_orderArray<String>

Get schemas in dependency order

Returns:

  • (Array<String>)

    schema names ordered by dependencies



89
90
91
92
# File 'lib/synthra/generator/resolver.rb', line 89

def dependency_order
  dependencies = build_dependency_graph
  topological_sort(dependencies)
end

#detect_cyclesArray<Array<String>>

Detect cycles in schema references

Returns:

  • (Array<Array<String>>)

    array of cycle paths



99
100
101
102
103
104
105
106
107
108
109
# File 'lib/synthra/generator/resolver.rb', line 99

def detect_cycles
  cycles = []
  visited = Set.new
  stack = []

  @registry.names.each do |name|
    detect_cycles_from(name, visited, stack, cycles)
  end

  cycles
end

#detect_cycles_from(node, visited, stack, cycles) ⇒ void (private)

This method returns an undefined value.

Detect cycles starting from a node

Recursively traverses the dependency graph to detect cycles. Uses depth-first search with cycle detection.

Parameters:

  • node (String)

    schema name to start from

  • visited (Set)

    set of visited nodes

  • stack (Array)

    current path stack

  • cycles (Array)

    array to collect detected cycles



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/synthra/generator/resolver.rb', line 225

def detect_cycles_from(node, visited, stack, cycles)
  return if visited.include?(node)

  if stack.include?(node)
    cycle_start = stack.index(node)
    cycles << stack[cycle_start..] + [node]
    return
  end

  stack.push(node)

  begin
    schema = @registry.schema(node)
    schema.fields.each do |field|
      if field.type_name == "reference"
        ref = field.type_args[:ref]
        ref_schema = ref.respond_to?(:schema_name) ? ref.schema_name : ref&.dig(:schema_name)
        detect_cycles_from(ref_schema, visited, stack, cycles) if ref_schema
      end
    end
  rescue KeyError

    # Schema not found
  end

  stack.pop
  visited.add(node)
end

#extract_path(record, path) ⇒ Object? (private)

Extract value from record using dot-separated path

Traverses nested hashes using dot notation (e.g., "address.city").

Parameters:

  • record (Hash)

    record to extract from

  • path (String)

    dot-separated field path

Returns:

  • (Object, nil)

    extracted value or nil if not found



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/synthra/generator/resolver.rb', line 135

def extract_path(record, path)
  parts = path.to_s.split(".")
  value = record

  parts.each do |part|
    case value
    when Hash
      value = value[part] || value[part.to_sym]
    else
      return nil
    end
    return nil if value.nil?
  end

  value
end

#resolve(schema_name, field_path, depth: 0, seed: nil) ⇒ Object

Resolve a reference to another schema's field

Parameters:

  • schema_name (String)

    the schema name

  • field_path (String)

    dot-separated field path

  • depth (Integer) (defaults to: 0)

    current recursion depth (default: 0)

  • seed (Integer, nil) (defaults to: nil)

    deterministic seed for the referenced schema's generation (derived from the parent RNG by the Reference type) — makes ref() values reproducible

Returns:

  • (Object)

    the resolved value

Raises:



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/synthra/generator/resolver.rb', line 26

def resolve(schema_name, field_path, depth: 0, seed: nil)

  # Validate recursion depth against configured limits (before mutex to avoid deadlock)
  # Check depth + 1 because we're about to generate at that depth
  Synthra.configuration.limits.validate_recursion!(depth + 1)


  # Check cache first (thread-safe read)
  cache_key = "#{schema_name}.#{field_path}"
  @mutex.synchronize do
    return @cache[cache_key] if @cache.key?(cache_key)
  end


  # Check for cycles BEFORE acquiring mutex to avoid deadlock on recursive calls
  @mutex.synchronize do
    if @generating.include?(schema_name)
      cycle = @generating.to_a + [schema_name]
      raise CyclicReferenceError, cycle
    end
  end


  # Generate the referenced schema (outside mutex to allow recursive calls)
  begin
    @mutex.synchronize do
      @generating.add(schema_name)
    end

    schema = @registry.schema(schema_name)

    # Pass this resolver instance to ensure cycle detection works
    # The Engine will reuse this resolver instead of creating a new one.
    # `seed` (from the parent RNG) makes the referenced record reproducible by seed.
    record = schema.generate(seed: seed, registry: @registry, depth: depth + 1, resolver: self)


    # Cache all fields from this schema
    @mutex.synchronize do
      record.each do |key, val|
        @cache["#{schema_name}.#{key}"] = val
      end


      # Extract the specific field
      value = extract_path(record, field_path)
      @cache[cache_key] = value
      value
    end
  rescue KeyError
    raise MissingReferenceError.new(schema_name: schema_name, field_path: field_path)
  ensure
    @mutex.synchronize do
      @generating.delete(schema_name)
    end
  end
end

#topological_sort(graph) ⇒ Array<String> (private)

Topologically sort schemas by dependencies

Orders schemas so that dependencies come before dependents. Uses Kahn's algorithm for topological sorting.

Parameters:

  • graph (Hash)

    dependency graph

Returns:

  • (Array<String>)

    sorted schema names



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/synthra/generator/resolver.rb', line 186

def topological_sort(graph)
  in_degree = Hash.new(0)
  all_nodes = Set.new(@registry.names)

  graph.each do |node, deps|
    deps.each { |dep| in_degree[dep] += 1 }
  end

  queue = all_nodes.select { |n| in_degree[n].zero? }
  result = []

  until queue.empty?
    node = queue.shift
    result << node

    graph[node].each do |dep|
      in_degree[dep] -= 1
      queue << dep if in_degree[dep].zero?
    end
  end


  # If we couldn't sort all, there's a cycle - just return all names
  result.length == all_nodes.length ? result : @registry.names
end