O framework PHP por trás do SitioYa

Documentação do Koshkil

Dados e segurança

Modelos e banco de dados

Definir um modelo

Cada tabela tem uma classe que estende TModel. O arquivo fica em src/Models/<nome>.php (ou plugins/<plugin>/src/Models/) e a classe se chama TModel + o nome em 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';

    // Somente estes campos são atribuídos com create(), fill() e update()
    protected $fillable = ['cat_codigo', 'prd_nombre', 'prd_precio', 'prd_destacado', 'prd_alta', 'prd_datos'];

    protected $dates = ['prd_alta'];   // exibidos como dd/mm/aaaa e gravados como aaaa-mm-dd
    protected $json  = ['prd_datos'];  // decodificado para array na leitura

    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')                  // adiciona cat_codigo + chave estrangeira
            ->addKey('idx_prd_nombre', 'prd_nombre');
    }

    // Método próprio: usado como $producto->precioConIva()
    public function precioConIva() {
        return round($this->prd_precio * 1.21, 2);
    }
}

Para usá-lo:

Koshkil::UsesModel('productos');            // inclui o arquivo e cria o apelido TMProductos
Koshkil::UsesModel('noticias.noticias');    // modelo de um plugin -> TMNoticias

UsesModel() procura primeiro em src/Models/ e depois em core/models/, então um projeto pode substituir um modelo do núcleo.

Propriedades do modelo

Propriedade Uso
$tableName Nome da tabela.
$primaryKeyColumn Chave primária (pública).
$fillable Campos que podem ser atribuídos em massa. Os demais são ignorados ao gravar.
$dates Campos de data: lidos como dd/mm/aaaa [hh:mm:ss] e gravados em formato SQL.
$json Campos decodificados de JSON na leitura.
$skipEncoding Campos que não passam por htmlentities() ao gravar (por exemplo HTML de um editor).
$encodeQuotes Campos em que as aspas também são codificadas.
$readOnly true desativa create, update e delete.
$behaviors Comportamentos a carregar (veja abaixo).
$dependantModels Modelos cujos registros dependem deste (usados por canDelete() e cascadeDelete()).
$alias Apelido padrão da tabela nas consultas.

Codificação ao gravar

Por padrão, os textos são gravados passando por htmlentities() (preservando <, >, & e aspas). Se um campo guarda HTML ou dados que não devem ser transformados, adicione-o a $skipEncoding.

O esquema

setupTableStructure() descreve a tabela com $this->table, um construtor encadeável.

Colunas

Método Assinatura
id id($coluna) — inteiro autoincremental e chave primária
integer integer($coluna, $tamanho, $null = true, $default = null)
tinyint tinyint($coluna, $tamanho, $null = true, $default = null)
decimal decimal($coluna, $tamanho, $decimais, $null = true, $default = null)
float float($coluna, $tamanho, $decimais, $null = true, $default = null)
double double($coluna, $null = true, $default = null)
char / varchar varchar($coluna, $tamanho, $null = true, $default = null, $collate = null)
text / longtext text($coluna, $null = true, $default = null)
enum enum($coluna, $valores, $null = true, $default = null)
date / datetime datetime($coluna, $null = true, $default = null)
blob / longblob blob($coluna)

Todos aceitam um último argumento $options (por exemplo ['extra' => 'AUTO_INCREMENT']).

Chaves, opções da tabela e dados iniciais

$this->table
    ->addKey('idx_email', 'usr_email')
    ->addKey('idx_composta', ['cat_codigo', 'prd_nombre'])
    ->engine('InnoDB')
    ->charset('utf8mb4')
    ->collate('utf8mb4_unicode_ci')
    ->initialRecords([
        ['cat_nombre' => 'Geral'],
        ['cat_nombre' => 'Ofertas'],
    ]);

initialRecords() insere esses registros quando a tabela é criada.

Sincronização automática

Com Database.AutoUpdateSchema = true, na primeira vez que um modelo é instanciado (e sempre que seu arquivo muda), Manager::checkTable() compara a definição com a tabela real:

  • se a tabela não existe, cria-a com suas chaves e dados iniciais;
  • adiciona as colunas novas e altera as que mudaram de tipo ou tamanho;
  • cria as chaves e chaves estrangeiras que faltam (se a tabela referenciada ainda não existe, a chave fica pendente até que exista).

As colunas que não estão no modelo são apagadas

A sincronização executa DROP em toda coluna da tabela que não esteja em setupTableStructure(). Antes de apontar um modelo para uma tabela existente, declare todas as suas colunas. Em produção você pode desativar AutoUpdateSchema e aplicar as mudanças com migrações.

A estrutura resultante é guardada no cache models junto com a data do arquivo do modelo; enquanto o arquivo não mudar, o banco não é consultado de novo.

Criar, ler, atualizar e apagar

// Criar: retorna o registro criado (com sua chave primária)
$producto = TMProductos::create([
    'prd_nombre' => 'Notebook 14"',
    'prd_precio' => 850000,
    'prd_alta'   => date('d/m/Y H:i:s'),
]);

// Ler
$producto = TMProductos::find(15);                   // pela chave primária, ou null
$producto->prd_nombre;                               // como propriedade
$producto['prd_nombre'];                             // ou como array
$producto->record();                                 // todos os campos em um array

// Atualizar
$producto->fill(['prd_precio' => 799000])->update();
$producto->prd_destacado = '1';
$producto->update();

// Apagar
$producto->delete();                                 // respeita readOnly e comportamentos
if ($categoria->canDelete()) {                       // tem registros dependentes?
    $categoria->cascadeDelete();                     // apaga também os dependentes
}

// Operações em massa (uma única instrução, sem eventos)
TMProductos::updateAll(['prd_destacado' => '0'], ['cat_codigo' => 3]);
TMProductos::deleteAll(['cat_codigo' => [7, 8]]);    // um array vira IN (…)

create() também aceita um segundo argumento com campos extras permitidos só naquela chamada.

Consultas

Chamadas estáticas a métodos que o modelo não tem são encaminhadas ao seu TQueryBuilder, então as consultas começam direto na classe:

$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;
}

Referência do construtor de consultas

Método Exemplo
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('SQL literal'), where(['campo', '>', 5]) (condição como array)
orWhere(…) orWhere([['usr_email', $x], ['usr_user', $x]])(… OR …)
whereNull($campo)
rawWhere(…) condição sem processar (SQL literal)
join($tabela, $condição, $tipo = 'inner') join(TMCategorias::alias('c'), 'c.cat_codigo = p.cat_codigo', 'left')
joinUsing($tabela, $coluna) joinUsing(TMUsuariosRoles::alias('ur'), 'usr_codigo')
group($campos) / having(…) / orHaving(…)
order($campo, $dir) campo e direção validados; order('expressão SQL') com um argumento é literal
take($n) / offset($n) limite e deslocamento
pageSize($n)->page($p) paginação
get() executa e retorna uma TCollection
first() primeiro registro ou null
find($id) pela chave primária
getAsArray() arrays em vez de modelos
lists('campo') array de valores; com vários campos, array de linhas
each($callback) percorre os resultados
dataTable() resposta no formato do jQuery DataTables
compile() retorna o SQL sem executá-lo
debug(true) registra o SQL
noEvents() não dispara eventos do modelo

Coleções

get() retorna uma TCollection: é percorrida com foreach, acessada por índice e oferece count(), first(), last(), map(), each(), slice() e getKeys(). A propriedade totalRecords tem o total de registros sem o limite (útil para paginar) e compiledSQL, a consulta executada.

$pagina  = TMProductos::order('prd_nombre')->pageSize(20)->page(2)->get();
$paginas = ceil($pagina->totalRecords / 20);

Segurança nas consultas

insert e update usam instruções preparadas, e o construtor de consultas escapa automaticamente todos os valores recebidos por where(), orWhere(), having(), orHaving(), whereEncoding(), find(), lists(), updateAll() e deleteAll(). Os dados do usuário são passados como estão, sem escapá-los antes:

TMUsuarios::where('usr_email', $this->Request->email)->first();          // escapado pelo where()
TMProductos::where('prd_nombre', 'like', "%{$this->Request->q}%")->get();
TMProductos::where('cat_codigo', [$a, $b, $c])->get();                    // cada elemento é escapado

Não escape duas vezes

Passar para where() um valor já escapado (Koshkil::escape(), $this->esc()) o escapa de novo, e as buscas com aspas ou barras deixam de coincidir.

Além disso, o construtor valida o que não pode escapar:

Parte Proteção
Operador (where('campo', $op, $valor)) Só aceita =, <>, !=, <, >, <=, >=, <=>, LIKE, NOT LIKE, IN, NOT IN, IS, IS NOT, REGEXP, NOT REGEXP e RLIKE. Qualquer outro lança TQueryBuilderException.
order($campo, $dir) O campo precisa ser um nome de coluna (coluna ou tabela.coluna) e a direção ASC ou DESC; caso contrário, lança TQueryBuilderException. Assim é possível ordenar por uma coluna escolhida na interface.
take() / offset() São convertidos para inteiro.

Continuam sendo SQL literal, e nunca devem receber dados do usuário sem tratamento: where('SQL literal') com um único argumento, rawWhere(), as condições de join(), select(), group() e order() com um único argumento. Se precisar de um valor dentro desses trechos, escape-o com Koshkil::escapeString() (sem aspas) ou Koshkil::quote() (com aspas), ou converta-o com 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 um endpoint da API, $this->esc($valor) equivale a Koshkil::escapeString().

Relações

São declaradas em setupTableStructure() e geram métodos virtuais no modelo.

// Em TModelProductos: cada produto pertence a uma categoria
$this->table->belongsTo('categorias');
// cria a coluna cat_codigo (inteiro, chave primária de categorias), sua chave
// estrangeira e o método $producto->categoria() (nome do modelo no singular)

// Em TModelCategorias: uma categoria tem muitos produtos
$this->table->hasMany('productos', ['foreignKey' => 'cat_codigo', 'virtualMethodName' => 'productos', 'orderBy' => ['prd_nombre' => 'ASC']]);

// Um para um com condições extras
$this->table->hasOne('galeria', [
    'foreignKey'        => 'gal_relacionado',
    'columnName'        => 'usr_codigo',
    'virtualMethodName' => 'imagenPerfil',
    'where'             => [['gal_grupo', 'usuarios'], ['gal_uso', 'profile']],
]);
Opção Relação Significado
column_name belongsTo Coluna local (por padrão, com o mesmo nome da chave primária do modelo relacionado). Serve para pertencer duas vezes ao mesmo modelo.
columnName hasMany, hasOne Coluna local comparada (por padrão, a chave primária própria).
foreignKey hasMany, hasOne Coluna do modelo relacionado que aponta para este. Convém informá-la sempre.
virtualMethodName todas Nome do método gerado.
where hasMany, hasOne Condições adicionais.
orderBy hasMany Ordem dos resultados: ['coluna' => 'ASC', …].
$categoria = TMCategorias::find(3);
foreach ($categoria->productos() as $producto) {  }
$foto = $usuario->imagenPerfil();

Eventos

Um método chamado <prefixo>_event_<evento> é registrado automaticamente como manipulador. O prefixo é livre (serve para agrupar, por exemplo pelo nome de um trait).

Evento Quando Recebe / retorna
setupTableStructure depois de definir o esquema
precreate antes de inserir recebe e retorna o modelo a inserir
create depois de inserir recebe e retorna o registro criado
update depois de atualizar
delete depois de apagar
getrecord ao construir cada item de uma coleção
class TModelProductos extends TModel {
    public function productos_event_precreate($record) {
        $record->prd_alta = date('d/m/Y H:i:s');
        return $record;
    }
}

TMProductos::noEvents()->… desativa os eventos em uma consulta.

Traits incluídos

Trait Arquivo Adiciona
THierarchyTrait sys.db.traits.hierarchy Árvores pai-filho ($parentField, $textField): roots(), children(), getParent(), getRoot(), createAsChild(), makeRoot(), makeChildOf().
TOrderableTrait sys.db.traits.orderable Ordem manual ($orderField, padrão idx_order): indexUp(), indexDown(), setOrderIndex(), nextIndex().
TOwnableTrait sys.db.traits.ownable Atribui o usuário atual (usr_codigo) ao criar: belongsToUser(), strictlyBelongsToUser(), isMine(), owner().
TTranslatableTrait sys.db.traits.translatable Registros traduzidos por idioma (idi_codigo, $translationParent): translation($idioma), getTranslations(), mainLanguage().
TMultimediaTrait sys.db.traits.multimedia Arquivos associados na galeria: 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';     // a coluna é adicionada pelo trait
        $this->textField   = 'cat_nombre';
        $this->table->id('cat_codigo')->varchar('cat_nombre', 45, false, '');
    }
}

Comportamentos

Um comportamento adiciona métodos a vários modelos sem herança. É uma classe <Nome>Behavior que estende Behavior, em src/Models/Behaviors/<Nome>.php. Em um plugin, a classe leva também o nome do plugin como prefixo (<Plugin><Nome>Behavior) e é carregada como '<plugin>.<nome>'. Seus métodos são chamados como se fossem do modelo, que ele recebe em $this->record. Se definir beforeDelete($modelo), ele é executado 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();

Migrações

src/Migrations/ guarda scripts versionados com métodos up() e down() para mudanças que o esquema declarativo não cobre (dados, tabelas sem modelo, mudanças delicadas em produção). O núcleo não inclui um executor: rode-os a partir de um script próprio.

<?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`");
    }
}

Acesso direto ao banco

Koshkil::$db (ou Koshkil::getDatabase()) é o driver ativo:

$db = Koshkil::getDatabase();
$db->execute("UPDATE tbl_productos SET prd_precio = prd_precio * 1.1");
$linha = $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);                  // com PDO retorna o valor já entre aspas
Koshkil::escapeString($texto);        // igual com qualquer driver, sem aspas
Koshkil::quote($texto);               // 'texto'
$db->lastInsertId();

O driver é escolhido com Database.master.Driver: MySQLi, PostgreSQL, SQLite, SQLServer ou Oracle. O Koshkil tenta primeiro a versão PDO e, se não estiver disponível, a nativa.