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 = []; $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 = '' . '
'; foreach (self::$debugData as $cont => $q) { if ($index > -1 && $cont != $index) continue; $result .= '
' . '
' . $q['hora'] . '
'; if (self::$backTracePoint) $result .= self::$backTracePoint . '
'; $result .= $q['query'] . '
' . $q['time'] . ' microsegundos
' . "" . "" . '
' . '' . '
'; } return $result . '
'; } static function setBacktracePoint($string) { self::$backTracePoint = $string; } static function getTrackData() { return self::$trackData; } static function setTrackData($init = false, $type = null, $id = null, $data = []) { if ($init) { $pushedData = [ 'ip' => $_SERVER['REMOTE_ADDR'], 'timestamp' => round(floatval(microtime(true) * 1000), 4), 'totalTime' => 0, 'host' => $_SERVER['HTTP_HOST'], 'url' => $_SERVER['REQUEST_URI'], 'trackData' => [], ]; self::$trackData[] = $pushedData; } else { $last = count(self::$trackData) - 1; $prevTimestamp = self::$trackData[$last]['timestamp']; $pushedData = [ 'timestamp' => round((floatval(microtime(true) * 1000) - $prevTimestamp), 4), 'type' => $type, 'id' => $id, 'transferKeys' => !isset($data[0]) ? array_keys($data) : (is_array($data[0]) ? array_keys($data[0]) : ['undefined' => $data[0]]), ]; self::$trackData[$last]['trackData'][] = $pushedData; self::$trackData[$last]['totalTime'] = $pushedData['timestamp']; $prevPercent = 0; foreach (self::$trackData[$last]['trackData'] as $c => $td) { $pct = ($td['timestamp'] * 100) / self::$trackData[$last]['totalTime']; self::$trackData[$last]['trackData'][$c]['percent'] = round($pct, 2); self::$trackData[$last]['trackData'][$c]['initPercent'] = $prevPercent; self::$trackData[$last]['trackData'][$c]['widthPercent'] = $pct - $prevPercent; $prevPercent = $pct; } } return $pushedData; } /** * Carga y cachea la configuración de plugins desde aux_plg_config. * Misma firma que CocoDB::getPluginsConfig(). */ static function getPluginsConfig($table, $where) { global $TABLE_PREFIX; if (!self::$pluginsConfig) { $db = Db::getInstance(); $configTable = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table) ?: "aux_plg_config"; $rows = self::stringifyRows($db->executeS("SELECT * FROM `".$configTable."` ORDER BY num DESC") ?: []); self::$pluginsConfig = []; foreach ($rows as $row) { if (!isset(self::$pluginsConfig[$row['plugin']])) { self::$pluginsConfig[$row['plugin']] = $row; } } } $key = str_replace([' ', "'", '"', 'plugin='], '', strtolower(trim($where))); return isset(self::$pluginsConfig[$key]) ? self::$pluginsConfig[$key] : []; } /** * Aplica traducciones a un registro completo. * Alias público de applyTranslations() para compatibilidad con * CocoDB::t_recursivo(). */ static function t_recursivo($record, $idx = null) { if (!is_array($record)) return $record; self::applyTranslations($record); return $record; } // ── Internal read helpers (stubs — expanded in Phases 3-5) ────────── // ═══════════════════════════════════════════════════════════════════ // PHASE 4 — BATCH UPLOAD LOADING // ═══════════════════════════════════════════════════════════════════ /** * Loads all uploads for a result set in ONE query per upload field * (vs CocoDB's one query per record). * * Returns an index structured as: * $index[$fieldName][$recordNum] = [upload_row, upload_row, ...] */ private static function batchLoadUploads(array $rows, $tableName, array $schema, array $options, $prefix) { // Collect upload field names declared in schema $uploadFields = []; foreach ($schema as $fieldName => $field) { if (is_array($field) && isset($field['type']) && $field['type'] === 'upload') { $uploadFields[] = $fieldName; } } if (empty($uploadFields)) return []; // Collect all record nums from the result $nums = array_filter(array_column($rows, 'num')); if (empty($nums)) return []; $db = Db::getInstance(); $ph = implode(',', array_fill(0, count($nums), '?')); $fieldPh = implode(',', array_fill(0, count($uploadFields), '?')); $selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, ' . 'info1, info2, info3, info4, info5, alt'; $params = array_merge(array_values($nums), [$tableName], array_values($uploadFields)); $uploads = $db->executeS( "SELECT $selectCols FROM `{$prefix}uploads` WHERE recordNum IN ($ph) AND tableName = ? AND fieldName IN ($fieldPh) ORDER BY `order` ASC", $params ); $uploads = self::stringifyRows($uploads); // paridad de tipos con mysql_* legacy if (empty($uploads)) return []; // Re-index for O(1) lookup $index = []; foreach ($uploads as $upload) { if ($options['useAbsoluteUrls'] && isset($upload['urlPath']) && strpos($upload['urlPath'], '/cms') === 0 ) { $upload['urlPath'] = 'https://' . $_SERVER['HTTP_HOST'] . $upload['urlPath']; } $field = $upload['fieldName']; $recNum = $upload['recordNum']; $index[$field][$recNum][] = $upload; } return $index; } /** * Injects upload arrays from the pre-built index into a single record row. * Each upload field becomes an array of upload objects (same as CocoDB). */ private static function injectUploads(array &$row, $tableName, array $schema, array $uploadsIndex) { if (!isset($row['num'])) return; foreach ($schema as $fieldName => $field) { if (!is_array($field) || !isset($field['type']) || $field['type'] !== 'upload') continue; $row[$fieldName] = isset($uploadsIndex[$fieldName][$row['num']]) ? $uploadsIndex[$fieldName][$row['num']] : []; } } // ═══════════════════════════════════════════════════════════════════ // PHASE 3 — RELATION LOADING // ═══════════════════════════════════════════════════════════════════ /** * Resolves list relations, decodes multitext, applies translations, * and sets breadcrumb helpers on a single record. * * list/optionsType=text → pure PHP, no DB * list/optionsType=query → getEvalOutput() with per-query static cache * list/optionsType=table → batch-loaded after the main loop (see batchLoadRelations) * multitext → json_decode → _bd */ private static function hydrateRecord(array &$row, $tableName, array $schema, array $options, $prefix) { global $TABLE_PREFIX; $row['tableName'] = $tableName; $relationsFilter = $options['relations']; $relationsDepth = (int) $options['relationsDepth']; foreach ($schema as $fieldName => $field) { if (!is_array($field) || !isset($field['type'])) continue; // Honour the `relations` option — skip fields not in the whitelist if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue; $rawValue = isset($row[$fieldName]) ? $row[$fieldName] : null; switch ($field['type']) { case 'list': $optionsType = isset($field['optionsType']) ? $field['optionsType'] : ''; switch ($optionsType) { case 'text': // Parse inline option list: "value|Label\nvalue2|Label2" $lines = array_filter(explode("\n", isset($field['optionsText']) ? $field['optionsText'] : '')); $map = []; foreach ($lines as $line) { $parts = explode('|', $line); $map[trim($parts[0])] = isset($parts[1]) ? trim($parts[1]) : trim($parts[0]); } $resultDatas = ($rawValue !== null && $rawValue !== '') ? explode("\t", $rawValue) : []; $row[$fieldName . '_bd'] = []; foreach ($resultDatas as $rval) { $rval = trim($rval); if ($rval === '') continue; $row[$fieldName . '_bd'][] = [ 'key' => $rval, 'value' => isset($map[$rval]) ? t_var($map[$rval]) : t_var($rval), ]; } break; case 'query': // Evaluates PHP/SQL expression from schema; result is cached statically $query = function_exists('getEvalOutput') ? getEvalOutput(isset($field['optionsQuery']) ? $field['optionsQuery'] : '') : (isset($field['optionsQuery']) ? $field['optionsQuery'] : ''); $cacheKey = md5($query); if (!isset(self::$queryCaches[$cacheKey])) { $db = Db::getInstance(); self::$queryCaches[$cacheKey] = self::stringifyRows($db->executeS($query) ?: []); } // Extract table name from the query for tableName field $tableQuery = null; if (preg_match('/FROM\s+(\S+)/i', $query, $m)) { $tableQuery = str_replace($TABLE_PREFIX, '', trim($m[1])); } $result = array_values(array_filter( self::$queryCaches[$cacheKey], function ($rec) use ($rawValue) { $firstVal = reset($rec); return $rawValue == $firstVal; } )); $row[$fieldName . '_bd'] = array_map(function ($rec) use ($tableQuery) { if ($tableQuery) $rec['tableName'] = $tableQuery; return $rec; }, $result); break; case 'table': // Resolved in batchLoadRelations() after the main loop. // Only pre-set the key when relations will actually be loaded; // with depth=0 CocoDB leaves these keys absent entirely. if ($relationsDepth > 0 && !isset($row[$fieldName . '_bd'])) { $row[$fieldName . '_bd'] = []; } break; } break; case 'multitext': $row[$fieldName . '_bd'] = $rawValue !== null ? json_decode($rawValue, true) : null; break; } } // ── Post-processing (Phase 5) ───────────────────────────────────── self::applyPostProcessing($row, $schema, $options); } /** * Batch-resolves all list/table relations for an entire result set. * Called from get() after the per-record loop so we can collect all * needed IDs first and issue one IN query per relation field. * * Mutates $records in place, populating fieldName_bd arrays. */ private static function batchLoadRelations( array &$records, array $schema, array $options, $prefix ) { if (empty($records) || (int) $options['relationsDepth'] <= 0) return; $relationsFilter = $options['relations']; $subDepth = (int) $options['relationsDepth'] - 1; $definitions = []; $pools = []; // Group fields sharing the same related table and key in one IN query. foreach ($schema as $fieldName => $field) { if (!is_array($field)) continue; if (($field['type'] ?? null) !== 'list' || ($field['optionsType'] ?? '') !== 'table') continue; if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue; $relTable = $field['optionsTablename'] ?? null; $relValueField = $field['optionsValueField'] ?? 'num'; $listType = $field['listType'] ?? 'pulldown'; if (!$relTable) continue; $poolKey = $relTable . '|' . $relValueField; $definitions[$fieldName] = ['poolKey' => $poolKey, 'listType' => $listType]; if (!isset($pools[$poolKey])) { $pools[$poolKey] = ['table' => $relTable, 'valueField' => $relValueField, 'ids' => [], 'byId' => []]; } foreach ($records as $record) { $raw = $record[$fieldName] ?? ''; $ids = $listType === 'pulldownMulti' ? array_filter(explode("\t", (string) $raw)) : (($raw !== null && $raw !== '') ? [$raw] : []); foreach ($ids as $id) $pools[$poolKey]['ids'][(string) $id] = true; } } foreach ($pools as &$pool) { $ids = array_keys($pool['ids']); if (!$ids) continue; $related = self::get($pool['table'], $pool['valueField'] . ' IN (' . implode(',', $ids) . ')', null, null, [ 'relationsDepth' => $subDepth, 'translates' => $options['translates'], 'uploads' => $options['uploads'], 'prefix' => $prefix, ]); foreach ($related as $relatedRecord) { if (array_key_exists($pool['valueField'], $relatedRecord)) { $pool['byId'][(string) $relatedRecord[$pool['valueField']]] = $relatedRecord; } } } unset($pool); foreach ($definitions as $fieldName => $definition) { $byId = $pools[$definition['poolKey']]['byId']; foreach ($records as &$record) { $raw = $record[$fieldName] ?? ''; if ($definition['listType'] === 'pulldownMulti') { $ids = array_filter(explode("\t", (string) $raw)); if (!$ids) { unset($record[$fieldName . '_bd']); continue; } $record[$fieldName . '_bd'] = array_values(array_filter(array_map( function ($id) use ($byId) { return $byId[(string) $id] ?? null; }, $ids ))); } else { if ($raw === null || $raw === '') { unset($record[$fieldName . '_bd']); continue; } $record[$fieldName . '_bd'] = isset($byId[(string) $raw]) ? [$byId[(string) $raw]] : []; } } unset($record); } } // PHASE 5 — POST-PROCESSING (per record) // ═══════════════════════════════════════════════════════════════════ /** * Sets breadcrumb helper fields and applies translations. * Runs after relation/upload injection so all data is present. */ private static function applyPostProcessing(array &$row, array $schema, array $options) { // Breadcrumb metadata $breadcrumbField = isset($schema['breadcrumbField']) ? $schema['breadcrumbField'] : ''; $row['breadcrumbField'] = $breadcrumbField; if ($breadcrumbField === 'parentNum') { $row['optionsTablename'] = $row['tableName']; $row['optionsValueField'] = 'num'; } elseif ($breadcrumbField && isset($schema[$breadcrumbField])) { $row['optionsTablename'] = isset($schema[$breadcrumbField]['optionsTablename']) ? $schema[$breadcrumbField]['optionsTablename'] : null; $row['optionsValueField'] = isset($schema[$breadcrumbField]['optionsValueField']) ? $schema[$breadcrumbField]['optionsValueField'] : null; } // Main display field for link/breadcrumb generation $row['mainFieldBreadcrumb'] = null; foreach (['name', 'title', 'titulo', 'nombre'] as $candidate) { if (!empty($row[$candidate])) { $row['mainFieldBreadcrumb'] = $row[$candidate]; break; } } if ($row['mainFieldBreadcrumb'] === null) { // Fall back to first textfield in schema order foreach ($schema as $key => $field) { if (!is_array($field)) continue; if (isset($field['type']) && $field['type'] === 'textfield' && $key !== 'enlace') { $row['mainFieldBreadcrumb'] = isset($row[$key]) ? $row[$key] : null; break; } } } } /** * Applies translations to a fully hydrated record tree. * * Required keys are collected first and fetched in batches grouped by * language and table. The request-local maps also prevent repeated work * when nested get() calls return records later embedded in a parent query. */ private static function applyTranslations(array &$record, $language = null) { global $TABLE_PREFIX; $language = $language ?: (isset($_REQUEST['idioma']) ? $_REQUEST['idioma'] : null); if (!$language) return; if (self::$allowedTranslateFields === null) { $db = Db::getInstance(); $fields = $db->executeS("SELECT DISTINCT fieldName FROM `{$TABLE_PREFIX}traducciones`"); self::$allowedTranslateFields = array_flip(array_column($fields ?: [], 'fieldName')); } // Spanish is the source content: parse it directly and never query // cms_traducciones, which only stores en/de/fr in this project. if ($language === 'es') { self::applyTranslationValues($record, $language, []); return; } static $knownKeys = []; static $translatedValues = []; $requests = []; self::collectTranslationRequests($record, $language, $requests); $missing = []; foreach ($requests as $key => $meta) { if (!isset($knownKeys[$key])) { $knownKeys[$key] = true; $missing[$key] = $meta; } } if ($missing) { $groups = []; foreach ($missing as $meta) { $groupKey = $meta['tableName']; $groups[$groupKey]['recordNums'][$meta['recordNum']] = true; $groups[$groupKey]['fieldNames'][$meta['fieldName']] = true; } $db = Db::getInstance(); foreach ($groups as $tableName => $group) { $fieldNames = array_keys($group['fieldNames']); if (!$fieldNames) continue; foreach (array_chunk(array_keys($group['recordNums']), 400) as $recordNums) { $recordPh = implode(',', array_fill(0, count($recordNums), '?')); $fieldPh = implode(',', array_fill(0, count($fieldNames), '?')); $params = array_merge([$language, $tableName], $recordNums, $fieldNames); $rows = $db->executeS( "SELECT num, tableName, recordNum, fieldName, uploadNum, fieldValue FROM `{$TABLE_PREFIX}traducciones` WHERE prefix = ? AND tableName = ? AND recordNum IN ($recordPh) AND fieldName IN ($fieldPh) ORDER BY num ASC", $params ) ?: []; $rows = self::stringifyRows($rows); // paridad de tipos con mysql_* legacy foreach ($rows as $row) { $uploadNum = $row['tableName'] === 'uploads' ? (int) $row['uploadNum'] : null; $key = self::translationCacheKey( $language, $row['tableName'], (int) $row['recordNum'], $row['fieldName'], $uploadNum ); // Preserve the previous LIMIT 1 behaviour when duplicates exist. if (!array_key_exists($key, $translatedValues)) { $translatedValues[$key] = $row['fieldValue']; } } } } } self::applyTranslationValues($record, $language, $translatedValues); } /** Collects unique translation lookup keys from a nested record tree. */ private static function collectTranslationRequests(array $node, $language, array &$requests) { foreach ($node as $fieldName => $value) { if (is_array($value)) { self::collectTranslationRequests($value, $language, $requests); continue; } if (!isset(self::$allowedTranslateFields[$fieldName])) continue; $meta = self::translationMeta($node, $fieldName); if (!$meta) continue; $key = self::translationCacheKey( $language, $meta['tableName'], $meta['recordNum'], $meta['fieldName'], $meta['uploadNum'] ); $requests[$key] = $meta; } } /** Builds the legacy translation identity for regular and upload records. */ private static function translationMeta(array $record, $fieldName) { if (empty($record['num']) || empty($record['tableName'])) return null; if (!empty($record['urlPath']) || !empty($record['info1'])) { return [ 'tableName' => 'uploads', 'recordNum' => (int) ($record['recordNum'] ?? 0), 'fieldName' => $fieldName, 'uploadNum' => (int) $record['num'], ]; } return [ 'tableName' => $record['tableName'], 'recordNum' => (int) $record['num'], 'fieldName' => $fieldName, 'uploadNum' => null, ]; } /** Creates an unambiguous key for the request-local translation map. */ private static function translationCacheKey($language, $tableName, $recordNum, $fieldName, $uploadNum) { return json_encode([$language, $tableName, (int) $recordNum, $fieldName, $uploadNum]); } /** Applies source or translated values recursively without further SQL. */ private static function applyTranslationValues(array &$node, $language, array $translatedValues) { global $hashTraducciones; foreach ($node as $fieldName => &$value) { if (is_array($value)) { self::applyTranslationValues($value, $language, $translatedValues); continue; } if (!isset(self::$allowedTranslateFields[$fieldName])) continue; $translated = null; $hasTranslation = false; $meta = self::translationMeta($node, $fieldName); if ($language !== 'es' && $meta) { $key = self::translationCacheKey( $language, $meta['tableName'], $meta['recordNum'], $meta['fieldName'], $meta['uploadNum'] ); if (array_key_exists($key, $translatedValues)) { $translated = $translatedValues[$key]; $hasTranslation = true; } } $rawValue = $hasTranslation ? $translated : $value; if ($hasTranslation && function_exists('parsea_texto2')) { $rawValue = parsea_texto2($rawValue); } $value = class_exists('CocoParser') ? CocoParser::parsea_codigos_en_linea($rawValue) : $rawValue; // Keep the legacy per-request t() cache coherent for later direct calls. if ($meta) { $legacyHash = md5( $meta['uploadNum'] !== null ? $meta['uploadNum'] . $fieldName . 'uploads' . $language . json_encode($_REQUEST) : $meta['recordNum'] . $fieldName . $meta['tableName'] . $language . json_encode($_REQUEST) ); $hashTraducciones[$legacyHash] = $value; } if (isset($node[$fieldName . '_bd']) && is_string($value) && substr($value, 0, 1) === '[' && substr($value, -1) === ']' ) { $node[$fieldName . '_bd'] = json_decode($value, true); } } unset($value); } /** Assembles the meta array returned with withMetas=true. */ private static function buildMeta($totalRecords, $perPage) { return [ 'totalRecords' => $totalRecords, 'perPage' => $perPage, 'totalPages' => ceil($totalRecords / max(1, $perPage)), ]; } // ═══════════════════════════════════════════════════════════════════ // WRITE HELPERS (private) // ═══════════════════════════════════════════════════════════════════ /** * Builds the SET clause for an INSERT or UPDATE using positional PDO params. * Upload fields (type=upload) have no column in the main table and are * returned separately for post-processing. * * @return array [$setParts, $params, $uploadFields] * $setParts string[] e.g. ['`title` = ?', '`num` = NULL', ...] * $params mixed[] positional values matching each `?` * $uploadFields array [fieldName => [urlPath, ...]] */ /** * Devuelve las filas con TODOS los valores escalares como string (NULL se * mantiene NULL) — la semantica exacta del mysql_* legacy. PDO con * prepares nativos devuelve tipos reales (int/float) y 20 años de * templates/codigo Acai comparan estrictamente contra strings * ("depth is same as('0')", === "1"...): sin esta normalizacion los * filtros estrictos fallan EN SILENCIO (p.ej. menu del header vacio en * webs importadas). Se aplica en todos los paths de lectura que devuelven * datos hacia fuera de la clase. */ private static function stringifyRows($rows) { if (!is_array($rows)) return []; foreach ($rows as $i => $row) { if (!is_array($row)) continue; foreach ($row as $k => $v) { if ($v !== null && is_scalar($v) && !is_string($v)) { $rows[$i][$k] = (string) $v; } } } return $rows; } private static function buildSetClause( array $record, array $schema, array $functions, $ignoreSchema, $isInsert, $forceNum ) { $now = date('Y-m-d H:i:s'); $user = 1; if (class_exists('API') && isset(API::$user['num'])) { $user = (int) API::$user['num'] ?: 1; } $menuType = isset($schema['menuType']) ? $schema['menuType'] : ''; $setParts = []; $params = []; $uploadFields = []; // ── Audit fields — not overridable by the caller ───────────────── // Solo con esquema: las tablas raw (ignoreSchema — uploads, // traducciones, aux_*...) no tienen columnas de auditoria y el // INSERT/UPDATE reventaria con "Unknown column". Misma semantica que // CocoDB legacy (prepareBaseSQL solo añadia estos campos con schema). if (!$ignoreSchema) { $setParts[] = '`updatedDate` = ?'; $params[] = $now; $setParts[] = '`updatedByUserNum` = ?'; $params[] = $user; } // ── INSERT-only defaults ────────────────────────────────────────── if ($isInsert) { // Auto-increment num — use literal NULL; forceNum overrides if ($forceNum && isset($record['num'])) { $setParts[] = '`num` = ?'; $params[] = (int) $record['num']; } else { $setParts[] = '`num` = NULL'; } if (!$ignoreSchema && !isset($record['createdDate'])) { $setParts[] = '`createdDate` = ?'; $params[] = $now; } if (!$ignoreSchema && !isset($record['createdByUserNum'])) { $setParts[] = '`createdByUserNum` = ?'; $params[] = $user; } // Category tree initialisation if ($menuType === 'category') { $catDefaults = [ 'globalOrder' => 0, 'siblingOrder' => 0, 'depth' => 0, 'parentNum' => 0, 'lineage' => '', 'breadcrumb' => '', ]; foreach ($catDefaults as $field => $default) { if (!isset($record[$field])) { $setParts[] = "`$field` = ?"; $params[] = $default; } } } // Multi table drag-sort initialisation if ($menuType === 'multi' && !isset($record['dragSortOrder'])) { $setParts[] = '`dragSortOrder` = ?'; $params[] = time(); } } // ── User-supplied fields ───────────────────────────────────────── // num is always protected unless forceNum is set $skip = $forceNum ? [] : ['num']; foreach ($record as $key => $value) { if (in_array($key, $skip, true)) continue; $schemaField = isset($schema[$key]) ? $schema[$key] : null; // When a schema is available, silently drop fields not declared in it if (!$ignoreSchema && $schema && $schemaField === null) continue; // Upload fields: no column in the main table — collected for later if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'upload') { $vals = is_array($value) ? $value : array_filter([$value]); if ($vals) $uploadFields[$key] = array_values($vals); continue; } // Apply custom per-field callback, or schema-driven type coercion if (isset($functions[$key]) && is_callable($functions[$key])) { $value = $functions[$key]($value); } elseif (!$ignoreSchema && $schemaField) { $value = self::coerceValue($value, $schemaField); } if ($value === null) continue; if (is_array($value)) $value = json_encode($value); $setParts[] = "`$key` = ?"; $params[] = $value; } return [$setParts, $params, $uploadFields]; } /** * Coerces a value to its correct database representation * based on the schema field definition. */ private static function coerceValue($value, array $field) { $type = isset($field['type']) ? $field['type'] : ''; switch ($type) { case 'checkbox': // Stored as tinyint 0/1 return $value ? 1 : 0; case 'list': // Multi-select lists store values tab-delimited: \t1\t3\t7\t if (isset($field['listType']) && $field['listType'] === 'pulldownMulti' && is_array($value) ) { return "\t" . implode("\t", $value) . "\t"; } break; case 'multitext': // Structured repeater stored as JSON if (is_array($value)) return json_encode($value); break; } return $value; } /** * Builds a parameterised WHERE clause compatible with CocoDB's parse_where(). * Populates $params with positional PDO values. * * Input formats: * null / '' → '' (no WHERE) * 'num=1 AND visible=1' → passed through as raw SQL * ['field' => 'value', ...] → simple equality conditions (AND) * [['column','value','operator', → structured conditions * 'or','not','raw_key'], ...] * * Operators: = != < > <= >= LIKE IN IS NULL * Flags: or → join to previous condition with OR (wraps in parentheses) * not → NOT prefix * raw_key → skip backtick quoting on the column name * * @param mixed $where * @param array $params Populated by reference * @return string|null SQL fragment (no WHERE keyword), or null on invalid input */ private static function buildWhere($where, array &$params = []) { if (!$where && $where !== '0') return ''; if (is_string($where)) return $where; if (!is_array($where)) return ''; $parts = []; $links = []; $addParenthesis = false; foreach ($where as $key => $w) { if (is_array($w)) { if (!isset($w['column']) || !array_key_exists('value', $w)) return null; $operator = strtoupper(isset($w['operator']) ? $w['operator'] : '='); $not = !empty($w['not']) ? 'NOT ' : ''; $link = !empty($w['or']) ? 'OR' : 'AND'; if (!empty($w['or'])) $addParenthesis = true; $col = empty($w['raw_key']) ? $w['column'] : '`' . $w['column'] . '`'; switch ($operator) { case 'LIKE': $parts[] = "$col {$not}LIKE ?"; $params[] = $w['value']; break; case 'IS NULL': // IS NULL / IS NOT NULL take no parameter $parts[] = $not ? "$col IS NOT NULL" : "$col IS NULL"; break; case 'IN': $vals = (array) $w['value']; $ph = implode(',', array_fill(0, count($vals), '?')); $parts[] = "$col {$not}IN ($ph)"; foreach ($vals as $v) $params[] = $v; break; default: if (!in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) { $operator = '='; } // "col NOT = ?" is invalid SQL; wrap as NOT (col op ?) instead if ($not && in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) { $parts[] = "NOT ($col $operator ?)"; } else { $parts[] = "$col $not$operator ?"; } $params[] = $w['value']; } $links[] = $link; } else { // Simple associative key => value $parts[] = "`$key` = ?"; $params[] = $w; $links[] = 'AND'; } } if (empty($parts)) return ''; // Join conditions: links[$i] is the separator BEFORE parts[$i] $sql = $parts[0]; for ($i = 1, $n = count($parts); $i < $n; $i++) { $sql .= ' ' . $links[$i] . ' ' . $parts[$i]; } return $addParenthesis ? "($sql)" : $sql; } // ═══════════════════════════════════════════════════════════════════ // SCHEMA (private) // ═══════════════════════════════════════════════════════════════════ /** * Loads and caches the schema for a table. * loadSchema() strips TABLE_PREFIX internally, so both 'blog' and * 'cms_blog' resolve to the same file. */ static function schema($table) { if (!isset(self::$schemaCache[$table])) { self::$schemaCache[$table] = loadSchema($table) ?: []; } return self::$schemaCache[$table]; } // ═══════════════════════════════════════════════════════════════════ // ERROR HANDLING // ═══════════════════════════════════════════════════════════════════ static function error(array $data = []) { if (class_exists('API') && class_exists('ApiError')) { API::$die = true; API::error(new ApiError(json_encode($data))); } else { die(json_encode($data)); } } // ═══════════════════════════════════════════════════════════════════ // CATEGORY METADATA (adapted from CocoDB, uses PDO) // ═══════════════════════════════════════════════════════════════════ /** * Rebuilds globalOrder / siblingOrder / depth / lineage / breadcrumb * for all records in a category-type table. */ static function updateCategoryMetadata($table, $prefix = '') { global $TABLE_PREFIX; if (!$prefix) $prefix = $TABLE_PREFIX; $fullTable = $prefix . str_replace($prefix, '', $table); $schema = self::schema($fullTable); if (!isset($schema['menuType']) || $schema['menuType'] !== 'category') return; $db = Db::getInstance(); $rows = $db->executeS("SELECT * FROM `$fullTable` ORDER BY globalOrder ASC"); if (empty($rows)) return; $byNum = []; $childNums = []; foreach ($rows as $row) { $byNum[$row['num']] = $row; $childNums[(int) $row['parentNum']][] = $row['num']; } self::_rebuildCategoryBranch([ 'branchParent' => 0, 'records' => &$byNum, 'childNodes' => $childNums, ]); foreach ($byNum as $num => $cat) { $db->execute( "UPDATE `$fullTable` SET globalOrder = ?, siblingOrder = ?, depth = ?, lineage = ?, breadcrumb = ? WHERE num = ?", [ $cat['globalOrder'], $cat['siblingOrder'], $cat['depth'], $cat['lineage'], $cat['breadcrumb'], $num, ] ); } } /** * Recursive depth-first traversal that assigns order/depth/lineage/breadcrumb. */ private static function _rebuildCategoryBranch(array $args) { if (!isset($args['globalOrder'])) $args['globalOrder'] = 0; if (!isset($args['depth'])) $args['depth'] = 0; if (!isset($args['lineage'])) $args['lineage'] = ':'; $children = isset($args['childNodes'][$args['branchParent']]) ? $args['childNodes'][$args['branchParent']] : []; if (empty($children)) return; // Sort by current siblingOrder ascending $sortable = []; foreach ($children as $childNum) { $sortable[$childNum] = isset($args['records'][$childNum]['siblingOrder']) ? (int) $args['records'][$childNum]['siblingOrder'] : 0; } asort($sortable); $siblingOrder = 0; foreach (array_keys($sortable) as $childNum) { $r = &$args['records'][$childNum]; $r['globalOrder'] = ++$args['globalOrder']; $r['siblingOrder'] = ++$siblingOrder; $r['depth'] = $args['depth']; $r['lineage'] = $args['lineage'] . $childNum . ':'; $r['breadcrumb'] = isset($args['breadcrumb']) ? $args['breadcrumb'] . ' : ' . (isset($r['name']) ? $r['name'] : '') : (isset($r['name']) ? $r['name'] : ''); if (!empty($args['childNodes'][$childNum])) { self::_rebuildCategoryBranch([ 'branchParent' => $childNum, 'globalOrder' => &$args['globalOrder'], 'records' => &$args['records'], 'childNodes' => $args['childNodes'], 'depth' => $args['depth'] + 1, 'lineage' => $r['lineage'], 'breadcrumb' => $r['breadcrumb'], ]); } } } }