hecks 2.9.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +51 -6
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/memory.rb +16 -3
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
- data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
- data/lib/hecks/adapters/driven/postgres.rb +4 -2
- data/lib/hecks/adapters/driven/prism.rb +41 -8
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
- data/lib/hecks/behaviors/expectations.rb +1 -1
- data/lib/hecks/behaviors.rb +1 -1
- data/lib/hecks/bench/cli.rb +62 -0
- data/lib/hecks/bench/environment.rb +71 -0
- data/lib/hecks/bench/postgres_probe.rb +30 -0
- data/lib/hecks/bench/report.rb +76 -0
- data/lib/hecks/bench/ruby_runner.rb +93 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +101 -0
- data/lib/hecks/bench/stats.rb +46 -0
- data/lib/hecks/bench/suite.rb +98 -0
- data/lib/hecks/bench/workload.rb +89 -0
- data/lib/hecks/bench.rb +17 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
- data/lib/hecks/bluebook/attribute.rb +1 -1
- data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
- data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
- data/lib/hecks/bluebook/chapter.rb +6 -2
- data/lib/hecks/bluebook/domain_port.rb +36 -5
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
- data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
- data/lib/hecks/bluebook/expression/resolver.rb +3 -2
- data/lib/hecks/bluebook/hexagon.rb +13 -5
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
- data/lib/hecks/bluebook/meta_validator.rb +9 -4
- data/lib/hecks/bluebook/model_check.rb +23 -0
- data/lib/hecks/bluebook/pattern_subset.rb +38 -0
- data/lib/hecks/bluebook/query.rb +20 -5
- data/lib/hecks/bluebook/smoke_test.rb +1 -1
- data/lib/hecks/canonical_json.rb +30 -0
- data/lib/hecks/chapters.rb +102 -0
- data/lib/hecks/cli/behaviors.rb +61 -0
- data/lib/hecks/cli/canonicalise.rb +25 -0
- data/lib/hecks/cli/check_engine_agreement.rb +32 -0
- data/lib/hecks/cli/check_era.rb +88 -0
- data/lib/hecks/cli/console.rb +66 -0
- data/lib/hecks/cli/deploy_template_diff.rb +40 -0
- data/lib/hecks/cli/doc_coverage.rb +32 -0
- data/lib/hecks/cli/document.rb +2 -2
- data/lib/hecks/cli/expression_projection.rb +41 -0
- data/lib/hecks/cli/follow.rb +88 -0
- data/lib/hecks/cli/history.rb +44 -0
- data/lib/hecks/cli/ir.rb +1 -1
- data/lib/hecks/cli/mcp.rb +3 -3
- data/lib/hecks/cli/model_check.rb +7 -4
- data/lib/hecks/cli/pattern_cases.rb +47 -0
- data/lib/hecks/cli/present.rb +39 -0
- data/lib/hecks/cli/project_cli.rb +224 -32
- data/lib/hecks/cli/project_diagrams.rb +4 -3
- data/lib/hecks/cli/project_glossary.rb +44 -0
- data/lib/hecks/cli/reference.rb +46 -0
- data/lib/hecks/cli/refresh_projections.rb +44 -0
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
- data/lib/hecks/cli/release.rb +70 -0
- data/lib/hecks/cli/release_gem.rb +59 -0
- data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
- data/lib/hecks/cli/rspec_shard_files.rb +66 -0
- data/lib/hecks/cli/run.rb +3 -3
- data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
- data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
- data/lib/hecks/cli/shape.rb +70 -0
- data/lib/hecks/cli/smoke_http.rb +230 -0
- data/lib/hecks/cli/smoke_test.rb +5 -3
- data/lib/hecks/cli/spec_example.rb +27 -0
- data/lib/hecks/cli/statements.rb +32 -0
- data/lib/hecks/cli/stores.rb +4 -5
- data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
- data/lib/hecks/cli.rb +201 -52
- data/lib/hecks/codemod.rb +324 -0
- data/lib/hecks/corpus.rb +423 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
- data/lib/hecks/deploy/oidc.json +82 -2
- data/lib/hecks/doc/reference.rb +351 -0
- data/lib/hecks/doors/cli_door.rb +196 -0
- data/lib/hecks/doors/cli_runner.rb +297 -0
- data/lib/hecks/{facade → doors}/command_request.rb +1 -1
- data/lib/hecks/{facade → doors}/handle.rb +2 -2
- data/lib/hecks/{facade → doors}/json_door.rb +4 -4
- data/lib/hecks/doors/launcher_options.rb +129 -0
- data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
- data/lib/hecks/doors/mcp_door_scope.rb +97 -0
- data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
- data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
- data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
- data/lib/hecks/doors.rb +61 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +1 -1
- data/lib/hecks/engine_agreement.rb +156 -0
- data/lib/hecks/facade.rb +24 -37
- data/lib/hecks/forms/app.rb +6 -6
- data/lib/hecks/forms/banking_presentation.rb +50 -0
- data/lib/hecks/forms/field_shape.rb +2 -1
- data/lib/hecks/forms/port_argument.rb +1 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
- data/lib/hecks/fuzzing/combination_miner.rb +86 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
- data/lib/hecks/fuzzing/differential.rb +156 -0
- data/lib/hecks/fuzzing/domain_generator.rb +615 -0
- data/lib/hecks/fuzzing/era_boundary.rb +71 -0
- data/lib/hecks/fuzzing/form_census.rb +136 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
- data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
- data/lib/hecks/fuzzing/outside_world.rb +45 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
- data/lib/hecks/fuzzing/properties/guards.rb +190 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
- data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
- data/lib/hecks/fuzzing/properties/querying.rb +175 -0
- data/lib/hecks/fuzzing/properties.rb +162 -0
- data/lib/hecks/fuzzing/qa_settings.rb +123 -0
- data/lib/hecks/fuzzing/replay.rb +505 -0
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
- data/lib/hecks/fuzzing/self_consistency.rb +339 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
- data/lib/hecks/fuzzing/shrinker.rb +166 -0
- data/lib/hecks/fuzzing/structural_skips.rb +33 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
- data/lib/hecks/fuzzing/value_generator.rb +216 -0
- data/lib/hecks/fuzzing.rb +23 -0
- data/lib/hecks/grammar/evolve.rb +336 -0
- data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
- data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
- data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
- data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
- data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
- data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
- data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
- data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
- data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
- data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
- data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
- data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
- data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
- data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
- data/lib/hecks/hecks/adapters/git.adapter +7 -0
- data/lib/hecks/hecks/adapters/git.rb +102 -0
- data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
- data/lib/hecks/hecks/adapters/host_http.rb +65 -0
- data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
- data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
- data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
- data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
- data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
- data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
- data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
- data/lib/hecks/hecks/adapters/local_files.rb +46 -0
- data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
- data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
- data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
- data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
- data/lib/hecks/hecks/adapters/shell.adapter +7 -0
- data/lib/hecks/hecks/adapters/shell.rb +49 -0
- data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
- data/lib/hecks/hecks/adapters/terminal.rb +74 -0
- data/lib/hecks/hecks/codebase.behaviors +172 -0
- data/lib/hecks/hecks/codebase.bluebook +2453 -0
- data/lib/hecks/hecks/context_map.hecksagon +13 -0
- data/lib/hecks/hecks/custodian.behaviors +179 -0
- data/lib/hecks/hecks/custodian.bluebook +1847 -0
- data/lib/hecks/hecks/deploy.behaviors +74 -0
- data/lib/hecks/hecks/deploy.world +12 -0
- data/lib/hecks/hecks/environments/memory.world +10 -0
- data/lib/hecks/hecks/governance.world +7 -0
- data/lib/hecks/hecks/hecks.behaviors +62 -0
- data/lib/hecks/hecks/hecks.bluebook +124 -0
- data/lib/hecks/hecks/hecks.hecksagon +390 -0
- data/lib/hecks/hecks/hecks.world +42 -0
- data/lib/hecks/kernel_capabilities.rb +194 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
- data/lib/hecks/language/bluebook/query.bluebook +7 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
- data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
- data/lib/hecks/mcp_stdio_guard.rb +2 -2
- data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
- data/lib/hecks/persistence_legacy_fixture.rb +113 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
- data/lib/hecks/ports/persistence/state_codec.rb +24 -5
- data/lib/hecks/projection_files.rb +285 -0
- data/lib/hecks/projections/bootstrap_table.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +5 -5
- data/lib/hecks/projections/deploy/lambda.rb +43 -32
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
- data/lib/hecks/projections/deploy/preview/template.rb +1 -1
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/shared.rb +1 -1
- data/lib/hecks/projections/diagrams.rb +3 -3
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/parser_table.rb +4 -4
- data/lib/hecks/projections/reference.rb +2 -3
- data/lib/hecks/projections/rust_vocabulary.rb +1 -1
- data/lib/hecks/projector/cli_projector.rb +106 -37
- data/lib/hecks/projector.rb +1 -1
- data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
- data/lib/hecks/quality_control/adapters/agent.rb +58 -0
- data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
- data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
- data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
- data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
- data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
- data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
- data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
- data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
- data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
- data/lib/hecks/quality_control/cli/child.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
- data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
- data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
- data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
- data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
- data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
- data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
- data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
- data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
- data/lib/hecks/query_ir.rb +346 -0
- data/lib/hecks/query_ir_mcp.rb +198 -0
- data/lib/hecks/release/runner/ci_publisher.rb +2 -2
- data/lib/hecks/release/runner/clean_tree.rb +42 -0
- data/lib/hecks/release/runner/console.rb +6 -0
- data/lib/hecks/release/runner/gem_publisher.rb +14 -7
- data/lib/hecks/release/runner/npm_publisher.rb +6 -36
- data/lib/hecks/release/runner/preflight.rb +26 -2
- data/lib/hecks/release/runner/published.rb +8 -26
- data/lib/hecks/release/runner/tagger.rb +30 -15
- data/lib/hecks/release/runner.rb +57 -6
- data/lib/hecks/router/namespace_installer.rb +3 -3
- data/lib/hecks/runtime/adapter_lookup.rb +124 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
- data/lib/hecks/runtime/dependency_planning.rb +20 -2
- data/lib/hecks/runtime/dispatcher.rb +53 -3
- data/lib/hecks/runtime/interpreting.rb +3 -0
- data/lib/hecks/runtime/loader.rb +65 -12
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/policy_interpreter.rb +5 -2
- data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
- data/lib/hecks/runtime/query_interpreter.rb +77 -0
- data/lib/hecks/runtime/reaction_outcome.rb +89 -0
- data/lib/hecks/runtime/refusal_wording.rb +23 -1
- data/lib/hecks/runtime/registry/verification.rb +163 -4
- data/lib/hecks/runtime/registry.rb +57 -3
- data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
- data/lib/hecks/runtime/value/coercion.rb +23 -135
- data/lib/hecks/runtime/value/field_checks.rb +172 -0
- data/lib/hecks/runtime/value.rb +4 -1
- data/lib/hecks/runtime.rb +20 -6
- data/lib/hecks/rust_build/conformance.rb +158 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
- data/lib/hecks/rust_build/coverage.rb +218 -0
- data/lib/hecks/rust_build/native_build.rb +124 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
- data/lib/hecks/rust_build/project_rust.rb +332 -0
- data/lib/hecks/rust_build/wasm.rb +105 -0
- data/lib/hecks/rust_build/wasm_browser.rb +113 -0
- data/lib/hecks/rust_build.rb +144 -0
- data/lib/hecks/storehouse.rb +16 -16
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
- data/lib/hecks/three_zero/forms.yml +127 -0
- data/lib/hecks/three_zero.rb +12 -0
- data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
- data/lib/hecks/tools/comment_style.rb +1070 -0
- data/lib/hecks/tools/corpus_report.rb +87 -0
- data/lib/hecks/tools/deploy_recipe.rb +255 -0
- data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
- data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
- data/lib/hecks/tools/era_projection_backfill.rb +94 -0
- data/lib/hecks/tools/era_reattest.rb +146 -0
- data/lib/hecks/tools/evolve_run.rb +189 -0
- data/lib/hecks/tools/fuzz_sweep.rb +373 -0
- data/lib/hecks/tools/heki_compaction.rb +100 -0
- data/lib/hecks/tools/hoist_local_givens.rb +246 -0
- data/lib/hecks/tools/journal_compaction.rb +89 -0
- data/lib/hecks/tools/oidc_manifests.rb +70 -0
- data/lib/hecks/tools/query_ir_run.rb +81 -0
- data/lib/hecks/tools/regeneration_run.rb +166 -0
- data/lib/hecks/tools/rust_comment_style.rb +642 -0
- data/lib/hecks/tools/sequence_script.rb +62 -0
- data/lib/hecks/tools/tail_merge.rb +117 -0
- data/lib/hecks/tools/tenant_provisioning.rb +142 -0
- data/lib/hecks/tools/translation_audit.rb +208 -0
- data/lib/hecks/tools/translation_scaffold.rb +131 -0
- data/lib/hecks/tools.rb +86 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +32 -15
- data/qa/settings.yml +66 -0
- data/rust/.cargo/config.toml +6 -0
- data/rust/.gitignore +2 -0
- data/rust/Cargo.lock +54 -0
- data/rust/Cargo.toml +61 -0
- data/rust/build/.cargo/config.toml +3 -0
- data/rust/build/.gitignore +1 -0
- data/rust/build/Cargo.lock +7 -0
- data/rust/build/Cargo.toml +19 -0
- data/rust/build/src/build_artifact.rs +47 -0
- data/rust/build/src/cargo_sync.rs +248 -0
- data/rust/build/src/json.rs +421 -0
- data/rust/build/src/lineage_pass.rs +285 -0
- data/rust/build/src/main.rs +51 -0
- data/rust/build/src/optional_pass.rs +150 -0
- data/rust/build/src/pipeline.rs +264 -0
- data/rust/build/src/reserved_names.rs +76 -0
- data/rust/build/src/resolve.rs +124 -0
- data/rust/build/src/root.rs +25 -0
- data/rust/build/src/sidecars.rs +73 -0
- data/rust/build/src/subprocess.rs +51 -0
- data/rust/build/src/tmp.rs +31 -0
- data/rust/codegen/.cargo/config.toml +3 -0
- data/rust/codegen/.gitignore +1 -0
- data/rust/codegen/Cargo.lock +7 -0
- data/rust/codegen/Cargo.toml +20 -0
- data/rust/codegen/src/attr.rs +32 -0
- data/rust/codegen/src/bridging.rs +481 -0
- data/rust/codegen/src/commands.rs +1226 -0
- data/rust/codegen/src/constraints.rs +109 -0
- data/rust/codegen/src/dependency_planning.rs +209 -0
- data/rust/codegen/src/domain_generator.rs +1288 -0
- data/rust/codegen/src/exemplar.rs +230 -0
- data/rust/codegen/src/expr_emitter.rs +87 -0
- data/rust/codegen/src/fielded.rs +192 -0
- data/rust/codegen/src/hecks_naming.rs +33 -0
- data/rust/codegen/src/json.rs +335 -0
- data/rust/codegen/src/json_codec.rs +697 -0
- data/rust/codegen/src/literal.rs +207 -0
- data/rust/codegen/src/main.rs +290 -0
- data/rust/codegen/src/manifest.rs +227 -0
- data/rust/codegen/src/mutations.rs +784 -0
- data/rust/codegen/src/naming.rs +286 -0
- data/rust/codegen/src/ports.rs +208 -0
- data/rust/codegen/src/prelude.rs +116 -0
- data/rust/codegen/src/queries.rs +786 -0
- data/rust/codegen/src/reactions.rs +532 -0
- data/rust/codegen/src/read_models.rs +697 -0
- data/rust/codegen/src/reference_specs.rs +40 -0
- data/rust/codegen/src/registry.rs +966 -0
- data/rust/codegen/src/reserved_names.rs +76 -0
- data/rust/codegen/src/shared.rs +93 -0
- data/rust/codegen/src/skip_reason.rs +25 -0
- data/rust/codegen/src/types.rs +362 -0
- data/rust/host/.cargo/config.toml +3 -0
- data/rust/host/.gitignore +1 -0
- data/rust/host/Cargo.lock +4297 -0
- data/rust/host/Cargo.toml +61 -0
- data/rust/host/HECKS_RELEASE +1 -0
- data/rust/host/rds-ca-bundle.pem +2736 -0
- data/rust/host/src/api.rs +1596 -0
- data/rust/host/src/approval.rs +652 -0
- data/rust/host/src/auth.rs +1287 -0
- data/rust/host/src/bin/expr_harness.rs +46 -0
- data/rust/host/src/bin/lineage_harness.rs +119 -0
- data/rust/host/src/bin/mint_harness.rs +135 -0
- data/rust/host/src/checkout.rs +265 -0
- data/rust/host/src/dispatch.rs +1443 -0
- data/rust/host/src/expr_json.rs +1117 -0
- data/rust/host/src/field_hints.rs +52 -0
- data/rust/host/src/ir.rs +733 -0
- data/rust/host/src/journal.rs +874 -0
- data/rust/host/src/lambda_client.rs +378 -0
- data/rust/host/src/log.rs +147 -0
- data/rust/host/src/main.rs +474 -0
- data/rust/host/src/mint.rs +1865 -0
- data/rust/host/src/payments/keystore.rs +330 -0
- data/rust/host/src/payments/tests.rs +1400 -0
- data/rust/host/src/payments.rs +767 -0
- data/rust/host/src/presentation.rs +698 -0
- data/rust/host/src/presentation_write.rs +1431 -0
- data/rust/host/src/query_step.rs +149 -0
- data/rust/host/src/rate_limit.rs +904 -0
- data/rust/host/src/reference_transform.rs +241 -0
- data/rust/host/src/reference_validate.rs +338 -0
- data/rust/host/src/resend.rs +296 -0
- data/rust/host/src/secrets.rs +106 -0
- data/rust/host/src/server.rs +663 -0
- data/rust/host/src/storage_shape.rs +175 -0
- data/rust/host/src/test_pg.rs +40 -0
- data/rust/host/src/ui_schema.rs +1351 -0
- data/rust/host/src/wasm_runner.rs +191 -0
- data/rust/host/src/web/newsletter/cooldown.rs +126 -0
- data/rust/host/src/web/newsletter/tests.rs +104 -0
- data/rust/host/src/web/newsletter.rs +310 -0
- data/rust/host/src/web/newsletter_send.rs +357 -0
- data/rust/host/src/web/registrations.rs +1505 -0
- data/rust/host/src/web.rs +2937 -0
- data/rust/lsp/.gitignore +1 -0
- data/rust/lsp/Cargo.lock +7 -0
- data/rust/lsp/Cargo.toml +16 -0
- data/rust/lsp/README.md +133 -0
- data/rust/lsp/src/diagnostics.rs +165 -0
- data/rust/lsp/src/json.rs +385 -0
- data/rust/lsp/src/main.rs +399 -0
- data/rust/lsp/src/outline.rs +177 -0
- data/rust/lsp/src/rpc.rs +67 -0
- data/rust/parser/.cargo/config.toml +5 -0
- data/rust/parser/.gitignore +1 -0
- data/rust/parser/Cargo.lock +7 -0
- data/rust/parser/Cargo.toml +21 -0
- data/rust/parser/src/build/closed_sets.rs +27 -0
- data/rust/parser/src/build/identity.rs +276 -0
- data/rust/parser/src/build/mod.rs +12 -0
- data/rust/parser/src/build/naming.rs +141 -0
- data/rust/parser/src/build/pattern_subset.rs +161 -0
- data/rust/parser/src/build/query_derive.rs +72 -0
- data/rust/parser/src/build/query_inference.rs +269 -0
- data/rust/parser/src/build/query_options.rs +100 -0
- data/rust/parser/src/build/read_model.rs +94 -0
- data/rust/parser/src/build/references.rs +32 -0
- data/rust/parser/src/canonical.rs +114 -0
- data/rust/parser/src/diag.rs +55 -0
- data/rust/parser/src/emit.rs +953 -0
- data/rust/parser/src/expr/ast_json.rs +147 -0
- data/rust/parser/src/expr/evaluator.rs +81 -0
- data/rust/parser/src/expr/mod.rs +84 -0
- data/rust/parser/src/expr/resolver.rs +497 -0
- data/rust/parser/src/ir.rs +387 -0
- data/rust/parser/src/keywords.rs +585 -0
- data/rust/parser/src/lex.rs +662 -0
- data/rust/parser/src/main.rs +252 -0
- data/rust/parser/src/parse/aggregate.rs +612 -0
- data/rust/parser/src/parse/chapter.rs +517 -0
- data/rust/parser/src/parse/command.rs +637 -0
- data/rust/parser/src/parse/domain_port.rs +127 -0
- data/rust/parser/src/parse/entity.rs +527 -0
- data/rust/parser/src/parse/file.rs +67 -0
- data/rust/parser/src/parse/hecksagon.rs +231 -0
- data/rust/parser/src/parse/lifecycle.rs +162 -0
- data/rust/parser/src/parse/mod.rs +1262 -0
- data/rust/parser/src/parse/policy.rs +87 -0
- data/rust/parser/src/parse/process_manager.rs +305 -0
- data/rust/parser/src/parse/query.rs +100 -0
- data/rust/parser/src/parse/read_model.rs +214 -0
- data/rust/parser/src/parse/value_object.rs +250 -0
- data/rust/parser/src/ruby_value.rs +422 -0
- data/rust/project/bridging.rb +257 -0
- data/rust/project/commands.rb +877 -0
- data/rust/project/constraints.rb +86 -0
- data/rust/project/dependency_planning.rb +178 -0
- data/rust/project/domain_generator.rb +980 -0
- data/rust/project/exemplar.rb +148 -0
- data/rust/project/expr_emitter.rb +72 -0
- data/rust/project/fielded.rb +141 -0
- data/rust/project/json_codec.rb +641 -0
- data/rust/project/mutations.rb +721 -0
- data/rust/project/naming.rb +205 -0
- data/rust/project/ports.rb +65 -0
- data/rust/project/queries.rb +493 -0
- data/rust/project/reactions.rb +317 -0
- data/rust/project/read_models.rb +690 -0
- data/rust/project/reference_specs.rb +27 -0
- data/rust/project/registry.rb +396 -0
- data/rust/project/skip_reason.rb +29 -0
- data/rust/project/types.rb +225 -0
- data/rust/project/write_if_changed.rb +83 -0
- data/rust/project.rb +30 -0
- data/rust/project_rust_pipeline.rb +383 -0
- data/rust/src/exemplar/commands.rs +279 -0
- data/rust/src/exemplar/constraints.rs +18 -0
- data/rust/src/exemplar/fielded.rs +280 -0
- data/rust/src/exemplar/json.rs +277 -0
- data/rust/src/exemplar/mod.rs +17 -0
- data/rust/src/exemplar/mutations.rs +80 -0
- data/rust/src/exemplar/queries.rs +33 -0
- data/rust/src/exemplar/reactions.rs +88 -0
- data/rust/src/exemplar/read_models.rs +48 -0
- data/rust/src/exemplar/registry.rs +166 -0
- data/rust/src/exemplar/types.rs +44 -0
- data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
- data/rust/src/kernel/attribute_shapes/list.rs +18 -0
- data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
- data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
- data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
- data/rust/src/kernel/cli.rs +585 -0
- data/rust/src/kernel/dispatch.rs +1090 -0
- data/rust/src/kernel/expr.rs +458 -0
- data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
- data/rust/src/kernel/expression_operators/comparison.rs +48 -0
- data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
- data/rust/src/kernel/expression_operators/logical.rs +13 -0
- data/rust/src/kernel/expression_operators/membership.rs +18 -0
- data/rust/src/kernel/expression_operators/mod.rs +68 -0
- data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
- data/rust/src/kernel/expression_operators/positional.rs +126 -0
- data/rust/src/kernel/expression_operators/presence.rs +101 -0
- data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
- data/rust/src/kernel/expression_operators/sized.rs +35 -0
- data/rust/src/kernel/expression_operators/text.rs +79 -0
- data/rust/src/kernel/expression_operators/to_string.rs +18 -0
- data/rust/src/kernel/json.rs +779 -0
- data/rust/src/kernel/mod.rs +133 -0
- data/rust/src/kernel/named_query.rs +219 -0
- data/rust/src/kernel/orchestrate.rs +1810 -0
- data/rust/src/kernel/pattern.rs +307 -0
- data/rust/src/kernel/query_comparators.rs +206 -0
- data/rust/src/kernel/query_ordering.rs +103 -0
- data/rust/src/kernel/read_model.rs +914 -0
- data/rust/src/kernel/reference_lookup.rs +149 -0
- data/rust/src/kernel/repository.rs +468 -0
- data/rust/src/kernel/routing.rs +612 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
- data/rust/src/kernel/vocab/field_hint.rs +70 -0
- data/rust/src/kernel/vocab/mod.rs +20 -0
- data/rust/src/kernel/vocab/query_comparator.rs +65 -0
- data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
- data/rust/src/lib.rs +8 -0
- data/rust/src/main.rs +16 -0
- data/rust/web/.gitignore +1 -0
- data/rust/web/Cargo.lock +119 -0
- data/rust/web/Cargo.toml +18 -0
- data/rust/web/src/lib.rs +8 -0
- metadata +477 -12
- data/lib/hecks/facade/cli_door.rb +0 -96
- data/lib/hecks/facade/cli_runner.rb +0 -127
- data/lib/hecks/mcp_door_scope.rb +0 -95
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
//! Interprets a compiled `report` block (`ReadModelDef`) against a domain's generated
|
|
2
|
+
//! `READ_MODELS` table — the hand-written counterpart to `rust/project/read_models.rb`.
|
|
3
|
+
|
|
4
|
+
use super::refusal_wording::{InvariantViolationGroupByCollisionArgs, NotFoundReadModelReferenceMissingArgs, UnauthorizedTenantRequiredArgs};
|
|
5
|
+
use super::{named_query, query_comparators, query_ordering, repository, AggregateScan, Json, QueryCondition, QueryConditionValue, Refusal};
|
|
6
|
+
|
|
7
|
+
/// A `where` clause that crosses one or more references on the eligible head
|
|
8
|
+
/// (`account/status`, or a longer `member/sponsor/standing` chain via `through`).
|
|
9
|
+
#[derive(Debug, Clone, Copy)]
|
|
10
|
+
pub struct ReferenceHopCondition {
|
|
11
|
+
/// The eligible head's own reference field this hop crosses through first.
|
|
12
|
+
pub via_field: &'static str,
|
|
13
|
+
/// The first hop's target aggregate, domain-qualified ("Banking::Account").
|
|
14
|
+
pub target_aggregate: &'static str,
|
|
15
|
+
/// Further chain steps beyond the first hop, in declared order; `&[]` for a single hop.
|
|
16
|
+
pub through: &'static [HopStep],
|
|
17
|
+
/// The remaining where-clause field, resolved against the chain's final target.
|
|
18
|
+
pub inner_field: &'static str,
|
|
19
|
+
pub inner_comparator: query_comparators::QueryComparator,
|
|
20
|
+
pub inner_value: QueryConditionValue,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// One further step in a `ReferenceHopCondition` chain.
|
|
24
|
+
#[derive(Debug, Clone, Copy)]
|
|
25
|
+
pub struct HopStep {
|
|
26
|
+
pub via_field: &'static str,
|
|
27
|
+
pub target_aggregate: &'static str,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// A reference field on a non-root head's own aggregate that may match back to `target_aggregate`.
|
|
31
|
+
#[derive(Debug, Clone, Copy)]
|
|
32
|
+
pub struct ReferenceField {
|
|
33
|
+
pub target_aggregate: &'static str,
|
|
34
|
+
pub field: &'static str,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// One declared `include`, plus `is_root`/`reference_fields` precomputed at codegen time rather
|
|
38
|
+
/// than re-derived per call. Both are empty/false for the root head, which is fetched directly
|
|
39
|
+
/// by the caller's id argument rather than matched by reference.
|
|
40
|
+
#[derive(Debug, Clone, Copy)]
|
|
41
|
+
pub struct ReadModelHead {
|
|
42
|
+
pub aggregate: &'static str,
|
|
43
|
+
pub as_name: &'static str,
|
|
44
|
+
pub many: bool,
|
|
45
|
+
pub is_root: bool,
|
|
46
|
+
pub reference_fields: &'static [ReferenceField],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// A read model's declared `order_by`, applying to `ReadModelDef::filtered_head` alone — an
|
|
50
|
+
/// alias, not a distinct type, since `named_query.rs` needs the identical shape.
|
|
51
|
+
pub type ReadModelOrderBy = query_ordering::OrderBy;
|
|
52
|
+
|
|
53
|
+
/// A read model's declared `limit N`, applying to `ReadModelDef::filtered_head` alone.
|
|
54
|
+
pub type ReadModelLimit = query_ordering::Limit;
|
|
55
|
+
|
|
56
|
+
/// A read model's declared `offset N`, applying to `ReadModelDef::filtered_head` alone.
|
|
57
|
+
pub type ReadModelOffset = query_ordering::Offset;
|
|
58
|
+
|
|
59
|
+
/// One many-side head's own where/order_by/offset/limit. ADR 0055's `on:` lets a read model
|
|
60
|
+
/// with several many-side heads filter/order/page each independently; a read model with exactly
|
|
61
|
+
/// one many-side head and untargeted options still compiles to a single entry here, unchanged
|
|
62
|
+
/// from the shape this carried before ADR 0055 (`filtered_head`/`conditions`/... lived directly
|
|
63
|
+
/// on `ReadModelDef` then, one per whole read model rather than one per head).
|
|
64
|
+
#[derive(Debug, Clone, Copy)]
|
|
65
|
+
pub struct FilteredHead {
|
|
66
|
+
/// The `ReadModelHead::as_name` this entry's options apply to.
|
|
67
|
+
pub as_name: &'static str,
|
|
68
|
+
pub conditions: &'static [QueryCondition],
|
|
69
|
+
/// `where` clauses that hop through a reference, on this same head.
|
|
70
|
+
pub reference_hop_conditions: &'static [ReferenceHopCondition],
|
|
71
|
+
pub order_by: Option<ReadModelOrderBy>,
|
|
72
|
+
pub offset: Option<ReadModelOffset>,
|
|
73
|
+
pub limit: Option<ReadModelLimit>,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// A compiled `report "X" do ... end` block — the read-model analogue of `QueryDef`.
|
|
77
|
+
/// `verb` is the "Domain.Name" wire string a read-model ask is matched against.
|
|
78
|
+
#[derive(Debug, Clone, Copy)]
|
|
79
|
+
pub struct ReadModelDef {
|
|
80
|
+
pub verb: &'static str,
|
|
81
|
+
/// `None` for a rootless read model (no `reference_to` declared) — every head then reads its
|
|
82
|
+
/// own aggregate's whole table instead of being fetched/matched by a caller-supplied id.
|
|
83
|
+
pub reference_name: Option<&'static str>,
|
|
84
|
+
pub heads: &'static [ReadModelHead],
|
|
85
|
+
/// One entry per many-side head this read model declares where/order_by/offset/limit for
|
|
86
|
+
/// (ADR 0055's `on:`) — `&[]` when none do. At most one entry when the read model has a
|
|
87
|
+
/// single many-side head (the pre-ADR-0055 shape); more than one only when `on:` targets
|
|
88
|
+
/// distinct heads, since an untargeted option is only legal with a single many-side head
|
|
89
|
+
/// (`ReadModelBuilder#seal_query_options` refuses any other combination before this table is
|
|
90
|
+
/// ever built).
|
|
91
|
+
pub filtered_heads: &'static [FilteredHead],
|
|
92
|
+
/// `authorize policy, tenant: :field` — reuses `named_query::TenantAuth` directly; the
|
|
93
|
+
/// tenant-argument check runs at the same point in `run` as it does for a declared query.
|
|
94
|
+
/// `on:` doesn't extend to `tenant:` (a deliberate ADR 0055 scope limit — a read model with
|
|
95
|
+
/// several many-side heads and a declared tenant is refused outright, not attached to one),
|
|
96
|
+
/// so this is only ever `Some` alongside a single many-side head, the same as before.
|
|
97
|
+
pub authorization: Option<named_query::TenantAuth>,
|
|
98
|
+
/// `group_by :field, ...`, applied to the one `many`-side head this read model declares.
|
|
99
|
+
/// A per-read-model generated function rather than data here, because unwrapping a
|
|
100
|
+
/// single-attribute value object needs codegen-time type knowledge `run`'s generic body,
|
|
101
|
+
/// holding already-serialized `Json`, doesn't have.
|
|
102
|
+
pub group_by: Option<fn(Vec<(String, Json)>) -> Result<Json, Refusal>>,
|
|
103
|
+
/// `count` on the one `many`-side head — a bare marker; row-count needs no per-read-model
|
|
104
|
+
/// generated function, unlike `group_by`.
|
|
105
|
+
pub count: bool,
|
|
106
|
+
/// `median :field` — the declared numeric field's median across the one `many`-side head.
|
|
107
|
+
pub median_field: Option<&'static str>,
|
|
108
|
+
/// `sum :field` — the declared Integer field's total (ADR 0078).
|
|
109
|
+
pub sum_field: Option<&'static str>,
|
|
110
|
+
/// `avg :field` — the declared Integer field's mean (ADR 0078).
|
|
111
|
+
pub avg_field: Option<&'static str>,
|
|
112
|
+
/// `min :field` — the declared numeric field's smallest value (ADR 0078).
|
|
113
|
+
pub min_field: Option<&'static str>,
|
|
114
|
+
/// `max :field` — the declared numeric field's largest value (ADR 0078).
|
|
115
|
+
pub max_field: Option<&'static str>,
|
|
116
|
+
/// `percentile :field, at:` — the declared numeric field's value at one interpolated
|
|
117
|
+
/// rank; `median` is the fixed `at: 0.5` case (ADR 0078).
|
|
118
|
+
pub percentile_field: Option<&'static str>,
|
|
119
|
+
pub percentile_at: Option<f64>,
|
|
120
|
+
/// `any :field` — whether any row's declared boolean field is `true` (ADR 0078).
|
|
121
|
+
pub any_field: Option<&'static str>,
|
|
122
|
+
/// `all :field` — whether every row's declared boolean field is `true` (ADR 0078).
|
|
123
|
+
pub all_field: Option<&'static str>,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Whether a `group_by` leaf is checked for a second row — decided once, by the generator, from
|
|
127
|
+
/// the declaration alone (ADR 0061, decision D1).
|
|
128
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
129
|
+
pub enum LeafCheck {
|
|
130
|
+
/// The key path covers the grouped aggregate's whole identity, so no two
|
|
131
|
+
/// rows can reach one leaf and none is checked.
|
|
132
|
+
IdentityCovered,
|
|
133
|
+
/// Any other key path: two rows reaching one leaf refuse, naming this
|
|
134
|
+
/// read model (its bare declared name).
|
|
135
|
+
RefuseCollision(&'static str),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// One level of nesting per declared `group_by` field. Grouping is a Vec-based linear scan, not
|
|
139
|
+
/// a `HashMap`, because `Json` has no `Hash` impl and groups must stay in first-occurrence order
|
|
140
|
+
/// (matching Ruby's `Hash#group_by`) so both runtimes report the same first collision.
|
|
141
|
+
pub fn nest(rows: Vec<Json>, fields: &[&str], check: LeafCheck) -> Result<Json, Refusal> {
|
|
142
|
+
nest_level(rows, fields, fields, check, &[])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// One level of `nest`. `all_fields` is the whole declared `group_by`
|
|
146
|
+
/// (the refusal names it); `reached` is the key path walked so far, one
|
|
147
|
+
/// `(field, key)` per level above this one.
|
|
148
|
+
fn nest_level(rows: Vec<Json>, all_fields: &[&str], fields: &[&str], check: LeafCheck, reached: &[(&str, Json)]) -> Result<Json, Refusal> {
|
|
149
|
+
let (field, rest) = fields.split_first().expect("nest called with empty fields — read_model_skip_reason should refuse a group_by with none");
|
|
150
|
+
let mut groups: Vec<(Json, Vec<Json>)> = Vec::new();
|
|
151
|
+
for row in rows {
|
|
152
|
+
let (key, stripped) = match row {
|
|
153
|
+
Json::Object(pairs) => {
|
|
154
|
+
let key = pairs.iter().find(|(k, _)| k == field).map(|(_, v)| v.clone()).unwrap_or(Json::Null);
|
|
155
|
+
let stripped = Json::Object(pairs.into_iter().filter(|(k, _)| k != field).collect());
|
|
156
|
+
(key, stripped)
|
|
157
|
+
}
|
|
158
|
+
other => (Json::Null, other),
|
|
159
|
+
};
|
|
160
|
+
match groups.iter_mut().find(|(k, _)| *k == key) {
|
|
161
|
+
Some((_, group)) => group.push(stripped),
|
|
162
|
+
None => groups.push((key, vec![stripped])),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let mut out = Vec::with_capacity(groups.len());
|
|
166
|
+
for (key, group) in groups {
|
|
167
|
+
let mut path = reached.to_vec();
|
|
168
|
+
path.push((field, key.clone()));
|
|
169
|
+
let value = if rest.is_empty() { leaf(group, all_fields, check, &path)? } else { nest_level(group, all_fields, rest, check, &path)? };
|
|
170
|
+
// A non-String key must stringify the same way Ruby's `Hash#[]=` does when used as a
|
|
171
|
+
// JSON object key.
|
|
172
|
+
out.push((super::query_comparators::to_s(&key), value));
|
|
173
|
+
}
|
|
174
|
+
Ok(Json::Object(out))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// The one row a `group_by` key path reached, or the `group_by_collision` refusal when a
|
|
178
|
+
/// checked path was reached by more than one.
|
|
179
|
+
fn leaf(group: Vec<Json>, all_fields: &[&str], check: LeafCheck, path: &[(&str, Json)]) -> Result<Json, Refusal> {
|
|
180
|
+
let read_model = match check {
|
|
181
|
+
LeafCheck::RefuseCollision(read_model) if group.len() > 1 => read_model,
|
|
182
|
+
_ => return Ok(group.into_iter().next().unwrap_or(Json::Null)),
|
|
183
|
+
};
|
|
184
|
+
let ids: Vec<String> = group.iter().map(|row| row.get("id").map(super::query_comparators::to_s).unwrap_or_default()).collect();
|
|
185
|
+
let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
|
|
186
|
+
let key = path.iter().map(|(field, value)| format!("{field} = {}", super::query_comparators::to_s(value))).collect::<Vec<_>>().join(", ");
|
|
187
|
+
Err(Refusal::InvariantViolation(
|
|
188
|
+
InvariantViolationGroupByCollisionArgs { read_model, fields: all_fields, ids: &id_refs, key: &key }.render_args(),
|
|
189
|
+
))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// The declared field's value at one interpolated rank (`at`, `0.0`..`1.0`) across `rows` —
|
|
193
|
+
/// linear interpolation between the two closest ranks, matching SQL's `PERCENTILE_CONT`
|
|
194
|
+
/// (ADR 0078). Landing exactly on a rank returns that value unconverted; interpolating
|
|
195
|
+
/// between two returns a `Json::Float` (matching Ruby's forced float division). An empty
|
|
196
|
+
/// collection is `Json::Null`, not zero, so a caller cannot mistake "nothing to average" for
|
|
197
|
+
/// "averaged to zero".
|
|
198
|
+
fn percentile(rows: &[(String, Json)], field: &'static str, at: f64) -> Json {
|
|
199
|
+
let mut values: Vec<Json> = rows
|
|
200
|
+
.iter()
|
|
201
|
+
.filter_map(|(_, row)| row.get(field))
|
|
202
|
+
.map(query_comparators::comparable)
|
|
203
|
+
.filter(|value| !matches!(value, Json::Null))
|
|
204
|
+
.collect();
|
|
205
|
+
if values.is_empty() {
|
|
206
|
+
return Json::Null;
|
|
207
|
+
}
|
|
208
|
+
values.sort_by(|a, b| query_comparators::as_f64(a).partial_cmp(&query_comparators::as_f64(b)).unwrap_or(std::cmp::Ordering::Equal));
|
|
209
|
+
|
|
210
|
+
let position = at * (values.len() - 1) as f64;
|
|
211
|
+
let lower = position.floor() as usize;
|
|
212
|
+
let fraction = position - lower as f64;
|
|
213
|
+
if fraction == 0.0 {
|
|
214
|
+
values[lower].clone()
|
|
215
|
+
} else {
|
|
216
|
+
let lo = query_comparators::as_f64(&values[lower]);
|
|
217
|
+
let hi = query_comparators::as_f64(&values[lower + 1]);
|
|
218
|
+
Json::Float(lo + fraction * (hi - lo))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// The standard median: the fixed `at: 0.5` case of `percentile` (ADR 0078) — the middle
|
|
223
|
+
/// value when odd, the average of the two middle values when even.
|
|
224
|
+
fn median(rows: &[(String, Json)], field: &'static str) -> Json {
|
|
225
|
+
percentile(rows, field, 0.5)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// The total of the declared Integer field across `rows`; `0`, not `Json::Null`, on no rows —
|
|
229
|
+
/// "no disputes" means zero exposure, where `median`/`min`/`max` have no answer to give
|
|
230
|
+
/// (ADR 0078). Always `Json::Num`, never `Json::Float`: `sum` admits only an Integer field.
|
|
231
|
+
fn sum(rows: &[(String, Json)], field: &'static str) -> Json {
|
|
232
|
+
let total = rows
|
|
233
|
+
.iter()
|
|
234
|
+
.filter_map(|(_, row)| row.get(field))
|
|
235
|
+
.map(query_comparators::comparable)
|
|
236
|
+
.filter(|value| !matches!(value, Json::Null))
|
|
237
|
+
.fold(0.0, |acc, value| acc + query_comparators::as_f64(&value));
|
|
238
|
+
Json::Num(total, None)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// The mean of the declared Integer field across `rows`; `Json::Null` on no rows — a rate of
|
|
242
|
+
/// nothing is undefined, not zero (ADR 0078). `sum / count` as a plain `f64` division: both
|
|
243
|
+
/// operands are exact integers within 2^53 (`sum`'s own overflow refusal), so IEEE 754's
|
|
244
|
+
/// correctly-rounded division agrees bit for bit with Ruby's own `sum.to_f / count.to_f`.
|
|
245
|
+
fn avg(rows: &[(String, Json)], field: &'static str) -> Json {
|
|
246
|
+
let values: Vec<f64> = rows
|
|
247
|
+
.iter()
|
|
248
|
+
.filter_map(|(_, row)| row.get(field))
|
|
249
|
+
.map(query_comparators::comparable)
|
|
250
|
+
.filter(|value| !matches!(value, Json::Null))
|
|
251
|
+
.map(|value| query_comparators::as_f64(&value))
|
|
252
|
+
.collect();
|
|
253
|
+
if values.is_empty() {
|
|
254
|
+
return Json::Null;
|
|
255
|
+
}
|
|
256
|
+
Json::Float(values.iter().sum::<f64>() / values.len() as f64)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// The smallest (`want_max: false`) or largest (`true`) declared field value across `rows`,
|
|
260
|
+
/// its own `Json` variant preserved (an Integer field stays `Num`, a Float field stays
|
|
261
|
+
/// `Float`); `Json::Null` on no rows.
|
|
262
|
+
fn extreme(rows: &[(String, Json)], field: &'static str, want_max: bool) -> Json {
|
|
263
|
+
let mut values: Vec<Json> = rows
|
|
264
|
+
.iter()
|
|
265
|
+
.filter_map(|(_, row)| row.get(field))
|
|
266
|
+
.map(query_comparators::comparable)
|
|
267
|
+
.filter(|value| !matches!(value, Json::Null))
|
|
268
|
+
.collect();
|
|
269
|
+
if values.is_empty() {
|
|
270
|
+
return Json::Null;
|
|
271
|
+
}
|
|
272
|
+
values.sort_by(|a, b| query_comparators::as_f64(a).partial_cmp(&query_comparators::as_f64(b)).unwrap_or(std::cmp::Ordering::Equal));
|
|
273
|
+
if want_max { values.pop().expect("checked non-empty above") } else { values.remove(0) }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// Whether any (`want_all: false`) or every (`true`) row's declared boolean field is `true`
|
|
277
|
+
/// across `rows` — the ordinary OR-identity/AND-identity vacuous-truth reading on no rows
|
|
278
|
+
/// (`any` of nothing is `false`, `all` of nothing is `true`; ADR 0078).
|
|
279
|
+
fn boolean_reduce(rows: &[(String, Json)], field: &'static str, want_all: bool) -> Json {
|
|
280
|
+
let values: Vec<bool> = rows
|
|
281
|
+
.iter()
|
|
282
|
+
.filter_map(|(_, row)| row.get(field))
|
|
283
|
+
.map(|value| query_comparators::comparable(value))
|
|
284
|
+
.filter_map(|value| value.as_bool())
|
|
285
|
+
.collect();
|
|
286
|
+
Json::Bool(if want_all { values.iter().all(|v| *v) } else { values.iter().any(|v| *v) })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// The lookup a read-model ask (a bare "Domain.Name" string, no "::") dispatches through — a
|
|
290
|
+
/// linear scan over a generated domain's `READ_MODELS` table. Accepts either the declared verb
|
|
291
|
+
/// spelling or its snake_case alias (`matches_snake_alias`, below), since a real corpus caller
|
|
292
|
+
/// uses the snake_case form for this read model's own query step.
|
|
293
|
+
pub fn find<'a>(table: &'a [ReadModelDef], verb: &str) -> Option<&'a ReadModelDef> {
|
|
294
|
+
table.iter().find(|def| def.verb == verb || matches_snake_alias(def.verb, verb))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// True when `declared_verb` and `asked` name the same domain and `asked`'s name half equals
|
|
298
|
+
/// `declared_verb`'s name half run through `to_snake`.
|
|
299
|
+
fn matches_snake_alias(declared_verb: &str, asked: &str) -> bool {
|
|
300
|
+
let Some((domain, name)) = declared_verb.split_once('.') else { return false };
|
|
301
|
+
let Some((asked_domain, asked_name)) = asked.split_once('.') else { return false };
|
|
302
|
+
|
|
303
|
+
domain == asked_domain && to_snake(name) == asked_name
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/// A port of `Hecks::Naming.snake` (lib/hecks/naming.rb). No regex engine in this
|
|
307
|
+
/// dependency-free kernel, so the two `gsub` passes collapse into one left-to-right scan of
|
|
308
|
+
/// adjacent-character pairs; every declared read-model/query name is plain ASCII.
|
|
309
|
+
fn to_snake(name: &str) -> String {
|
|
310
|
+
let chars: Vec<char> = name.chars().collect();
|
|
311
|
+
let mut out = String::with_capacity(name.len() + 4);
|
|
312
|
+
|
|
313
|
+
for (i, &c) in chars.iter().enumerate() {
|
|
314
|
+
if i > 0 {
|
|
315
|
+
let prev = chars[i - 1];
|
|
316
|
+
// "HTTPServer" splits before the "S" of "Server", not before the "H" of "HTTP".
|
|
317
|
+
let rule1 = prev.is_ascii_uppercase() && c.is_ascii_uppercase() && chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
|
|
318
|
+
// "customerPortfolio" splits before "P".
|
|
319
|
+
let rule2 = (prev.is_ascii_lowercase() || prev.is_ascii_digit()) && c.is_ascii_uppercase();
|
|
320
|
+
if rule1 || rule2 {
|
|
321
|
+
out.push('_');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
out.push(c);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
out.make_ascii_lowercase();
|
|
328
|
+
out
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[cfg(test)]
|
|
332
|
+
mod snake_alias_tests {
|
|
333
|
+
use super::*;
|
|
334
|
+
|
|
335
|
+
// Every case here was checked against the real Ruby `Naming.snake`, not just this port.
|
|
336
|
+
#[test]
|
|
337
|
+
fn to_snake_matches_ruby_naming_snake() {
|
|
338
|
+
assert_eq!(to_snake("CustomerPortfolio"), "customer_portfolio");
|
|
339
|
+
assert_eq!(to_snake("ComplianceDashboard"), "compliance_dashboard");
|
|
340
|
+
assert_eq!(to_snake("Active"), "active");
|
|
341
|
+
assert_eq!(to_snake("ByFee"), "by_fee");
|
|
342
|
+
assert_eq!(to_snake("HTTPServer"), "http_server");
|
|
343
|
+
assert_eq!(to_snake("Type2Foo"), "type2_foo");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[test]
|
|
347
|
+
fn find_accepts_both_the_declared_and_the_snake_cased_spelling() {
|
|
348
|
+
let table = [ReadModelDef {
|
|
349
|
+
verb: "Banking.CustomerPortfolio",
|
|
350
|
+
reference_name: Some("reference"),
|
|
351
|
+
heads: &[],
|
|
352
|
+
filtered_heads: &[],
|
|
353
|
+
authorization: None,
|
|
354
|
+
group_by: None,
|
|
355
|
+
count: false,
|
|
356
|
+
median_field: None,
|
|
357
|
+
sum_field: None,
|
|
358
|
+
avg_field: None,
|
|
359
|
+
min_field: None,
|
|
360
|
+
max_field: None,
|
|
361
|
+
percentile_field: None,
|
|
362
|
+
percentile_at: None,
|
|
363
|
+
any_field: None,
|
|
364
|
+
all_field: None,
|
|
365
|
+
}];
|
|
366
|
+
|
|
367
|
+
assert!(find(&table, "Banking.CustomerPortfolio").is_some());
|
|
368
|
+
assert!(find(&table, "Banking.customer_portfolio").is_some());
|
|
369
|
+
assert!(find(&table, "Banking.somethingelse").is_none());
|
|
370
|
+
assert!(find(&table, "Other.customer_portfolio").is_none());
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/// ADR 0061, decision D1 — a `group_by` leaf holds one row.
|
|
375
|
+
#[cfg(test)]
|
|
376
|
+
mod nest_tests {
|
|
377
|
+
use super::*;
|
|
378
|
+
|
|
379
|
+
fn part(id: &str, bin: &str) -> Json {
|
|
380
|
+
Json::obj(vec![("bin", Json::str(bin)), ("id", Json::str(id))])
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn refuses_two_rows_on_one_checked_key_path() {
|
|
385
|
+
let rows = vec![part("p2", "b1"), part("p3", "b2"), part("p1", "b1")];
|
|
386
|
+
|
|
387
|
+
let err = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin")).expect_err("p1 and p2 share bin b1");
|
|
388
|
+
|
|
389
|
+
assert_eq!(
|
|
390
|
+
err,
|
|
391
|
+
Refusal::InvariantViolation(
|
|
392
|
+
"PartsByBin groups by bin, but rows \"p1\", \"p2\" share bin = b1 — a group_by leaf holds one row; add a field that tells them apart"
|
|
393
|
+
.to_string()
|
|
394
|
+
)
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#[test]
|
|
399
|
+
fn answers_when_every_key_path_holds_one_row() {
|
|
400
|
+
let rows = vec![part("p1", "b1"), part("p3", "b2")];
|
|
401
|
+
|
|
402
|
+
let nested = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin")).expect("no two parts share a bin");
|
|
403
|
+
|
|
404
|
+
assert_eq!(nested, Json::obj(vec![("b1", Json::obj(vec![("id", Json::str("p1"))])), ("b2", Json::obj(vec![("id", Json::str("p3"))]))]));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#[test]
|
|
408
|
+
fn names_every_level_of_a_multi_field_key_path() {
|
|
409
|
+
let row = |id: &str| Json::obj(vec![("bin", Json::str("b1")), ("shelf", Json::Num(2.0, None)), ("id", Json::str(id))]);
|
|
410
|
+
|
|
411
|
+
let err = nest(vec![row("p1"), row("p2")], &["bin", "shelf"], LeafCheck::RefuseCollision("PartsByShelf")).expect_err("same bin and shelf");
|
|
412
|
+
|
|
413
|
+
assert_eq!(
|
|
414
|
+
err,
|
|
415
|
+
Refusal::InvariantViolation(
|
|
416
|
+
"PartsByShelf groups by bin, shelf, but rows \"p1\", \"p2\" share bin = b1, shelf = 2 — a group_by leaf holds one row; add a field that tells them apart"
|
|
417
|
+
.to_string()
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/// Exercises `ReferenceHopCondition`'s fold end-to-end through `run`, not just the isolated
|
|
424
|
+
/// `apply_filtered_head_options` helper, so a wiring mistake at either call site fails this the
|
|
425
|
+
/// same way it would fail a real caller.
|
|
426
|
+
#[cfg(test)]
|
|
427
|
+
mod reference_hop_tests {
|
|
428
|
+
use super::*;
|
|
429
|
+
use crate::kernel::AggregateScan;
|
|
430
|
+
|
|
431
|
+
/// `domain` plus `(bare aggregate name, entries)` pairs, searched the same
|
|
432
|
+
/// domain-qualified way a real generated `Store::scan` is.
|
|
433
|
+
struct FakeStore {
|
|
434
|
+
domain: &'static str,
|
|
435
|
+
aggregates: Vec<(&'static str, Vec<(String, Json)>)>,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
impl AggregateScan for FakeStore {
|
|
439
|
+
fn scan(&self, aggregate: &str) -> Option<Vec<(String, Json)>> {
|
|
440
|
+
let bare = aggregate.strip_prefix(&format!("{}::", self.domain))?;
|
|
441
|
+
self.aggregates.iter().find(|(name, _)| *name == bare).map(|(_, entries)| entries.clone())
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
fn account(status: &str, customer: &str) -> Json {
|
|
446
|
+
Json::obj(vec![("status", Json::str(status)), ("customer", Json::str(customer))])
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
fn customer(status: &str) -> Json {
|
|
450
|
+
Json::obj(vec![("status", Json::str(status))])
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
fn store() -> FakeStore {
|
|
454
|
+
FakeStore {
|
|
455
|
+
domain: "Banking",
|
|
456
|
+
aggregates: vec![
|
|
457
|
+
(
|
|
458
|
+
"Account",
|
|
459
|
+
vec![
|
|
460
|
+
("acc-suspended-open".to_string(), account("open", "cust-suspended")),
|
|
461
|
+
("acc-active-open".to_string(), account("open", "cust-active")),
|
|
462
|
+
("acc-suspended-closed".to_string(), account("closed", "cust-suspended")),
|
|
463
|
+
],
|
|
464
|
+
),
|
|
465
|
+
(
|
|
466
|
+
"Customer",
|
|
467
|
+
vec![
|
|
468
|
+
("cust-suspended".to_string(), customer("suspended")),
|
|
469
|
+
("cust-active".to_string(), customer("active")),
|
|
470
|
+
],
|
|
471
|
+
),
|
|
472
|
+
],
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/// A rootless read model with one `many` head carrying both an ordinary local condition
|
|
477
|
+
/// (`status == "open"`) and one hop condition (`customer/status == "suspended"`).
|
|
478
|
+
fn open_for_suspended_customers_def() -> ReadModelDef {
|
|
479
|
+
ReadModelDef {
|
|
480
|
+
verb: "Banking.OpenForSuspendedCustomers",
|
|
481
|
+
reference_name: None,
|
|
482
|
+
heads: &[ReadModelHead { aggregate: "Banking::Account", as_name: "accounts", many: true, is_root: false, reference_fields: &[] }],
|
|
483
|
+
filtered_heads: &[FilteredHead {
|
|
484
|
+
as_name: "accounts",
|
|
485
|
+
conditions: &[QueryCondition { field: "status", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("open") }],
|
|
486
|
+
reference_hop_conditions: &[ReferenceHopCondition {
|
|
487
|
+
via_field: "customer",
|
|
488
|
+
target_aggregate: "Banking::Customer",
|
|
489
|
+
through: &[],
|
|
490
|
+
inner_field: "status",
|
|
491
|
+
inner_comparator: query_comparators::QueryComparator::Eq,
|
|
492
|
+
inner_value: QueryConditionValue::Literal("suspended"),
|
|
493
|
+
}],
|
|
494
|
+
order_by: None,
|
|
495
|
+
offset: None,
|
|
496
|
+
limit: None,
|
|
497
|
+
}],
|
|
498
|
+
authorization: None,
|
|
499
|
+
group_by: None,
|
|
500
|
+
count: false,
|
|
501
|
+
median_field: None,
|
|
502
|
+
sum_field: None,
|
|
503
|
+
avg_field: None,
|
|
504
|
+
min_field: None,
|
|
505
|
+
max_field: None,
|
|
506
|
+
percentile_field: None,
|
|
507
|
+
percentile_at: None,
|
|
508
|
+
any_field: None,
|
|
509
|
+
all_field: None,
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#[test]
|
|
514
|
+
fn a_hop_condition_keeps_only_rows_whose_local_and_hopped_conditions_both_hold() {
|
|
515
|
+
let def = open_for_suspended_customers_def();
|
|
516
|
+
let result = run(&store(), &def, &Json::obj(vec![])).expect("a rootless read model with no authorization needs no args");
|
|
517
|
+
|
|
518
|
+
let accounts = result.get("accounts").expect("the one declared head").as_array().expect("a many head answers an array");
|
|
519
|
+
let ids: Vec<&str> = accounts.iter().map(|row| row.get("id").and_then(Json::as_str).expect("row_json always stamps id")).collect();
|
|
520
|
+
|
|
521
|
+
assert_eq!(ids, vec!["acc-suspended-open"]);
|
|
522
|
+
// Named explicitly so a regression that widens either condition fails on the specific
|
|
523
|
+
// wrong row it would admit, not just a count.
|
|
524
|
+
assert!(!ids.contains(&"acc-active-open"), "open but NOT suspended — the hop condition alone should have excluded this");
|
|
525
|
+
assert!(!ids.contains(&"acc-suspended-closed"), "suspended customer but NOT open — the ordinary local condition alone should have excluded this");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#[test]
|
|
529
|
+
fn a_hop_against_an_unscannable_target_aggregate_refuses_cleanly() {
|
|
530
|
+
let mut def = open_for_suspended_customers_def();
|
|
531
|
+
def.filtered_heads = &[FilteredHead {
|
|
532
|
+
as_name: "accounts",
|
|
533
|
+
conditions: &[QueryCondition { field: "status", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("open") }],
|
|
534
|
+
reference_hop_conditions: &[ReferenceHopCondition {
|
|
535
|
+
via_field: "customer",
|
|
536
|
+
target_aggregate: "Banking::NoSuchAggregate",
|
|
537
|
+
through: &[],
|
|
538
|
+
inner_field: "status",
|
|
539
|
+
inner_comparator: query_comparators::QueryComparator::Eq,
|
|
540
|
+
inner_value: QueryConditionValue::Literal("suspended"),
|
|
541
|
+
}],
|
|
542
|
+
order_by: None,
|
|
543
|
+
offset: None,
|
|
544
|
+
limit: None,
|
|
545
|
+
}];
|
|
546
|
+
|
|
547
|
+
let err = run(&store(), &def, &Json::obj(vec![])).expect_err("scanning an aggregate this store doesn't declare must refuse, not silently answer empty");
|
|
548
|
+
assert!(matches!(err, Refusal::TypeMismatch(_)));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// An included nested entity has no table of its own; the head answers an empty array and
|
|
552
|
+
// the sibling head's rows are untouched.
|
|
553
|
+
#[test]
|
|
554
|
+
fn a_head_with_no_table_of_its_own_reads_as_empty_rather_than_refusing() {
|
|
555
|
+
let mut def = open_for_suspended_customers_def();
|
|
556
|
+
def.filtered_heads = &[];
|
|
557
|
+
def.heads = &[
|
|
558
|
+
ReadModelHead { aggregate: "Banking::Account", as_name: "accounts", many: true, is_root: false, reference_fields: &[] },
|
|
559
|
+
ReadModelHead { aggregate: "Banking::LedgerEntry", as_name: "ledger_entries", many: true, is_root: false, reference_fields: &[] },
|
|
560
|
+
];
|
|
561
|
+
|
|
562
|
+
let result = run(&store(), &def, &Json::obj(vec![])).expect("an entity head must not refuse the read model");
|
|
563
|
+
|
|
564
|
+
assert_eq!(result.get("accounts").and_then(|v| v.as_array()).map(|rows| rows.len()), Some(3));
|
|
565
|
+
assert_eq!(result.get("ledger_entries").and_then(|v| v.as_array()).map(|rows| rows.len()), Some(0));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// A declared query folds its hop the same way a read model head does.
|
|
569
|
+
#[test]
|
|
570
|
+
fn a_named_query_folds_its_hop_conditions_like_a_read_model_head() {
|
|
571
|
+
let def = crate::kernel::named_query::QueryDef {
|
|
572
|
+
verb: "Banking::Account.OpenForSuspendedCustomers",
|
|
573
|
+
aggregate: "Banking::Account",
|
|
574
|
+
conditions: &[QueryCondition { field: "status", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("open") }],
|
|
575
|
+
reference_hop_conditions: &[ReferenceHopCondition {
|
|
576
|
+
via_field: "customer",
|
|
577
|
+
target_aggregate: "Banking::Customer",
|
|
578
|
+
through: &[],
|
|
579
|
+
inner_field: "status",
|
|
580
|
+
inner_comparator: query_comparators::QueryComparator::Eq,
|
|
581
|
+
inner_value: QueryConditionValue::Literal("suspended"),
|
|
582
|
+
}],
|
|
583
|
+
order_by: None,
|
|
584
|
+
offset: None,
|
|
585
|
+
limit: None,
|
|
586
|
+
authorization: None,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
let rows = crate::kernel::named_query::run(&store(), &def, &Json::obj(vec![]), None).expect("no authorization, no args needed");
|
|
590
|
+
let ids: Vec<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
|
|
591
|
+
|
|
592
|
+
assert_eq!(ids, vec!["acc-suspended-open"]);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// An entity query flattens every card's withdrawals, ordered by card then sequence, capped.
|
|
596
|
+
#[test]
|
|
597
|
+
fn an_entity_query_flattens_every_owners_list_ordered_by_parent_then_identity() {
|
|
598
|
+
fn withdrawal(sequence: f64, state: &str) -> Json {
|
|
599
|
+
Json::obj(vec![("sequence", Json::obj(vec![("value", Json::Num(sequence, None))])), ("state", Json::str(state))])
|
|
600
|
+
}
|
|
601
|
+
let cards = FakeStore {
|
|
602
|
+
domain: "Banking",
|
|
603
|
+
aggregates: vec![(
|
|
604
|
+
"ATMCard",
|
|
605
|
+
vec![
|
|
606
|
+
("card-2".to_string(), Json::obj(vec![("withdrawals", Json::Array(vec![withdrawal(2.0, "taken"), withdrawal(1.0, "taken")]))])),
|
|
607
|
+
("card-1".to_string(), Json::obj(vec![("withdrawals", Json::Array(vec![withdrawal(1.0, "disputed"), withdrawal(3.0, "taken")]))])),
|
|
608
|
+
],
|
|
609
|
+
)],
|
|
610
|
+
};
|
|
611
|
+
let def = crate::kernel::named_query::EntityQueryDef {
|
|
612
|
+
verb: "Banking::ATMCard.Withdrawal.Recent",
|
|
613
|
+
aggregate: "Banking::ATMCard",
|
|
614
|
+
list_field: "withdrawals",
|
|
615
|
+
parent_key: "atm_card",
|
|
616
|
+
identity_keys: &["sequence"],
|
|
617
|
+
conditions: &[QueryCondition { field: "state", comparator: query_comparators::QueryComparator::Eq, value: QueryConditionValue::Literal("taken") }],
|
|
618
|
+
order_by: None,
|
|
619
|
+
offset: None,
|
|
620
|
+
limit: Some(query_ordering::Limit::Literal(2)),
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
let rows = crate::kernel::named_query::run_entity(&cards, &def, &Json::obj(vec![])).expect("no args needed");
|
|
624
|
+
let keyed: Vec<(String, String)> = rows
|
|
625
|
+
.iter()
|
|
626
|
+
.map(|row| {
|
|
627
|
+
let parent = row.get("atm_card").and_then(Json::as_str).expect("parent key leads every row").to_string();
|
|
628
|
+
let sequence = format!("{:?}", row.dig("sequence.value"));
|
|
629
|
+
(parent, sequence)
|
|
630
|
+
})
|
|
631
|
+
.collect();
|
|
632
|
+
|
|
633
|
+
assert_eq!(keyed.len(), 2, "limit 2 over three taken withdrawals: {keyed:?}");
|
|
634
|
+
assert_eq!(keyed[0].0, "card-1");
|
|
635
|
+
assert_eq!(keyed[1].0, "card-2");
|
|
636
|
+
assert!(keyed[1].1.contains('1'), "card-2's lowest sequence comes first: {keyed:?}");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// A chain: the inner clause picks sponsors, the middle step keeps members sponsored by one,
|
|
640
|
+
// and the head keeps referrals issued by one of those members.
|
|
641
|
+
#[test]
|
|
642
|
+
fn a_hop_chain_folds_inside_out_through_every_step() {
|
|
643
|
+
let chain_store = FakeStore {
|
|
644
|
+
domain: "ReferralChain",
|
|
645
|
+
aggregates: vec![
|
|
646
|
+
(
|
|
647
|
+
"Sponsor",
|
|
648
|
+
vec![
|
|
649
|
+
("s-good".to_string(), Json::obj(vec![("standing", Json::str("good"))])),
|
|
650
|
+
("s-poor".to_string(), Json::obj(vec![("standing", Json::str("poor"))])),
|
|
651
|
+
],
|
|
652
|
+
),
|
|
653
|
+
(
|
|
654
|
+
"Member",
|
|
655
|
+
vec![
|
|
656
|
+
("m-good".to_string(), Json::obj(vec![("sponsor", Json::str("s-good"))])),
|
|
657
|
+
("m-poor".to_string(), Json::obj(vec![("sponsor", Json::str("s-poor"))])),
|
|
658
|
+
],
|
|
659
|
+
),
|
|
660
|
+
(
|
|
661
|
+
"Referral",
|
|
662
|
+
vec![
|
|
663
|
+
("r-good".to_string(), Json::obj(vec![("member", Json::str("m-good"))])),
|
|
664
|
+
("r-poor".to_string(), Json::obj(vec![("member", Json::str("m-poor"))])),
|
|
665
|
+
],
|
|
666
|
+
),
|
|
667
|
+
],
|
|
668
|
+
};
|
|
669
|
+
let def = crate::kernel::named_query::QueryDef {
|
|
670
|
+
verb: "ReferralChain::Referral.FromGoodSponsors",
|
|
671
|
+
aggregate: "ReferralChain::Referral",
|
|
672
|
+
conditions: &[],
|
|
673
|
+
reference_hop_conditions: &[ReferenceHopCondition {
|
|
674
|
+
via_field: "member",
|
|
675
|
+
target_aggregate: "ReferralChain::Member",
|
|
676
|
+
through: &[HopStep { via_field: "sponsor", target_aggregate: "ReferralChain::Sponsor" }],
|
|
677
|
+
inner_field: "standing",
|
|
678
|
+
inner_comparator: query_comparators::QueryComparator::Eq,
|
|
679
|
+
inner_value: QueryConditionValue::Literal("good"),
|
|
680
|
+
}],
|
|
681
|
+
order_by: None,
|
|
682
|
+
offset: None,
|
|
683
|
+
limit: None,
|
|
684
|
+
authorization: None,
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
let rows = crate::kernel::named_query::run(&chain_store, &def, &Json::obj(vec![]), None).expect("no authorization, no args needed");
|
|
688
|
+
let ids: Vec<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
|
|
689
|
+
|
|
690
|
+
assert_eq!(ids, vec!["r-good"]);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/// The generic read-model interpreter — the single projected row, one entry per declared
|
|
695
|
+
/// head's `as_name`, for exactly the subset this module admits.
|
|
696
|
+
///
|
|
697
|
+
/// Heads compute root-first regardless of declared `include` order: a non-root head matches
|
|
698
|
+
/// against whatever is already computed, so processing it before its own root would always
|
|
699
|
+
/// compare against nothing.
|
|
700
|
+
pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Result<Json, Refusal> {
|
|
701
|
+
let reference_id = match def.reference_name {
|
|
702
|
+
Some(name) => Some(
|
|
703
|
+
args.get(name)
|
|
704
|
+
.and_then(Json::as_str)
|
|
705
|
+
.ok_or_else(|| Refusal::TypeMismatch(format!("{}: missing reference argument {name:?}", def.verb)))?
|
|
706
|
+
.to_string(),
|
|
707
|
+
),
|
|
708
|
+
// Rootless: no caller-supplied id to require or resolve at all.
|
|
709
|
+
None => None,
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// Checked right after the reference id resolves, before any head is computed.
|
|
713
|
+
if let Some(auth) = &def.authorization {
|
|
714
|
+
if args.get(auth.tenant_field).is_none() {
|
|
715
|
+
return Err(Refusal::Unauthorized(
|
|
716
|
+
UnauthorizedTenantRequiredArgs { query: auth.query_name, field: auth.tenant_field }.render_args(),
|
|
717
|
+
));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
let (root_heads, other_heads): (Vec<&ReadModelHead>, Vec<&ReadModelHead>) = def.heads.iter().partition(|head| head.is_root);
|
|
722
|
+
|
|
723
|
+
// One entry per head already computed, in computation order (root-first): every candidate
|
|
724
|
+
// record is checked against every entry seen so far, not just the immediately-preceding one.
|
|
725
|
+
let mut projected: Vec<(&'static str, Vec<(String, Json)>)> = Vec::new();
|
|
726
|
+
let mut rows_by_as: std::collections::HashMap<&'static str, (bool, Vec<(String, Json)>)> = std::collections::HashMap::new();
|
|
727
|
+
let mut grouped_heads: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
|
|
728
|
+
|
|
729
|
+
for head in root_heads.into_iter().chain(other_heads) {
|
|
730
|
+
let mut rows = if reference_id.is_none() {
|
|
731
|
+
// Rootless: each head reads its own aggregate's whole table independently, with no
|
|
732
|
+
// cross-referencing against `projected`. A head with no table reads as empty.
|
|
733
|
+
store.scan(head.aggregate).unwrap_or_default()
|
|
734
|
+
} else if head.is_root {
|
|
735
|
+
vec![fetch_root(store, head, reference_id.as_deref().unwrap())?]
|
|
736
|
+
} else {
|
|
737
|
+
scan_matching(store, head, &projected)?
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// Applied before this head's rows go into `projected`, so any later head's own
|
|
741
|
+
// reference-matching sees the filtered rows, not the pre-filter scan.
|
|
742
|
+
if let Some(filtered) = def.filtered_heads.iter().find(|fh| fh.as_name == head.as_name) {
|
|
743
|
+
rows = apply_filtered_head_options(rows, filtered, args, store)?;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Recorded so the output loop below knows not to wrap these rows again — the generated
|
|
747
|
+
// transform already does its own row_json-equivalent wrapping internally.
|
|
748
|
+
if def.group_by.is_some() && head.many {
|
|
749
|
+
grouped_heads.insert(head.as_name);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
projected.push((head.aggregate, rows.clone()));
|
|
753
|
+
rows_by_as.insert(head.as_name, (head.many, rows));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Declared order, for the output only — `def.heads` itself, not the root-first order
|
|
757
|
+
// `projected`/`rows_by_as` were just built in. `repository::row_json` wraps every record
|
|
758
|
+
// here, at every level of nesting, since this kernel's generated `to_json()` doesn't carry
|
|
759
|
+
// `id` the way a live Ruby record's `to_h` does.
|
|
760
|
+
let mut out = Vec::with_capacity(def.heads.len());
|
|
761
|
+
for head in def.heads {
|
|
762
|
+
let (many, rows) = rows_by_as
|
|
763
|
+
.remove(head.as_name)
|
|
764
|
+
.expect("every head in def.heads was computed in the loop above — as_name is unique per read model (ReadModelBuilder#add_aggregate_head)");
|
|
765
|
+
let value = if grouped_heads.contains(head.as_name) {
|
|
766
|
+
// Already fully formed by the generated transform — must not be wrapped again the
|
|
767
|
+
// way an ordinary head's rows are.
|
|
768
|
+
(def.group_by.expect("grouped_heads is only ever populated when def.group_by is Some"))(rows)?
|
|
769
|
+
} else if many && def.count {
|
|
770
|
+
Json::Num(rows.len() as f64, None)
|
|
771
|
+
} else if many && def.median_field.is_some() {
|
|
772
|
+
median(&rows, def.median_field.expect("checked by the branch guard"))
|
|
773
|
+
} else if many && def.sum_field.is_some() {
|
|
774
|
+
sum(&rows, def.sum_field.expect("checked by the branch guard"))
|
|
775
|
+
} else if many && def.avg_field.is_some() {
|
|
776
|
+
avg(&rows, def.avg_field.expect("checked by the branch guard"))
|
|
777
|
+
} else if many && def.min_field.is_some() {
|
|
778
|
+
extreme(&rows, def.min_field.expect("checked by the branch guard"), false)
|
|
779
|
+
} else if many && def.max_field.is_some() {
|
|
780
|
+
extreme(&rows, def.max_field.expect("checked by the branch guard"), true)
|
|
781
|
+
} else if many && def.percentile_field.is_some() {
|
|
782
|
+
percentile(
|
|
783
|
+
&rows,
|
|
784
|
+
def.percentile_field.expect("checked by the branch guard"),
|
|
785
|
+
def.percentile_at.expect("percentile_field implies percentile_at"),
|
|
786
|
+
)
|
|
787
|
+
} else if many && def.any_field.is_some() {
|
|
788
|
+
boolean_reduce(&rows, def.any_field.expect("checked by the branch guard"), false)
|
|
789
|
+
} else if many && def.all_field.is_some() {
|
|
790
|
+
boolean_reduce(&rows, def.all_field.expect("checked by the branch guard"), true)
|
|
791
|
+
} else if many {
|
|
792
|
+
Json::Array(rows.into_iter().map(|(id, record)| repository::row_json(id, record)).collect())
|
|
793
|
+
} else {
|
|
794
|
+
rows.into_iter().next().map(|(id, record)| repository::row_json(id, record)).unwrap_or(Json::Null)
|
|
795
|
+
};
|
|
796
|
+
out.push((head.as_name.to_string(), value));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
Ok(Json::Object(out))
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/// The root head's own single row: find the one instance by id, `NotFound` if it isn't there.
|
|
803
|
+
/// Unlike every other head, the root is looked up directly rather than scanned-and-matched.
|
|
804
|
+
fn fetch_root(store: &impl AggregateScan, head: &ReadModelHead, reference_id: &str) -> Result<(String, Json), Refusal> {
|
|
805
|
+
let entries = store
|
|
806
|
+
.scan(head.aggregate)
|
|
807
|
+
.ok_or_else(|| Refusal::TypeMismatch(format!("unknown aggregate {:?}", head.aggregate)))?;
|
|
808
|
+
|
|
809
|
+
entries
|
|
810
|
+
.into_iter()
|
|
811
|
+
.find(|(id, _)| id == reference_id)
|
|
812
|
+
.ok_or_else(|| {
|
|
813
|
+
// Named by its bare bluebook name; `head.aggregate` is the domain-qualified one.
|
|
814
|
+
let bare = head.aggregate.rsplit("::").next().unwrap_or(head.aggregate);
|
|
815
|
+
Refusal::NotFound(
|
|
816
|
+
NotFoundReadModelReferenceMissingArgs { aggregate: bare, offered: &format!("{reference_id:?}") }
|
|
817
|
+
.render_args(),
|
|
818
|
+
)
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/// A non-root head's own rows: every instance of this head's aggregate whose reference field(s)
|
|
823
|
+
/// point back at a row already in `projected`, sorted by id ascending.
|
|
824
|
+
fn scan_matching(
|
|
825
|
+
store: &impl AggregateScan,
|
|
826
|
+
head: &ReadModelHead,
|
|
827
|
+
projected: &[(&'static str, Vec<(String, Json)>)],
|
|
828
|
+
) -> Result<Vec<(String, Json)>, Refusal> {
|
|
829
|
+
// The generator emits a head with no table only when it names a nested entity; that reads
|
|
830
|
+
// as empty here, while the root still refuses in `fetch_root`.
|
|
831
|
+
let Some(entries) = store.scan(head.aggregate) else {
|
|
832
|
+
return Ok(Vec::new());
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
let mut matched: Vec<(String, Json)> = entries.into_iter().filter(|(_, record)| record_matches(record, head, projected)).collect();
|
|
836
|
+
matched.sort_by(|a, b| a.0.cmp(&b.0));
|
|
837
|
+
Ok(matched)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/// Whether `record` holds one of its precomputed `reference_fields` pointing at a row already
|
|
841
|
+
/// in `projected`.
|
|
842
|
+
fn record_matches(record: &Json, head: &ReadModelHead, projected: &[(&'static str, Vec<(String, Json)>)]) -> bool {
|
|
843
|
+
head.reference_fields.iter().any(|reference_field| {
|
|
844
|
+
let Some(held_id) = record.get(reference_field.field).and_then(Json::as_str) else { return false };
|
|
845
|
+
projected
|
|
846
|
+
.iter()
|
|
847
|
+
.any(|(aggregate, rows)| *aggregate == reference_field.target_aggregate && rows.iter().any(|(id, _)| id == held_id))
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/// One targeted head's own where/order_by/offset/limit. Where-filtering chains
|
|
852
|
+
/// `repository::filter_entries` per condition; order/limit reuse `query_ordering::apply`
|
|
853
|
+
/// rather than reimplementing the identity-sort/declared-order/limit logic a declared
|
|
854
|
+
/// aggregate query already needs.
|
|
855
|
+
fn apply_filtered_head_options(
|
|
856
|
+
mut rows: Vec<(String, Json)>,
|
|
857
|
+
filtered: &FilteredHead,
|
|
858
|
+
args: &Json,
|
|
859
|
+
store: &impl AggregateScan,
|
|
860
|
+
) -> Result<Vec<(String, Json)>, Refusal> {
|
|
861
|
+
for condition in filtered.conditions {
|
|
862
|
+
let want = match condition.value {
|
|
863
|
+
QueryConditionValue::Literal(text) => Json::Str(text.to_string()),
|
|
864
|
+
QueryConditionValue::NumericLiteral(n) => Json::Num(n, None),
|
|
865
|
+
QueryConditionValue::Arg(name) => args.get(name).cloned().unwrap_or(Json::Null),
|
|
866
|
+
};
|
|
867
|
+
rows = repository::filter_entries(rows, condition.field, condition.comparator, &want);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
rows = apply_reference_hops(rows, filtered.reference_hop_conditions, args, store)?;
|
|
871
|
+
|
|
872
|
+
Ok(query_ordering::apply(rows, filtered.order_by.as_ref(), filtered.offset.as_ref(), filtered.limit.as_ref(), args))
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/// Folds a `ReferenceHopCondition` chain: one query against the chain's final target picks
|
|
876
|
+
/// matching ids, then each earlier step's `in` filter narrows toward these rows. Shared by a
|
|
877
|
+
/// read model's eligible head and a declared query (`named_query::run_cross_domain`).
|
|
878
|
+
pub(super) fn apply_reference_hops(
|
|
879
|
+
mut rows: Vec<(String, Json)>,
|
|
880
|
+
hops: &[ReferenceHopCondition],
|
|
881
|
+
args: &Json,
|
|
882
|
+
store: &impl AggregateScan,
|
|
883
|
+
) -> Result<Vec<(String, Json)>, Refusal> {
|
|
884
|
+
for hop in hops {
|
|
885
|
+
let inner_want = match hop.inner_value {
|
|
886
|
+
QueryConditionValue::Literal(text) => Json::Str(text.to_string()),
|
|
887
|
+
QueryConditionValue::NumericLiteral(n) => Json::Num(n, None),
|
|
888
|
+
QueryConditionValue::Arg(name) => args.get(name).cloned().unwrap_or(Json::Null),
|
|
889
|
+
};
|
|
890
|
+
let scan = |aggregate: &str| {
|
|
891
|
+
store.scan(aggregate).ok_or_else(|| Refusal::TypeMismatch(format!("unknown aggregate {aggregate:?}")))
|
|
892
|
+
};
|
|
893
|
+
let ids_of = |entries: Vec<(String, Json)>| -> Vec<Json> { entries.into_iter().map(|(id, _)| Json::Str(id)).collect() };
|
|
894
|
+
|
|
895
|
+
// The chain, first hop included: `(via_field, target_aggregate)` pairs, each via_field a
|
|
896
|
+
// reference on the previous target.
|
|
897
|
+
let chain: Vec<(&str, &str)> = std::iter::once((hop.via_field, hop.target_aggregate))
|
|
898
|
+
.chain(hop.through.iter().map(|step| (step.via_field, step.target_aggregate)))
|
|
899
|
+
.collect();
|
|
900
|
+
|
|
901
|
+
// Inside out: the inner clause picks ids on the last target, and each earlier step keeps
|
|
902
|
+
// the rows of its target whose next via_field points at one of them.
|
|
903
|
+
let (_, last_target) = chain[chain.len() - 1];
|
|
904
|
+
let mut ids = ids_of(repository::filter_entries(scan(last_target)?, hop.inner_field, hop.inner_comparator, &inner_want));
|
|
905
|
+
for step in (1..chain.len()).rev() {
|
|
906
|
+
let (via_field, _) = chain[step];
|
|
907
|
+
let (_, source) = chain[step - 1];
|
|
908
|
+
ids = ids_of(repository::filter_entries(scan(source)?, via_field, query_comparators::QueryComparator::In, &Json::Array(ids)));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
rows = repository::filter_entries(rows, hop.via_field, query_comparators::QueryComparator::In, &Json::Array(ids));
|
|
912
|
+
}
|
|
913
|
+
Ok(rows)
|
|
914
|
+
}
|