AcaiAPI: PDO-based data access layer (prepared statements, JOIN relation loading, batch upload fetching) developed on villagrancanaria. CocoDB_alias declares the CocoDB facade delegating to AcaiAPI; the legacy implementation stays available as CocoDB_old.
1689 lines
73 KiB
PHP
1689 lines
73 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;
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
$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;
|
|
|
|
// ── 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 = [];
|
|
|
|
// ── 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(' ', ' ', 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(' ', ' ', 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 = $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
|
|
);
|
|
|
|
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] = $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
|
|
) ?: [];
|
|
|
|
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, ...]]
|
|
*/
|
|
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 — always written, not overridable by the caller ──
|
|
$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 (!isset($record['createdDate'])) {
|
|
$setParts[] = '`createdDate` = ?';
|
|
$params[] = $now;
|
|
}
|
|
if (!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'],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|