From 7b37ece72de3fa4e2e233538c4582d06e26d43e7 Mon Sep 17 00:00:00 2001 From: JJ Fullmer <7743340+darksidemilk@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:57:54 -0500 Subject: [PATCH] test(orm): pin the relationship filter to the JOIN ON clause 1cd7446f6 moved the relationship map's optional 4th element out of the WHERE clause and into the join's ON clause, and shipped no test. This is that test. The filter it protects is the only one in the codebase: 'MACAddressAssociation' => ['hostID', 'id', 'primac', ['primary' => 1]] and a WHERE predicate on the right-hand table of a LEFT OUTER JOIN is not a filter -- it is an INNER JOIN written the long way, which dropped any host with no hmPrimary='1' row out of its own query. buildQuery() recurses, so it took Task, SnapinJob, SnapinTask, ImagingLog, NodeFailure, UserTracking and OUAssociation with it. Structural, and deliberately so: the defect is in the SQL text, so the SQL text is what it reads, and it runs where CI runs -- with no server and no rows. It asserts the filter is still declared (or every other check would pass vacuously), that it lands in the ON clause, that the join is still LEFT OUTER, and that no class reaching Host puts a hostMAC predicate in WHERE. Each gate mutation-verified against this branch's own fix: emitting the filter to WHERE fails 9 checks, turning the join INNER fails 1, dropping the filter fails 1. dev-branch gets the same file, plus a behavioural counterpart that drives real rows, in #1234. Co-Authored-By: Claude --- tests/relationship-filter-in-join.test.php | 134 +++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/relationship-filter-in-join.test.php diff --git a/tests/relationship-filter-in-join.test.php b/tests/relationship-filter-in-join.test.php new file mode 100644 index 0000000000..37b5a87c43 --- /dev/null +++ b/tests/relationship-filter-in-join.test.php @@ -0,0 +1,134 @@ + ['hostID', 'id', 'primac', ['primary' => 1]] + * + * so that `$host->get('primac')` is the host's PRIMARY MAC rather than + * whichever row came back first. `buildQuery()` used to emit that filter into + * `$whereArrayAnd`, and a WHERE predicate on the right-hand table of a LEFT + * OUTER JOIN is not a filter -- it is an INNER JOIN written the long way. + * Rows with nothing to join to are dropped by the WHERE, so a host with no + * `hmPrimary='1'` row stopped existing: + * + * new Host($id)->isValid() -> false + * HostManager->find(...) -> 0 objects + * GET /fog/host/$id -> 404 + * + * on a row sitting in the table. Un-loadable, un-editable, un-deletable. + * And `buildQuery()` recurses, so the same predicate reached every class + * whose relationship chain passes through Host -- a task belonging to such a + * host was invisible to TaskManager, which is the half that turns a display + * bug into an operational one. + * + * This branch fixed it in 1cd7446f6 and shipped no test with the fix, which + * is what this file is. It is STRUCTURAL and needs no database: the defect is + * in the SQL text, so the SQL text is what it reads -- and so it runs in CI, + * where there is no server to drive rows through. dev-branch, which got the + * same fix later, carries this file plus a behavioural counterpart that does + * drive real rows. + * + * Usage: php tests/relationship-filter-in-join.test.php + * Exit status 0 = pass, 1 = fail. + */ + +require __DIR__ . '/lib/fog-test-harness.php'; + +FogTestHarness::boot('relfilter'); +FogTestHarness::fakeDb(); + +$t = new FogChecks(); + +/** + * Builds one class's join text and its WHERE additions. + * + * @param string $class the class to build for + * + * @return array [joins, whereArrayAnd] + */ +function relFilterBuild($class) +{ + $obj = FOGCore::getClass($class); + $join = []; + $where = []; + $c = null; + return $obj->buildQuery($join, $where, $c); +} + +/* + * 1. The filter is really there. Without this every assertion below would + * pass just as well against a relationship map that had quietly lost it, + * and the test would be measuring nothing. + */ +$relProp = new \ReflectionProperty( + get_class(FOGCore::getClass('Host')), + 'databaseFieldClassRelationships' +); +$relProp->setAccessible(true); +$rels = $relProp->getValue(FOGCore::getClass('Host')); +$macRel = $rels['MACAddressAssociation'] ?? null; +$t->check( + 'Host still declares a filtered relationship to MACAddressAssociation', + is_array($macRel) && isset($macRel[3]) && is_array($macRel[3]) + && array_key_exists('primary', $macRel[3]) +); + +/* + * 2. It is emitted inside the ON clause of the hostMAC join, and the join is + * still an outer one. Both halves matter: moving the predicate to ON while + * turning the join inner would drop exactly the same rows. + */ +[$joins, $where] = relFilterBuild('Host'); +$t->check( + 'the hostMAC join is still a LEFT OUTER JOIN', + false !== strpos($joins, 'LEFT OUTER JOIN `hostMAC` ON ') +); +$t->check( + "hmPrimary is part of the hostMAC ON clause", + false !== strpos( + $joins, + "ON `hostMAC`.`hmHostID`=`hosts`.`hostID` AND `hostMAC`.`hmPrimary` = '1'" + ) +); + +/* + * 3. And nothing about the optional table reached WHERE -- for Host, and for + * every class that inherits the join transitively. The list is spelled out + * rather than derived so that a class LOSING its path to Host shows up as + * a skipped name here, not as silence. + */ +$classes = [ + 'Host', + 'Task', + 'SnapinJob', + 'SnapinTask', + 'ImagingLog', + 'NodeFailure', + 'UserTracking', + 'OUAssociation', +]; +foreach ($classes as $class) { + if (!class_exists($class)) { + $t->check("$class exists, so its join is actually being checked", false); + continue; + } + [$j, $w] = relFilterBuild($class); + $t->check( + "$class puts no hostMAC predicate in WHERE", + !preg_grep('/hostMAC|hmPrimary/', (array)$w) + ); + // A class that reaches Host must actually carry the join, or "no + // predicate in WHERE" is true for the boring reason. + if ('Host' !== $class) { + $t->check( + "$class reaches the hostMAC join at all", + false !== strpos($j, 'JOIN `hostMAC` ON ') + ); + } +} + +$t->finish();