Datos y seguridad
Modelos y base de datos
Definir un modelo
Cada tabla tiene una clase que extiende TModel. El archivo va en src/Models/<nombre>.php (o plugins/<plugin>/src/Models/) y la clase se llama TModel + el nombre en CamelCase.
<?php
// src/Models/productos.php
Koshkil::Uses('sys.db.model');
Koshkil::UsesModel('categorias');
class TModelProductos extends TModel {
protected $tableName = 'tbl_productos';
public $primaryKeyColumn = 'prd_codigo';
// Solo estos campos se asignan con create(), fill() y update()
protected $fillable = ['cat_codigo', 'prd_nombre', 'prd_precio', 'prd_destacado', 'prd_alta', 'prd_datos'];
protected $dates = ['prd_alta']; // se muestran como dd/mm/aaaa y se guardan como aaaa-mm-dd
protected $json = ['prd_datos']; // se decodifica a arreglo al leer
protected function setupTableStructure() {
$this->table->id('prd_codigo')
->varchar('prd_nombre', 120, false, '')
->decimal('prd_precio', 10, 2, false, 0)
->enum('prd_destacado', ['0', '1'], false, '0')
->datetime('prd_alta')
->text('prd_datos')
->belongsTo('categorias') // agrega cat_codigo + clave foránea
->addKey('idx_prd_nombre', 'prd_nombre');
}
// Método propio: se usa como $producto->precioConIva()
public function precioConIva() {
return round($this->prd_precio * 1.21, 2);
}
}
Para usarlo:
Koshkil::UsesModel('productos'); // incluye el archivo y crea el alias TMProductos
Koshkil::UsesModel('noticias.noticias'); // modelo de un plugin -> TMNoticias
UsesModel() busca primero en src/Models/ y después en core/models/, así que un proyecto puede reemplazar un modelo del núcleo.
Propiedades del modelo
| Propiedad | Uso |
|---|---|
$tableName |
Nombre de la tabla. |
$primaryKeyColumn |
Clave primaria (pública). |
$fillable |
Campos que se pueden asignar en masa. Los demás se ignoran al guardar. |
$dates |
Campos de fecha: se leen como dd/mm/aaaa [hh:mm:ss] y se guardan en formato SQL. |
$json |
Campos que se decodifican de JSON al leer. |
$skipEncoding |
Campos que no pasan por htmlentities() al guardar (por ejemplo HTML de un editor). |
$encodeQuotes |
Campos donde también se codifican las comillas. |
$readOnly |
true desactiva create, update y delete. |
$behaviors |
Comportamientos a cargar (ver más abajo). |
$dependantModels |
Modelos cuyos registros dependen de este (los usa canDelete() y cascadeDelete()). |
$alias |
Alias por defecto de la tabla en las consultas. |
Codificación al guardar
Por defecto, los textos se guardan pasando por htmlentities() (conservando <, >, & y comillas). Si un campo guarda HTML o datos que no deben transformarse, agregalo a $skipEncoding.
El esquema
setupTableStructure() describe la tabla con $this->table, un constructor encadenable.
Columnas
| Método | Firma |
|---|---|
id |
id($columna) — entero autoincremental y clave primaria |
integer |
integer($columna, $largo, $null = true, $default = null) |
tinyint |
tinyint($columna, $largo, $null = true, $default = null) |
decimal |
decimal($columna, $largo, $decimales, $null = true, $default = null) |
float |
float($columna, $largo, $decimales, $null = true, $default = null) |
double |
double($columna, $null = true, $default = null) |
char / varchar |
varchar($columna, $largo, $null = true, $default = null, $collate = null) |
text / longtext |
text($columna, $null = true, $default = null) |
enum |
enum($columna, $valores, $null = true, $default = null) |
date / datetime |
datetime($columna, $null = true, $default = null) |
blob / longblob |
blob($columna) |
Todos aceptan un último argumento $options (por ejemplo ['extra' => 'AUTO_INCREMENT']).
Claves, tabla y datos iniciales
$this->table
->addKey('idx_email', 'usr_email')
->addKey('idx_compuesta', ['cat_codigo', 'prd_nombre'])
->engine('InnoDB')
->charset('utf8mb4')
->collate('utf8mb4_unicode_ci')
->initialRecords([
['cat_nombre' => 'General'],
['cat_nombre' => 'Ofertas'],
]);
initialRecords() inserta esos registros cuando se crea la tabla.
Sincronización automática
Con Database.AutoUpdateSchema = true, la primera vez que se instancia un modelo (y cada vez que cambia su archivo), Manager::checkTable() compara la definición con la tabla real:
- si la tabla no existe, la crea con sus claves y datos iniciales;
- agrega las columnas nuevas y modifica las que cambiaron de tipo o largo;
- crea las claves y claves foráneas que falten (si la tabla referenciada todavía no existe, la clave queda pendiente hasta que exista).
Las columnas que no están en el modelo se eliminan
La sincronización ejecuta DROP sobre toda columna de la tabla que no figure en setupTableStructure(). Antes de apuntar un modelo a una tabla existente, declará todas sus columnas. En producción podés desactivar AutoUpdateSchema y aplicar los cambios con migraciones.
La estructura resultante se guarda en la caché models junto con la fecha del archivo del modelo; mientras el archivo no cambie, no se vuelve a consultar la base.
Crear, leer, actualizar y borrar
// Crear: devuelve el registro creado (con su clave primaria)
$producto = TMProductos::create([
'prd_nombre' => 'Notebook 14"',
'prd_precio' => 850000,
'prd_alta' => date('d/m/Y H:i:s'),
]);
// Leer
$producto = TMProductos::find(15); // por clave primaria, o null
$producto->prd_nombre; // como propiedad
$producto['prd_nombre']; // o como arreglo
$producto->record(); // todos los campos en un arreglo
// Actualizar
$producto->fill(['prd_precio' => 799000])->update();
$producto->prd_destacado = '1';
$producto->update();
// Borrar
$producto->delete(); // respeta readOnly y comportamientos
if ($categoria->canDelete()) { // ¿tiene registros dependientes?
$categoria->cascadeDelete(); // borra también los dependientes
}
// Operaciones masivas (una sola sentencia, sin eventos)
TMProductos::updateAll(['prd_destacado' => '0'], ['cat_codigo' => 3]);
TMProductos::deleteAll(['cat_codigo' => [7, 8]]); // un arreglo se convierte en IN (…)
create() también acepta un segundo argumento con campos extra permitidos solo para esa llamada.
Consultas
Los métodos estáticos que no existen en el modelo se envían a su TQueryBuilder, así que las consultas empiezan directamente en la clase:
$lista = TMProductos::where('prd_destacado', '1')
->where('prd_precio', '<', 100000)
->order('prd_nombre', 'ASC')
->take(12)
->get(); // TCollection de TMProductos
foreach ($lista as $producto) {
echo $producto->prd_nombre;
}
Referencia del constructor de consultas
| Método | Ejemplo |
|---|---|
select($campos) |
select('prd_codigo, prd_nombre') |
distinct() |
|
alias($a) |
TMProductos::alias('p') |
where(…) |
where('campo', 'valor'), where('campo', '>', 5), where('campo', ['a','b']) (IN), where('campo', 'in', [1,2]), where('texto SQL'), where(['campo', '>', 5]) (condición como arreglo) |
orWhere(…) |
orWhere([['usr_email', $x], ['usr_user', $x]]) → (… OR …) |
whereNull($campo) |
|
rawWhere(…) |
condición sin procesar (SQL literal) |
join($tabla, $condición, $tipo = 'inner') |
join(TMCategorias::alias('c'), 'c.cat_codigo = p.cat_codigo', 'left') |
joinUsing($tabla, $columna) |
joinUsing(TMUsuariosRoles::alias('ur'), 'usr_codigo') |
group($campos) / having(…) / orHaving(…) |
|
order($campo, $dir) |
campo y dirección validados; order('expresión SQL') con un argumento es literal |
take($n) / offset($n) |
límite y desplazamiento |
pageSize($n)->page($p) |
paginación |
get() |
ejecuta y devuelve una TCollection |
first() |
primer registro o null |
find($id) |
por clave primaria |
getAsArray() |
arreglos en vez de modelos |
lists('campo') |
arreglo de valores; con varios campos, arreglo de filas |
each($callback) |
recorre los resultados |
dataTable() |
respuesta en el formato de jQuery DataTables |
compile() |
devuelve el SQL sin ejecutarlo |
debug(true) |
registra el SQL |
noEvents() |
no dispara eventos del modelo |
Colecciones
get() devuelve una TCollection: se recorre con foreach, se accede por índice y además ofrece count(), first(), last(), map(), each(), slice() y getKeys(). La propiedad totalRecords tiene el total de registros sin el límite (útil para paginar) y compiledSQL la consulta ejecutada.
$pagina = TMProductos::order('prd_nombre')->pageSize(20)->page(2)->get();
$paginas = ceil($pagina->totalRecords / 20);
Seguridad en las consultas
insert y update usan sentencias preparadas, y el constructor de consultas escapa automáticamente todos los valores que recibe where(), orWhere(), having(), orHaving(), whereEncoding(), find(), lists(), updateAll() y deleteAll(). Los datos del usuario se pasan tal cual, sin escaparlos antes:
TMUsuarios::where('usr_email', $this->Request->email)->first(); // escapado por where()
TMProductos::where('prd_nombre', 'like', "%{$this->Request->q}%")->get();
TMProductos::where('cat_codigo', [$a, $b, $c])->get(); // cada elemento se escapa
No escapes dos veces
Pasarle a where() un valor ya escapado (Koshkil::escape(), $this->esc()) lo escapa de nuevo, y las búsquedas con comillas o barras dejan de coincidir.
Además, el constructor valida lo que no puede escapar:
| Parte | Protección |
|---|---|
Operador (where('campo', $op, $valor)) |
Solo acepta =, <>, !=, <, >, <=, >=, <=>, LIKE, NOT LIKE, IN, NOT IN, IS, IS NOT, REGEXP, NOT REGEXP y RLIKE. Cualquier otro lanza TQueryBuilderException. |
order($campo, $dir) |
El campo tiene que ser un nombre de columna (columna o tabla.columna) y la dirección ASC o DESC; si no, lanza TQueryBuilderException. Así se puede ordenar por una columna elegida en la interfaz. |
take() / offset() |
Se convierten a entero. |
Siguen siendo SQL literal, y nunca deben llevar datos del usuario sin tratar: where('texto SQL') con un solo argumento, rawWhere(), las condiciones de join(), select(), group() y order() con un solo argumento. Si necesitás un valor dentro de esos fragmentos, escapalo con Koshkil::escapeString() (sin comillas) o Koshkil::quote() (con comillas), o convertilo con intval():
$q = Koshkil::escapeString($this->Request->q);
$qb->where("MATCH(prd_nombre) AGAINST ('{$q}')");
$qb->where("FIND_IN_SET(" . intval($this->Request->grupo) . ", prd_grupos)");
$qb->where('p.cat_codigo = ' . Koshkil::quote($this->Request->categoria));
Dentro de un endpoint de la API, $this->esc($valor) equivale a Koshkil::escapeString().
Relaciones
Se declaran en setupTableStructure() y generan métodos virtuales en el modelo.
// En TModelProductos: cada producto pertenece a una categoría
$this->table->belongsTo('categorias');
// crea la columna cat_codigo (entero, clave primaria de categorías), su clave
// foránea y el método $producto->categoria() (nombre del modelo en singular)
// En TModelCategorias: una categoría tiene muchos productos
$this->table->hasMany('productos', ['foreignKey' => 'cat_codigo', 'virtualMethodName' => 'productos', 'orderBy' => ['prd_nombre' => 'ASC']]);
// Uno a uno con condiciones extra
$this->table->hasOne('galeria', [
'foreignKey' => 'gal_relacionado',
'columnName' => 'usr_codigo',
'virtualMethodName' => 'imagenPerfil',
'where' => [['gal_grupo', 'usuarios'], ['gal_uso', 'profile']],
]);
| Opción | Relación | Significado |
|---|---|---|
column_name |
belongsTo |
Columna local (por defecto, con el mismo nombre que la clave primaria del modelo relacionado). Sirve para pertenecer dos veces al mismo modelo. |
columnName |
hasMany, hasOne |
Columna local que se compara (por defecto, la clave primaria propia). |
foreignKey |
hasMany, hasOne |
Columna del modelo relacionado que apunta a este. Conviene indicarla siempre. |
virtualMethodName |
todas | Nombre del método que se genera. |
where |
hasMany, hasOne |
Condiciones adicionales. |
orderBy |
hasMany |
Orden de los resultados: ['columna' => 'ASC', …]. |
$categoria = TMCategorias::find(3);
foreach ($categoria->productos() as $producto) { … }
$foto = $usuario->imagenPerfil();
Eventos
Un método llamado <prefijo>_event_<evento> se registra solo como manejador. El prefijo es libre (se usa para agrupar, por ejemplo el nombre de un trait).
| Evento | Cuándo | Recibe / devuelve |
|---|---|---|
setupTableStructure |
después de definir el esquema | |
precreate |
antes de insertar | recibe y devuelve el modelo a insertar |
create |
después de insertar | recibe y devuelve el registro creado |
update |
después de actualizar | |
delete |
después de borrar | |
getrecord |
al construir cada elemento de una colección |
class TModelProductos extends TModel {
public function productos_event_precreate($record) {
$record->prd_alta = date('d/m/Y H:i:s');
return $record;
}
}
TMProductos::noEvents()->… desactiva los eventos para una consulta.
Traits incluidos
| Trait | Archivo | Agrega |
|---|---|---|
THierarchyTrait |
sys.db.traits.hierarchy |
Árboles padre-hijo ($parentField, $textField): roots(), children(), getParent(), getRoot(), createAsChild(), makeRoot(), makeChildOf(). |
TOrderableTrait |
sys.db.traits.orderable |
Orden manual ($orderField, por defecto idx_order): indexUp(), indexDown(), setOrderIndex(), nextIndex(). |
TOwnableTrait |
sys.db.traits.ownable |
Asigna el usuario actual (usr_codigo) al crear: belongsToUser(), strictlyBelongsToUser(), isMine(), owner(). |
TTranslatableTrait |
sys.db.traits.translatable |
Registros traducidos por idioma (idi_codigo, $translationParent): translation($idioma), getTranslations(), mainLanguage(). |
TMultimediaTrait |
sys.db.traits.multimedia |
Archivos asociados en la galería: imagenes(), documentos(), videos(), sonidos(), multimedia(). |
Koshkil::uses('sys.db.traits.hierarchy');
Koshkil::uses('sys.db.traits.ownable');
class TModelCategorias extends TModel {
use THierarchyTrait, TOwnableTrait;
protected function setupTableStructure() {
$this->parentField = 'cat_parent'; // la columna la agrega el trait
$this->textField = 'cat_nombre';
$this->table->id('cat_codigo')->varchar('cat_nombre', 45, false, '');
}
}
Comportamientos
Un comportamiento agrega métodos a varios modelos sin herencia. Es una clase <Nombre>Behavior que extiende Behavior, en src/Models/Behaviors/<Nombre>.php. En un plugin, la clase lleva además el nombre del plugin como prefijo (<Plugin><Nombre>Behavior) y se carga como '<plugin>.<nombre>'. Sus métodos se llaman como si fueran del modelo, que recibe en $this->record. Si define beforeDelete($modelo), se ejecuta antes de cada delete().
<?php
// src/Models/Behaviors/Publicable.php
Koshkil::uses('sys.db.behavior');
class PublicableBehavior extends Behavior {
public function publicar() {
$this->record->fill(['pub_estado' => '1'])->update();
}
}
class TModelArticulos extends TModel {
protected $behaviors = ['publicable'];
}
TMArticulos::find(4)->publicar();
Migraciones
src/Migrations/ guarda scripts versionados con métodos up() y down() para cambios que el esquema declarativo no cubre (datos, tablas sin modelo, cambios delicados en producción). El núcleo no incluye un ejecutor: se corren desde un script propio.
<?php
// src/Migrations/20260425120000_create_personal_negocios_table.php
class Migration20260425120000 {
public function up() {
Koshkil::$db->execute("CREATE TABLE IF NOT EXISTS `tbl_personal_negocios` (…)");
}
public function down() {
Koshkil::$db->execute("DROP TABLE IF EXISTS `tbl_personal_negocios`");
}
}
Acceso directo a la base
Koshkil::$db (o Koshkil::getDatabase()) es el driver activo:
$db = Koshkil::getDatabase();
$db->execute("UPDATE tbl_productos SET prd_precio = prd_precio * 1.1");
$fila = $db->getRow("SELECT COUNT(*) AS total FROM tbl_productos");
$res = $db->getRecords("SELECT * FROM tbl_productos WHERE cat_codigo = 3"); // ['data' => […], 'records' => N]
$db->performTransaction([$sql1, $sql2]);
$db->escape($texto); // con PDO devuelve el valor ya entre comillas
Koshkil::escapeString($texto); // igual con cualquier driver, sin comillas
Koshkil::quote($texto); // 'texto'
$db->lastInsertId();
El driver se elige con Database.master.Driver: MySQLi, PostgreSQL, SQLite, SQLServer u Oracle. Koshkil intenta primero la versión PDO y, si no está disponible, la nativa.