Repository navigation
Bugfix Issue 668 Xmlrpc FetchRowsIntoMap MissingColumnExecId
Issue 668 — getLastExecutionResult: spurious E_USER_NOTICE missing column:exec_id from fetchRowsIntoMap() on every never-executed query
Issue: #668
Branch: fix/issue-668
Status: FIXED & VERIFIED (2026-08-24)
Every tl.getLastExecutionResult call for a test case with no executions
(response [{"id":-1}]) logged one spurious event into the events table:
E_USER_NOTICE
database/fetchRowsIntoMap - missing column:exec_id - SQL: SELECT MAX(id) AS exec_id
FROM executions WHERE testplan_id = 4 AND tcversion_id IN( SELECT id FROM nodes_hierarchy
WHERE parent_id = 101) - in .../lib/functions/database.class.php - Line 679
Responses were always correct — pure audit/log noise, one row per API request, polluting the Event Viewer.
-
lib/api/xmlrpc/v1/xmlrpc.class.php:1630— the never-executed lookup uses an aggregate:SELECT MAX(id) AS exec_id FROM executions WHERE .... - MariaDB semantics: an aggregate query without GROUP BY always returns exactly
one row; on an empty set that row is
exec_id = NULL(verified directly: plain query → 1 row/NULL; same query withHAVING exec_id IS NOT NULL→ 0 rows). -
database.class.php:658—exec_query()returns a truthy result, so the fetch loop at :671 runs once overarray('exec_id' => null). -
database.class.php:675— guardif( !isset($row[$column]) ): PHP'sisset()returns false for keys whose value is NULL, so a present-but-NULL column is misclassified as "missing column". -
database.class.php:677-680→trigger_error(..., E_USER_NOTICE)+return null. - Caller
xmlrpc.class.php:1641-1649reads null as "never executed" →[{"id":-1}].
Not an alias-resolution problem (as first hypothesized in the issue) — the alias
resolves fine; it is the classic isset()-on-NULL false negative colliding with
SQL aggregate semantics.
| Site | Exposure |
|---|---|
xmlrpc.class.php:1630 (getLastExecutionResult, MAX aggregate) |
ONLY site feeding fetchRowsIntoMap a column that can be NULL |
xmlrpc.class.php:1777 (getAllExecutionsResults) |
immune — plain SELECT(id); empty set ⇒ zero rows ⇒ clean null |
xmlrpc.class.php:7558 (getTestCaseBugs) |
immune — same plain-column shape |
fetchRowsIntoMap itself (~303 callers under lib/) |
deliberately NOT touched |
Changing the DB class guard to array_key_exists() was rejected: for this caller it
would build array('' => row) instead of returning null, so intval(key($rs))
(xmlrpc.class.php:1649) would yield 0 → WHERE id=0 → the wire response would change
from {"id":-1} to a null entry — an API contract break across a 303-caller surface.
Append HAVING exec_id IS NOT NULL to the MAX() query so an empty execution set
produces zero rows instead of one NULL row. The fetch loop then never runs and
fetchRowsIntoMap() returns null cleanly — no trigger_error, identical caller flow,
identical wire response.
$sql = " SELECT MAX(id) AS exec_id FROM {$this->tables['executions']} "
. " WHERE testplan_id = {$this->args[self::$testPlanIDParamName]} "
. " AND tcversion_id IN( SELECT id FROM {$this->tables['nodes_hierarchy']} "
. " WHERE parent_id = {$this->args[self::$testCaseIDParamName]})";
// optional AND build_id / AND platform_id appended here ...
$sql .= " HAVING exec_id IS NOT NULL"; // MUST come lastNote (portability): HAVING exec_id IS NOT NULL references the SELECT alias,
which MariaDB/MySQL accept on implicit-aggregate queries; PostgreSQL would require
HAVING MAX(id) IS NOT NULL. Fine for this project's MariaDB target.
Placement matters: build_id/platform_id filters are concatenated AFTER the base
string, so the HAVING clause is appended LAST. The first attempt embedded it in the
base string producing ... HAVING exec_id IS NOT NULL AND build_id = 1 → MariaDB
error 1054 ("Unknown column 'build_id' in 'HAVING'") on build-filtered calls. Caught
by regression item R5 before landing; corrected in commit bda84f3bc. The two ERROR
events from that intermediate state (13:43/13:44) are documented in the issue trail.
Live XML-RPC matrix against http://localhost:8082; fixtures project 3 / plan 4 / build B1-668 / cases 101, 104; events watermark 5:
| # | Case | Result |
|---|---|---|
| R1 | fresh never-executed case → getLastExecutionResult |
[{"id":-1}], events 5→5 |
| R2 | getAllExecutionsResults never-executed |
[{"id":-1}], no notice |
| R3 | executed once (pass) → re-query | real exec id, status p, full fields |
| R4 | executed twice (fail) → MAX semantics preserved | latest id returned |
| R5 | build-filtered query | hit on build 1, valid SQL post-correction |
| R6 | Event Viewer delta + UI inspection | zero new ERROR/WARNING post-fix |

-
e20b0bb7efix(api): initial HAVING clause (superseded — placement flaw caught by R5) -
bda84f3bcfix(api): append HAVING after optional build/platform filters (final)