KenedoDatabase, the database layer
- Version
- 4.0 preview
- Updated
A reference for KenedoDatabase (external/kenedo/classes/KenedoDatabase.php, 1235 lines) — the
one class every query in CBX goes through. It is a thin, deliberate wrapper over mysqli: you set a
query, you run it, you fetch through a load* method. There is no query builder and no ORM at this
level — the "ORM" is the property-driven KenedoModel sitting on top (see
com_configbox_kenedo_model.md). What this class does own is the things that must not vary per
caller: the connection's sql_mode and charset, the #__ prefix replacement, escaping/quoting
helpers, an opt-in prepared statement path, savepoint-emulated nested transactions, and one logged,
thrown failure path for every query that goes wrong. Line references are to KenedoDatabase.php
unless another file is named.
1. Getting the instance
$db = KenedoPlatform::getDb();
That is the whole access pattern (KenedoPlatform.php:65 — it delegates to the active platform
object, which holds one KenedoDatabase per request). There is no DI and no per-caller
connection; every model, helper and property shares the same instance, which is also what makes the
savepoint stack (§6) coherent — nesting only works because everyone pushes and pops on the same
object. The constructor connects immediately and configures the session (§7).
2. The classic path — setQuery() + query() + load*()
The traditional Kenedo way is a manually built SQL string:
$db->setQuery("
SELECT ".$db->getQuoted('id').", ".$db->getQuoted('title')."
FROM #__configbox_products
WHERE ".$db->getQuoted('published')." = '".$db->getEscaped($published)."'
");
$products = $db->loadObjectList();
setQuery($query, $start, $limit) (:202) stores the SQL (appending a LIMIT clause when
$start/$limit are given); query() (:286) replaces #__ with the real table prefix
(:294), runs it, and either returns the result or throws (§7). The load* methods each call
query() for you.
2.1 The escaping discipline — getQuoted() vs getEscaped()
The two helpers answer two different questions, and mixing them up is the classic mistake:
getQuoted($text)(:175) is for identifiers — table and column names. It wraps the text in backticks and does nothing else: no escaping, no validation. It exists so an identifier can collide with a reserved word, not so an identifier can carry user input. Never feed it anything request-derived.getEscaped($text)(:171) is for values, and only inside quotes you write yourself:"… = '".$db->getEscaped($value)."'". It ismysqli_real_escape_string()— correct for the connection charset, useless without the surrounding quotes.
IDs are conventionally intval()-cast at interpolation instead of escaped — cheaper and
self-documenting (KenedoModel::getRecord() and friends do exactly this).
2.2 The load* family
All of them run the current query and fetch; pick by the shape you want back:
| Method | Returns |
|---|---|
loadResult() | the first column of the first row, or null |
loadResultList($indexField, $valueField) | a flat array — first column by default, or key/value by the named columns |
loadRow() / loadRowList($indexField) | one numeric row / a list of them |
loadAssoc() / loadAssocList($indexField) | one assoc row / a list of them |
loadObject($class) / loadObjectList($indexField, $class) | one object / a list (default class KenedoObject) |
getCount() | the number of rows the query returns |
2.3 Writing objects — insertObject(), updateObject(), replaceObject()
insertObject($table, &$object, $keyName) (:928) builds an INSERT from the object's scalar
properties (non-scalars are skipped, NULL becomes SQL NULL) and — unless the primary key is
empty — appends ON DUPLICATE KEY UPDATE, which is what lets KenedoModel::store() use one call
for insert and update. On an insert it back-fills the new id into $object->{$keyName}.
updateObject() (:1043) is the plain UPDATE … WHERE key = … variant; replaceObject() (:898)
issues a REPLACE INTO; insertObjects() (:983) is the batched multi-row insert (optionally
chunked via $batchSize).
3. Prepared statements — setPreparedQuery() (:253)
Since 2026-08 there is a second, preferred-for-new-queries path: real mysqli prepared statements.
The values never touch the SQL string, so there is nothing to escape and no way to forget escaping.
Every load* method, getAffectedRows() and insertid() work unchanged on top.
// Named placeholders (recommended)
$db->setPreparedQuery("
SELECT * FROM #__configbox_questions
WHERE product_id = :productId AND published = :published
", array('productId' => $productId, 'published' => 1));
$questions = $db->loadObjectList();
// Positional
$db->setPreparedQuery("UPDATE #__configbox_products SET title = ? WHERE id = ?", array($title, $id));
$db->query();
// IN lists — an array value expands to one placeholder per element
$db->setPreparedQuery("SELECT * FROM #__cbcheckout_orders WHERE id IN (:ids)", array('ids' => $orderIds));
The rules (:214-245, enforced in compilePreparedQuery() :489):
- Either
:namedplaceholders (string keys) or?placeholders (plain list) — not both in one query. - Values may be scalars,
NULL, or arrays of scalars (IN lists). An empty array throws — guard before querying: an emptyIN ()is not valid SQL, and quietly making it "match nothing" would makeNOT INmean the opposite of what the caller intended. - ints bind as ints, floats as doubles, bools as 0/1, everything else as string,
NULLas SQL NULL. - Placeholders inside string literals, quoted identifiers and comments are left alone — a colon
in a time literal or a
?in aLIKEstring never binds anything. - Every named parameter must have a placeholder and every placeholder a parameter — a typo throws instead of silently binding nothing.
#__prefixing and the$start/$limitclause work exactly as insetQuery(). But mind that the prefix replacement only sees the SQL string — a bound value carrying a#__table name is not replaced (resolve it yourself, asgetColumnInfo()does,:1098-1103).
Under the hood, executePrepared() (:403) requires the mysqlnd driver
(mysqli_stmt_get_result()), captures the statement-level affected_rows/insert_id before the
statement closes (the connection-level counters do not reliably cover prepared statements — that is
why getAffectedRows() :655 and insertid() :1080 check the stmt-level memo first), and
normalizes the host platforms' differing global mysqli report modes so a failed prepared query
always takes the same logged path as a failed classic one (§7).
When to use which: reach for setPreparedQuery() in new queries — it is the one sanctioned
modernization in Kenedo code. When editing existing string-built queries, follow the neighbouring
code's concatenation discipline (§2.1) rather than half-converting a query; a rewrite to the prepared
form is welcome when you are reworking the query anyway, not as a drive-by.
4. The #__ prefix
Every query — classic or prepared — writes table names as #__configbox_products; query() replaces
#__ with the install's real prefix (:294), which comes from the platform's connection data. Never
hardcode a prefix, and remember §3's caveat: replacement happens on the SQL string only, not on bound
values.
5. What the connection guarantees (createDatabaseLink(), :61)
The constructor sets session state explicitly, so ConfigBox behaves the same on every host
regardless of how the server's globals are tuned (:95-110):
- Strict
sql_mode— the MySQL 8.0 defaults (STRICT_TRANS_TABLES,ONLY_FULL_GROUP_BY, …). This used to besql_mode = '', lax mode everywhere, which quietly converted real write errors into silent implicit defaults: an INSERT omitting a NOT NULL column stored''with a warning instead of refusing. Every write path is expected to satisfy strict mode now; the platform-filtered-property default fill inKenedoModel::store()covers the one case that could not (seecom_configbox_kenedo_model.md§5). utf8mb4, not MySQL's three-byte "utf8" — the tables have been utf8mb4 for years; the three-byte connection was the only thing rejecting four-byte characters (emoji, plenty of CJK), and it did so as a hard "Incorrect string value" on write.REPEATABLE-READisolation,group_concat_max_len = 20000.- A customization hook:
system_overrides/getInitQueries.phpmay definegetPostDbConnectQueries()returning extra session queries (:112-127).
6. Transactions — savepoint-emulated nesting (:1141-1232)
MySQL has no nested transactions; Kenedo needs them (copy() recurses, store() runs inside the
controller's copy() transaction). The emulation is a stack of savepoint ids:
startTransaction()(:1160) — issuesSTART TRANSACTIONwhen the stack is empty, else creates aSAVEPOINT, and pushes a level.commitTransaction()(:1181) — issuesCOMMITat depth one, elseRELEASE SAVEPOINT, and pops — but only once the statement has run. It used to pop first, so aCOMMITthat threw left the level off the stack while its caller still believed it open, and that caller's catch block then rolled back its parent's savepoint.rollbackTransaction()(:1208) —ROLLBACKat depth one, elseROLLBACK TO SAVEPOINT, and pops the same way. (Rolling back to a savepoint does not release it in MySQL, but the stack models nesting, not MySQL's savepoint list: one push per start, one pop per commit-or-rollback.)
The rule that follows, and that KenedoModel::store()/copy() spell out in their comments:
exactly one rollback per level, in the catch block, never both. A level that rolls back inside
its try and then again in its catch pops two savepoints for one nesting level — every enclosing
rollback then pops the wrong one, and the outermost throws "no transaction was started", replacing
the real failure with a spurious one. Corollary: catch Throwable, not Exception, around a
transaction — a TypeError sailing past an Exception-only catch leaves the level's savepoint on
the stack with the same off-by-one result.
7. Error handling and logging
A failed query throws — always. Both the classic and the prepared path route through
handleQueryFailure() (:339), which walks the backtrace to name the calling class/method/file
(not this class), logs the full detail — caller, error number, error message, the SQL as sent, and
for prepared queries the bound parameters — to the db_error log, appends to the in-memory
failed-query log (getFailedQueryCount()/getFailedQueryLog()), and throws an Exception whose
code is the MySQL error number. That code is load-bearing: KenedoModel::delete() catches code
1451 (FK violation) and turns it into the operator-readable "linked with other records" refusal.
Under strict mode (§5), writes that lax mode used to wave through — omitted NOT NULL columns (1364),
out-of-range values — surface here as real, logged failures; that is the intended behaviour, not a
regression. The prepared path additionally handles hosts where the global mysqli report mode is
PHP's strict default (throwing mysqli_sql_exception instead of returning false — Magento and
standalone) so failures land in the same logged path on every platform (:414-418).
Query metrics ride along: getQueryCount(), and — when CONFIGBOX_ENABLE_PERFORMANCE_TRACKING is
defined — a per-caller timing list via getQueryList()/getTotalQueryTime() (:666).
8. Gotchas
getQuoted()does not escape. It backticks an identifier, verbatim. Anything request-derived in an identifier position is an injection waiting to happen — identifiers must come from code or developer-authored metadata only.getEscaped()needs your quotes. It escapes the value; the surrounding'…'is your job.- An empty IN-list array throws on the prepared path — deliberately. Guard the query with a
count()check instead of expecting an emptyIN ()to "match nothing". - Don't mix
:namedand?in one prepared query; don't expect#__replacement inside bound values. - One rollback per level. Rolling back in both the try and the catch pops two savepoints and
corrupts the stack for every enclosing level (§6). And catch
Throwable, so anErrorcannot skip the rollback. insertObject()is an upsert (ON DUPLICATE KEY UPDATE) whenever the key field is non-empty — which is exactly whatstoreExternallyproperties rely on, and why their foreign key must carry a UNIQUE index (seecom_configbox_property_types.md§3).- Prepared statements need mysqlnd. On a host without
mysqli_stmt_get_result()the prepared path throws with a message saying to use the classic path; virtually every modern PHP build has it. getAffectedRows()/insertid()after a prepared query come from the statement-level capture, not the connection — that is handled for you, but only through this class; do not reach formysqli_insert_id()on the raw link.
See also
technical/com_configbox_kenedo_model.md— the main consumer: the property-driven SQL assembly (§4), the SQL-building discipline in context (§4.3), andstore()/copy()/delete()as the transaction-nesting case studies (§5, §8).technical/com_configbox_kenedo_mvc.md— where the database layer sits in the framework (§8: the string-built-SQL caveat and the modernization anchors aroundInterfaceKenedoDatabase).technical/com_configbox_migrations.md— the update scripts run through this same class, with idempotency guards instead of transactions (migration scripts get no transaction wrapper).- Key source:
external/kenedo/classes/KenedoDatabase.php(createDatabaseLink:61,getEscaped/getQuoted:171/:175,setQuery:202,setPreparedQuery:253,query:286,handleQueryFailure:339,executePrepared:403,compilePreparedQuery:489,insertObject:928, transactions:1141-1232),external/kenedo/classes/KenedoPlatform.php(getDb:65).