4 changed files with 310 additions and 4 deletions
+291 -4
View File
@@ -87,9 +87,14 @@ class AcaiAPI
$lastId = 0;
$count = 0;
// Columnas que MySQL exige en un INSERT (NOT NULL sin DEFAULT): solo a
// esas se les pone valor si no vienen. null = no se pudo saber.
$requiredCols = $ignoreSchema ? [] : self::requiredColumns($db, $prefix . $table);
foreach ($records as $record) {
$record = self::uniqueEnlace($db, $prefix . $table, $record, [], $options);
list($setParts, $params, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, true, $forceNum
$record, $schema, $functions, $ignoreSchema, true, $forceNum, $requiredCols
);
if (empty($setParts)) continue;
@@ -102,7 +107,7 @@ class AcaiAPI
$result = $db->execute($sql, $params ? $params : null);
if ($result === false) {
self::error(['error' => "Error al insertar en `$table`"]);
self::error(['error' => "Error al insertar en `$table`" . self::dbErrorSuffix($db)]);
continue;
}
@@ -197,6 +202,22 @@ class AcaiAPI
foreach ($records as $record) {
$lastNum = isset($record['num']) ? $record['num'] : null;
if (isset($record['enlace'])) {
// Solo si el enlace CAMBIA: el formulario reenvia todos los
// campos al guardar, y un registro que ya compartia enlace
// (duplicado anterior a esta regla) no debe quedar bloqueado
// por editar otra cosa.
$targetNums = [];
$sinCambio = true;
foreach ((array) $db->executeS("SELECT num, enlace FROM `{$prefix}{$table}` WHERE " . $whereSql, $whereParams ?: null) as $row) {
if (!isset($row['num'])) continue;
$targetNums[] = (string) $row['num'];
if ((string) $row['enlace'] !== trim((string) $record['enlace'])) $sinCambio = false;
}
if ($targetNums && !$sinCambio) {
$record = self::uniqueEnlace($db, $prefix . $table, $record, $targetNums, $options);
}
}
list($setParts, $setParams, $uploadFields) = self::buildSetClause(
$record, $schema, $functions, $ignoreSchema, false, false
@@ -214,7 +235,7 @@ class AcaiAPI
$result = $db->execute($sql, $allParams ? $allParams : null);
if ($result === false) {
self::error(['error' => "Error al actualizar `$table`"]);
self::error(['error' => "Error al actualizar `$table`" . self::dbErrorSuffix($db)]);
continue;
}
$count++;
@@ -1366,7 +1387,8 @@ class AcaiAPI
array $functions,
$ignoreSchema,
$isInsert,
$forceNum
$forceNum,
$requiredCols = null
) {
$now = date('Y-m-d H:i:s');
$user = 1;
@@ -1438,6 +1460,7 @@ class AcaiAPI
$setParts[] = '`dragSortOrder` = ?';
$params[] = time();
}
}
// ── User-supplied fields ─────────────────────────────────────────
@@ -1477,9 +1500,14 @@ class AcaiAPI
// - INSERT: mismo comportamiento que el Admin -> fecha actual.
// - UPDATE: se omite la columna, para no pisar con "hoy" un valor
// ya guardado solo porque el caller mando la clave vacia.
// - Pero SOLO si la columna la exige (NOT NULL sin DEFAULT). En una
// que admite NULL, vacio significa "sin fecha": se omite y queda
// NULL (antes quedaba "ahora": una nota con deleted_at vacio
// nacia borrada). Si no se sabe ($requiredCols null), como antes.
if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'date'
&& ($value === null || (is_string($value) && trim($value) === ''))) {
if (!$isInsert) continue;
if (is_array($requiredCols) && !isset($requiredCols[$key])) continue;
$value = $now;
}
@@ -1490,9 +1518,111 @@ class AcaiAPI
$params[] = $value;
}
// ── Valores por defecto del INSERT (regla UNICA) ──────────────────
if ($isInsert && !$ignoreSchema && $schema) {
list($defParts, $defParams) = self::insertDefaults(
$setParts, $schema, $requiredCols, $now);
$setParts = array_merge($setParts, $defParts);
$params = array_merge($params, $defParams);
}
return [$setParts, $params, $uploadFields];
}
/**
* Valores de las columnas que un INSERT no trae. UNICA fuente de la regla
* (antes repartida entre el Admin clasico, el server Python y aqui):
*
* 1. Default EXPLICITO del schema, siempre que el campo falte: checkbox
* marcado por defecto -> checkedValue; defaultContent (textbox,
* wysiwyg, codigo) o defaultValue. Nunca se evalua como PHP: un
* default con `<?` se ignora.
* 2. Sin default explicito, SOLO si la columna es NOT NULL sin DEFAULT
* (MySQL rechazaria el INSERT): checkbox -> uncheckedValue; si no, por
* tipo de columna (fecha -> ahora, numero -> 0, enum -> primer valor,
* resto -> ''), como el Admin clasico. Incluye columnas obligatorias
* que el schema no declara (p.ej. flags de `accounts`).
* 3. Nada mas: una columna que admite NULL o tiene DEFAULT se deja a MySQL
* (vacio puede significar algo: deleted_at = no borrada).
*
* $requiredCols null = no se pudo consultar la tabla: solo se aplica (1).
*/
private static function insertDefaults(array $setParts, array $schema, $requiredCols, $now)
{
$present = [];
foreach ($setParts as $part) {
if (preg_match('/^`([^`]+)`/', $part, $m)) $present[$m[1]] = true;
}
$parts = [];
$params = [];
$add = function ($col, $val) use (&$parts, &$params, &$present) {
$parts[] = "`$col` = ?";
$params[] = $val;
$present[$col] = true;
};
foreach ($schema as $field => $def) {
if (!is_array($def) || isset($present[$field])) continue;
$type = isset($def['type']) ? $def['type'] : '';
if (in_array($type, ['upload', 'separator', 'none'], true)) continue;
if (!empty($def['isSystemField'])) continue;
$explicit = self::schemaDefault($def);
if ($explicit !== null) {
$add($field, $explicit);
} elseif (is_array($requiredCols) && isset($requiredCols[$field])) {
$add($field, $type === 'checkbox'
? (string) (isset($def['uncheckedValue']) && $def['uncheckedValue'] !== '' ? $def['uncheckedValue'] : '0')
: self::columnFallback($requiredCols[$field], $now));
}
}
if (is_array($requiredCols)) {
foreach ($requiredCols as $col => $meta) {
if (isset($present[$col]) || $col === 'num') continue;
$add($col, self::columnFallback($meta, $now));
}
}
return [$parts, $params];
}
/** Default explicito del schema para un campo, o null si no declara uno. */
private static function schemaDefault(array $def)
{
$type = isset($def['type']) ? $def['type'] : '';
if ($type === 'checkbox') {
$checked = isset($def['checkedByDefault']) ? $def['checkedByDefault'] : 0;
if (!in_array($checked, [1, '1', true], true)) return null;
return (string) (isset($def['checkedValue']) && $def['checkedValue'] !== '' ? $def['checkedValue'] : '1');
}
$keys = in_array($type, ['textbox', 'wysiwyg', 'codigo'], true)
? ['defaultContent', 'defaultValue'] : ['defaultValue'];
foreach ($keys as $k) {
if (!isset($def[$k]) || !is_scalar($def[$k])) continue;
$v = (string) $def[$k];
if ($v === '' || strpos($v, '<?') !== false) continue;
return $v;
}
return null;
}
/** Valor por tipo de columna para una columna NOT NULL sin DEFAULT. */
private static function columnFallback(array $meta, $now)
{
$t = isset($meta['data_type']) ? $meta['data_type'] : '';
if (in_array($t, ['datetime', 'timestamp'], true)) return $now;
if ($t === 'date') return substr($now, 0, 10);
if ($t === 'time') return '00:00:00';
if ($t === 'year') return substr($now, 0, 4);
if (in_array($t, ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint',
'decimal', 'numeric', 'float', 'double', 'real', 'bit'], true)) return 0;
if ($t === 'enum' && preg_match("/^enum\('((?:[^'\\\\]|\\\\.|'')*)'/i",
isset($meta['column_type']) ? $meta['column_type'] : '', $m)) {
return str_replace("''", "'", $m[1]);
}
return '';
}
/**
* Coerces a value to its correct database representation
* based on the schema field definition.
@@ -1640,6 +1770,163 @@ class AcaiAPI
// ERROR HANDLING
// ═══════════════════════════════════════════════════════════════════
/**
* ": <mensaje de MySQL>" del ultimo execute() fallido, o "" si no lo hay.
* Sin el, un alta que MySQL rechaza (p.ej. "Field 'x' doesn't have a
* default value") llegaba como "Error al insertar" a secas y el agente
* reintentaba a ciegas. isset(): una web con Db.php personalizado (sin
* lastError) sigue funcionando, solo que sin el detalle.
*/
/** Cache por peticion: tablas de la BD con columna `enlace` (null = sin consultar). */
private static $enlaceTablesCache = null;
/** Tablas de la BD actual con columna `enlace`, o null si no se pudo saber. */
private static function enlaceTables($db)
{
if (self::$enlaceTablesCache !== null) return self::$enlaceTablesCache ?: null;
try {
$rows = $db->executeS(
"SELECT TABLE_NAME FROM information_schema.COLUMNS"
. " WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'enlace'"
);
} catch (\Throwable $e) {
self::$enlaceTablesCache = false;
return null;
}
$tables = [];
foreach ((array) $rows as $row) {
if (is_array($row) && !empty($row['TABLE_NAME'])) $tables[] = $row['TABLE_NAME'];
}
self::$enlaceTablesCache = $tables;
return $tables;
}
/**
* Quien usa ya `$enlace` en CUALQUIER tabla con columna enlace, sin contar
* `$fullTable` + `$excludeNums`. Devuelve [tabla, num] o null.
*/
private static function enlaceOwner($db, $enlace, $fullTable, array $excludeNums)
{
$tables = self::enlaceTables($db);
if (!$tables) return null;
$parts = [];
$params = [];
foreach ($tables as $t) {
$parts[] = "SELECT ? AS t, num FROM `$t` WHERE enlace = ?";
$params[] = $t;
$params[] = $enlace;
}
try {
$rows = $db->executeS(implode(" UNION ALL ", $parts), $params);
} catch (\Throwable $e) {
return null;
}
foreach ((array) $rows as $row) {
if ($row['t'] === $fullTable && in_array((string) $row['num'], $excludeNums, true)) continue;
return [$row['t'], $row['num']];
}
return null;
}
/**
* `enlace` UNICO entre todas las tablas: slug.php resuelve una URL
* buscandola en todas las tablas con columna enlace, asi que dos registros
* con el mismo enlace dejan uno inaccesible. Si choca:
* - options['enlaceOnConflict'] === 'error' -> error claro (enlace escrito
* a mano: no se le cambia al usuario una URL que ha elegido).
* - si no (por defecto) -> sufijo -2, -3... hasta que quede libre, para
* no romper importadores, hooks ni formularios.
* Se ignoran vacio, '#' y URLs externas. Devuelve el record (copia).
*/
private static function uniqueEnlace($db, $fullTable, array $record, array $excludeNums, array $options)
{
if (!isset($record['enlace']) || !is_string($record['enlace'])) return $record;
$enlace = trim($record['enlace']);
if ($enlace === '' || $enlace === '#' || preg_match('#^(https?:)?//#i', $enlace)) return $record;
$tables = self::enlaceTables($db);
if (!$tables || !in_array($fullTable, $tables, true)) return $record;
if (count($excludeNums) > 1) {
self::error(['error' => [
'message' => "No se puede poner el mismo enlace '$enlace' a varios registros a la vez",
'code' => 'ENLACE_DUPLICADO',
]]);
return $record;
}
$owner = self::enlaceOwner($db, $enlace, $fullTable, $excludeNums);
if (!$owner) return $record;
$prefix = defined('TABLE_PREFIX') ? TABLE_PREFIX : (isset($GLOBALS['TABLE_PREFIX']) ? $GLOBALS['TABLE_PREFIX'] : 'cms_');
$ownerName = strpos($owner[0], $prefix) === 0 ? substr($owner[0], strlen($prefix)) : $owner[0];
// La home ('/') no admite sufijo: un segundo '/' es siempre un error.
if (rtrim($enlace, '/') === '' || (isset($options['enlaceOnConflict']) && $options['enlaceOnConflict'] === 'error')) {
self::error(['error' => [
'message' => "El enlace '$enlace' ya lo usa $ownerName #{$owner[1]}: elige otro",
'code' => 'ENLACE_DUPLICADO',
]]);
return $record;
}
$slash = substr($enlace, -1) === '/' ? '/' : '';
$base = rtrim($enlace, '/');
for ($i = 2; $i <= 200; $i++) {
$candidate = $base . '-' . $i . $slash;
if (!self::enlaceOwner($db, $candidate, $fullTable, $excludeNums)) {
$record['enlace'] = $candidate;
return $record;
}
}
self::error(['error' => [
'message' => "No se encontro un enlace libre para '$enlace'",
'code' => 'ENLACE_DUPLICADO',
]]);
return $record;
}
/** Cache por peticion: tabla completa => [columna => true]. */
private static $requiredColsCache = [];
/**
* Columnas de `$fullTable` que un INSERT tiene que traer: NOT NULL, sin
* DEFAULT y no auto_increment. Devuelve [columna => [data_type, column_type]], o null si no se
* pudo consultar (el llamador no rellena nada en ese caso). Solo usa
* Db::executeS, que existe en todas las versiones de Db.php.
*/
private static function requiredColumns($db, $fullTable)
{
if (array_key_exists($fullTable, self::$requiredColsCache)) {
return self::$requiredColsCache[$fullTable];
}
try {
$rows = $db->executeS(
"SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE FROM information_schema.COLUMNS"
. " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
. " AND IS_NULLABLE = 'NO' AND COLUMN_DEFAULT IS NULL"
. " AND EXTRA NOT LIKE '%auto_increment%'",
[$fullTable]
);
} catch (\Throwable $e) {
return self::$requiredColsCache[$fullTable] = null;
}
$cols = [];
foreach ((array) $rows as $row) {
if (!is_array($row) || empty($row['COLUMN_NAME'])) continue;
$cols[$row['COLUMN_NAME']] = [
'data_type' => strtolower((string) ($row['DATA_TYPE'] ?? '')),
'column_type' => (string) ($row['COLUMN_TYPE'] ?? ''),
];
}
return self::$requiredColsCache[$fullTable] = $cols;
}
private static function dbErrorSuffix($db)
{
return (is_object($db) && isset($db->lastError) && $db->lastError !== '')
? ': ' . $db->lastError
: '';
}
static function error(array $data = [])
{
if (class_exists('API') && class_exists('ApiError')) {
+6
View File
@@ -7,6 +7,10 @@ class Db {
private $link; // MySQLi Link
private static $instance = false; // Db Class Instance
/** Mensaje del ultimo execute() fallido (null si fue bien). AcaiAPI lo
* devuelve en su error para que el caller sepa POR QUE fallo. */
public $lastError = null;
/**
* @param host: DB Host
@@ -69,12 +73,14 @@ class Db {
* @return bool
*/
public function execute($sql, $params = null) {
$this->lastError = null;
if ($params) {
$stmt = $this->link->prepare($sql);
try {
$stmt->execute($params);
}
catch(Exception $e) {
$this->lastError = $e->getMessage();
return false;
}
}
+6
View File
@@ -259,6 +259,12 @@ function muestra_breadcrumb($record = array(), $previousLinks = array(),$class =
}
if (!@$breadcrumbField || !@$breadcrumbRecord["optionsTablename"]) break;
// Registro sin valor en su campo de migas (p.ej. producto sin
// categoria): la consulta quedaba "num=" -> error SQL -> excepcion que
// tumbaba el modulo entero y la ficha salia en blanco. Las migas se
// quedan en este nivel.
$valorMigas = @$breadcrumbRecord[$breadcrumbField];
if ($valorMigas === null || $valorMigas === '' || is_array($valorMigas)) break;
$breadcrumbRecord = dame_registros($breadcrumbRecord["optionsTablename"], $breadcrumbRecord["optionsValueField"]."=".mysql_real_escape_string($breadcrumbRecord[$breadcrumbField]));
$breadcrumbRecord = @$breadcrumbRecord[0];
+7
View File
@@ -300,6 +300,13 @@ if (!$routeResolved){
$encontrado = mysql_fetch_assoc(mysql_query("SELECT * FROM ".$tableName." WHERE ".$columnName."='".$enlaceEscaped."' LIMIT 1"));
// Enlace repetido entre tablas: se mantiene el comportamiento de
// siempre (gana la ULTIMA tabla recorrida; hay webs que dependen de
// ello, p.ej. apartados + servicios con el mismo enlace) pero ya no en
// silencio. Los nuevos duplicados los impide AcaiAPI::uniqueEnlace.
if (@$encontrado && @$tabla){
error_log("[slug] enlace duplicado '".$enlace."': ".$tabla."#".$registro." y ".$tableName."#".@$encontrado["num"]." (se sirve el segundo)");
}
if (@$encontrado){
$tabla = $tableName;
$registro = $encontrado["num"];