Files
acai-vscode-webbase/cms/lib/classes/AcaiAPI.php
Jordan Diaz 6397abfabb fix(AcaiAPI): fecha vacia, sellado de auditoria y escapado de ids
1) Un campo `date` con valor vacio rompia TODA insercion en la tabla.

   MySQL rechaza la cadena vacia en una columna datetime ("Incorrect datetime
   value") tanto si es NOT NULL como si es nullable, y bajo el sql_mode
   TRADITIONAL que fuerza Db.php no hay ningun valor "vacio" valido: '' falla,
   NULL falla si la columna es NOT NULL, '0000-00-00' falla por NO_ZERO_DATE y
   omitir la columna falla por no tener default. Bastaba UN campo fecha para que
   dejara de entrar cualquier registro, con un 400 "Error al insertar" sin
   detalle.

   El Admin clasico nunca se topa con esto porque ya lo resuelve:
   menus/default/save.php:287 pone la fecha actual cuando el campo no viene
   ("default date fields to current date"). AcaiAPI nunca replico esa regla —
   coerceValue tiene casos para checkbox, list y multitext, pero no para date.
   Ahora en INSERT hace lo mismo que el Admin; en UPDATE omite la columna, para
   no pisar con "hoy" un valor ya guardado solo porque venga la clave vacia.

   Se descarto la alternativa de crear la columna como NULL DEFAULT NULL: no
   arregla ninguna de las tablas ya existentes (el sync copia ficheros, no
   ejecuta ALTER) y ademas '' seguiria fallando igual.

2) Auditoria: `updatedDate`/`updatedByUserNum` pasan a respetar el valor que
   traiga el caller y generarse solo si no viene. Antes se pisaban siempre
   ("not overridable by the caller"). Alineado con la intencion del cambio de
   Anael en core_client, pero con los guards FUERA del if ($isInsert): dentro,
   un UPDATE se quedaba sin sellar la fecha porque no actuaba ninguna rama.
   No hay doble escritura: cuando el caller lo trae, lo escribe el bucle de
   campos y el guard evita que el bloque de auditoria lo repita en el SET.

3) Seguridad: los ids de las relaciones se escapan antes de interpolarlos en el
   IN (...). Salen del valor guardado de un campo `list`, que es un mediumtext
   sin validacion, asi que era una inyeccion SQL de segundo orden. Ya estaba
   corregido en core_client y faltaba aqui.

Ref: docs/escalados/estado.md 3.1 (repo acai-vscode-plugin)
2026-08-25 15:06:21 +00:00

1761 lines
77 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;
// Los ids salen del valor guardado del campo `list` (mediumtext), asi
// que se escapan antes de interpolarlos: sin esto es una inyeccion SQL
// de segundo orden. Alineado con core_client.
$ids = array_map(function ($id) { return mysql_real_escape_string((string) $id); }, $ids);
$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).
// El caller manda si trae el valor; si no, se genera. Aplica a INSERT y a
// UPDATE: un update sin updatedDate explicita tiene que sellar la fecha.
// (El bucle de campos de abajo escribe el valor del caller cuando viene,
// asi que el guard evita ponerlo dos veces en el SET.)
if (!$ignoreSchema && !isset($record['updatedDate'])) {
$setParts[] = '`updatedDate` = ?'; $params[] = $now;
}
if (!$ignoreSchema && !isset($record['updatedByUserNum'])) {
$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);
}
// Un campo `date` NUNCA acepta cadena vacia: MySQL la rechaza con
// "Incorrect datetime value" tanto si la columna es NOT NULL como si
// es nullable. El Admin clasico ya lo resuelve poniendo la fecha
// actual cuando el campo no viene (menus/default/save.php:287,
// "default date fields to current date"), pero AcaiAPI nunca replico
// esa regla: por eso un alta con la fecha vacia moria con un 400
// "Error al insertar" sin ningun detalle, y bastaba UN campo fecha
// para que dejara de entrar cualquier registro en la tabla.
// - INSERT: mismo comportamiento que el Admin -> fecha actual.
// - UPDATE: se omite la columna, para no pisar con "hoy" un valor
// ya guardado solo porque el caller mando la clave vacia.
if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'date'
&& ($value === null || (is_string($value) && trim($value) === ''))) {
if (!$isInsert) continue;
$value = $now;
}
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'],
]);
}
}
}
}