Class: Synthra::Validator::PathValidator

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/validator/path_validator.rb

Overview

Validates copy() path references against schema structure

The PathValidator scans all schemas in a registry for copy() type fields and validates that the referenced paths actually exist. It handles:

  • Direct field references (e.g., "user_id")
  • Nested field references (e.g., "address.city")
  • map_by_field alias references (e.g., "users.sender.id")

Examples:

Validate all paths in a registry

validator = PathValidator.new(registry)
validator.validate!  # Raises PathError if invalid paths found

Get validation results without raising

errors = validator.validate
errors.each { |e| puts e.message }

Instance Method Summary collapse

Constructor Details

#initialize(registry) ⇒ PathValidator

Returns a new instance of PathValidator.

Parameters:

  • registry (Registry)

    the registry containing schemas to validate



43
44
45
46
47
48
49
# File 'lib/synthra/validator/path_validator.rb', line 43

def initialize(registry)
  @registry = registry
  @path_index = {}
  @copy_paths = []
  # Cache schemas to avoid mutex deadlock when registry calls back
  @schemas_cache = registry.schemas
end

Instance Method Details

#array_with_nested_schema?(field) ⇒ Boolean (private)

Check if field is an array with nested schema

Parameters:

  • field (Field)

    the field to check

Returns:

  • (Boolean)

    true if array with schema element



481
482
483
484
485
486
487
488
# File 'lib/synthra/validator/path_validator.rb', line 481

def array_with_nested_schema?(field)
  return false unless field.type_name == "array"

  element_type = field.type_args[:element]
  return false unless element_type

  schema_exists?(element_type.to_s)
end

#build_map_by_field_paths(field, prefix, visited) ⇒ void (private)

This method returns an undefined value.

Build paths for map_by_field entries

Parameters:

  • field (Field)

    the map_by_field field

  • prefix (String)

    path prefix

  • visited (Set)

    schemas already visited in this path



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/synthra/validator/path_validator.rb', line 145

def build_map_by_field_paths(field, prefix, visited)
  entries = field.type_args[:entries] || {}

  entries.each do |entry_name, |
    entry_path = "#{prefix}.#{entry_name}"
    @path_index[entry_path] = { schema: nil, field: field, entry: entry_name }

    # Get the schema name (handle both string and hash formats)
    schema_name = if .is_a?(Hash)
                    [:schema] || ["schema"]
                  else
                    .to_s
                  end

    if schema_exists?(schema_name) && !visited.include?(schema_name)
      nested_schema = get_schema(schema_name)
      build_schema_paths(schema_name, nested_schema, entry_path, visited.dup)
    end
  end
end

#build_path_indexvoid (private)

This method returns an undefined value.

Build an index of all available field paths in all schemas



85
86
87
88
89
90
91
92
93
# File 'lib/synthra/validator/path_validator.rb', line 85

def build_path_index
  @path_index = {}

  @schemas_cache.each do |name, schema|
    # Track visited schemas per top-level schema to detect cycles
    visited = Set.new
    build_schema_paths(name, schema, "", visited)
  end
end

#build_schema_paths(name, schema, prefix, visited) ⇒ void (private)

This method returns an undefined value.

Build paths for a single schema

Parameters:

  • name (String)

    schema name

  • schema (Schema)

    the schema

  • prefix (String)

    path prefix for nested fields

  • visited (Set)

    schemas already visited in this path



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/synthra/validator/path_validator.rb', line 103

def build_schema_paths(name, schema, prefix, visited)
  # Avoid infinite recursion on circular references
  return if visited.include?(name)
  visited.add(name)

  schema.fields.each do |field|
    field_path = prefix.empty? ? field.name : "#{prefix}.#{field.name}"
    @path_index[field_path] = { schema: name, field: field }

    # Handle nested schema types
    if nested_schema?(field)
      nested_name = field.type_name
      if schema_exists?(nested_name) && !visited.include?(nested_name)
        nested_schema = get_schema(nested_name)
        build_schema_paths(nested_name, nested_schema, field_path, visited.dup)
      end
    end

    # Handle map_by_field entries
    if map_by_field?(field)
      build_map_by_field_paths(field, field_path, visited)
    end

    # Handle array types with nested schemas
    if array_with_nested_schema?(field)
      element_type = field.type_args[:element]
      if schema_exists?(element_type.to_s) && !visited.include?(element_type.to_s)
        nested_schema = get_schema(element_type.to_s)
        # Arrays can be accessed by index like "items.0.name"
        build_schema_paths(element_type.to_s, nested_schema, "#{field_path}.0", visited.dup)
      end
    end
  end
end

#collect_copy_pathsvoid (private)

This method returns an undefined value.

Collect all copy() paths from all schemas



170
171
172
173
174
175
176
177
# File 'lib/synthra/validator/path_validator.rb', line 170

def collect_copy_paths
  @copy_paths = []
  @collected_schemas = Set.new

  @schemas_cache.each do |name, schema|
    collect_schema_copy_paths(name, schema)
  end
end

#collect_schema_copy_paths(name, schema) ⇒ void (private)

This method returns an undefined value.

Collect copy() paths from a single schema

Parameters:

  • name (String)

    schema name

  • schema (Schema)

    the schema



185
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/validator/path_validator.rb', line 185

def collect_schema_copy_paths(name, schema)
  # Avoid infinite recursion on circular references
  return if @collected_schemas.include?(name)
  @collected_schemas.add(name)

  schema.fields.each do |field|
    if copy_type?(field)
      path = extract_copy_path(field)
      @copy_paths << {
        path: path,
        schema: name,
        field: field.name,
        line: nil  # Line info not available on Field
      } if path
    end

    # Also check nested schemas recursively
    if nested_schema?(field)
      nested_name = field.type_name
      if schema_exists?(nested_name) && !@collected_schemas.include?(nested_name)
        nested_schema = get_schema(nested_name)
        collect_schema_copy_paths(nested_name, nested_schema)
      end
    end
  end
end

#copy_type?(field) ⇒ Boolean (private)

Check if field is a copy() type

Parameters:

  • field (Field)

    the field to check

Returns:

  • (Boolean)

    true if copy type



431
432
433
# File 'lib/synthra/validator/path_validator.rb', line 431

def copy_type?(field)
  field.type_name == "copy"
end

#detect_cyclesArray<CycleError> (private)

Detect circular references in schema definitions

Returns:

  • (Array<CycleError>)

    list of cycle errors



512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/synthra/validator/path_validator.rb', line 512

def detect_cycles
  errors = []
  visited = {}
  rec_stack = {}

  @schemas_cache.each_key do |schema_name|
    cycle = find_cycle(schema_name, visited, rec_stack, [])
    errors << Errors::CycleError.new(cycle) if cycle
  end

  errors
end

#extract_copy_path(field) ⇒ String? (private)

Extract the path argument from a copy() type

Parameters:

  • field (Field)

    the copy field

Returns:

  • (String, nil)

    the path or nil



440
441
442
443
# File 'lib/synthra/validator/path_validator.rb', line 440

def extract_copy_path(field)
  args = field.type_args
  args[:path] || args["path"] || args.values.first
end

#find_cycle(name, visited, rec_stack, path) ⇒ Array<String>? (private)

Find a cycle starting from the given schema

Parameters:

  • name (String)

    schema name to start from

  • visited (Hash)

    globally visited schemas

  • rec_stack (Hash)

    current recursion stack

  • path (Array<String>)

    current path through schemas

Returns:

  • (Array<String>, nil)

    cycle path if found, nil otherwise



533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
# File 'lib/synthra/validator/path_validator.rb', line 533

def find_cycle(name, visited, rec_stack, path)
  # Skip if already fully processed
  return nil if visited[name]

  # If we're back in the recursion stack, we found a cycle
  if rec_stack[name]
    cycle_start = path.index(name)
    return path[cycle_start..] + [name]
  end

  # Skip if schema doesn't exist
  return nil unless schema_exists?(name)

  # Mark as being processed
  rec_stack[name] = true
  current_path = path + [name]

  schema = get_schema(name)
  return nil unless schema

  # Check all referenced schemas
  schema.fields.each do |field|
    referenced_schemas = get_referenced_schemas(field)

    referenced_schemas.each do |ref_name|
      cycle = find_cycle(ref_name, visited, rec_stack, current_path)
      return cycle if cycle
    end
  end

  # Mark as fully processed
  rec_stack[name] = false
  visited[name] = true
  nil
end

#find_similar_paths(path) ⇒ Array<String> (private)

Find paths similar to the given path

Parameters:

  • path (String)

    the path to match

Returns:

  • (Array<String>)

    similar paths



422
423
424
# File 'lib/synthra/validator/path_validator.rb', line 422

def find_similar_paths(path)
  Utils::StringDistance.similar_strings(path, @path_index.keys, max_distance: 3, limit: 3)
end

#get_referenced_schemas(field) ⇒ Array<String> (private)

Get all schemas referenced by a field

Parameters:

  • field (Field)

    the field to check

Returns:

  • (Array<String>)

    list of referenced schema names



574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
# File 'lib/synthra/validator/path_validator.rb', line 574

def get_referenced_schemas(field)
  refs = []

  # Direct schema reference
  if nested_schema?(field)
    refs << field.type_name
  end

  # Array element type
  if array_with_nested_schema?(field)
    element_type = field.type_args[:element]
    refs << element_type.to_s if element_type
  end

  # map_by_field entries
  if map_by_field?(field)
    entries = field.type_args[:entries] || {}
    entries.each do |_, |
      schema_name = if .is_a?(Hash)
                      [:schema] || ["schema"]
                    else
                      .to_s
                    end
      refs << schema_name if schema_name
    end
  end

  # one_of schemas
  if field.type_name == "one_of"
    schemas = field.type_args[:schemas] || []
    schemas.each do |s|
      schema_name = s.is_a?(Hash) ? (s[:schema] || s["schema"]) : s.to_s
      refs << schema_name if schema_name
    end
  end

  refs.select { |r| schema_exists?(r) }
end

#get_schema(name) ⇒ Schema (private)

Get a schema (without mutex deadlock)

Parameters:

  • name (String)

    schema name

Returns:



504
505
506
# File 'lib/synthra/validator/path_validator.rb', line 504

def get_schema(name)
  @schemas_cache[name.to_s]
end

#map_by_field?(field) ⇒ Boolean (private)

Check if field is a map_by_field type

Parameters:

  • field (Field)

    the field to check

Returns:

  • (Boolean)

    true if map_by_field



463
464
465
# File 'lib/synthra/validator/path_validator.rb', line 463

def map_by_field?(field)
  field.type_name == "map_by_field"
end

#nested_schema?(field) ⇒ Boolean (private)

Check if field is a nested schema type

Parameters:

  • field (Field)

    the field to check

Returns:

  • (Boolean)

    true if nested schema



450
451
452
453
454
455
456
# File 'lib/synthra/validator/path_validator.rb', line 450

def nested_schema?(field)
  type_name = field.type_name
  return false unless type_name

  # Not a builtin type and exists in registry
  !Types::Registry.registered?(type_name) && schema_exists?(type_name)
end

#path_exists?(path) ⇒ Boolean (private)

Check if a path exists in the index

Parameters:

  • path (String)

    the path to check

Returns:

  • (Boolean)

    true if path exists



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/synthra/validator/path_validator.rb', line 380

def path_exists?(path)
  return true if @path_index.key?(path)

  # Try partial path matching (for nested paths that may be dynamically generated)
  parts = path.split(".")
  current_path = ""

  parts.each_with_index do |part, idx|
    current_path = idx.zero? ? part : "#{current_path}.#{part}"

    # Check if this is the last part
    if idx == parts.length - 1
      return @path_index.key?(current_path)
    end

    # If path doesn't exist and we're not at the end, check if we have a dynamic path
    next if @path_index.key?(current_path)

    # Check for array index patterns (e.g., "0" for first element)
    return true if part =~ /^\d+$/

    return false
  end

  false
end

#path_needs_runtime_resolution?(path) ⇒ Boolean (private)

Check if a path needs runtime resolution

Parameters:

  • path (String)

    the path to check

Returns:

  • (Boolean)

    true if path needs runtime resolution



412
413
414
415
# File 'lib/synthra/validator/path_validator.rb', line 412

def path_needs_runtime_resolution?(path)
  # Paths with array indices may need runtime resolution
  path =~ /\.\d+\./ || path.start_with?(".")
end

#ref_type?(field) ⇒ Boolean (private)

Check if field is a Ref() type

Parameters:

  • field (Field)

    the field to check

Returns:

  • (Boolean)

    true if Ref type



472
473
474
# File 'lib/synthra/validator/path_validator.rb', line 472

def ref_type?(field)
  field.type_name == "reference" || field.type_name == "Ref"
end

#schema_exists?(name) ⇒ Boolean (private)

Check if a schema exists (without mutex deadlock)

Parameters:

  • name (String)

    schema name

Returns:

  • (Boolean)

    true if schema exists



495
496
497
# File 'lib/synthra/validator/path_validator.rb', line 495

def schema_exists?(name)
  @schemas_cache.key?(name.to_s)
end

#validateArray<PathError, CycleError>

Validate all copy() paths in the registry

Returns:

  • (Array<PathError, CycleError>)

    list of validation errors (empty if valid)



55
56
57
58
59
60
61
62
63
64
65
# File 'lib/synthra/validator/path_validator.rb', line 55

def validate
  build_path_index
  collect_copy_paths

  errors = []
  errors.concat(validate_paths)
  errors.concat(validate_field_order)
  errors.concat(validate_ref_targets)
  errors.concat(detect_cycles)
  errors
end

#validate!true

Validate all copy() paths, raising on first error

Returns:

  • (true)

    if all paths are valid

Raises:



72
73
74
75
76
77
# File 'lib/synthra/validator/path_validator.rb', line 72

def validate!
  errors = validate
  raise errors.first if errors.any?

  true
end

#validate_field_orderArray<Errors::FieldOrderError> (private)

Validate that copy() fields don't reference later-defined fields

copy() can only reference fields that are defined BEFORE it in the schema. This is because fields are generated in definition order, so referencing a later field would result in nil.

Returns:



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/synthra/validator/path_validator.rb', line 251

def validate_field_order
  errors = []

  @schemas_cache.each do |schema_name, schema|
    field_names = schema.fields.map(&:name)

    schema.fields.each_with_index do |field, field_index|
      next unless copy_type?(field)

      copy_path = extract_copy_path(field)
      next unless copy_path

      # Get the root field name from the path (e.g., "users.sender.id" -> "users")
      root_field = copy_path.split(".").first
      next unless root_field

      # Check if the root field is defined in this schema
      root_index = field_names.index(root_field)
      next unless root_index # Field is from parent context or nested schema

      # If the copy references a field defined AFTER it, that's a problem
      if root_index >= field_index
        errors << Errors::FieldOrderError.new(
          field_name: field.name,
          copy_path: copy_path,
          schema_name: schema_name,
          suggestion: "Move '#{root_field}' field before '#{field.name}' in schema '#{schema_name}'"
        )
      end
    end
  end

  errors
end

#validate_map_by_field_keysArray<PathError> (private)

Validate that map_by_field key fields exist in referenced schemas

Returns:

  • (Array<PathError>)

    list of validation errors



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/synthra/validator/path_validator.rb', line 337

def validate_map_by_field_keys
  errors = []

  @schemas_cache.each do |schema_name, schema|
    schema.fields.each do |field|
      next unless map_by_field?(field)

      key_field = field.type_args[:key_field] || field.type_args["key_field"] || "id"
      entries = field.type_args[:entries] || field.type_args["entries"] || {}

      entries.each do |entry_name, |
        # Get the schema name (handle both string and hash formats)
        entry_schema_name = if .is_a?(Hash)
                               [:schema] || ["schema"]
                             else
                               .to_s
                             end

        next unless entry_schema_name && schema_exists?(entry_schema_name)

        entry_schema = get_schema(entry_schema_name)
        field_names = entry_schema.fields.map(&:name)

        unless field_names.include?(key_field)
          similar = Utils::StringDistance.similar_strings(key_field, field_names, max_distance: 2, limit: 3)
          errors << Errors::PathError.new(
            "#{key_field}' in schema '#{entry_schema_name}",
            suggestions: similar.map { |f| "#{entry_schema_name}.#{f}" },
            line: nil
          )
        end
      end
    end
  end

  errors
end

#validate_pathsArray<PathError> (private)

Validate all collected copy() paths

Returns:

  • (Array<PathError>)

    list of validation errors



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/synthra/validator/path_validator.rb', line 216

def validate_paths
  errors = []

  @copy_paths.each do |path_info|
    path = path_info[:path]

    # Skip validation if path contains parent context indicators
    # (these need runtime resolution)
    next if path_needs_runtime_resolution?(path)

    unless path_exists?(path)
      suggestions = find_similar_paths(path)
      errors << Errors::PathError.new(
        path,
        available_paths: @path_index.keys.first(20),
        suggestions: suggestions,
        line: path_info[:line]
      )
    end
  end

  # Also validate map_by_field key fields
  errors.concat(validate_map_by_field_keys)

  errors
end

#validate_ref_targetsArray<PathError> (private)

Validate that Ref() targets exist and have the referenced field

Returns:

  • (Array<PathError>)

    list of validation errors



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/synthra/validator/path_validator.rb', line 290

def validate_ref_targets
  errors = []

  @schemas_cache.each do |schema_name, schema|
    schema.fields.each do |field|
      next unless ref_type?(field)

      ref_schema = field.type_args[:schema] || field.type_args["schema"]
      ref_field = field.type_args[:field] || field.type_args["field"]

      next unless ref_schema

      # Check if referenced schema exists
      unless schema_exists?(ref_schema)
        similar = Utils::StringDistance.similar_strings(ref_schema, @schemas_cache.keys, max_distance: 2, limit: 3)
        errors << Errors::PathError.new(
          "Ref(#{ref_schema}.#{ref_field})",
          suggestions: similar.map { |s| "Ref(#{s}.#{ref_field || 'id'})" },
          line: nil
        )
        next
      end

      # Check if referenced field exists in target schema
      if ref_field
        target_schema = get_schema(ref_schema)
        field_names = target_schema.fields.map(&:name)

        unless field_names.include?(ref_field)
          similar = Utils::StringDistance.similar_strings(ref_field, field_names, max_distance: 2, limit: 3)
          errors << Errors::PathError.new(
            "#{ref_schema}.#{ref_field}",
            suggestions: similar.map { |f| "Ref(#{ref_schema}.#{f})" },
            line: nil
          )
        end
      end
    end
  end

  errors
end