El framework PHP detrás de SitioYa

Documentación de Koshkil

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.