hecks 3.14.0 → 5.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/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/exe/hecks +1 -1
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +9 -16
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +2 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/cli/project_cli/launcher_source.rb +1 -1
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/app/responses.rb +3 -1
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/first_admin.rb +3 -3
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +3 -80
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +7 -13
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +7 -2
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value/unwrapping.rb +60 -0
- data/lib/hecks/runtime/value.rb +10 -21
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal} +0 -0
- data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal} +0 -0
- data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json} +1 -1
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +306 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +151 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/presenters.rs +335 -0
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +574 -229
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +1037 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +39 -7
- data/rust/host/src/payments/tests.rs +1427 -282
- data/rust/host/src/payments.rs +391 -112
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +555 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -1
- data/rust/parser/src/build/named_pattern.rs +4 -1
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -516
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +22 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +244 -114
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +244 -148
- data/rust/src/kernel/mod.rs +47 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/present.rs +261 -0
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +85 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/rust/host/src/approval.rs +0 -652
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
//! Interprets a compiled `report` block (`ReadModelDef`) against a domain's generated
|
|
2
2
|
//! `READ_MODELS` table — the hand-written counterpart to the retired Ruby generator's `read_models.rb`.
|
|
3
3
|
|
|
4
|
-
use super::refusal_wording::{
|
|
5
|
-
|
|
4
|
+
use super::refusal_wording::{
|
|
5
|
+
InvariantViolationGroupByCollisionArgs, NotFoundReadModelReferenceMissingArgs,
|
|
6
|
+
UnauthorizedTenantRequiredArgs,
|
|
7
|
+
};
|
|
8
|
+
use super::{
|
|
9
|
+
named_query, query_comparators, query_ordering, repository, AggregateScan, Json,
|
|
10
|
+
QueryCondition, QueryConditionValue, Refusal,
|
|
11
|
+
};
|
|
6
12
|
|
|
7
13
|
/// A `where` clause that crosses one or more references on the eligible head
|
|
8
14
|
/// (`account/status`, or a longer `member/sponsor/standing` chain via `through`).
|
|
@@ -56,6 +62,9 @@ pub type ReadModelLimit = query_ordering::Limit;
|
|
|
56
62
|
/// A read model's declared `offset N`, applying to `ReadModelDef::filtered_head` alone.
|
|
57
63
|
pub type ReadModelOffset = query_ordering::Offset;
|
|
58
64
|
|
|
65
|
+
/// A per-read-model generated function that groups the rows of the one `many`-side head.
|
|
66
|
+
pub type GroupByFn = fn(Vec<(String, Json)>) -> Result<Json, Refusal>;
|
|
67
|
+
|
|
59
68
|
/// One many-side head's own where/order_by/offset/limit. ADR 0055's `on:` lets a read model
|
|
60
69
|
/// with several many-side heads filter/order/page each independently; a read model with exactly
|
|
61
70
|
/// one many-side head and untargeted options still compiles to a single entry here, unchanged
|
|
@@ -99,28 +108,48 @@ pub struct ReadModelDef {
|
|
|
99
108
|
/// A per-read-model generated function rather than data here, because unwrapping a
|
|
100
109
|
/// single-attribute value object needs codegen-time type knowledge `run`'s generic body,
|
|
101
110
|
/// holding already-serialized `Json`, doesn't have.
|
|
102
|
-
pub group_by: Option<
|
|
103
|
-
///
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
/// `
|
|
111
|
-
|
|
112
|
-
/// `
|
|
113
|
-
|
|
114
|
-
/// `
|
|
115
|
-
|
|
116
|
-
/// `
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
/// `
|
|
121
|
-
|
|
122
|
-
/// `
|
|
123
|
-
|
|
111
|
+
pub group_by: Option<GroupByFn>,
|
|
112
|
+
/// What the one `many`-side head is reduced to, in place of its rows (ADR 0078).
|
|
113
|
+
pub reduction: Option<Reduction>,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// One reduction a read model declares over its `many`-side head's rows.
|
|
117
|
+
#[derive(Debug, Clone, Copy)]
|
|
118
|
+
pub enum Reduction {
|
|
119
|
+
/// `count` — a bare marker; row-count needs no per-read-model generated function.
|
|
120
|
+
Count,
|
|
121
|
+
/// `median :field` — the declared numeric field's median.
|
|
122
|
+
Median(&'static str),
|
|
123
|
+
/// `sum :field` — the declared Integer field's total.
|
|
124
|
+
Sum(&'static str),
|
|
125
|
+
/// `avg :field` — the declared Integer field's mean.
|
|
126
|
+
Avg(&'static str),
|
|
127
|
+
/// `min :field` — the declared numeric field's smallest value.
|
|
128
|
+
Min(&'static str),
|
|
129
|
+
/// `max :field` — the declared numeric field's largest value.
|
|
130
|
+
Max(&'static str),
|
|
131
|
+
/// `percentile :field, at:` — the declared numeric field's value at one interpolated rank;
|
|
132
|
+
/// `median` is the fixed `at: 0.5` case.
|
|
133
|
+
Percentile { field: &'static str, at: f64 },
|
|
134
|
+
/// `any :field` — whether any row's declared boolean field is `true`.
|
|
135
|
+
Any(&'static str),
|
|
136
|
+
/// `all :field` — whether every row's declared boolean field is `true`.
|
|
137
|
+
All(&'static str),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// The head's rows reduced as declared.
|
|
141
|
+
fn reduce(rows: &[(String, Json)], reduction: Reduction) -> Json {
|
|
142
|
+
match reduction {
|
|
143
|
+
Reduction::Count => Json::Num(rows.len() as f64, None),
|
|
144
|
+
Reduction::Median(field) => median(rows, field),
|
|
145
|
+
Reduction::Sum(field) => sum(rows, field),
|
|
146
|
+
Reduction::Avg(field) => avg(rows, field),
|
|
147
|
+
Reduction::Min(field) => extreme(rows, field, false),
|
|
148
|
+
Reduction::Max(field) => extreme(rows, field, true),
|
|
149
|
+
Reduction::Percentile { field, at } => percentile(rows, field, at),
|
|
150
|
+
Reduction::Any(field) => boolean_reduce(rows, field, false),
|
|
151
|
+
Reduction::All(field) => boolean_reduce(rows, field, true),
|
|
152
|
+
}
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
/// Whether a `group_by` leaf is checked for a second row — decided once, by the generator, from
|
|
@@ -145,14 +174,29 @@ pub fn nest(rows: Vec<Json>, fields: &[&str], check: LeafCheck) -> Result<Json,
|
|
|
145
174
|
/// One level of `nest`. `all_fields` is the whole declared `group_by`
|
|
146
175
|
/// (the refusal names it); `reached` is the key path walked so far, one
|
|
147
176
|
/// `(field, key)` per level above this one.
|
|
148
|
-
fn nest_level(
|
|
149
|
-
|
|
177
|
+
fn nest_level(
|
|
178
|
+
rows: Vec<Json>,
|
|
179
|
+
all_fields: &[&str],
|
|
180
|
+
fields: &[&str],
|
|
181
|
+
check: LeafCheck,
|
|
182
|
+
reached: &[(&str, Json)],
|
|
183
|
+
) -> Result<Json, Refusal> {
|
|
184
|
+
let Some((field, rest)) = fields.split_first() else {
|
|
185
|
+
return Err(Refusal::Fault(
|
|
186
|
+
"nest called with empty fields — read_model_skip_reason should refuse a group_by with none"
|
|
187
|
+
.to_string(),
|
|
188
|
+
));
|
|
189
|
+
};
|
|
150
190
|
let mut groups: Vec<(Json, Vec<Json>)> = Vec::new();
|
|
151
191
|
for row in rows {
|
|
152
192
|
let (key, stripped) = match row {
|
|
153
193
|
Json::Object(pairs) => {
|
|
154
|
-
let key = pairs
|
|
155
|
-
|
|
194
|
+
let key = pairs
|
|
195
|
+
.iter()
|
|
196
|
+
.find(|(k, _)| k == field)
|
|
197
|
+
.map_or(Json::Null, |(_, v)| v.clone());
|
|
198
|
+
let stripped =
|
|
199
|
+
Json::Object(pairs.into_iter().filter(|(k, _)| k != field).collect());
|
|
156
200
|
(key, stripped)
|
|
157
201
|
}
|
|
158
202
|
other => (Json::Null, other),
|
|
@@ -166,7 +210,11 @@ fn nest_level(rows: Vec<Json>, all_fields: &[&str], fields: &[&str], check: Leaf
|
|
|
166
210
|
for (key, group) in groups {
|
|
167
211
|
let mut path = reached.to_vec();
|
|
168
212
|
path.push((field, key.clone()));
|
|
169
|
-
let value = if rest.is_empty() {
|
|
213
|
+
let value = if rest.is_empty() {
|
|
214
|
+
leaf(group, all_fields, check, &path)?
|
|
215
|
+
} else {
|
|
216
|
+
nest_level(group, all_fields, rest, check, &path)?
|
|
217
|
+
};
|
|
170
218
|
// A non-String key must stringify the same way Ruby's `Hash#[]=` does when used as a
|
|
171
219
|
// JSON object key.
|
|
172
220
|
out.push((super::query_comparators::to_s(&key), value));
|
|
@@ -176,16 +224,38 @@ fn nest_level(rows: Vec<Json>, all_fields: &[&str], fields: &[&str], check: Leaf
|
|
|
176
224
|
|
|
177
225
|
/// The one row a `group_by` key path reached, or the `group_by_collision` refusal when a
|
|
178
226
|
/// checked path was reached by more than one.
|
|
179
|
-
fn leaf(
|
|
227
|
+
fn leaf(
|
|
228
|
+
group: Vec<Json>,
|
|
229
|
+
all_fields: &[&str],
|
|
230
|
+
check: LeafCheck,
|
|
231
|
+
path: &[(&str, Json)],
|
|
232
|
+
) -> Result<Json, Refusal> {
|
|
180
233
|
let read_model = match check {
|
|
181
234
|
LeafCheck::RefuseCollision(read_model) if group.len() > 1 => read_model,
|
|
182
235
|
_ => return Ok(group.into_iter().next().unwrap_or(Json::Null)),
|
|
183
236
|
};
|
|
184
|
-
let ids: Vec<String> = group
|
|
237
|
+
let ids: Vec<String> = group
|
|
238
|
+
.iter()
|
|
239
|
+
.map(|row| {
|
|
240
|
+
row.get("id")
|
|
241
|
+
.map(super::query_comparators::to_s)
|
|
242
|
+
.unwrap_or_default()
|
|
243
|
+
})
|
|
244
|
+
.collect();
|
|
185
245
|
let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
|
|
186
|
-
let key = path
|
|
246
|
+
let key = path
|
|
247
|
+
.iter()
|
|
248
|
+
.map(|(field, value)| format!("{field} = {}", super::query_comparators::to_s(value)))
|
|
249
|
+
.collect::<Vec<_>>()
|
|
250
|
+
.join(", ");
|
|
187
251
|
Err(Refusal::InvariantViolation(
|
|
188
|
-
InvariantViolationGroupByCollisionArgs {
|
|
252
|
+
InvariantViolationGroupByCollisionArgs {
|
|
253
|
+
read_model,
|
|
254
|
+
fields: all_fields,
|
|
255
|
+
ids: &id_refs,
|
|
256
|
+
key: &key,
|
|
257
|
+
}
|
|
258
|
+
.render_args(),
|
|
189
259
|
))
|
|
190
260
|
}
|
|
191
261
|
|
|
@@ -205,7 +275,11 @@ fn percentile(rows: &[(String, Json)], field: &'static str, at: f64) -> Json {
|
|
|
205
275
|
if values.is_empty() {
|
|
206
276
|
return Json::Null;
|
|
207
277
|
}
|
|
208
|
-
values.sort_by(|a, b|
|
|
278
|
+
values.sort_by(|a, b| {
|
|
279
|
+
query_comparators::as_f64(a)
|
|
280
|
+
.partial_cmp(&query_comparators::as_f64(b))
|
|
281
|
+
.unwrap_or(std::cmp::Ordering::Equal)
|
|
282
|
+
});
|
|
209
283
|
|
|
210
284
|
let position = at * (values.len() - 1) as f64;
|
|
211
285
|
let lower = position.floor() as usize;
|
|
@@ -266,11 +340,18 @@ fn extreme(rows: &[(String, Json)], field: &'static str, want_max: bool) -> Json
|
|
|
266
340
|
.map(query_comparators::comparable)
|
|
267
341
|
.filter(|value| !matches!(value, Json::Null))
|
|
268
342
|
.collect();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
343
|
+
values.sort_by(|a, b| {
|
|
344
|
+
query_comparators::as_f64(a)
|
|
345
|
+
.partial_cmp(&query_comparators::as_f64(b))
|
|
346
|
+
.unwrap_or(std::cmp::Ordering::Equal)
|
|
347
|
+
});
|
|
348
|
+
let mut sorted = values.into_iter();
|
|
349
|
+
let picked = if want_max {
|
|
350
|
+
sorted.next_back()
|
|
351
|
+
} else {
|
|
352
|
+
sorted.next()
|
|
353
|
+
};
|
|
354
|
+
picked.unwrap_or(Json::Null)
|
|
274
355
|
}
|
|
275
356
|
|
|
276
357
|
/// Whether any (`want_all: false`) or every (`true`) row's declared boolean field is `true`
|
|
@@ -280,10 +361,14 @@ fn boolean_reduce(rows: &[(String, Json)], field: &'static str, want_all: bool)
|
|
|
280
361
|
let values: Vec<bool> = rows
|
|
281
362
|
.iter()
|
|
282
363
|
.filter_map(|(_, row)| row.get(field))
|
|
283
|
-
.map(
|
|
364
|
+
.map(query_comparators::comparable)
|
|
284
365
|
.filter_map(|value| value.as_bool())
|
|
285
366
|
.collect();
|
|
286
|
-
Json::Bool(if want_all {
|
|
367
|
+
Json::Bool(if want_all {
|
|
368
|
+
values.iter().all(|v| *v)
|
|
369
|
+
} else {
|
|
370
|
+
values.iter().any(|v| *v)
|
|
371
|
+
})
|
|
287
372
|
}
|
|
288
373
|
|
|
289
374
|
/// The lookup a read-model ask (a bare "Domain.Name" string, no "::") dispatches through — a
|
|
@@ -291,21 +376,29 @@ fn boolean_reduce(rows: &[(String, Json)], field: &'static str, want_all: bool)
|
|
|
291
376
|
/// spelling or its snake_case alias (`matches_snake_alias`, below), since a real corpus caller
|
|
292
377
|
/// uses the snake_case form for this read model's own query step.
|
|
293
378
|
pub fn find<'a>(table: &'a [ReadModelDef], verb: &str) -> Option<&'a ReadModelDef> {
|
|
294
|
-
table
|
|
379
|
+
table
|
|
380
|
+
.iter()
|
|
381
|
+
.find(|def| def.verb == verb || matches_snake_alias(def.verb, verb))
|
|
295
382
|
}
|
|
296
383
|
|
|
297
384
|
/// True when `declared_verb` and `asked` name the same domain and `asked`'s name half equals
|
|
298
385
|
/// `declared_verb`'s name half run through `to_snake`.
|
|
299
386
|
fn matches_snake_alias(declared_verb: &str, asked: &str) -> bool {
|
|
300
|
-
let Some((domain, name)) = declared_verb.split_once('.') else {
|
|
301
|
-
|
|
387
|
+
let Some((domain, name)) = declared_verb.split_once('.') else {
|
|
388
|
+
return false;
|
|
389
|
+
};
|
|
390
|
+
let Some((asked_domain, asked_name)) = asked.split_once('.') else {
|
|
391
|
+
return false;
|
|
392
|
+
};
|
|
302
393
|
|
|
303
394
|
domain == asked_domain && to_snake(name) == asked_name
|
|
304
395
|
}
|
|
305
396
|
|
|
306
397
|
/// A port of `Hecks::Naming.snake` (lib/hecks/naming.rb). No regex engine in this
|
|
307
398
|
/// dependency-free kernel, so the two `gsub` passes collapse into one left-to-right scan of
|
|
308
|
-
/// adjacent-character pairs
|
|
399
|
+
/// adjacent-character pairs. The kernel stays self-contained on purpose: it is compiled into every
|
|
400
|
+
/// domain's wasm module from a workspace that holds only this crate, so it cannot include a file
|
|
401
|
+
/// from `rust/codegen` the way the host, parser and build crates share `hecks_naming.rs`.
|
|
309
402
|
fn to_snake(name: &str) -> String {
|
|
310
403
|
let chars: Vec<char> = name.chars().collect();
|
|
311
404
|
let mut out = String::with_capacity(name.len() + 4);
|
|
@@ -314,9 +407,12 @@ fn to_snake(name: &str) -> String {
|
|
|
314
407
|
if i > 0 {
|
|
315
408
|
let prev = chars[i - 1];
|
|
316
409
|
// "HTTPServer" splits before the "S" of "Server", not before the "H" of "HTTP".
|
|
317
|
-
let rule1 = prev.is_ascii_uppercase()
|
|
410
|
+
let rule1 = prev.is_ascii_uppercase()
|
|
411
|
+
&& c.is_ascii_uppercase()
|
|
412
|
+
&& chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
|
|
318
413
|
// "customerPortfolio" splits before "P".
|
|
319
|
-
let rule2 =
|
|
414
|
+
let rule2 =
|
|
415
|
+
(prev.is_ascii_lowercase() || prev.is_ascii_digit()) && c.is_ascii_uppercase();
|
|
320
416
|
if rule1 || rule2 {
|
|
321
417
|
out.push('_');
|
|
322
418
|
}
|
|
@@ -324,8 +420,9 @@ fn to_snake(name: &str) -> String {
|
|
|
324
420
|
out.push(c);
|
|
325
421
|
}
|
|
326
422
|
|
|
327
|
-
|
|
328
|
-
|
|
423
|
+
// Ruby's `downcase` is Unicode, one character at a time (a word-final capital sigma stays a
|
|
424
|
+
// plain sigma); only the word boundaries above are ASCII-only, as in Ruby's patterns.
|
|
425
|
+
out.chars().flat_map(char::to_lowercase).collect()
|
|
329
426
|
}
|
|
330
427
|
|
|
331
428
|
#[cfg(test)]
|
|
@@ -341,6 +438,9 @@ mod snake_alias_tests {
|
|
|
341
438
|
assert_eq!(to_snake("ByFee"), "by_fee");
|
|
342
439
|
assert_eq!(to_snake("HTTPServer"), "http_server");
|
|
343
440
|
assert_eq!(to_snake("Type2Foo"), "type2_foo");
|
|
441
|
+
// Ruby lowercases every Unicode capital, though only ASCII ones start a word boundary.
|
|
442
|
+
assert_eq!(to_snake("ÉcoleDeParis"), "école_de_paris");
|
|
443
|
+
assert_eq!(to_snake("ΟΔΥΣΣΕΑΣ"), "οδυσσεασ");
|
|
344
444
|
}
|
|
345
445
|
|
|
346
446
|
#[test]
|
|
@@ -352,16 +452,7 @@ mod snake_alias_tests {
|
|
|
352
452
|
filtered_heads: &[],
|
|
353
453
|
authorization: None,
|
|
354
454
|
group_by: None,
|
|
355
|
-
|
|
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,
|
|
455
|
+
reduction: None,
|
|
365
456
|
}];
|
|
366
457
|
|
|
367
458
|
assert!(find(&table, "Banking.CustomerPortfolio").is_some());
|
|
@@ -384,7 +475,8 @@ mod nest_tests {
|
|
|
384
475
|
fn refuses_two_rows_on_one_checked_key_path() {
|
|
385
476
|
let rows = vec![part("p2", "b1"), part("p3", "b2"), part("p1", "b1")];
|
|
386
477
|
|
|
387
|
-
let err = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin"))
|
|
478
|
+
let err = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin"))
|
|
479
|
+
.expect_err("p1 and p2 share bin b1");
|
|
388
480
|
|
|
389
481
|
assert_eq!(
|
|
390
482
|
err,
|
|
@@ -399,16 +491,34 @@ mod nest_tests {
|
|
|
399
491
|
fn answers_when_every_key_path_holds_one_row() {
|
|
400
492
|
let rows = vec![part("p1", "b1"), part("p3", "b2")];
|
|
401
493
|
|
|
402
|
-
let nested = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin"))
|
|
494
|
+
let nested = nest(rows, &["bin"], LeafCheck::RefuseCollision("PartsByBin"))
|
|
495
|
+
.expect("no two parts share a bin");
|
|
403
496
|
|
|
404
|
-
assert_eq!(
|
|
497
|
+
assert_eq!(
|
|
498
|
+
nested,
|
|
499
|
+
Json::obj(vec![
|
|
500
|
+
("b1", Json::obj(vec![("id", Json::str("p1"))])),
|
|
501
|
+
("b2", Json::obj(vec![("id", Json::str("p3"))]))
|
|
502
|
+
])
|
|
503
|
+
);
|
|
405
504
|
}
|
|
406
505
|
|
|
407
506
|
#[test]
|
|
408
507
|
fn names_every_level_of_a_multi_field_key_path() {
|
|
409
|
-
let row = |id: &str|
|
|
508
|
+
let row = |id: &str| {
|
|
509
|
+
Json::obj(vec![
|
|
510
|
+
("bin", Json::str("b1")),
|
|
511
|
+
("shelf", Json::Num(2.0, None)),
|
|
512
|
+
("id", Json::str(id)),
|
|
513
|
+
])
|
|
514
|
+
};
|
|
410
515
|
|
|
411
|
-
let err = nest(
|
|
516
|
+
let err = nest(
|
|
517
|
+
vec![row("p1"), row("p2")],
|
|
518
|
+
&["bin", "shelf"],
|
|
519
|
+
LeafCheck::RefuseCollision("PartsByShelf"),
|
|
520
|
+
)
|
|
521
|
+
.expect_err("same bin and shelf");
|
|
412
522
|
|
|
413
523
|
assert_eq!(
|
|
414
524
|
err,
|
|
@@ -438,12 +548,18 @@ mod reference_hop_tests {
|
|
|
438
548
|
impl AggregateScan for FakeStore {
|
|
439
549
|
fn scan(&self, aggregate: &str) -> Option<Vec<(String, Json)>> {
|
|
440
550
|
let bare = aggregate.strip_prefix(&format!("{}::", self.domain))?;
|
|
441
|
-
self.aggregates
|
|
551
|
+
self.aggregates
|
|
552
|
+
.iter()
|
|
553
|
+
.find(|(name, _)| *name == bare)
|
|
554
|
+
.map(|(_, entries)| entries.clone())
|
|
442
555
|
}
|
|
443
556
|
}
|
|
444
557
|
|
|
445
558
|
fn account(status: &str, customer: &str) -> Json {
|
|
446
|
-
Json::obj(vec![
|
|
559
|
+
Json::obj(vec![
|
|
560
|
+
("status", Json::str(status)),
|
|
561
|
+
("customer", Json::str(customer)),
|
|
562
|
+
])
|
|
447
563
|
}
|
|
448
564
|
|
|
449
565
|
fn customer(status: &str) -> Json {
|
|
@@ -457,9 +573,18 @@ mod reference_hop_tests {
|
|
|
457
573
|
(
|
|
458
574
|
"Account",
|
|
459
575
|
vec![
|
|
460
|
-
(
|
|
461
|
-
|
|
462
|
-
|
|
576
|
+
(
|
|
577
|
+
"acc-suspended-open".to_string(),
|
|
578
|
+
account("open", "cust-suspended"),
|
|
579
|
+
),
|
|
580
|
+
(
|
|
581
|
+
"acc-active-open".to_string(),
|
|
582
|
+
account("open", "cust-active"),
|
|
583
|
+
),
|
|
584
|
+
(
|
|
585
|
+
"acc-suspended-closed".to_string(),
|
|
586
|
+
account("closed", "cust-suspended"),
|
|
587
|
+
),
|
|
463
588
|
],
|
|
464
589
|
),
|
|
465
590
|
(
|
|
@@ -479,10 +604,20 @@ mod reference_hop_tests {
|
|
|
479
604
|
ReadModelDef {
|
|
480
605
|
verb: "Banking.OpenForSuspendedCustomers",
|
|
481
606
|
reference_name: None,
|
|
482
|
-
heads: &[ReadModelHead {
|
|
607
|
+
heads: &[ReadModelHead {
|
|
608
|
+
aggregate: "Banking::Account",
|
|
609
|
+
as_name: "accounts",
|
|
610
|
+
many: true,
|
|
611
|
+
is_root: false,
|
|
612
|
+
reference_fields: &[],
|
|
613
|
+
}],
|
|
483
614
|
filtered_heads: &[FilteredHead {
|
|
484
615
|
as_name: "accounts",
|
|
485
|
-
conditions: &[QueryCondition {
|
|
616
|
+
conditions: &[QueryCondition {
|
|
617
|
+
field: "status",
|
|
618
|
+
comparator: query_comparators::QueryComparator::Eq,
|
|
619
|
+
value: QueryConditionValue::Literal("open"),
|
|
620
|
+
}],
|
|
486
621
|
reference_hop_conditions: &[ReferenceHopCondition {
|
|
487
622
|
via_field: "customer",
|
|
488
623
|
target_aggregate: "Banking::Customer",
|
|
@@ -497,31 +632,37 @@ mod reference_hop_tests {
|
|
|
497
632
|
}],
|
|
498
633
|
authorization: None,
|
|
499
634
|
group_by: None,
|
|
500
|
-
|
|
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,
|
|
635
|
+
reduction: None,
|
|
510
636
|
}
|
|
511
637
|
}
|
|
512
638
|
|
|
513
639
|
#[test]
|
|
514
640
|
fn a_hop_condition_keeps_only_rows_whose_local_and_hopped_conditions_both_hold() {
|
|
515
641
|
let def = open_for_suspended_customers_def();
|
|
516
|
-
let result = run(&store(), &def, &Json::obj(vec![]))
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
let
|
|
642
|
+
let result = run(&store(), &def, &Json::obj(vec![]))
|
|
643
|
+
.expect("a rootless read model with no authorization needs no args");
|
|
644
|
+
|
|
645
|
+
let accounts = result
|
|
646
|
+
.get("accounts")
|
|
647
|
+
.expect("the one declared head")
|
|
648
|
+
.as_array()
|
|
649
|
+
.expect("a many head answers an array");
|
|
650
|
+
let ids: Vec<&str> = accounts
|
|
651
|
+
.iter()
|
|
652
|
+
.map(|row| {
|
|
653
|
+
row.get("id")
|
|
654
|
+
.and_then(Json::as_str)
|
|
655
|
+
.expect("row_json always stamps id")
|
|
656
|
+
})
|
|
657
|
+
.collect();
|
|
520
658
|
|
|
521
659
|
assert_eq!(ids, vec!["acc-suspended-open"]);
|
|
522
660
|
// Named explicitly so a regression that widens either condition fails on the specific
|
|
523
661
|
// wrong row it would admit, not just a count.
|
|
524
|
-
assert!(
|
|
662
|
+
assert!(
|
|
663
|
+
!ids.contains(&"acc-active-open"),
|
|
664
|
+
"open but NOT suspended — the hop condition alone should have excluded this"
|
|
665
|
+
);
|
|
525
666
|
assert!(!ids.contains(&"acc-suspended-closed"), "suspended customer but NOT open — the ordinary local condition alone should have excluded this");
|
|
526
667
|
}
|
|
527
668
|
|
|
@@ -530,7 +671,11 @@ mod reference_hop_tests {
|
|
|
530
671
|
let mut def = open_for_suspended_customers_def();
|
|
531
672
|
def.filtered_heads = &[FilteredHead {
|
|
532
673
|
as_name: "accounts",
|
|
533
|
-
conditions: &[QueryCondition {
|
|
674
|
+
conditions: &[QueryCondition {
|
|
675
|
+
field: "status",
|
|
676
|
+
comparator: query_comparators::QueryComparator::Eq,
|
|
677
|
+
value: QueryConditionValue::Literal("open"),
|
|
678
|
+
}],
|
|
534
679
|
reference_hop_conditions: &[ReferenceHopCondition {
|
|
535
680
|
via_field: "customer",
|
|
536
681
|
target_aggregate: "Banking::NoSuchAggregate",
|
|
@@ -555,14 +700,39 @@ mod reference_hop_tests {
|
|
|
555
700
|
let mut def = open_for_suspended_customers_def();
|
|
556
701
|
def.filtered_heads = &[];
|
|
557
702
|
def.heads = &[
|
|
558
|
-
ReadModelHead {
|
|
559
|
-
|
|
703
|
+
ReadModelHead {
|
|
704
|
+
aggregate: "Banking::Account",
|
|
705
|
+
as_name: "accounts",
|
|
706
|
+
many: true,
|
|
707
|
+
is_root: false,
|
|
708
|
+
reference_fields: &[],
|
|
709
|
+
},
|
|
710
|
+
ReadModelHead {
|
|
711
|
+
aggregate: "Banking::LedgerEntry",
|
|
712
|
+
as_name: "ledger_entries",
|
|
713
|
+
many: true,
|
|
714
|
+
is_root: false,
|
|
715
|
+
reference_fields: &[],
|
|
716
|
+
},
|
|
560
717
|
];
|
|
561
718
|
|
|
562
|
-
let result = run(&store(), &def, &Json::obj(vec![]))
|
|
719
|
+
let result = run(&store(), &def, &Json::obj(vec![]))
|
|
720
|
+
.expect("an entity head must not refuse the read model");
|
|
563
721
|
|
|
564
|
-
assert_eq!(
|
|
565
|
-
|
|
722
|
+
assert_eq!(
|
|
723
|
+
result
|
|
724
|
+
.get("accounts")
|
|
725
|
+
.and_then(|v| v.as_array())
|
|
726
|
+
.map(<[Json]>::len),
|
|
727
|
+
Some(3)
|
|
728
|
+
);
|
|
729
|
+
assert_eq!(
|
|
730
|
+
result
|
|
731
|
+
.get("ledger_entries")
|
|
732
|
+
.and_then(|v| v.as_array())
|
|
733
|
+
.map(<[Json]>::len),
|
|
734
|
+
Some(0)
|
|
735
|
+
);
|
|
566
736
|
}
|
|
567
737
|
|
|
568
738
|
// A declared query folds its hop the same way a read model head does.
|
|
@@ -571,7 +741,11 @@ mod reference_hop_tests {
|
|
|
571
741
|
let def = crate::kernel::named_query::QueryDef {
|
|
572
742
|
verb: "Banking::Account.OpenForSuspendedCustomers",
|
|
573
743
|
aggregate: "Banking::Account",
|
|
574
|
-
conditions: &[QueryCondition {
|
|
744
|
+
conditions: &[QueryCondition {
|
|
745
|
+
field: "status",
|
|
746
|
+
comparator: query_comparators::QueryComparator::Eq,
|
|
747
|
+
value: QueryConditionValue::Literal("open"),
|
|
748
|
+
}],
|
|
575
749
|
reference_hop_conditions: &[ReferenceHopCondition {
|
|
576
750
|
via_field: "customer",
|
|
577
751
|
target_aggregate: "Banking::Customer",
|
|
@@ -586,7 +760,8 @@ mod reference_hop_tests {
|
|
|
586
760
|
authorization: None,
|
|
587
761
|
};
|
|
588
762
|
|
|
589
|
-
let rows = crate::kernel::named_query::run(&store(), &def, &Json::obj(vec![]), None)
|
|
763
|
+
let rows = crate::kernel::named_query::run(&store(), &def, &Json::obj(vec![]), None)
|
|
764
|
+
.expect("no authorization, no args needed");
|
|
590
765
|
let ids: Vec<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
|
|
591
766
|
|
|
592
767
|
assert_eq!(ids, vec!["acc-suspended-open"]);
|
|
@@ -596,15 +771,36 @@ mod reference_hop_tests {
|
|
|
596
771
|
#[test]
|
|
597
772
|
fn an_entity_query_flattens_every_owners_list_ordered_by_parent_then_identity() {
|
|
598
773
|
fn withdrawal(sequence: f64, state: &str) -> Json {
|
|
599
|
-
Json::obj(vec![
|
|
774
|
+
Json::obj(vec![
|
|
775
|
+
(
|
|
776
|
+
"sequence",
|
|
777
|
+
Json::obj(vec![("value", Json::Num(sequence, None))]),
|
|
778
|
+
),
|
|
779
|
+
("state", Json::str(state)),
|
|
780
|
+
])
|
|
600
781
|
}
|
|
601
782
|
let cards = FakeStore {
|
|
602
783
|
domain: "Banking",
|
|
603
784
|
aggregates: vec![(
|
|
604
785
|
"ATMCard",
|
|
605
786
|
vec![
|
|
606
|
-
(
|
|
607
|
-
|
|
787
|
+
(
|
|
788
|
+
"card-2".to_string(),
|
|
789
|
+
Json::obj(vec![(
|
|
790
|
+
"withdrawals",
|
|
791
|
+
Json::Array(vec![withdrawal(2.0, "taken"), withdrawal(1.0, "taken")]),
|
|
792
|
+
)]),
|
|
793
|
+
),
|
|
794
|
+
(
|
|
795
|
+
"card-1".to_string(),
|
|
796
|
+
Json::obj(vec![(
|
|
797
|
+
"withdrawals",
|
|
798
|
+
Json::Array(vec![
|
|
799
|
+
withdrawal(1.0, "disputed"),
|
|
800
|
+
withdrawal(3.0, "taken"),
|
|
801
|
+
]),
|
|
802
|
+
)]),
|
|
803
|
+
),
|
|
608
804
|
],
|
|
609
805
|
)],
|
|
610
806
|
};
|
|
@@ -614,26 +810,42 @@ mod reference_hop_tests {
|
|
|
614
810
|
list_field: "withdrawals",
|
|
615
811
|
parent_key: "atm_card",
|
|
616
812
|
identity_keys: &["sequence"],
|
|
617
|
-
conditions: &[QueryCondition {
|
|
813
|
+
conditions: &[QueryCondition {
|
|
814
|
+
field: "state",
|
|
815
|
+
comparator: query_comparators::QueryComparator::Eq,
|
|
816
|
+
value: QueryConditionValue::Literal("taken"),
|
|
817
|
+
}],
|
|
618
818
|
order_by: None,
|
|
619
819
|
offset: None,
|
|
620
820
|
limit: Some(query_ordering::Limit::Literal(2)),
|
|
621
821
|
};
|
|
622
822
|
|
|
623
|
-
let rows = crate::kernel::named_query::run_entity(&cards, &def, &Json::obj(vec![]))
|
|
823
|
+
let rows = crate::kernel::named_query::run_entity(&cards, &def, &Json::obj(vec![]))
|
|
824
|
+
.expect("no args needed");
|
|
624
825
|
let keyed: Vec<(String, String)> = rows
|
|
625
826
|
.iter()
|
|
626
827
|
.map(|row| {
|
|
627
|
-
let parent = row
|
|
828
|
+
let parent = row
|
|
829
|
+
.get("atm_card")
|
|
830
|
+
.and_then(Json::as_str)
|
|
831
|
+
.expect("parent key leads every row")
|
|
832
|
+
.to_string();
|
|
628
833
|
let sequence = format!("{:?}", row.dig("sequence.value"));
|
|
629
834
|
(parent, sequence)
|
|
630
835
|
})
|
|
631
836
|
.collect();
|
|
632
837
|
|
|
633
|
-
assert_eq!(
|
|
838
|
+
assert_eq!(
|
|
839
|
+
keyed.len(),
|
|
840
|
+
2,
|
|
841
|
+
"limit 2 over three taken withdrawals: {keyed:?}"
|
|
842
|
+
);
|
|
634
843
|
assert_eq!(keyed[0].0, "card-1");
|
|
635
844
|
assert_eq!(keyed[1].0, "card-2");
|
|
636
|
-
assert!(
|
|
845
|
+
assert!(
|
|
846
|
+
keyed[1].1.contains('1'),
|
|
847
|
+
"card-2's lowest sequence comes first: {keyed:?}"
|
|
848
|
+
);
|
|
637
849
|
}
|
|
638
850
|
|
|
639
851
|
// A chain: the inner clause picks sponsors, the middle step keeps members sponsored by one,
|
|
@@ -646,22 +858,40 @@ mod reference_hop_tests {
|
|
|
646
858
|
(
|
|
647
859
|
"Sponsor",
|
|
648
860
|
vec![
|
|
649
|
-
(
|
|
650
|
-
|
|
861
|
+
(
|
|
862
|
+
"s-good".to_string(),
|
|
863
|
+
Json::obj(vec![("standing", Json::str("good"))]),
|
|
864
|
+
),
|
|
865
|
+
(
|
|
866
|
+
"s-poor".to_string(),
|
|
867
|
+
Json::obj(vec![("standing", Json::str("poor"))]),
|
|
868
|
+
),
|
|
651
869
|
],
|
|
652
870
|
),
|
|
653
871
|
(
|
|
654
872
|
"Member",
|
|
655
873
|
vec![
|
|
656
|
-
(
|
|
657
|
-
|
|
874
|
+
(
|
|
875
|
+
"m-good".to_string(),
|
|
876
|
+
Json::obj(vec![("sponsor", Json::str("s-good"))]),
|
|
877
|
+
),
|
|
878
|
+
(
|
|
879
|
+
"m-poor".to_string(),
|
|
880
|
+
Json::obj(vec![("sponsor", Json::str("s-poor"))]),
|
|
881
|
+
),
|
|
658
882
|
],
|
|
659
883
|
),
|
|
660
884
|
(
|
|
661
885
|
"Referral",
|
|
662
886
|
vec![
|
|
663
|
-
(
|
|
664
|
-
|
|
887
|
+
(
|
|
888
|
+
"r-good".to_string(),
|
|
889
|
+
Json::obj(vec![("member", Json::str("m-good"))]),
|
|
890
|
+
),
|
|
891
|
+
(
|
|
892
|
+
"r-poor".to_string(),
|
|
893
|
+
Json::obj(vec![("member", Json::str("m-poor"))]),
|
|
894
|
+
),
|
|
665
895
|
],
|
|
666
896
|
),
|
|
667
897
|
],
|
|
@@ -673,7 +903,10 @@ mod reference_hop_tests {
|
|
|
673
903
|
reference_hop_conditions: &[ReferenceHopCondition {
|
|
674
904
|
via_field: "member",
|
|
675
905
|
target_aggregate: "ReferralChain::Member",
|
|
676
|
-
through: &[HopStep {
|
|
906
|
+
through: &[HopStep {
|
|
907
|
+
via_field: "sponsor",
|
|
908
|
+
target_aggregate: "ReferralChain::Sponsor",
|
|
909
|
+
}],
|
|
677
910
|
inner_field: "standing",
|
|
678
911
|
inner_comparator: query_comparators::QueryComparator::Eq,
|
|
679
912
|
inner_value: QueryConditionValue::Literal("good"),
|
|
@@ -684,13 +917,17 @@ mod reference_hop_tests {
|
|
|
684
917
|
authorization: None,
|
|
685
918
|
};
|
|
686
919
|
|
|
687
|
-
let rows = crate::kernel::named_query::run(&chain_store, &def, &Json::obj(vec![]), None)
|
|
920
|
+
let rows = crate::kernel::named_query::run(&chain_store, &def, &Json::obj(vec![]), None)
|
|
921
|
+
.expect("no authorization, no args needed");
|
|
688
922
|
let ids: Vec<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
|
|
689
923
|
|
|
690
924
|
assert_eq!(ids, vec!["r-good"]);
|
|
691
925
|
}
|
|
692
926
|
}
|
|
693
927
|
|
|
928
|
+
/// One head's records by id, with whether it is a `many` head.
|
|
929
|
+
type HeadRows = (bool, Vec<(String, Json)>);
|
|
930
|
+
|
|
694
931
|
/// The generic read-model interpreter — the single projected row, one entry per declared
|
|
695
932
|
/// head's `as_name`, for exactly the subset this module admits.
|
|
696
933
|
///
|
|
@@ -702,7 +939,12 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
|
|
|
702
939
|
Some(name) => Some(
|
|
703
940
|
args.get(name)
|
|
704
941
|
.and_then(Json::as_str)
|
|
705
|
-
.ok_or_else(||
|
|
942
|
+
.ok_or_else(|| {
|
|
943
|
+
Refusal::TypeMismatch(format!(
|
|
944
|
+
"{}: missing reference argument {name:?}",
|
|
945
|
+
def.verb
|
|
946
|
+
))
|
|
947
|
+
})?
|
|
706
948
|
.to_string(),
|
|
707
949
|
),
|
|
708
950
|
// Rootless: no caller-supplied id to require or resolve at all.
|
|
@@ -713,33 +955,42 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
|
|
|
713
955
|
if let Some(auth) = &def.authorization {
|
|
714
956
|
if args.get(auth.tenant_field).is_none() {
|
|
715
957
|
return Err(Refusal::Unauthorized(
|
|
716
|
-
UnauthorizedTenantRequiredArgs {
|
|
958
|
+
UnauthorizedTenantRequiredArgs {
|
|
959
|
+
query: auth.query_name,
|
|
960
|
+
field: auth.tenant_field,
|
|
961
|
+
}
|
|
962
|
+
.render_args(),
|
|
717
963
|
));
|
|
718
964
|
}
|
|
719
965
|
}
|
|
720
966
|
|
|
721
|
-
let (root_heads, other_heads): (Vec<&ReadModelHead>, Vec<&ReadModelHead>) =
|
|
967
|
+
let (root_heads, other_heads): (Vec<&ReadModelHead>, Vec<&ReadModelHead>) =
|
|
968
|
+
def.heads.iter().partition(|head| head.is_root);
|
|
722
969
|
|
|
723
970
|
// One entry per head already computed, in computation order (root-first): every candidate
|
|
724
971
|
// record is checked against every entry seen so far, not just the immediately-preceding one.
|
|
725
972
|
let mut projected: Vec<(&'static str, Vec<(String, Json)>)> = Vec::new();
|
|
726
|
-
let mut rows_by_as: std::collections::HashMap<&'static str,
|
|
727
|
-
|
|
973
|
+
let mut rows_by_as: std::collections::HashMap<&'static str, HeadRows> =
|
|
974
|
+
std::collections::HashMap::new();
|
|
975
|
+
let mut grouped_heads: std::collections::HashSet<&'static str> =
|
|
976
|
+
std::collections::HashSet::new();
|
|
728
977
|
|
|
729
978
|
for head in root_heads.into_iter().chain(other_heads) {
|
|
730
|
-
let mut rows =
|
|
979
|
+
let mut rows = match reference_id.as_deref() {
|
|
731
980
|
// Rootless: each head reads its own aggregate's whole table independently, with no
|
|
732
981
|
// cross-referencing against `projected`. A head with no table reads as empty.
|
|
733
|
-
store.scan(head.aggregate).unwrap_or_default()
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
} else {
|
|
737
|
-
scan_matching(store, head, &projected)?
|
|
982
|
+
None => store.scan(head.aggregate).unwrap_or_default(),
|
|
983
|
+
Some(id) if head.is_root => vec![fetch_root(store, head, id)?],
|
|
984
|
+
Some(_) => scan_matching(store, head, &projected),
|
|
738
985
|
};
|
|
739
986
|
|
|
740
987
|
// Applied before this head's rows go into `projected`, so any later head's own
|
|
741
988
|
// reference-matching sees the filtered rows, not the pre-filter scan.
|
|
742
|
-
if let Some(filtered) = def
|
|
989
|
+
if let Some(filtered) = def
|
|
990
|
+
.filtered_heads
|
|
991
|
+
.iter()
|
|
992
|
+
.find(|fh| fh.as_name == head.as_name)
|
|
993
|
+
{
|
|
743
994
|
rows = apply_filtered_head_options(rows, filtered, args, store)?;
|
|
744
995
|
}
|
|
745
996
|
|
|
@@ -759,39 +1010,33 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
|
|
|
759
1010
|
// `id` the way a live Ruby record's `to_h` does.
|
|
760
1011
|
let mut out = Vec::with_capacity(def.heads.len());
|
|
761
1012
|
for head in def.heads {
|
|
762
|
-
let (many, rows) = rows_by_as
|
|
763
|
-
|
|
764
|
-
|
|
1013
|
+
let (many, rows) = rows_by_as.remove(head.as_name).ok_or_else(|| {
|
|
1014
|
+
Refusal::Fault(
|
|
1015
|
+
"every head in def.heads was computed in the loop above — as_name is unique per read model (ReadModelBuilder#add_aggregate_head)"
|
|
1016
|
+
.to_string(),
|
|
1017
|
+
)
|
|
1018
|
+
})?;
|
|
765
1019
|
let value = if grouped_heads.contains(head.as_name) {
|
|
766
1020
|
// Already fully formed by the generated transform — must not be wrapped again the
|
|
767
1021
|
// way an ordinary head's rows are.
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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)
|
|
1022
|
+
let group_by = def.group_by.ok_or_else(|| {
|
|
1023
|
+
Refusal::Fault(
|
|
1024
|
+
"grouped_heads is only ever populated when def.group_by is Some".to_string(),
|
|
1025
|
+
)
|
|
1026
|
+
})?;
|
|
1027
|
+
group_by(rows)?
|
|
1028
|
+
} else if let Some(reduction) = def.reduction.filter(|_| many) {
|
|
1029
|
+
reduce(&rows, reduction)
|
|
791
1030
|
} else if many {
|
|
792
|
-
Json::Array(
|
|
1031
|
+
Json::Array(
|
|
1032
|
+
rows.into_iter()
|
|
1033
|
+
.map(|(id, record)| repository::row_json(id, record))
|
|
1034
|
+
.collect(),
|
|
1035
|
+
)
|
|
793
1036
|
} else {
|
|
794
|
-
rows.into_iter()
|
|
1037
|
+
rows.into_iter()
|
|
1038
|
+
.next()
|
|
1039
|
+
.map_or(Json::Null, |(id, record)| repository::row_json(id, record))
|
|
795
1040
|
};
|
|
796
1041
|
out.push((head.as_name.to_string(), value));
|
|
797
1042
|
}
|
|
@@ -801,22 +1046,36 @@ pub fn run(store: &impl AggregateScan, def: &ReadModelDef, args: &Json) -> Resul
|
|
|
801
1046
|
|
|
802
1047
|
/// The root head's own single row: find the one instance by id, `NotFound` if it isn't there.
|
|
803
1048
|
/// Unlike every other head, the root is looked up directly rather than scanned-and-matched.
|
|
804
|
-
fn fetch_root(
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1049
|
+
fn fetch_root(
|
|
1050
|
+
store: &impl AggregateScan,
|
|
1051
|
+
head: &ReadModelHead,
|
|
1052
|
+
reference_id: &str,
|
|
1053
|
+
) -> Result<(String, Json), Refusal> {
|
|
1054
|
+
// Borrowed rows: only the one that matches is cloned.
|
|
1055
|
+
let mut found = None;
|
|
1056
|
+
let known = store.scan_each(head.aggregate, &mut |id, record| {
|
|
1057
|
+
if found.is_none() && id == reference_id {
|
|
1058
|
+
found = Some((id.to_string(), record.clone()));
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
if !known {
|
|
1062
|
+
return Err(Refusal::TypeMismatch(format!(
|
|
1063
|
+
"unknown aggregate {:?}",
|
|
1064
|
+
head.aggregate
|
|
1065
|
+
)));
|
|
1066
|
+
}
|
|
808
1067
|
|
|
809
|
-
|
|
810
|
-
.
|
|
811
|
-
.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1068
|
+
found.ok_or_else(|| {
|
|
1069
|
+
// Named by its bare bluebook name; `head.aggregate` is the domain-qualified one.
|
|
1070
|
+
let bare = head.aggregate.rsplit("::").next().unwrap_or(head.aggregate);
|
|
1071
|
+
Refusal::NotFound(
|
|
1072
|
+
NotFoundReadModelReferenceMissingArgs {
|
|
1073
|
+
aggregate: bare,
|
|
1074
|
+
offered: &format!("{reference_id:?}"),
|
|
1075
|
+
}
|
|
1076
|
+
.render_args(),
|
|
1077
|
+
)
|
|
1078
|
+
})
|
|
820
1079
|
}
|
|
821
1080
|
|
|
822
1081
|
/// A non-root head's own rows: every instance of this head's aggregate whose reference field(s)
|
|
@@ -825,26 +1084,36 @@ fn scan_matching(
|
|
|
825
1084
|
store: &impl AggregateScan,
|
|
826
1085
|
head: &ReadModelHead,
|
|
827
1086
|
projected: &[(&'static str, Vec<(String, Json)>)],
|
|
828
|
-
) ->
|
|
1087
|
+
) -> Vec<(String, Json)> {
|
|
829
1088
|
// The generator emits a head with no table only when it names a nested entity; that reads
|
|
830
1089
|
// as empty here, while the root still refuses in `fetch_root`.
|
|
831
1090
|
let Some(entries) = store.scan(head.aggregate) else {
|
|
832
|
-
return
|
|
1091
|
+
return Vec::new();
|
|
833
1092
|
};
|
|
834
1093
|
|
|
835
|
-
let mut matched: Vec<(String, Json)> = entries
|
|
1094
|
+
let mut matched: Vec<(String, Json)> = entries
|
|
1095
|
+
.into_iter()
|
|
1096
|
+
.filter(|(_, record)| record_matches(record, head, projected))
|
|
1097
|
+
.collect();
|
|
836
1098
|
matched.sort_by(|a, b| a.0.cmp(&b.0));
|
|
837
|
-
|
|
1099
|
+
matched
|
|
838
1100
|
}
|
|
839
1101
|
|
|
840
1102
|
/// Whether `record` holds one of its precomputed `reference_fields` pointing at a row already
|
|
841
1103
|
/// in `projected`.
|
|
842
|
-
fn record_matches(
|
|
1104
|
+
fn record_matches(
|
|
1105
|
+
record: &Json,
|
|
1106
|
+
head: &ReadModelHead,
|
|
1107
|
+
projected: &[(&'static str, Vec<(String, Json)>)],
|
|
1108
|
+
) -> bool {
|
|
843
1109
|
head.reference_fields.iter().any(|reference_field| {
|
|
844
|
-
let Some(held_id) = record.get(reference_field.field).and_then(Json::as_str) else {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1110
|
+
let Some(held_id) = record.get(reference_field.field).and_then(Json::as_str) else {
|
|
1111
|
+
return false;
|
|
1112
|
+
};
|
|
1113
|
+
projected.iter().any(|(aggregate, rows)| {
|
|
1114
|
+
*aggregate == reference_field.target_aggregate
|
|
1115
|
+
&& rows.iter().any(|(id, _)| id == held_id)
|
|
1116
|
+
})
|
|
848
1117
|
})
|
|
849
1118
|
}
|
|
850
1119
|
|
|
@@ -869,7 +1138,13 @@ fn apply_filtered_head_options(
|
|
|
869
1138
|
|
|
870
1139
|
rows = apply_reference_hops(rows, filtered.reference_hop_conditions, args, store)?;
|
|
871
1140
|
|
|
872
|
-
Ok(query_ordering::apply(
|
|
1141
|
+
Ok(query_ordering::apply(
|
|
1142
|
+
rows,
|
|
1143
|
+
filtered.order_by.as_ref(),
|
|
1144
|
+
filtered.offset.as_ref(),
|
|
1145
|
+
filtered.limit.as_ref(),
|
|
1146
|
+
args,
|
|
1147
|
+
))
|
|
873
1148
|
}
|
|
874
1149
|
|
|
875
1150
|
/// Folds a `ReferenceHopCondition` chain: one query against the chain's final target picks
|
|
@@ -888,27 +1163,50 @@ pub(super) fn apply_reference_hops(
|
|
|
888
1163
|
QueryConditionValue::Arg(name) => args.get(name).cloned().unwrap_or(Json::Null),
|
|
889
1164
|
};
|
|
890
1165
|
let scan = |aggregate: &str| {
|
|
891
|
-
store
|
|
1166
|
+
store
|
|
1167
|
+
.scan(aggregate)
|
|
1168
|
+
.ok_or_else(|| Refusal::TypeMismatch(format!("unknown aggregate {aggregate:?}")))
|
|
1169
|
+
};
|
|
1170
|
+
let ids_of = |entries: Vec<(String, Json)>| -> Vec<Json> {
|
|
1171
|
+
entries.into_iter().map(|(id, _)| Json::Str(id)).collect()
|
|
892
1172
|
};
|
|
893
|
-
let ids_of = |entries: Vec<(String, Json)>| -> Vec<Json> { entries.into_iter().map(|(id, _)| Json::Str(id)).collect() };
|
|
894
1173
|
|
|
895
1174
|
// The chain, first hop included: `(via_field, target_aggregate)` pairs, each via_field a
|
|
896
1175
|
// reference on the previous target.
|
|
897
1176
|
let chain: Vec<(&str, &str)> = std::iter::once((hop.via_field, hop.target_aggregate))
|
|
898
|
-
.chain(
|
|
1177
|
+
.chain(
|
|
1178
|
+
hop.through
|
|
1179
|
+
.iter()
|
|
1180
|
+
.map(|step| (step.via_field, step.target_aggregate)),
|
|
1181
|
+
)
|
|
899
1182
|
.collect();
|
|
900
1183
|
|
|
901
1184
|
// Inside out: the inner clause picks ids on the last target, and each earlier step keeps
|
|
902
1185
|
// the rows of its target whose next via_field points at one of them.
|
|
903
1186
|
let (_, last_target) = chain[chain.len() - 1];
|
|
904
|
-
let mut ids = ids_of(repository::filter_entries(
|
|
1187
|
+
let mut ids = ids_of(repository::filter_entries(
|
|
1188
|
+
scan(last_target)?,
|
|
1189
|
+
hop.inner_field,
|
|
1190
|
+
hop.inner_comparator,
|
|
1191
|
+
&inner_want,
|
|
1192
|
+
));
|
|
905
1193
|
for step in (1..chain.len()).rev() {
|
|
906
1194
|
let (via_field, _) = chain[step];
|
|
907
1195
|
let (_, source) = chain[step - 1];
|
|
908
|
-
ids = ids_of(repository::filter_entries(
|
|
1196
|
+
ids = ids_of(repository::filter_entries(
|
|
1197
|
+
scan(source)?,
|
|
1198
|
+
via_field,
|
|
1199
|
+
query_comparators::QueryComparator::In,
|
|
1200
|
+
&Json::Array(ids),
|
|
1201
|
+
));
|
|
909
1202
|
}
|
|
910
1203
|
|
|
911
|
-
rows = repository::filter_entries(
|
|
1204
|
+
rows = repository::filter_entries(
|
|
1205
|
+
rows,
|
|
1206
|
+
hop.via_field,
|
|
1207
|
+
query_comparators::QueryComparator::In,
|
|
1208
|
+
&Json::Array(ids),
|
|
1209
|
+
);
|
|
912
1210
|
}
|
|
913
1211
|
Ok(rows)
|
|
914
1212
|
}
|