Skip to main content
Version: 4.0 preview

KenedoDatabase, the database layer

Version
4.0 preview
Updated
View markdown

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 is mysqli_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:

MethodReturns
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 :named placeholders (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 empty IN () is not valid SQL, and quietly making it "match nothing" would make NOT IN mean the opposite of what the caller intended.
  • ints bind as ints, floats as doubles, bools as 0/1, everything else as string, NULL as SQL NULL.
  • Placeholders inside string literals, quoted identifiers and comments are left alone — a colon in a time literal or a ? in a LIKE string 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/$limit clause work exactly as in setQuery(). But mind that the prefix replacement only sees the SQL string — a bound value carrying a #__ table name is not replaced (resolve it yourself, as getColumnInfo() 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.

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 be sql_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 in KenedoModel::store() covers the one case that could not (see com_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-READ isolation, group_concat_max_len = 20000.
  • A customization hook: system_overrides/getInitQueries.php may define getPostDbConnectQueries() 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) — issues START TRANSACTION when the stack is empty, else creates a SAVEPOINT, and pushes a level.
  • commitTransaction() (:1181) — issues COMMIT at depth one, else RELEASE SAVEPOINT, and pops — but only once the statement has run. It used to pop first, so a COMMIT that 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) — ROLLBACK at depth one, else ROLLBACK 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 empty IN () to "match nothing".
  • Don't mix :named and ? 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 an Error cannot skip the rollback.
  • insertObject() is an upsert (ON DUPLICATE KEY UPDATE) whenever the key field is non-empty — which is exactly what storeExternally properties rely on, and why their foreign key must carry a UNIQUE index (see com_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 for mysqli_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), and store()/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 around InterfaceKenedoDatabase).
  • 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).