Module: LcpRuby::Pages::FilterFormValidator

Defined in:
lib/lcp_ruby/pages/filter_form_validator.rb

Overview

Boot-time + lint-time validator for ‘filter_form:` and `scope_filters:` blocks on page YAML / DB-backed page definitions. Split into two phases so the loader-time path (which runs while model_definitions is mid-build) can enforce shape-only rules; rules requiring `loader.model_definitions` run later from ConfigurationValidator (rake `lcp_ruby:validate`) and from Pages::DefinitionValidator (per-record AR save on DB-backed pages).

Spec: docs/design/page_filters_as_virtual_forms.md § 4 Validation, § 4 DB-backed page source, § 5 step 1 (PR 1 scope), § 5 step 8 (input_type allowlist + boot-reject set).

Constant Summary collapse

ALLOWED_FILTER_INPUT_TYPES =

Input types accepted in ‘filter_form:` v1. Closed allowlist —types outside this set are boot-rejected. Source of truth: spec § 5 step 8 decision table.

Set.new(%w[
  text textarea rich_text_editor
  number date date_picker datetime
  boolean checkbox toggle
  email tel url color
  select radio enum
  association_select multi_select
  date_range
  slider rating
]).freeze
BOOT_REJECT_FILTER_INPUT_TYPES =

Types explicitly mentioned in the boot-reject decision table (so the error message can name them precisely). Anything outside ALLOWED_FILTER_INPUT_TYPES is rejected; this set just gives nicer error wording for the documented rejections.

Set.new(%w[
  hidden tree_select array_input tags
  file_upload field_picker
  formatting_options import_mapper
]).freeze
KNOWN_PAGE_KEYS =

Top-level page YAML keys, kept in sync with lib/lcp_ruby/schemas/page.json. Used by ‘validate_top_level_keys!` for DidYouMean spell suggestions.

Set.new(%w[
  name slug model layout title title_key index_presenter
  dialog filter_form scope_filters auto_submit
  visible_when zones
]).freeze
VALID_AUTO_SUBMIT_VALUES =

Accepted values for the page-level ‘auto_submit:` key. `“derive”` picks `true` or `false` from the form’s contents (no multi/cascade ⇒ auto-submit; otherwise Apply button). Explicit ‘true` is rejected if any field declares `depends_on:` — see `validate_auto_submit!`.

[ "derive", true, false ].freeze
DEPENDS_ON_INPUT_TYPES =
%w[association_select multi_select].freeze

Class Method Summary collapse

Class Method Details

.association_select?(input_type) ⇒ Boolean

Returns:

  • (Boolean)


603
604
605
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 603

def association_select?(input_type)
  input_type == "association_select" || input_type == "multi_select"
end

.custom_field?(field_def, model_def, _field_name) ⇒ Boolean

Detects whether ‘field_name` resolves to a custom field (jsonb) on `model_def`. LCP custom fields are runtime-only — they have NO FieldDefinition, only a CustomFieldDefinition record describing a key inside the model’s ‘custom_data` jsonb column. v1 detection is narrow on purpose: reject the most common misuse (direct filter on the `custom_data` aggregate column). Per-key filtering (`custom_data.some_key`) is a future enhancement that needs boot-time CustomFieldDefinition loading order — out of v1 scope.

Returns:

  • (Boolean)


615
616
617
618
619
620
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 615

def custom_field?(field_def, model_def, _field_name)
  return false unless field_def
  return false unless model_def.respond_to?(:custom_fields_enabled?)
  return false unless model_def.custom_fields_enabled?
  field_def.name == "custom_data" && field_def.type == "json"
end

.each_presenter_action(actions, &block) ⇒ Object

Iterates every action hash regardless of ‘actions:` outer shape. Production LCP presenters declare actions grouped by context (`actions: { collection: […], single: […], batch: […], form: […] }`, per lib/lcp_ruby/schemas/presenter.json $defs/actions_config). A flat array is also tolerated for fixtures and older configs.



498
499
500
501
502
503
504
505
506
507
508
509
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 498

def each_presenter_action(actions, &block)
  return unless actions

  case actions
  when Hash
    actions.each_value do |group|
      group.each(&block) if group.is_a?(Array)
    end
  when Array
    actions.each(&block)
  end
end

.extract_field_refs(condition, depth: 0) ⇒ Object

Returns page-model field refs from a visible_when condition tree. Mirrors ConfigurationValidator#walk_condition_for_assoc_refs (configuration_validator.rb:1916) — but stops at ‘collection:` / `condition:` (target model has its own field namespace) and at `service:` (no field refs).



516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 516

def extract_field_refs(condition, depth: 0)
  return [] unless condition.is_a?(Hash)
  return [] if depth > 32

  normalized = condition.transform_keys(&:to_s)

  if normalized.key?("all") || normalized.key?("any")
    key = normalized.key?("all") ? "all" : "any"
    Array(normalized[key]).flat_map { |c| extract_field_refs(c, depth: depth + 1) }
  elsif normalized.key?("not")
    extract_field_refs(normalized["not"], depth: depth + 1)
  elsif normalized.key?("field")
    refs = [ normalized["field"].to_s ]
    value = normalized["value"]
    if value.is_a?(Hash)
      ref = value["field_ref"] || value[:field_ref]
      refs << ref.to_s if ref
    end
    refs
  else
    []
  end
end

.range_type?(input_type) ⇒ Boolean

Only ‘date_range` is treated as a tuple-range for Rule #3 (the value is already a two-element [from, to]). `slider`/`number` represent a single scalar value, so `multi:` on them is allowed.

Returns:

  • (Boolean)


599
600
601
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 599

def range_type?(input_type)
  input_type == "date_range"
end

.reject_page_filter_scope_collision!(hash, page_name:, loader:, source_path:) ⇒ Object

Spec § 3 Invariant: scope: :page_filter is reserved on pages with filter_form:.

The URL namespace ‘params` is owned by the filter-form code path. If a presenter on the same page declared `scope: page_filter` on any nested key (top-level scope, form scope, dialog_form scope), the form_with(scope:) emit would collide with the filter-form URL keys. Forward-compatible —presenter YAML doesn’t have form-level scope as a knob today, but this guards against silent breakage if one is added.



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 549

def reject_page_filter_scope_collision!(hash, page_name:, loader:, source_path:)
  return if (hash["filter_form"] || []).empty?

  (hash["zones"] || []).each do |zone|
    presenter_name = zone["presenter"]
    next if presenter_name.blank?

    presenter = loader.presenter_definitions[presenter_name.to_s]
    next unless presenter&.raw_hash

    if scope_literal_present?(presenter.raw_hash, "page_filter")
      raise MetadataError, with_location(
        "Page '#{page_name}' has filter_form: but presenter '#{presenter_name}' uses " \
        "`scope: page_filter` (the :page_filter URL namespace is reserved by filter_form — " \
        "see spec § 3 Invariants 'scope: :page_filter is reserved').",
        source_path
      )
    end
  end
end

.reject_parameters_block!(hash, page_name:, source_path:) ⇒ Object

─── Shape rules ──────────────────────────────────────────────

Raises:



96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 96

def reject_parameters_block!(hash, page_name:, source_path:)
  return unless hash.key?("parameters")

  raise MetadataError, with_location(
    "Page '#{page_name}': `parameters:` is no longer supported. " \
    "Migrate `type: association_select` / `type: enum_select` / `type: date_range` " \
    "entries to `filter_form:` (form-syntax fields with `input_type:`); " \
    "migrate `type: scope` entries to `scope_filters:` (predefined AR-scope toggles). " \
    "See docs/reference/page_filters.md § Migration from `parameters:` for the field-by-field guide.",
    source_path
  )
end

.reject_visible_when_against_multi_filter!(hash, page_name:, loader:, source_path:) ⇒ Object

─── Spec § 3 Invariant: visible_when × multi at all four levels ──

Field-level rejection lives in validate_filter_form_field_shape! above (shape pass). This walker covers the other three levels (page / zone / action) because they need access to the zones’ presenter raw_hash, which only exists after loader.presenter_definitions is built. Same recursion shape as ‘ConfigurationValidator#validate_condition` (file configuration_validator.rb:4372).



449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 449

def reject_visible_when_against_multi_filter!(hash, page_name:, loader:, source_path:)
  filter_form = hash["filter_form"] || []
  return if filter_form.empty?

  multi_field_names = filter_form.each_with_object(Set.new) do |f, set|
    set << f["field"].to_s if f.dig("input_options", "multi")
  end
  return if multi_field_names.empty?

  check = lambda do |condition, level|
    next unless condition.is_a?(Hash)

    extract_field_refs(condition).each do |ref|
      next unless multi_field_names.include?(ref)

      raise MetadataError, with_location(
        "Page '#{page_name}': visible_when at #{level} references filter_form field " \
        "'#{ref}' which has `input_options.multi: true` " \
        "(this combination is rejected at boot — see spec § 3 Invariants " \
        "'visible_when × multi data leak').",
        source_path
      )
    end
  end

  check.call(hash["visible_when"], "page level")

  (hash["zones"] || []).each do |zone|
    check.call(zone["visible_when"], "zone '#{zone['name']}'")

    presenter_name = zone["presenter"]
    next if presenter_name.blank?

    presenter = loader.presenter_definitions[presenter_name.to_s]
    next unless presenter&.raw_hash

    each_presenter_action(presenter.raw_hash["actions"]) do |action|
      label = "presenter '#{presenter_name}' action '#{action['name']}'"
      check.call(action["visible_when"], label)
      check.call(action["disable_when"], label)
    end
  end
end

.scope_defined_on_class?(model_name, scope_name) ⇒ Boolean

Returns true iff the AR class for ‘model_name` has a Pundit-style named scope with `scope_name` (declared via `scope :foo, -> { … }` in Ruby). Uses `scope_names` instead of bare `respond_to?` to avoid catching unrelated class methods (`:tap`, `:class`, …). The YAML path handles all YAML-declared scopes; this fallback is for bind_to: host AR classes with Ruby-defined scopes.

Returns:

  • (Boolean)


628
629
630
631
632
633
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 628

def scope_defined_on_class?(model_name, scope_name)
  return false unless LcpRuby.registry.registered?(model_name)
  target_class = LcpRuby.registry.model_for(model_name)
  return false unless target_class.respond_to?(:scope_names)
  target_class.scope_names.map(&:to_s).include?(scope_name.to_s)
end

.scope_literal_present?(node, literal, depth: 0) ⇒ Boolean

Walks a hash and returns true iff any ‘“scope”` key has the given literal value. Recurses into nested Hash / Array values so nested form configs (form/dialog_form/form_config) are covered without enumerating every known path.

Returns:

  • (Boolean)


574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 574

def scope_literal_present?(node, literal, depth: 0)
  return false if depth > 32

  case node
  when Hash
    node.any? do |k, v|
      (k.to_s == "scope" && v.to_s == literal) ||
        scope_literal_present?(v, literal, depth: depth + 1)
    end
  when Array
    node.any? { |v| scope_literal_present?(v, literal, depth: depth + 1) }
  else
    false
  end
end

.validate_against_models!(hash, page_name:, loader:, source_path: nil) ⇒ Object

Model-aware rules. Runs from ‘ConfigurationValidator` (rake-time, all model_definitions present) and from `Pages::DefinitionValidator` (AR save-time, same precondition).

Raises ‘LcpRuby::MetadataError` on the first violation.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 79

def validate_against_models!(hash, page_name:, loader:, source_path: nil)
  validate_filter_form_against_models!(
    hash["filter_form"], hash, page_name: page_name, loader: loader, source_path: source_path
  )
  validate_scope_filters_against_model!(
    hash["scope_filters"], hash, page_name: page_name, loader: loader, source_path: source_path
  )
  reject_visible_when_against_multi_filter!(
    hash, page_name: page_name, loader: loader, source_path: source_path
  )
  reject_page_filter_scope_collision!(
    hash, page_name: page_name, loader: loader, source_path: source_path
  )
end

.validate_auto_submit!(hash, page_name:, source_path:) ⇒ Object

Raises:



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 314

def validate_auto_submit!(hash, page_name:, source_path:)
  auto_submit = hash["auto_submit"]
  return if auto_submit.nil?

  unless VALID_AUTO_SUBMIT_VALUES.include?(auto_submit)
    allowed = VALID_AUTO_SUBMIT_VALUES.map(&:inspect).join(", ")
    raise MetadataError, with_location(
      "Page '#{page_name}': `auto_submit:` must be one of #{allowed} " \
      "(got #{auto_submit.inspect}).",
      source_path
    )
  end

  return unless auto_submit == true

  # auto_submit: true is incompatible with depends_on: (cascade
  # AJAX racing the auto-submit form post). Cycle-detected later;
  # here we reject the combination outright.
  filter_form = hash["filter_form"] || []
  has_cascade = filter_form.any? { |f| f.dig("input_options", "depends_on") }
  return unless has_cascade

  raise MetadataError, with_location(
    "Page '#{page_name}': `auto_submit: true` is incompatible with any `depends_on:` " \
    "field — cascade AJAX would race the auto-submit form post. " \
    "Use `auto_submit: derive` (default) or `auto_submit: false` instead.",
    source_path
  )
end

.validate_depends_on_shape!(depends_on, input_type:, field_name:, field_names:, page_name:, source_path:) ⇒ Object

Raises:



222
223
224
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
253
254
255
256
257
258
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 222

def validate_depends_on_shape!(depends_on, input_type:, field_name:, field_names:, page_name:, source_path:)
  unless DEPENDS_ON_INPUT_TYPES.include?(input_type)
    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "`input_options.depends_on:` is only supported on " \
      "`input_type: #{DEPENDS_ON_INPUT_TYPES.join(' or ')}` in v1 (got '#{input_type}').",
      source_path
    )
  end

  unless depends_on.is_a?(Hash) && depends_on["field"].present?
    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "`input_options.depends_on:` must be a hash with at least `field:` and `foreign_key:` keys.",
      source_path
    )
  end

  unless depends_on["foreign_key"].present?
    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "`input_options.depends_on.foreign_key:` is required (the column on the target model " \
      "used to filter children by parent value).",
      source_path
    )
  end

  parent_field = depends_on["field"].to_s
  return if field_names.include?(parent_field)

  raise MetadataError, with_location(
    "Page '#{page_name}', filter_form field '#{field_name}': " \
    "`depends_on.field: '#{parent_field}'` does not reference any other filter_form field. " \
    "Available filter_form fields: #{field_names.join(', ')}.",
    source_path
  )
end

.validate_field_against_models!(field, page_model_def:, page_name:, loader:, source_path:) ⇒ Object

Raises:



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
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
406
407
408
409
410
411
412
413
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 360

def validate_field_against_models!(field, page_model_def:, page_name:, loader:, source_path:)
  field_name = field["field"].to_s
  input_type = field["input_type"].to_s
  input_options = field["input_options"] || {}

  if page_model_def
    field_def = page_model_def.field(field_name)
    if custom_field?(field_def, page_model_def, field_name)
      raise MetadataError, with_location(
        "Page '#{page_name}', filter_form field '#{field_name}': " \
        "custom fields (jsonb) cannot be used in filter_form: in v1 " \
        "(filtering by the aggregate `custom_data` column requires per-key " \
        "indexing not supported in v1; use a regular column).",
        source_path
      )
    end
  end

  return unless association_select?(input_type) && (field["model"] || input_options["association"])

  target_model_name = (field["model"] || input_options["association"]).to_s
  target_def = loader.model_definitions[target_model_name]
  return unless target_def

  if input_options["multi"] && target_def.respond_to?(:api_model?) && target_def.api_model?
    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "multi-select against API-backed target model '#{target_model_name}' is not supported in v1. " \
      "AssociationOptionsBuilder fans out via AR relations + Pundit::Scope, neither of which " \
      "apply to api_model targets.",
      source_path
    )
  end

  return unless input_options["depends_on"]

  foreign_key = input_options.dig("depends_on", "foreign_key").to_s
  return if foreign_key.empty?
  return if target_def.fields.any? { |f| f.name == foreign_key }
  # `fields` lists declared `field :…` entries; FK columns
  # SchemaManager auto-creates for belongs_to live in
  # belongs_to_fk_map. Cascades like `bu_code → divisions.bu_code`
  # rely on the latter.
  fk_map = target_def.respond_to?(:belongs_to_fk_map) ? target_def.belongs_to_fk_map : {}
  return if fk_map.key?(foreign_key)

  available = (target_def.fields.map(&:name) + fk_map.keys).uniq
  raise MetadataError, with_location(
    "Page '#{page_name}', filter_form field '#{field_name}': " \
    "`depends_on.foreign_key: '#{foreign_key}'` is not a column on target model '#{target_model_name}'. " \
    "Available columns: #{available.join(', ')}.",
    source_path
  )
end

.validate_filter_form_against_models!(filter_form, hash, page_name:, loader:, source_path:) ⇒ Object

─── Model-aware rules ────────────────────────────────────────



346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 346

def validate_filter_form_against_models!(filter_form, hash, page_name:, loader:, source_path:)
  return unless filter_form && filter_form.any?

  page_model = hash["model"].to_s
  page_model_def = loader.model_definitions[page_model] if page_model.present?

  filter_form.each do |field|
    validate_field_against_models!(
      field, page_model_def: page_model_def,
      page_name: page_name, loader: loader, source_path: source_path
    )
  end
end

.validate_filter_form_cascade_cycles!(filter_form, page_name:, source_path:) ⇒ Object



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 260

def validate_filter_form_cascade_cycles!(filter_form, page_name:, source_path:)
  graph = filter_form.each_with_object({}) do |f, g|
    parent = f.dig("input_options", "depends_on", "field")
    g[f["field"].to_s] = parent.to_s if parent
  end

  graph.each_key do |start|
    seen = []
    current = start
    while current && graph[current]
      seen << current
      current = graph[current]
      if seen.include?(current)
        cycle = (seen.drop_while { |n| n != current } << current).join("")
        raise MetadataError, with_location(
          "Page '#{page_name}', filter_form: cascade cycle detected in `depends_on:` chain: " \
          "#{cycle}.",
          source_path
        )
      end
    end
  end
end

.validate_filter_form_field_shape!(field, field_names:, page_name:, source_path:) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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
211
212
213
214
215
216
217
218
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 162

def validate_filter_form_field_shape!(field, field_names:, page_name:, source_path:)
  field_name = field["field"]
  input_type = field["input_type"].to_s
  input_options = (field["input_options"] || {})

  unless ALLOWED_FILTER_INPUT_TYPES.include?(input_type)
    extra =
      if BOOT_REJECT_FILTER_INPUT_TYPES.include?(input_type)
        " (input_type '#{input_type}' is intentionally not supported on filter forms — see spec § 5 step 8 decision table)"
      else
        ""
      end

    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "input_type '#{input_type}' is not allowed on filter forms#{extra}. " \
      "Allowed: #{ALLOWED_FILTER_INPUT_TYPES.to_a.sort.join(', ')}.",
      source_path
    )
  end

  if input_options["allow_inline_create"]
    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "`input_options.allow_inline_create:` is not supported on filter form fields in v1 " \
      "(inline-create requires a presenter context; filter forms run at page level).",
      source_path
    )
  end

  if input_options["multi"] && range_type?(input_type)
    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "`input_options.multi: true` is not supported on range-type input '#{input_type}' " \
      "(a range is already a tuple of two values; multi: would be semantically nested).",
      source_path
    )
  end

  if input_options["depends_on"]
    validate_depends_on_shape!(
      input_options["depends_on"], input_type: input_type,
      field_name: field_name, field_names: field_names,
      page_name: page_name, source_path: source_path
    )
  end

  if input_options["multi"] && (field["visible_when"] || input_options["visible_when"])
    raise MetadataError, with_location(
      "Page '#{page_name}', filter_form field '#{field_name}': " \
      "`visible_when:` combined with `input_options.multi: true` is rejected at boot. " \
      "Multi-select × conditional visibility has unresolved trust-model implications — " \
      "see spec § 8 Open Questions 'Full visible_when × multi trust model'.",
      source_path
    )
  end
end

.validate_filter_form_shape!(filter_form, page_name:, source_path:) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 132

def validate_filter_form_shape!(filter_form, page_name:, source_path:)
  return unless filter_form

  unless filter_form.is_a?(Array)
    raise MetadataError, with_location(
      "Page '#{page_name}': `filter_form:` must be an array of field definitions.",
      source_path
    )
  end

  # Reject non-Hash entries before any `f["field"]` access, so a
  # malformed YAML (`filter_form: [42]` or stray `- null`) surfaces
  # as a clean MetadataError instead of crashing with NoMethodError.
  # JSON Schema would also catch this, but it runs after `from_hash`.
  unless filter_form.all?(Hash)
    raise MetadataError, with_location(
      "Page '#{page_name}': each `filter_form:` entry must be a hash with `field:` and `input_type:`. " \
      "Got: #{filter_form.reject { |f| f.is_a?(Hash) }.inspect}.",
      source_path
    )
  end

  field_names = filter_form.map { |f| f["field"].to_s }
  filter_form.each do |field|
    validate_filter_form_field_shape!(field, field_names: field_names, page_name: page_name, source_path: source_path)
  end

  validate_filter_form_cascade_cycles!(filter_form, page_name: page_name, source_path: source_path)
end

.validate_scope_filters_against_model!(scope_filters, hash, page_name:, loader:, source_path:) ⇒ Object



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 415

def validate_scope_filters_against_model!(scope_filters, hash, page_name:, loader:, source_path:)
  return unless scope_filters && scope_filters.any?

  page_model = hash["model"].to_s
  return if page_model.empty?

  page_model_def = loader.model_definitions[page_model]
  return unless page_model_def

  yaml_scope_names = page_model_def.scope_names

  scope_filters.each do |sf|
    sf["scopes"].each_key do |scope_key|
      next if yaml_scope_names.include?(scope_key.to_s)
      next if scope_defined_on_class?(page_model, scope_key)

      raise MetadataError, with_location(
        "Page '#{page_name}', scope_filter '#{sf['name']}': " \
        "scope `'#{scope_key}'` is not defined as a named scope on model '#{page_model}'.",
        source_path
      )
    end
  end
end

.validate_scope_filters_shape!(scope_filters, page_name:, source_path:) ⇒ Object



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 284

def validate_scope_filters_shape!(scope_filters, page_name:, source_path:)
  return unless scope_filters

  unless scope_filters.is_a?(Array)
    raise MetadataError, with_location(
      "Page '#{page_name}': `scope_filters:` must be an array of scope filter declarations.",
      source_path
    )
  end

  scope_filters.each do |sf|
    unless sf.is_a?(Hash) && sf["name"].present? && sf["scopes"].is_a?(Hash)
      raise MetadataError, with_location(
        "Page '#{page_name}': each scope_filter must have `name:` and `scopes:` (a hash of " \
        "scope_key → { label_key: ... }). Got: #{sf.inspect}.",
        source_path
      )
    end

    if sf["default"] && !sf["scopes"].key?(sf["default"].to_s)
      raise MetadataError, with_location(
        "Page '#{page_name}', scope_filter '#{sf['name']}': " \
        "`default: '#{sf['default']}'` is not one of the declared scopes " \
        "(#{sf['scopes'].keys.join(', ')}).",
        source_path
      )
    end
  end
end

.validate_shape!(hash, page_name:, source_path: nil) ⇒ Object

Shape-only rules. Runs from ‘PageDefinition.from_hash` —loader.model_definitions may be mid-build at that point, so rules that need model_definitions live in `validate_against_models!`.

Raises ‘LcpRuby::MetadataError` on the first violation; the error embeds page_name and (when supplied) source_path.



66
67
68
69
70
71
72
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 66

def validate_shape!(hash, page_name:, source_path: nil)
  reject_parameters_block!(hash, page_name: page_name, source_path: source_path)
  validate_top_level_keys!(hash, page_name: page_name, source_path: source_path)
  validate_filter_form_shape!(hash["filter_form"], page_name: page_name, source_path: source_path)
  validate_scope_filters_shape!(hash["scope_filters"], page_name: page_name, source_path: source_path)
  validate_auto_submit!(hash, page_name: page_name, source_path: source_path)
end

.validate_top_level_keys!(hash, page_name:, source_path:) ⇒ Object

Raises:



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 116

def validate_top_level_keys!(hash, page_name:, source_path:)
  unknown = hash.keys.map(&:to_s) - KNOWN_PAGE_KEYS.to_a
  return if unknown.empty?

  suggestions = unknown.map do |key|
    match = KNOWN_PAGE_KEYS_SPELL_CHECKER.correct(key).first
    match ? "'#{key}' (did you mean '#{match}'?)" : "'#{key}'"
  end

  raise MetadataError, with_location(
    "Page '#{page_name}': unknown top-level key(s): #{suggestions.join(', ')}. " \
    "Allowed: #{KNOWN_PAGE_KEYS.to_a.join(', ')}.",
    source_path
  )
end

.with_location(message, source_path) ⇒ Object

─── Helpers ──────────────────────────────────────────────────



592
593
594
# File 'lib/lcp_ruby/pages/filter_form_validator.rb', line 592

def with_location(message, source_path)
  source_path ? "#{message} (in #{source_path})" : message
end