Scope the extract gate to subqueries and correct the suppression doc
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Suppression matches a record's own `name` field, so a projection that
filters on `name` without returning it carries an upstream value through.
The README claimed the record was always dropped on every query shape.
State the rule the code implements and pin the shape with a test.

`hasExtract` exempted only `in`. openvoxdb's `valid-operator?`
(src/puppetlabs/puppetdb/query_eng/engine.clj:2779-2784) lists `subquery`
separately, and the AST-rewrite stage (:2111-2123) expands
["subquery" entity expr] into ["in" cols ["extract" cols ["select_x" expr]]]
before any plan node is built, so its operand is projected into a subquery
exactly like `in`'s (:2705-2712). Exempt `subquery` and the explicit
`select_<entity>` forms (:1889-1911).

Signed-off-by: unkin-agent <unkin-agent@unkin.net>
This commit is contained in:
2026-09-05 21:49:14 +10:00
parent a4a29866e1
commit c228597fb9
3 changed files with 66 additions and 21 deletions
+31
View File
@@ -302,6 +302,32 @@ func TestHandler_SourceDisabledKeepsUpstreamFact(t *testing.T) {
}
}
// Suppression matches a record's own name field, so rows from a projection that
// filters on name without returning it are not self-identifying and pass
// through. Pinned as a known limit of the guarantee, and documented as one.
func TestHandler_ProjectionWithoutNameColumnCarriesUpstreamValue(t *testing.T) {
a := newFakeBackend(t, `[`+node("h1", "2026-07-20T00:00:00Z")+`]`, `[]`)
b := newFakeBackend(t, `[]`, `[]`)
a.bodies[factsPath] = `[{"certname":"h1","value":"upstream-value"}]`
var buf bytes.Buffer
srv := NewServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic), log.New(&buf, "", 0))
rec := doGet(t, srv.Handler(), factsPath,
`["extract",["certname","value"],["=","name","`+defaultSourceFact+`"]]`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); !strings.Contains(got, "upstream-value") {
t.Errorf("unidentifiable row was dropped: %s", got)
}
if strings.Contains(buf.String(), "dropped") {
t.Errorf("a row with no name field was counted as suppressed: %s", buf.String())
}
if _, n := sourceValues(t, rec.Body.Bytes(), defaultSourceFact); n != 0 {
t.Errorf("gated projection gained %d synthetic records: %s", n, rec.Body.String())
}
}
// Covers only that an aggregate /facts response gains no synthetic record and no
// rewritten row. It does not cover whether the aggregate rows are correct:
// /facts has no parseAggregate branch, so its rows take the certname merge
@@ -427,6 +453,11 @@ func TestInjectable(t *testing.T) {
{"nested extract on nodes", `["and",["extract",["certname"]]]`, false, false},
// An extract inside an `in` operand projects the subquery, not the response.
{"extract under in stays injectable", `["in","certname",["extract",["certname"],["select_facts",["=","name","os"]]]]`, true, true},
// `subquery` is rewritten to ["in" ... ["extract" ... ["select_x" ...]]]
// before any plan node is built, so its operand is subquery-scoped too.
{"subquery operand stays injectable", `["and",["=","certname","h1"],["subquery","facts",["extract",["certname"],["=","name","os"]]]]`, true, true},
{"bare subquery stays injectable", `["subquery","facts",["extract",["certname"]]]`, true, true},
{"extract under select_facts stays injectable", `["and",["select_facts",["extract",["certname"]]]]`, true, true},
{"nodes name filter is not a fact filter", `["=","name","os"]`, false, true},
{"nodes extract", `["extract",["certname"]]`, false, false},
{"unparseable query", `not json`, true, false},