Files
acai-vscode-webbase/cms/lib/classes/AcaiAPI.php
Jordan Diaz 46ecbb3f09 fix: tolerar null en $functions/$options de AcaiAPI (compat firma legacy CocoDB)
Los hooks legacy llaman CocoDB::updateRecords(..., null) y el nuevo
buildSetClause tipa $functions como array estricto: TypeError fatal en
PHP 8 (p.ej. pago por transferencia via IPNGenerica). Se normaliza en la
frontera publica (insert/update/get/delete).
2026-07-29 11:32:33 +00:00

1734 lines
75 KiB
PHP

<?php
/**
* AcaiAPI — Data access layer
*
* Optimized replacement for CocoDB.
* Uses PDO (via Db singleton), prepared statements, JOIN-based relation
* loading, and batch upload fetching.
*
* Public write API is compatible with CocoDB: insert / update / delete.
* Public read API (get) is added in later phases.
*/
class AcaiAPI
{
// ── In-process caches ──────────────────────────────────────────────
private static $schemaCache = [];
private static $queryCaches = []; // list/query relation results
private static $requestGetCache = []; // unconditional per-request deduplication
// ── Public caches (mirror CocoDB interface) ───────────────────────
public static $getCaches = []; // in-memory result cache for get()
public static $allowedTranslateFields = null; // DISTINCT fieldName from traducciones
public static $pluginsConfig = []; // aux_plg_config cache
// ── Configuration ─────────────────────────────────────────────────
public static $defaultRelationsDepth = 2;
public static $storeDebugData = false;
public static $debugData = [];
public static $trackData = [];
public static $backTracePoint = null;
public static $noCacheURIS = [];
public static $noCacheTABLES = [];
// ── Cache flags (mirror CocoDB interface) ─────────────────────────
public static $redis = null;
public static $redis_expireTime = 0;
public static $force_load_cache = false;
public static $force_redis = false;
public static $force_redis_module = false; // consumed by external cache layer
public static $force_redis_html = false; // consumed by external cache layer
public static $force_json_cache_uploads = false;
// ═══════════════════════════════════════════════════════════════════
// PUBLIC WRITE API
// ═══════════════════════════════════════════════════════════════════
/**
* Insert one or more records into a table.
*
* @param string $table Table name (without prefix)
* @param array $records Single record or list of records
* @param array $functions Associative [fieldName => callable] applied before coercion
* @param array $options See option list below
* @return int Number of inserted records, or last inserted ID if return_last_id
*
* Options:
* prefix string Table prefix override (default: $TABLE_PREFIX)
* ignoreSchema bool Skip schema validation — trust all fields as-is
* forceNum bool Allow caller to set the num (PK) manually
* preSaveTempId string Link pre-uploaded files/translations to the new record
* generate_category_metadata bool Rebuild category tree metadata after insert
* return_last_id bool Return the last auto-increment ID instead of row count
* dieBeforeQuery bool Dump SQL + params and die (debug)
*/
static function insert($table, $records, $functions = [], $options = [])
{
self::$requestGetCache = [];
global $TABLE_PREFIX;
// Llamadas legacy (CocoDB) pasan null explicito como $functions
if (!is_array($functions)) $functions = [];
if (!is_array($options)) $options = [];
if (!isset($records[0])) $records = [$records];
$prefix = isset($options['prefix']) ? $options['prefix'] : $TABLE_PREFIX;
$ignoreSchema = !empty($options['ignoreSchema']);
$forceNum = !empty($options['forceNum']);
$schema = [];
if (!$ignoreSchema) {
$schema = self::schema($table);
if (!$schema) self::error(['error' => "Tabla no encontrada: $table"]);
}
$db = Db::getInstance();
$lastId = 0;
$count = 0;
foreach ($records as $record) {
list($setParts, $params, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, true, $forceNum
);
if (empty($setParts)) continue;
$sql = "INSERT INTO `{$prefix}{$table}` SET " . implode(', ', $setParts);
if (!empty($options['dieBeforeQuery'])) {
self::error(['info' => $sql, 'params' => $params]);
}
$result = $db->execute($sql, $params ? $params : null);
if ($result === false) {
self::error(['error' => "Error al insertar en `$table`"]);
continue;
}
$count++;
$row = $db->fetch('SELECT LAST_INSERT_ID() AS id');
$lastId = ($row && isset($row['id'])) ? (int) $row['id'] : 0;
// Register each upload in the uploads table
foreach ($uploadFields as $fieldName => $urlPaths) {
foreach ($urlPaths as $i => $urlPath) {
if (!$urlPath) continue;
self::insert('uploads', [
'urlPath' => $urlPath,
'filePath' => realpath(__DIR__ . '/../../../' . $urlPath),
'fieldName' => $fieldName,
'recordNum' => $lastId,
'tableName' => $table,
'createdTime' => date('Y-m-d H:i:s'),
'order' => time() + $i,
'width' => 640,
'height' => 480,
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
}
}
}
// Link uploads/traducciones that were pre-uploaded before the record existed
if (!empty($options['preSaveTempId'])) {
$db->execute(
"UPDATE `{$prefix}uploads`
SET recordNum = ?, preSaveTempId = ''
WHERE tableName = ? AND preSaveTempId = ?",
[$lastId, $table, $options['preSaveTempId']]
);
$db->execute(
"UPDATE `{$prefix}traducciones`
SET recordNum = ?, preSaveTempId = ''
WHERE tableName = ? AND preSaveTempId = ?",
[$lastId, $table, $options['preSaveTempId']]
);
}
if (!empty($options['generate_category_metadata'])) {
self::updateCategoryMetadata($table, $prefix);
}
return !empty($options['return_last_id']) ? $lastId : $count;
}
/**
* Update records in a table.
*
* @param string $table Table name (without prefix)
* @param array $records Single record or list of records
* @param string|array $where WHERE condition (same formats as buildWhere)
* @param array $functions Associative [fieldName => callable]
* @param array $options See option list below
* @return int Number of updated records
*
* Options (in addition to insert options):
* delete_old_uploads bool Delete existing uploads for each field before inserting new ones
* insert_new_uploads bool Insert new upload records after update
* delete_upload_nums int|int[] Delete specific upload records by their num
*/
static function update($table, $records, $where, $functions = [], $options = [])
{
self::$requestGetCache = [];
global $TABLE_PREFIX;
// Llamadas legacy (CocoDB) pasan null explicito como $functions
if (!is_array($functions)) $functions = [];
if (!is_array($options)) $options = [];
if (!isset($records[0])) $records = [$records];
$prefix = isset($options['prefix']) ? $options['prefix'] : $TABLE_PREFIX;
$ignoreSchema = !empty($options['ignoreSchema']);
$schema = [];
if (!$ignoreSchema) {
$schema = self::schema($table);
if (!$schema) self::error(['error' => "Tabla no encontrada: $table"]);
}
$db = Db::getInstance();
$whereParams = [];
$whereSql = self::buildWhere($where, $whereParams);
if ($whereSql === null || $whereSql === '') return 0;
$count = 0;
foreach ($records as $record) {
$lastNum = isset($record['num']) ? $record['num'] : null;
list($setParts, $setParams, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, false, false
);
if (empty($setParts)) continue;
$sql = "UPDATE `{$prefix}{$table}` SET " . implode(', ', $setParts)
. " WHERE " . $whereSql;
$allParams = array_merge($setParams, $whereParams);
if (!empty($options['dieBeforeQuery'])) {
self::error(['info' => $sql, 'params' => $allParams]);
}
$result = $db->execute($sql, $allParams ? $allParams : null);
if ($result === false) {
self::error(['error' => "Error al actualizar `$table`"]);
continue;
}
$count++;
// Delete uploads by specific num list
if ($lastNum && !empty($options['delete_upload_nums'])) {
$numsToDelete = (array) $options['delete_upload_nums'];
$ph = implode(',', array_fill(0, count($numsToDelete), '?'));
$db->execute(
"DELETE FROM `{$prefix}uploads` WHERE num IN ($ph)",
$numsToDelete
);
}
foreach ($uploadFields as $fieldName => $urlPaths) {
// Remove all existing uploads for this field+record before inserting
if ($lastNum && !empty($options['delete_old_uploads'])) {
$db->execute(
"DELETE FROM `{$prefix}uploads`
WHERE fieldName = ? AND recordNum = ? AND tableName = ?",
[$fieldName, $lastNum, $table]
);
}
if ($lastNum && !empty($options['insert_new_uploads'])) {
foreach ($urlPaths as $i => $urlPath) {
if (!$urlPath) continue;
self::insert('uploads', [
'urlPath' => $urlPath,
'filePath' => realpath(__DIR__ . '/../../../' . $urlPath),
'fieldName' => $fieldName,
'recordNum' => $lastNum,
'tableName' => $table,
'createdTime' => date('Y-m-d H:i:s'),
'order' => time() + $i,
'width' => 640,
'height' => 480,
], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
}
}
}
}
if (!empty($options['generate_category_metadata'])) {
self::updateCategoryMetadata($table, $prefix);
}
return $count;
}
/**
* Delete records from a table.
*
* @param string $table Table name (without prefix)
* @param string|array $where WHERE condition
* @param array $options prefix, dieBeforeQuery
* @return bool
*/
static function delete($table, $where, $options = [])
{
self::$requestGetCache = [];
global $TABLE_PREFIX;
if (!is_array($options)) $options = [];
$prefix = isset($options['prefix']) ? $options['prefix'] : $TABLE_PREFIX;
$params = [];
$whereSql = self::buildWhere($where, $params);
if (!$whereSql) return false;
$sql = "DELETE FROM `{$prefix}{$table}` WHERE {$whereSql}";
if (!empty($options['dieBeforeQuery'])) {
self::error(['info' => $sql, 'params' => $params]);
}
$db = Db::getInstance();
return $db->execute($sql, $params ? $params : null) !== false;
}
// ═══════════════════════════════════════════════════════════════════
// PUBLIC READ API
// ═══════════════════════════════════════════════════════════════════
/**
* Fetch records from one or more tables.
*
* @param string $table Table name, or comma-separated list for multi-table
* @param string|array $where WHERE condition — string (raw SQL) or array (structured)
* @param string|null $order ORDER BY clause
* @param mixed $limit int | 'offset,limit' | ['limit','offset'|'page'|'perPage']
* @param array $options See defaults below
* @return array Records array, or [$metaArray, $records] if withMetas=true
*
* Options:
* debug bool Render HTML debug panel after query
* translates string Language code for automatic translation
* uploads bool Load uploads for each record (default true)
* useAbsoluteUrls bool Prefix https://host to urlPath in uploads
* groupBy string GROUP BY clause
* ignoreSchema bool Skip schema/relation/upload enrichment
* aggregates array Extra SELECT expressions e.g. ['COUNT(*) AS n']
* relations bool|array true=all, ['field1']= only those fields
* relationsDepth int Recursive relation depth (default 2; 0=none)
* redis bool Cache this query in Redis
* redis_expire int Redis TTL in seconds (default 60)
* onlyFields array Restrict SELECT to these columns
* withMetas bool Return [$meta, $records] with pagination info
* returnDataByKey string Index result by this field instead of numeric keys
* dieBeforeQuery bool Dump SQL and die (debug)
* prefix string Table prefix override
*/
static function get($table, $where = null, $order = null, $limit = null, $options = [])
{
global $TABLE_PREFIX;
if (!is_array($options)) $options = [];
// ── Option defaults ──────────────────────────────────────────────
$defaults = [
'debug' => false,
'translates' => isset($_REQUEST['idioma']) ? $_REQUEST['idioma'] : null,
'uploads' => true,
'useAbsoluteUrls' => false,
'groupBy' => null,
'ignoreSchema' => false,
'aggregates' => [],
'relations' => true,
'redis' => null,
'onlyFields' => null,
'redis_expire' => 60,
'relationsDepth' => self::$defaultRelationsDepth,
'dieBeforeQuery' => false,
'prefix' => $TABLE_PREFIX,
'withMetas' => false,
'returnDataByKey' => false,
];
foreach ($defaults as $k => $v) {
if (!isset($options[$k])) $options[$k] = $v;
}
// CocoDB compat alias
if (!empty($options['ignoreSchemas'])) $options['ignoreSchema'] = true;
// Recursive calls bottom out at depth -1
if ((int) $options['relationsDepth'] < 0) return [];
// Redis force flags
if (self::$force_redis) {
if ($options['redis'] === null) $options['redis'] = true;
if (self::$redis_expireTime) $options['redis_expire'] = self::$redis_expireTime;
}
if ($options['redis']) self::initCache();
$prefix = $options['prefix'];
// ── Parse tables ─────────────────────────────────────────────────
$tables = array_values(array_map('trim', array_filter(explode(',', $table))));
$schemas = []; // field-only schema (no separators)
$fullSchemas = []; // raw schema including meta-keys
if (!$options['ignoreSchema']) {
foreach ($tables as $i => $tbl) {
$tblName = trim(explode(' ', $tbl)[0]);
$sc = self::schema($tblName);
if (empty($sc)) { unset($tables[$i]); continue; }
if (!$order && isset($sc['listPageOrder'])) $order = $sc['listPageOrder'];
$fullSchemas[$tblName] = $sc;
$schemas[$tblName] = array_filter($sc, function ($f) use ($options) {
if (!is_array($f) || !isset($f['type'])) return false;
if ($f['type'] === 'separator') return false;
if (!$options['uploads'] && $f['type'] === 'upload') return false;
return true;
});
}
$tables = array_values($tables);
}
if (empty($tables)) return [];
// ── Build SELECT ──────────────────────────────────────────────────
$select = $options['onlyFields'] ? (array) $options['onlyFields'] : [];
if (empty($select)) {
if (count($tables) === 1) {
$select = ['*'];
} else {
foreach ($tables as $i => $tbl) {
$parts = explode(' ', trim($tbl));
$tblName = $parts[0];
$ref = isset($parts[1]) ? $parts[1] : $prefix . $tblName;
if ($i === 0) {
$select[] = $ref . '.*';
} else {
if (!isset($schemas[$tblName])) continue;
$cols = array_keys($schemas[$tblName]);
$colExprs = array_map(function ($col) use ($ref) {
return $ref . '.' . $col . " AS '" . $ref . '.' . $col . "'";
}, $cols);
$select[] = implode(', ', $colExprs);
}
}
}
}
// Aggregates appended to SELECT
if (!empty($options['aggregates'])) {
foreach ((array) $options['aggregates'] as $agg) $select[] = $agg;
}
// ── Build FROM ────────────────────────────────────────────────────
$fromParts = array_map(function ($tbl) use ($prefix) {
$parts = explode(' ', trim($tbl));
$tblName = $parts[0];
$alias = isset($parts[1]) ? $parts[1] : null;
return $alias
? '`' . $prefix . $tblName . '` ' . $alias
: '`' . $prefix . $tblName . '`';
}, $tables);
$from = implode(', ', $fromParts);
// ── Build WHERE ───────────────────────────────────────────────────
$whereParams = [];
$whereSql = self::buildWhere($where, $whereParams);
// ── Build LIMIT ───────────────────────────────────────────────────
$metaLimit = 1000000;
$limitSql = '';
if ($limit !== null && $limit !== '') {
if (is_array($limit)) {
if (isset($limit['perPage'])) $limit['limit'] = $limit['perPage'];
if (!isset($limit['limit'])) self::error(['error' => 'Limit array requires a "limit" key']);
if (isset($limit['page']) && !isset($limit['offset'])) {
$limit['offset'] = (max(1, (int) $limit['page']) - 1) * (int) $limit['limit'];
}
$metaLimit = (int) $limit['limit'];
$limitSql = isset($limit['offset'])
? ((int) $limit['offset']) . ',' . ((int) $limit['limit'])
: (string) (int) $limit['limit'];
} else {
$limitSql = (string) $limit;
$metaLimit = (strpos($limit, ',') !== false)
? (int) explode(',', $limit)[1]
: (int) $limit;
}
}
// ── Assemble SQL ──────────────────────────────────────────────────
$sql = 'SELECT ' . implode(', ', $select) . ' FROM ' . $from;
if ($whereSql) $sql .= ' WHERE ' . $whereSql;
if ($options['groupBy']) $sql .= ' GROUP BY ' . $options['groupBy'];
if ($order) $sql .= ' ORDER BY ' . $order;
$sqlCount = $sql; // used for COUNT(*) — before LIMIT
if ($limitSql) $sql .= ' LIMIT ' . $limitSql;
if ($options['dieBeforeQuery']) self::error(['info' => $sql, 'params' => $whereParams]);
// ── Cache read ────────────────────────────────────────────────────
$hashSql = self::cacheHash(md5($sql . json_encode($whereParams) . json_encode($options)));
if (array_key_exists($hashSql, self::$requestGetCache)) {
return self::$requestGetCache[$hashSql];
}
if (self::shouldCache($table)) {
if ($options['redis'] && ($cached = self::cacheGet($hashSql))) {
return json_decode($cached, true);
}
if (self::$force_load_cache && isset(self::$getCaches[$hashSql])) {
return json_decode(self::$getCaches[$hashSql], true);
}
}
// ── Execute ───────────────────────────────────────────────────────
$db = Db::getInstance();
$pdoParams = $whereParams ? $whereParams : null;
$microtime = microtime(true);
// COUNT only when caller explicitly requests pagination metadata
$totalRecords = 0;
if ($options['withMetas']) {
$countSql = 'SELECT COUNT(*) AS n FROM ' . $from
. ($whereSql ? ' WHERE ' . $whereSql : '')
. ($options['groupBy'] ? ' GROUP BY ' . $options['groupBy'] : '');
$countRow = $db->fetch($countSql, $pdoParams);
$totalRecords = $countRow ? (int) $countRow['n'] : 0;
}
$rows = $db->executeS($sql, $pdoParams);
if (!is_array($rows)) $rows = [];
$rows = self::stringifyRows($rows); // paridad de tipos con mysql_* legacy
// ── Hydrate records ───────────────────────────────────────────────
$firstTableName = trim(explode(' ', $tables[0])[0]);
$firstSchema = isset($fullSchemas[$firstTableName]) ? $fullSchemas[$firstTableName] : [];
// Phase 3: batch-load uploads once for the entire result set
$uploadsIndex = [];
if ($options['uploads'] && !empty($firstSchema) && !empty($rows)) {
$uploadsIndex = self::batchLoadUploads($rows, $firstTableName, $firstSchema, $options, $prefix);
}
$records = [];
foreach ($rows as $row) {
// Inject uploads from pre-built index (Phase 4); pass even when empty so
// upload fields are initialized to [] for records with no uploads (matches CocoDB).
if ($options['uploads'] && !empty($firstSchema)) {
self::injectUploads($row, $firstTableName, $firstSchema, $uploadsIndex);
}
// Resolve text/query relations, decode multitext, set post-process fields (Phases 3 & 5)
if (!$options['ignoreSchema'] && !empty($firstSchema)) {
self::hydrateRecord($row, $firstTableName, $firstSchema, $options, $prefix);
}
if ($options['returnDataByKey']) {
$records[$row[$options['returnDataByKey']]] = $row;
} else {
$records[] = $row;
}
}
// Batch-resolve list/table relations for the full result set — one IN query per field (Phase 3)
if (!$options['ignoreSchema'] && !empty($firstSchema) && !empty($records)
&& $options['relations'] !== false
) {
self::batchLoadRelations($records, $firstSchema, $options, $prefix);
}
// Apply translations only after uploads and recursive relations have been hydrated.
// This allows AcaiAPI to resolve all required values in a few batched queries.
if (!$options['ignoreSchema'] && !empty($records) && !empty($options['translates'])) {
self::applyTranslations($records, $options['translates']);
}
// ── Debug ─────────────────────────────────────────────────────────
if (self::$storeDebugData) {
$entry = [
'hora' => date('Y-m-d H:i:s') . ' ' . microtime(),
'query' => $sql,
'records' => $records,
'time' => microtime(true) - $microtime,
];
if (self::$backTracePoint) $entry['backTracePoint'] = self::$backTracePoint;
self::$debugData[] = $entry;
}
// ── Cache write ───────────────────────────────────────────────────
if (self::shouldCache($table) && $hashSql) {
$payload = json_encode($options['withMetas']
? [self::buildMeta($totalRecords, $metaLimit), $records]
: $records
);
if ($options['redis']) {
self::cacheSet($hashSql, $payload, $options['redis_expire']);
} elseif (self::$force_load_cache) {
self::$getCaches[$hashSql] = $payload;
}
}
$result = $options['withMetas']
? [self::buildMeta($totalRecords, $metaLimit), $records]
: $records;
self::$requestGetCache[$hashSql] = $result;
return $result;
}
/** Activate in-memory per-request result cache. */
static function localCache()
{
self::$force_load_cache = true;
}
/** Activate Redis cache for all subsequent get() calls. */
static function fullCache($expireTime = 60)
{
self::$force_redis = true;
self::$redis_expireTime = $expireTime;
self::initCache();
}
/** Connect to Redis (idempotent). */
static function initCache()
{
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
if (!self::$redis) {
self::$redis = new Redis();
self::$redis->connect($redisHost, $redisPort);
} elseif (!self::$redis->isConnected()) {
self::$redis->connect($redisHost, $redisPort);
}
}
/** Check if cache should be used for a given table name. */
private static function shouldCache($table)
{
global $TABLE_PREFIX;
if (!self::$noCacheTABLES) return true;
$full = $TABLE_PREFIX . str_replace($TABLE_PREFIX, '', $table);
return !in_array($full, self::$noCacheTABLES, true);
}
/** Namespaced hash key: host + hash + expireTime. */
static function cacheHash($string)
{
return $_SERVER['HTTP_HOST'] . '_' . $string . '_' . self::$redis_expireTime;
}
static function cacheGet($hash)
{
if (!self::$redis || !self::$redis->isConnected()) return null;
return self::$redis->exists($hash) ? self::$redis->get($hash) : null;
}
static function cacheSet($hash, $data, $expireTime = null)
{
if (!self::$redis || !self::$redis->isConnected()) return;
if (!$expireTime) $expireTime = self::$redis_expireTime ?: 60;
self::$redis->set($hash, $data);
self::$redis->expire($hash, $expireTime);
}
/**
* Returns true if the current URL matches any pattern in $noCacheURIS.
*/
static function bloquedCacheByURL($url)
{
if (!self::$noCacheURIS) return false;
foreach (self::$noCacheURIS as $pattern) {
if ($url === $pattern) return true;
if (@preg_match('/' . $pattern . '/i', $url)) return true;
}
return false;
}
/**
* Replaces the security token in cached HTML with the current session token.
*/
static function replaceHooksToken($html)
{
session_start();
return preg_replace_callback(
"/var hooksToken(\s)?=(\s)?[\'\"]([a-zA-Z0-9]+)[\'\"]\;/i",
function ($m) {
$token = sha1(session_id() . $_SERVER['HTTP_HOST']);
return "var hooksToken = '$token'; console.log('⚡️ Render cached HTML ⚡️');";
},
$html
);
}
// ── Debug / tracking ─────────────────────────────────────────────────
/** Alias de cacheHash() para compatibilidad con CocoDB::cacheGenerateHash(). */
static function cacheGenerateHash($string) {
return self::cacheHash($string);
}
/** Renderiza el panel de debug (misma firma que CocoDB::showDebug()). */
static function showDebug($formated = false, $index = -1) {
if (!$formated) return json_encode(self::$debugData, JSON_PRETTY_PRINT);
if (!self::$debugData) return '';
$result = '<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">'
. '<div class="container mx-auto border p-4 flex flex-col pb-2">';
foreach (self::$debugData as $cont => $q) {
if ($index > -1 && $cont != $index) continue;
$result .= '<div class="w-full">'
. '<div class="border p-2 bg-green-200 text-green-800 text-xs border-green-400 mb-2 break-word block overflow-auto">'
. $q['hora'] . '<br>';
if (self::$backTracePoint) $result .= self::$backTracePoint . '<br>';
$result .= $q['query'] . '<br>' . $q['time'] . ' microsegundos<br>'
. "<button onclick='this.parentNode.parentNode.querySelector(\".info\").classList.toggle(\"hidden\")' class='text-white py-1 px-2 mt-2 mr-1 bg-green-500 hover:bg-green-400 focus:outline-none inline-block'>Ver resultado</button>"
. "<button onclick='this.parentNode.parentNode.querySelector(\".back\").classList.toggle(\"hidden\")' class='text-white py-1 px-2 mt-2 bg-blue-500 hover:bg-blue-400 focus:outline-none inline-block'>Ver backtrace</button>"
. '</div>'
. '<pre class="info hidden bg-gray-200 p-4 text-gray-600 whitespace-normal text-xs">'
. str_replace(' ', '&nbsp;&nbsp;', nl2br(json_encode($q['records'], JSON_PRETTY_PRINT)))
. '</pre>'
. '<pre class="back hidden bg-blue-200 p-4 text-blue-800 whitespace-normal text-xs">'
. str_replace(' ', '&nbsp;&nbsp;', nl2br(json_encode(debug_backtrace(), JSON_PRETTY_PRINT)))
. '</pre></div>';
}
return $result . '</div>';
}
static function setBacktracePoint($string) {
self::$backTracePoint = $string;
}
static function getTrackData() {
return self::$trackData;
}
static function setTrackData($init = false, $type = null, $id = null, $data = []) {
if ($init) {
$pushedData = [
'ip' => $_SERVER['REMOTE_ADDR'],
'timestamp' => round(floatval(microtime(true) * 1000), 4),
'totalTime' => 0,
'host' => $_SERVER['HTTP_HOST'],
'url' => $_SERVER['REQUEST_URI'],
'trackData' => [],
];
self::$trackData[] = $pushedData;
} else {
$last = count(self::$trackData) - 1;
$prevTimestamp = self::$trackData[$last]['timestamp'];
$pushedData = [
'timestamp' => round((floatval(microtime(true) * 1000) - $prevTimestamp), 4),
'type' => $type,
'id' => $id,
'transferKeys' => !isset($data[0])
? array_keys($data)
: (is_array($data[0]) ? array_keys($data[0]) : ['undefined' => $data[0]]),
];
self::$trackData[$last]['trackData'][] = $pushedData;
self::$trackData[$last]['totalTime'] = $pushedData['timestamp'];
$prevPercent = 0;
foreach (self::$trackData[$last]['trackData'] as $c => $td) {
$pct = ($td['timestamp'] * 100) / self::$trackData[$last]['totalTime'];
self::$trackData[$last]['trackData'][$c]['percent'] = round($pct, 2);
self::$trackData[$last]['trackData'][$c]['initPercent'] = $prevPercent;
self::$trackData[$last]['trackData'][$c]['widthPercent'] = $pct - $prevPercent;
$prevPercent = $pct;
}
}
return $pushedData;
}
/**
* Carga y cachea la configuración de plugins desde aux_plg_config.
* Misma firma que CocoDB::getPluginsConfig().
*/
static function getPluginsConfig($table, $where) {
global $TABLE_PREFIX;
if (!self::$pluginsConfig) {
$db = Db::getInstance();
$configTable = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table) ?: "aux_plg_config";
$rows = self::stringifyRows($db->executeS("SELECT * FROM `".$configTable."` ORDER BY num DESC") ?: []);
self::$pluginsConfig = [];
foreach ($rows as $row) {
if (!isset(self::$pluginsConfig[$row['plugin']])) {
self::$pluginsConfig[$row['plugin']] = $row;
}
}
}
$key = str_replace([' ', "'", '"', 'plugin='], '', strtolower(trim($where)));
return isset(self::$pluginsConfig[$key]) ? self::$pluginsConfig[$key] : [];
}
/**
* Aplica traducciones a un registro completo.
* Alias público de applyTranslations() para compatibilidad con
* CocoDB::t_recursivo().
*/
static function t_recursivo($record, $idx = null) {
if (!is_array($record)) return $record;
self::applyTranslations($record);
return $record;
}
// ── Internal read helpers (stubs — expanded in Phases 3-5) ──────────
// ═══════════════════════════════════════════════════════════════════
// PHASE 4 — BATCH UPLOAD LOADING
// ═══════════════════════════════════════════════════════════════════
/**
* Loads all uploads for a result set in ONE query per upload field
* (vs CocoDB's one query per record).
*
* Returns an index structured as:
* $index[$fieldName][$recordNum] = [upload_row, upload_row, ...]
*/
private static function batchLoadUploads(array $rows, $tableName, array $schema, array $options, $prefix)
{
// Collect upload field names declared in schema
$uploadFields = [];
foreach ($schema as $fieldName => $field) {
if (is_array($field) && isset($field['type']) && $field['type'] === 'upload') {
$uploadFields[] = $fieldName;
}
}
if (empty($uploadFields)) return [];
// Collect all record nums from the result
$nums = array_filter(array_column($rows, 'num'));
if (empty($nums)) return [];
$db = Db::getInstance();
$ph = implode(',', array_fill(0, count($nums), '?'));
$fieldPh = implode(',', array_fill(0, count($uploadFields), '?'));
$selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, '
. 'info1, info2, info3, info4, info5, alt';
$params = array_merge(array_values($nums), [$tableName], array_values($uploadFields));
$uploads = $db->executeS(
"SELECT $selectCols
FROM `{$prefix}uploads`
WHERE recordNum IN ($ph)
AND tableName = ?
AND fieldName IN ($fieldPh)
ORDER BY `order` ASC",
$params
);
$uploads = self::stringifyRows($uploads); // paridad de tipos con mysql_* legacy
if (empty($uploads)) return [];
// Re-index for O(1) lookup
$index = [];
foreach ($uploads as $upload) {
if ($options['useAbsoluteUrls'] && isset($upload['urlPath'])
&& strpos($upload['urlPath'], '/cms') === 0
) {
$upload['urlPath'] = 'https://' . $_SERVER['HTTP_HOST'] . $upload['urlPath'];
}
$field = $upload['fieldName'];
$recNum = $upload['recordNum'];
$index[$field][$recNum][] = $upload;
}
return $index;
}
/**
* Injects upload arrays from the pre-built index into a single record row.
* Each upload field becomes an array of upload objects (same as CocoDB).
*/
private static function injectUploads(array &$row, $tableName, array $schema, array $uploadsIndex)
{
if (!isset($row['num'])) return;
foreach ($schema as $fieldName => $field) {
if (!is_array($field) || !isset($field['type']) || $field['type'] !== 'upload') continue;
$row[$fieldName] = isset($uploadsIndex[$fieldName][$row['num']])
? $uploadsIndex[$fieldName][$row['num']]
: [];
}
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 3 — RELATION LOADING
// ═══════════════════════════════════════════════════════════════════
/**
* Resolves list relations, decodes multitext, applies translations,
* and sets breadcrumb helpers on a single record.
*
* list/optionsType=text → pure PHP, no DB
* list/optionsType=query → getEvalOutput() with per-query static cache
* list/optionsType=table → batch-loaded after the main loop (see batchLoadRelations)
* multitext → json_decode → _bd
*/
private static function hydrateRecord(array &$row, $tableName, array $schema, array $options, $prefix)
{
global $TABLE_PREFIX;
$row['tableName'] = $tableName;
$relationsFilter = $options['relations'];
$relationsDepth = (int) $options['relationsDepth'];
foreach ($schema as $fieldName => $field) {
if (!is_array($field) || !isset($field['type'])) continue;
// Honour the `relations` option — skip fields not in the whitelist
if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue;
$rawValue = isset($row[$fieldName]) ? $row[$fieldName] : null;
switch ($field['type']) {
case 'list':
$optionsType = isset($field['optionsType']) ? $field['optionsType'] : '';
switch ($optionsType) {
case 'text':
// Parse inline option list: "value|Label\nvalue2|Label2"
$lines = array_filter(explode("\n", isset($field['optionsText']) ? $field['optionsText'] : ''));
$map = [];
foreach ($lines as $line) {
$parts = explode('|', $line);
$map[trim($parts[0])] = isset($parts[1]) ? trim($parts[1]) : trim($parts[0]);
}
$resultDatas = ($rawValue !== null && $rawValue !== '')
? explode("\t", $rawValue)
: [];
$row[$fieldName . '_bd'] = [];
foreach ($resultDatas as $rval) {
$rval = trim($rval);
if ($rval === '') continue;
$row[$fieldName . '_bd'][] = [
'key' => $rval,
'value' => isset($map[$rval])
? t_var($map[$rval])
: t_var($rval),
];
}
break;
case 'query':
// Evaluates PHP/SQL expression from schema; result is cached statically
$query = function_exists('getEvalOutput')
? getEvalOutput(isset($field['optionsQuery']) ? $field['optionsQuery'] : '')
: (isset($field['optionsQuery']) ? $field['optionsQuery'] : '');
$cacheKey = md5($query);
if (!isset(self::$queryCaches[$cacheKey])) {
$db = Db::getInstance();
self::$queryCaches[$cacheKey] = self::stringifyRows($db->executeS($query) ?: []);
}
// Extract table name from the query for tableName field
$tableQuery = null;
if (preg_match('/FROM\s+(\S+)/i', $query, $m)) {
$tableQuery = str_replace($TABLE_PREFIX, '', trim($m[1]));
}
$result = array_values(array_filter(
self::$queryCaches[$cacheKey],
function ($rec) use ($rawValue) {
$firstVal = reset($rec);
return $rawValue == $firstVal;
}
));
$row[$fieldName . '_bd'] = array_map(function ($rec) use ($tableQuery) {
if ($tableQuery) $rec['tableName'] = $tableQuery;
return $rec;
}, $result);
break;
case 'table':
// Resolved in batchLoadRelations() after the main loop.
// Only pre-set the key when relations will actually be loaded;
// with depth=0 CocoDB leaves these keys absent entirely.
if ($relationsDepth > 0 && !isset($row[$fieldName . '_bd'])) {
$row[$fieldName . '_bd'] = [];
}
break;
}
break;
case 'multitext':
$row[$fieldName . '_bd'] = $rawValue !== null ? json_decode($rawValue, true) : null;
break;
}
}
// ── Post-processing (Phase 5) ─────────────────────────────────────
self::applyPostProcessing($row, $schema, $options);
}
/**
* Batch-resolves all list/table relations for an entire result set.
* Called from get() after the per-record loop so we can collect all
* needed IDs first and issue one IN query per relation field.
*
* Mutates $records in place, populating fieldName_bd arrays.
*/
private static function batchLoadRelations(
array &$records,
array $schema,
array $options,
$prefix
) {
if (empty($records) || (int) $options['relationsDepth'] <= 0) return;
$relationsFilter = $options['relations'];
$subDepth = (int) $options['relationsDepth'] - 1;
$definitions = [];
$pools = [];
// Group fields sharing the same related table and key in one IN query.
foreach ($schema as $fieldName => $field) {
if (!is_array($field)) continue;
if (($field['type'] ?? null) !== 'list' || ($field['optionsType'] ?? '') !== 'table') continue;
if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue;
$relTable = $field['optionsTablename'] ?? null;
$relValueField = $field['optionsValueField'] ?? 'num';
$listType = $field['listType'] ?? 'pulldown';
if (!$relTable) continue;
$poolKey = $relTable . '|' . $relValueField;
$definitions[$fieldName] = ['poolKey' => $poolKey, 'listType' => $listType];
if (!isset($pools[$poolKey])) {
$pools[$poolKey] = ['table' => $relTable, 'valueField' => $relValueField, 'ids' => [], 'byId' => []];
}
foreach ($records as $record) {
$raw = $record[$fieldName] ?? '';
$ids = $listType === 'pulldownMulti'
? array_filter(explode("\t", (string) $raw))
: (($raw !== null && $raw !== '') ? [$raw] : []);
foreach ($ids as $id) $pools[$poolKey]['ids'][(string) $id] = true;
}
}
foreach ($pools as &$pool) {
$ids = array_keys($pool['ids']);
if (!$ids) continue;
$related = self::get($pool['table'], $pool['valueField'] . ' IN (' . implode(',', $ids) . ')', null, null, [
'relationsDepth' => $subDepth,
'translates' => $options['translates'],
'uploads' => $options['uploads'],
'prefix' => $prefix,
]);
foreach ($related as $relatedRecord) {
if (array_key_exists($pool['valueField'], $relatedRecord)) {
$pool['byId'][(string) $relatedRecord[$pool['valueField']]] = $relatedRecord;
}
}
}
unset($pool);
foreach ($definitions as $fieldName => $definition) {
$byId = $pools[$definition['poolKey']]['byId'];
foreach ($records as &$record) {
$raw = $record[$fieldName] ?? '';
if ($definition['listType'] === 'pulldownMulti') {
$ids = array_filter(explode("\t", (string) $raw));
if (!$ids) {
unset($record[$fieldName . '_bd']);
continue;
}
$record[$fieldName . '_bd'] = array_values(array_filter(array_map(
function ($id) use ($byId) { return $byId[(string) $id] ?? null; }, $ids
)));
} else {
if ($raw === null || $raw === '') {
unset($record[$fieldName . '_bd']);
continue;
}
$record[$fieldName . '_bd'] = isset($byId[(string) $raw]) ? [$byId[(string) $raw]] : [];
}
}
unset($record);
}
}
// PHASE 5 — POST-PROCESSING (per record)
// ═══════════════════════════════════════════════════════════════════
/**
* Sets breadcrumb helper fields and applies translations.
* Runs after relation/upload injection so all data is present.
*/
private static function applyPostProcessing(array &$row, array $schema, array $options)
{
// Breadcrumb metadata
$breadcrumbField = isset($schema['breadcrumbField']) ? $schema['breadcrumbField'] : '';
$row['breadcrumbField'] = $breadcrumbField;
if ($breadcrumbField === 'parentNum') {
$row['optionsTablename'] = $row['tableName'];
$row['optionsValueField'] = 'num';
} elseif ($breadcrumbField && isset($schema[$breadcrumbField])) {
$row['optionsTablename'] = isset($schema[$breadcrumbField]['optionsTablename'])
? $schema[$breadcrumbField]['optionsTablename'] : null;
$row['optionsValueField'] = isset($schema[$breadcrumbField]['optionsValueField'])
? $schema[$breadcrumbField]['optionsValueField'] : null;
}
// Main display field for link/breadcrumb generation
$row['mainFieldBreadcrumb'] = null;
foreach (['name', 'title', 'titulo', 'nombre'] as $candidate) {
if (!empty($row[$candidate])) {
$row['mainFieldBreadcrumb'] = $row[$candidate];
break;
}
}
if ($row['mainFieldBreadcrumb'] === null) {
// Fall back to first textfield in schema order
foreach ($schema as $key => $field) {
if (!is_array($field)) continue;
if (isset($field['type']) && $field['type'] === 'textfield' && $key !== 'enlace') {
$row['mainFieldBreadcrumb'] = isset($row[$key]) ? $row[$key] : null;
break;
}
}
}
}
/**
* Applies translations to a fully hydrated record tree.
*
* Required keys are collected first and fetched in batches grouped by
* language and table. The request-local maps also prevent repeated work
* when nested get() calls return records later embedded in a parent query.
*/
private static function applyTranslations(array &$record, $language = null)
{
global $TABLE_PREFIX;
$language = $language ?: (isset($_REQUEST['idioma']) ? $_REQUEST['idioma'] : null);
if (!$language) return;
if (self::$allowedTranslateFields === null) {
$db = Db::getInstance();
$fields = $db->executeS("SELECT DISTINCT fieldName FROM `{$TABLE_PREFIX}traducciones`");
self::$allowedTranslateFields = array_flip(array_column($fields ?: [], 'fieldName'));
}
// Spanish is the source content: parse it directly and never query
// cms_traducciones, which only stores en/de/fr in this project.
if ($language === 'es') {
self::applyTranslationValues($record, $language, []);
return;
}
static $knownKeys = [];
static $translatedValues = [];
$requests = [];
self::collectTranslationRequests($record, $language, $requests);
$missing = [];
foreach ($requests as $key => $meta) {
if (!isset($knownKeys[$key])) {
$knownKeys[$key] = true;
$missing[$key] = $meta;
}
}
if ($missing) {
$groups = [];
foreach ($missing as $meta) {
$groupKey = $meta['tableName'];
$groups[$groupKey]['recordNums'][$meta['recordNum']] = true;
$groups[$groupKey]['fieldNames'][$meta['fieldName']] = true;
}
$db = Db::getInstance();
foreach ($groups as $tableName => $group) {
$fieldNames = array_keys($group['fieldNames']);
if (!$fieldNames) continue;
foreach (array_chunk(array_keys($group['recordNums']), 400) as $recordNums) {
$recordPh = implode(',', array_fill(0, count($recordNums), '?'));
$fieldPh = implode(',', array_fill(0, count($fieldNames), '?'));
$params = array_merge([$language, $tableName], $recordNums, $fieldNames);
$rows = $db->executeS(
"SELECT num, tableName, recordNum, fieldName, uploadNum, fieldValue
FROM `{$TABLE_PREFIX}traducciones`
WHERE prefix = ?
AND tableName = ?
AND recordNum IN ($recordPh)
AND fieldName IN ($fieldPh)
ORDER BY num ASC",
$params
) ?: [];
$rows = self::stringifyRows($rows); // paridad de tipos con mysql_* legacy
foreach ($rows as $row) {
$uploadNum = $row['tableName'] === 'uploads'
? (int) $row['uploadNum']
: null;
$key = self::translationCacheKey(
$language,
$row['tableName'],
(int) $row['recordNum'],
$row['fieldName'],
$uploadNum
);
// Preserve the previous LIMIT 1 behaviour when duplicates exist.
if (!array_key_exists($key, $translatedValues)) {
$translatedValues[$key] = $row['fieldValue'];
}
}
}
}
}
self::applyTranslationValues($record, $language, $translatedValues);
}
/** Collects unique translation lookup keys from a nested record tree. */
private static function collectTranslationRequests(array $node, $language, array &$requests)
{
foreach ($node as $fieldName => $value) {
if (is_array($value)) {
self::collectTranslationRequests($value, $language, $requests);
continue;
}
if (!isset(self::$allowedTranslateFields[$fieldName])) continue;
$meta = self::translationMeta($node, $fieldName);
if (!$meta) continue;
$key = self::translationCacheKey(
$language,
$meta['tableName'],
$meta['recordNum'],
$meta['fieldName'],
$meta['uploadNum']
);
$requests[$key] = $meta;
}
}
/** Builds the legacy translation identity for regular and upload records. */
private static function translationMeta(array $record, $fieldName)
{
if (empty($record['num']) || empty($record['tableName'])) return null;
if (!empty($record['urlPath']) || !empty($record['info1'])) {
return [
'tableName' => 'uploads',
'recordNum' => (int) ($record['recordNum'] ?? 0),
'fieldName' => $fieldName,
'uploadNum' => (int) $record['num'],
];
}
return [
'tableName' => $record['tableName'],
'recordNum' => (int) $record['num'],
'fieldName' => $fieldName,
'uploadNum' => null,
];
}
/** Creates an unambiguous key for the request-local translation map. */
private static function translationCacheKey($language, $tableName, $recordNum, $fieldName, $uploadNum)
{
return json_encode([$language, $tableName, (int) $recordNum, $fieldName, $uploadNum]);
}
/** Applies source or translated values recursively without further SQL. */
private static function applyTranslationValues(array &$node, $language, array $translatedValues)
{
global $hashTraducciones;
foreach ($node as $fieldName => &$value) {
if (is_array($value)) {
self::applyTranslationValues($value, $language, $translatedValues);
continue;
}
if (!isset(self::$allowedTranslateFields[$fieldName])) continue;
$translated = null;
$hasTranslation = false;
$meta = self::translationMeta($node, $fieldName);
if ($language !== 'es' && $meta) {
$key = self::translationCacheKey(
$language,
$meta['tableName'],
$meta['recordNum'],
$meta['fieldName'],
$meta['uploadNum']
);
if (array_key_exists($key, $translatedValues)) {
$translated = $translatedValues[$key];
$hasTranslation = true;
}
}
$rawValue = $hasTranslation ? $translated : $value;
if ($hasTranslation && function_exists('parsea_texto2')) {
$rawValue = parsea_texto2($rawValue);
}
$value = class_exists('CocoParser')
? CocoParser::parsea_codigos_en_linea($rawValue)
: $rawValue;
// Keep the legacy per-request t() cache coherent for later direct calls.
if ($meta) {
$legacyHash = md5(
$meta['uploadNum'] !== null
? $meta['uploadNum'] . $fieldName . 'uploads' . $language . json_encode($_REQUEST)
: $meta['recordNum'] . $fieldName . $meta['tableName'] . $language . json_encode($_REQUEST)
);
$hashTraducciones[$legacyHash] = $value;
}
if (isset($node[$fieldName . '_bd'])
&& is_string($value)
&& substr($value, 0, 1) === '['
&& substr($value, -1) === ']'
) {
$node[$fieldName . '_bd'] = json_decode($value, true);
}
}
unset($value);
}
/** Assembles the meta array returned with withMetas=true. */
private static function buildMeta($totalRecords, $perPage)
{
return [
'totalRecords' => $totalRecords,
'perPage' => $perPage,
'totalPages' => ceil($totalRecords / max(1, $perPage)),
];
}
// ═══════════════════════════════════════════════════════════════════
// WRITE HELPERS (private)
// ═══════════════════════════════════════════════════════════════════
/**
* Builds the SET clause for an INSERT or UPDATE using positional PDO params.
* Upload fields (type=upload) have no column in the main table and are
* returned separately for post-processing.
*
* @return array [$setParts, $params, $uploadFields]
* $setParts string[] e.g. ['`title` = ?', '`num` = NULL', ...]
* $params mixed[] positional values matching each `?`
* $uploadFields array [fieldName => [urlPath, ...]]
*/
/**
* Devuelve las filas con TODOS los valores escalares como string (NULL se
* mantiene NULL) — la semantica exacta del mysql_* legacy. PDO con
* prepares nativos devuelve tipos reales (int/float) y 20 años de
* templates/codigo Acai comparan estrictamente contra strings
* ("depth is same as('0')", === "1"...): sin esta normalizacion los
* filtros estrictos fallan EN SILENCIO (p.ej. menu del header vacio en
* webs importadas). Se aplica en todos los paths de lectura que devuelven
* datos hacia fuera de la clase.
*/
private static function stringifyRows($rows)
{
if (!is_array($rows)) return [];
foreach ($rows as $i => $row) {
if (!is_array($row)) continue;
foreach ($row as $k => $v) {
if ($v !== null && is_scalar($v) && !is_string($v)) {
$rows[$i][$k] = (string) $v;
}
}
}
return $rows;
}
private static function buildSetClause(
array $record,
array $schema,
array $functions,
$ignoreSchema,
$isInsert,
$forceNum
) {
$now = date('Y-m-d H:i:s');
$user = 1;
if (class_exists('API') && isset(API::$user['num'])) {
$user = (int) API::$user['num'] ?: 1;
}
$menuType = isset($schema['menuType']) ? $schema['menuType'] : '';
$setParts = [];
$params = [];
$uploadFields = [];
// ── Audit fields — not overridable by the caller ─────────────────
// Solo con esquema: las tablas raw (ignoreSchema — uploads,
// traducciones, aux_*...) no tienen columnas de auditoria y el
// INSERT/UPDATE reventaria con "Unknown column". Misma semantica que
// CocoDB legacy (prepareBaseSQL solo añadia estos campos con schema).
if (!$ignoreSchema) {
$setParts[] = '`updatedDate` = ?'; $params[] = $now;
$setParts[] = '`updatedByUserNum` = ?'; $params[] = $user;
}
// ── INSERT-only defaults ──────────────────────────────────────────
if ($isInsert) {
// Auto-increment num — use literal NULL; forceNum overrides
if ($forceNum && isset($record['num'])) {
$setParts[] = '`num` = ?';
$params[] = (int) $record['num'];
} else {
$setParts[] = '`num` = NULL';
}
if (!$ignoreSchema && !isset($record['createdDate'])) {
$setParts[] = '`createdDate` = ?';
$params[] = $now;
}
if (!$ignoreSchema && !isset($record['createdByUserNum'])) {
$setParts[] = '`createdByUserNum` = ?';
$params[] = $user;
}
// Category tree initialisation
if ($menuType === 'category') {
$catDefaults = [
'globalOrder' => 0,
'siblingOrder' => 0,
'depth' => 0,
'parentNum' => 0,
'lineage' => '',
'breadcrumb' => '',
];
foreach ($catDefaults as $field => $default) {
if (!isset($record[$field])) {
$setParts[] = "`$field` = ?";
$params[] = $default;
}
}
}
// Multi table drag-sort initialisation
if ($menuType === 'multi' && !isset($record['dragSortOrder'])) {
$setParts[] = '`dragSortOrder` = ?';
$params[] = time();
}
}
// ── User-supplied fields ─────────────────────────────────────────
// num is always protected unless forceNum is set
$skip = $forceNum ? [] : ['num'];
foreach ($record as $key => $value) {
if (in_array($key, $skip, true)) continue;
$schemaField = isset($schema[$key]) ? $schema[$key] : null;
// When a schema is available, silently drop fields not declared in it
if (!$ignoreSchema && $schema && $schemaField === null) continue;
// Upload fields: no column in the main table — collected for later
if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'upload') {
$vals = is_array($value) ? $value : array_filter([$value]);
if ($vals) $uploadFields[$key] = array_values($vals);
continue;
}
// Apply custom per-field callback, or schema-driven type coercion
if (isset($functions[$key]) && is_callable($functions[$key])) {
$value = $functions[$key]($value);
} elseif (!$ignoreSchema && $schemaField) {
$value = self::coerceValue($value, $schemaField);
}
if ($value === null) continue;
if (is_array($value)) $value = json_encode($value);
$setParts[] = "`$key` = ?";
$params[] = $value;
}
return [$setParts, $params, $uploadFields];
}
/**
* Coerces a value to its correct database representation
* based on the schema field definition.
*/
private static function coerceValue($value, array $field)
{
$type = isset($field['type']) ? $field['type'] : '';
switch ($type) {
case 'checkbox':
// Stored as tinyint 0/1
return $value ? 1 : 0;
case 'list':
// Multi-select lists store values tab-delimited: \t1\t3\t7\t
if (isset($field['listType']) && $field['listType'] === 'pulldownMulti'
&& is_array($value)
) {
return "\t" . implode("\t", $value) . "\t";
}
break;
case 'multitext':
// Structured repeater stored as JSON
if (is_array($value)) return json_encode($value);
break;
}
return $value;
}
/**
* Builds a parameterised WHERE clause compatible with CocoDB's parse_where().
* Populates $params with positional PDO values.
*
* Input formats:
* null / '' → '' (no WHERE)
* 'num=1 AND visible=1' → passed through as raw SQL
* ['field' => 'value', ...] → simple equality conditions (AND)
* [['column','value','operator', → structured conditions
* 'or','not','raw_key'], ...]
*
* Operators: = != < > <= >= LIKE IN IS NULL
* Flags: or → join to previous condition with OR (wraps in parentheses)
* not → NOT prefix
* raw_key → skip backtick quoting on the column name
*
* @param mixed $where
* @param array $params Populated by reference
* @return string|null SQL fragment (no WHERE keyword), or null on invalid input
*/
private static function buildWhere($where, array &$params = [])
{
if (!$where && $where !== '0') return '';
if (is_string($where)) return $where;
if (!is_array($where)) return '';
$parts = [];
$links = [];
$addParenthesis = false;
foreach ($where as $key => $w) {
if (is_array($w)) {
if (!isset($w['column']) || !array_key_exists('value', $w)) return null;
$operator = strtoupper(isset($w['operator']) ? $w['operator'] : '=');
$not = !empty($w['not']) ? 'NOT ' : '';
$link = !empty($w['or']) ? 'OR' : 'AND';
if (!empty($w['or'])) $addParenthesis = true;
$col = empty($w['raw_key']) ? $w['column'] : '`' . $w['column'] . '`';
switch ($operator) {
case 'LIKE':
$parts[] = "$col {$not}LIKE ?";
$params[] = $w['value'];
break;
case 'IS NULL':
// IS NULL / IS NOT NULL take no parameter
$parts[] = $not ? "$col IS NOT NULL" : "$col IS NULL";
break;
case 'IN':
$vals = (array) $w['value'];
$ph = implode(',', array_fill(0, count($vals), '?'));
$parts[] = "$col {$not}IN ($ph)";
foreach ($vals as $v) $params[] = $v;
break;
default:
if (!in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) {
$operator = '=';
}
// "col NOT = ?" is invalid SQL; wrap as NOT (col op ?) instead
if ($not && in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) {
$parts[] = "NOT ($col $operator ?)";
} else {
$parts[] = "$col $not$operator ?";
}
$params[] = $w['value'];
}
$links[] = $link;
} else {
// Simple associative key => value
$parts[] = "`$key` = ?";
$params[] = $w;
$links[] = 'AND';
}
}
if (empty($parts)) return '';
// Join conditions: links[$i] is the separator BEFORE parts[$i]
$sql = $parts[0];
for ($i = 1, $n = count($parts); $i < $n; $i++) {
$sql .= ' ' . $links[$i] . ' ' . $parts[$i];
}
return $addParenthesis ? "($sql)" : $sql;
}
// ═══════════════════════════════════════════════════════════════════
// SCHEMA (private)
// ═══════════════════════════════════════════════════════════════════
/**
* Loads and caches the schema for a table.
* loadSchema() strips TABLE_PREFIX internally, so both 'blog' and
* 'cms_blog' resolve to the same file.
*/
static function schema($table)
{
if (!isset(self::$schemaCache[$table])) {
self::$schemaCache[$table] = loadSchema($table) ?: [];
}
return self::$schemaCache[$table];
}
// ═══════════════════════════════════════════════════════════════════
// ERROR HANDLING
// ═══════════════════════════════════════════════════════════════════
static function error(array $data = [])
{
if (class_exists('API') && class_exists('ApiError')) {
API::$die = true;
API::error(new ApiError(json_encode($data)));
} else {
die(json_encode($data));
}
}
// ═══════════════════════════════════════════════════════════════════
// CATEGORY METADATA (adapted from CocoDB, uses PDO)
// ═══════════════════════════════════════════════════════════════════
/**
* Rebuilds globalOrder / siblingOrder / depth / lineage / breadcrumb
* for all records in a category-type table.
*/
static function updateCategoryMetadata($table, $prefix = '')
{
global $TABLE_PREFIX;
if (!$prefix) $prefix = $TABLE_PREFIX;
$fullTable = $prefix . str_replace($prefix, '', $table);
$schema = self::schema($fullTable);
if (!isset($schema['menuType']) || $schema['menuType'] !== 'category') return;
$db = Db::getInstance();
$rows = $db->executeS("SELECT * FROM `$fullTable` ORDER BY globalOrder ASC");
if (empty($rows)) return;
$byNum = [];
$childNums = [];
foreach ($rows as $row) {
$byNum[$row['num']] = $row;
$childNums[(int) $row['parentNum']][] = $row['num'];
}
self::_rebuildCategoryBranch([
'branchParent' => 0,
'records' => &$byNum,
'childNodes' => $childNums,
]);
foreach ($byNum as $num => $cat) {
$db->execute(
"UPDATE `$fullTable`
SET globalOrder = ?,
siblingOrder = ?,
depth = ?,
lineage = ?,
breadcrumb = ?
WHERE num = ?",
[
$cat['globalOrder'],
$cat['siblingOrder'],
$cat['depth'],
$cat['lineage'],
$cat['breadcrumb'],
$num,
]
);
}
}
/**
* Recursive depth-first traversal that assigns order/depth/lineage/breadcrumb.
*/
private static function _rebuildCategoryBranch(array $args)
{
if (!isset($args['globalOrder'])) $args['globalOrder'] = 0;
if (!isset($args['depth'])) $args['depth'] = 0;
if (!isset($args['lineage'])) $args['lineage'] = ':';
$children = isset($args['childNodes'][$args['branchParent']])
? $args['childNodes'][$args['branchParent']]
: [];
if (empty($children)) return;
// Sort by current siblingOrder ascending
$sortable = [];
foreach ($children as $childNum) {
$sortable[$childNum] = isset($args['records'][$childNum]['siblingOrder'])
? (int) $args['records'][$childNum]['siblingOrder']
: 0;
}
asort($sortable);
$siblingOrder = 0;
foreach (array_keys($sortable) as $childNum) {
$r = &$args['records'][$childNum];
$r['globalOrder'] = ++$args['globalOrder'];
$r['siblingOrder'] = ++$siblingOrder;
$r['depth'] = $args['depth'];
$r['lineage'] = $args['lineage'] . $childNum . ':';
$r['breadcrumb'] = isset($args['breadcrumb'])
? $args['breadcrumb'] . ' : ' . (isset($r['name']) ? $r['name'] : '')
: (isset($r['name']) ? $r['name'] : '');
if (!empty($args['childNodes'][$childNum])) {
self::_rebuildCategoryBranch([
'branchParent' => $childNum,
'globalOrder' => &$args['globalOrder'],
'records' => &$args['records'],
'childNodes' => $args['childNodes'],
'depth' => $args['depth'] + 1,
'lineage' => $r['lineage'],
'breadcrumb' => $r['breadcrumb'],
]);
}
}
}
}