Skip to main content

INTRODUÇÃO

O Accelero possui um ORM interno baseado no paradigma Active Record. Todas as tabelas do banco de dados accelero possuem um conjunto de classes correspondentes no código PHP que fazem o mapeamento dos dados e já resolvem as operações mais comuns de CRUD, tanto do ponto de vista de model quanto de controller (e essa mistura model/controller se mostrou um problema que terá que ser enfrentado um dia... assunto à parte).

BASE PARA OS EXEMPLOS

Daqui em diante vamos supor uma tabela no DB com a seguinte estrutura:

CREATE TABLE `pessoas`
`pesID` int(11) NOT NULL AUTO_INCREMENT,
`pesNome` varchar(100) NOT NULL,
`pesDocumento` varchar(50) NULL,
`pesObservacoes` text NULL
PRIMARY KEY (`pesID`)
;

ESTRUTURA DE CLASSES

Toda tabela do banco que deva participar do ORM deve ter duas classes correspondentes em PHP:

NOME DA CLASSEDESCRIÇÃOESTENDE A CLASSE
PessoaBaseDefine os campos e regras de validação básicas do DB. Em teoria, só deve ser alterada se houver alteração no DBDefaultDBObjectPublicMethods
PessoaRegras de negócio reais. Funcionalidades podem ser implementadas aqui. É uma classe "viva".PessoaBase

A criação dos arquivos PHP dessas classes deve ser feita manualmente. O plugin devtools ajuda a criar essas classes.

O nome das classes deve ser PascalCase, versão "em-singular" do nome da tabela correspondente (pessoas > Pessoa... areas > Area... linkpessoascategorias > LinkPessoaCategoria... etc...)

A classe DefaultDBObjectPublicMethods (e a classe DefaultDBObject que ela estende) são o ORM em si, responsável por trazer as funcionalidades para o código.

ESTRUTURA BÁSICA DE UMA TABELA

Toda tabela deve ter nome em minúsculas, no plural.

Toda tabela deve ter as colunas nomeadas no formato <prefixo-único-3-letras>Campo, camelCase. Exemplos: pesIDpesididpesApelidoapelido

Quando uma coluna fizer referência a um campo de outra tabela (foreign key), a coluna deve ter o mesmo nome do campo "original", pra ficar evidente a quê ele se refere. Por exemplo:

CREATE TABLE `pessoas`
`pesID` int(11) NOT NULL AUTO_INCREMENT,
`pesNome` varchar(100) NOT NULL,
`pesObservacoes` text NULL,
`areID` int(11) NOT NULL,
PRIMARY KEY (`pesID`)
CONSTRAINT `areas_ibfk_1` FOREIGN KEY (`areID`) REFERENCES `areas` (`areID`)
;

Toda tabela deve ter obrigatoriamente um campo <prefix>ID como chave primária. No caso de relacionamentos com outras tabelas, sempre usar esse campo. Em algumas situações muito específicas, pode ser necessário um "duplo relacionamento com a mesma tabela" (uma tabela que faça referência a dois pesID). Nesses casos, nomear as colunas dessa nova tabela como pesIDVisitante e pesIDVisitado, por exemplo.

Deve-se pensar muito bem (caso a caso) como lidar com cascateamento de UPDATE/DELETE em foreign keys pra evitar perda de dados. Também é caso a caso a definição de "o que é/não-é nullable". Pense, reflita e prefira sempre errar "pro lado seguro" (not-nullable, porque é mais fácil mudar de ideia no futuro).

ESTRUTURA BÁSICA DE UMA CLASSE/MODEL

Toda coluna de uma tabela deve ter métodos públicos getter/setter na classe PessoaBase com parâmetros/retornos string ou ?string. No exemplo:

public function getPesID() : string;
public function setPesID(string $pesID) : static;
public function getPesNome() : string;
public function setPesNome(string $pesNome) : static;
public function getPesDocumento() : string;
public function setPesDocumento(?string $pesDocumento) : static;
public function getPesObservacoes() : ?string;
public function setPesObservacoes(?string $pesDocumento) : static;

Também na classe PessoaBase há um método static helper chamado loadByPesID que devolve um objeto já preenchido com aquele pesID. Se não houver no DB nenhum objeto com aquele pesID, é devolvido um objeto "zerado". Toda classe tem um método com nome nesse formato loadBy<prefix>ID.

USO GERAL

Com um objeto "em mãos", você pode fazer o que quiser com ele, mas as alterações só serão salvas no DB depois de você "salvar" o objeto. Por exemplo:

$pes = new Pessoa();
$pes->setPesNome('João');
$pes->saveRecord(); //APENAS AQUI O OBJETO SERÁ SALVO NO DB

Note que em nenhum momento foi setado o pesID. Não é necessário, o ORM cuida disso pra você automaticamente.


O funcionamento é exatamente o mesmo quando você carrega um objeto do banco de dados:

$pes = Pessoa::loadByPesID('1234');
$pes->setPesNome('João');
$pes->saveRecord(); //APENAS AQUI O OBJETO SERÁ SALVO NO DB

Note que em nenhum momento foi especificado se a operação é um UPDATE ou um INSERT. O ORM cuida disso pra você automaticamente. Mas CUIDADO: se o objeto retornado pelo loadByPesID for um objeto "zerado" (não existia uma linha com pesID=1234 no DB), o objeto estará realmente ZERADO, e ao salvar será feito um INSERT com um pesID "esquisito" (vide explicação mais pra frente).

Para obter o pesID do objeto que foi salvo no DB, basta usar o getPesID() DEPOIS de salvar. Se você quiser saber se o objeto é "novo/zerado" depois de carregar do DB, use o método bool $pes->getIsNew().


Você também pode carregar vários objetos do DB (algo como uma collection array<Pessoa>). Pra isso, basta usar o método static Pessoa::loadAllThatMatches(). O primeiro parâmetro desse método é o mais utilizado, e é um array de filtros:

$arrayPessoas = Pessoa::loadAllThatMatches([
['pesNome', 'LIKE', '%oa%'],
['pesID', '<>', 1234],
]);
echo count($arrayPessoas) . ' pessoas encontradas!' . PHP_EOL;

foreach($arrayPessoas as $pes) {
echo $pes->getPesNome() . PHP_EOL;
}

Note que o filtro é um "array de arrays". Cada filtro tem o formato [<CAMPO>, <OPERADOR>, <VALOR>] (motivos de segurança -- parameter binding). Mais sobre isso (e sobre os outros parâmetros do método) adiante.

ID SEQUENCIAL VERSUS ALEATÓRIO

Ao salvar um novo objeto no DB sem especificar manualmente um ID, o ORM tem duas estratégias para seguir:

  1. deixar o DB gerar um ID automaticamente através de auto-incremento.
  2. gerar um ID aleatório (configuração padrão).

A geração de ID aleatório é a estratégia padrão para dificultar a vida de "curiosos" que queiram navegar pelo sistema.

Pra alterar esse comportamento, o que deve ser feito geralmente na hora de criar uma nova classe, basta incluir na classe Pessoa o seguinte parâmetro:

class Pessoa extends PessoaBase {
protected static bool $autoIncUID = true;
}

Muito cuidado ao fazer esse tipo de alteração em tabelas já existentes (especialmente ao alterar para "ID incremental"), pois o DB vai seguir a numeração do "ID mais alto já existente", que aleatoriamente pode ser um número próximo do limite (2 ** 31).

EXCLUSÃO DE OBJETOS

A exclusão/delete de objetos é simples:

$pes = Pessoa::loadByPesID('1234');
$pes->deleteMe();

Considere que o método deve retornar um boolean, e que também há a possibilidade de estourarem Exceptions por diversos motivos.

CACHE

O Accelero usa um cache pra evitar consultas frequentes no DB e uma estratégia write through cache. Quando você dá um saveRecord(), o Accelero automaticamente cria um cache local daquele objeto por 5 minutos. Se você tentar carregar aquele objeto PELO ID nesse período, será usado o valor em cache. Da mesma forma, se você tentar carregar um objeto e ele não existir em cache, o Accelero busca os dados do DB, popula o objeto e armazena o objeto em cache pelos mesmos 5 minutos.

Por padrão apenas buscas diretas pelo ID são passíveis de cache (porque é o único campo que é obrigatoriamente único), mas isso pode ser alterado. Para tornar outros campos "passíveis de cache" além do ID, basta incluir na classe Pessoa:

class Pessoa extends PessoaBase {
protected static array $cacheableFields = ['pesDocumento'];
}

É responsabilidade sua garantir que os campos sejam únicos no DB. Se não forem, você obviamente terá problemas.


Por conta do cache, é possível haver "dessincronização" entre cache/DB caso alterações sejam feitas diretos na tabela do DB (porque o cache não tem como saber que você fez um UPDATE direto no DB). Portanto, evite ao máximo alterar dados "direto no banco". Se tiver que fazer, invalide o cache do sistema (script CLI ou tela Avançado - Interno do sistema).

Informações importantes

Carregamento de objetos "em lote" (loadAllThatMatches, por exemplo) não passam pelo cache, use sem medo. Em geral, você não precisa se preocupar com o cache, ele é absolutamente transparente. É recomendável DESLIGAR o cache durante o desenvolvimento, mas LIGAR o cache na hora de fazer testes.

OPERAÇÕES COMUNS

Carregar uma Pessoa quando você sabe o pesID:

Pessoa::loadByPesID('1234');
Pessoa::loadByUID('1234'); //EQUIVALENTE

Carregar uma Pessoa pelo nome parcial (primeiro resultado):

Pessoa::loadFromWhereUnique([
['pesNome', 'LIKE', '%enat%'],
]);

Carregar todas Pessoa pelo nome parcial:

Pessoa::loadAllThatMatches([
['pesNome', 'LIKE', '%enat%'],
]);

Carregar todas Pessoa pelo nome parcial + cujo ID não esteja em uma lista:

Pessoa::loadAllThatMatches([
['pesNome', 'LIKE', '%enat%'],
['pesID', 'NOT IN', [7218, 87321, 216]],
]);

Carregar todas Pessoa cujo ID não esteja presente em uma subquery:

Pessoa::loadAllThatMatches([
['pesID', 'NOT IN', '(SELECT pesID FROM tabela1234)', null, false],
]);

Carregar uma Pessoa que tenha nome parcial OU documento parcial (primeiro resultado):

Pessoa::loadFromWhereUnique([
['pesNome', 'LIKE', '%enat%', 1],
['pesDocumento', 'LIKE', '%123%', 1],
]);

FILTROS

Os métodos loadAllThatMatches e loadFromWhereUnique admitem um primeiro parâmetro que é "array multidimensaional de filtros". Ele tem o seguinte formato:

[
[campo, operador, valor, level<optional, default null>, isBindParam<optional, default true>, type<optional, default 'WHERE'>]
];

Onde:

CAMPODESCRIÇÃO
campocampo a ser filtrado na query
operador=, <>, IN, NOT IN, etc...
valorstring
levelfiltros com level igual serão "OR" entre si... filtros com level diferente ou null (padrão) serão "AND" entre si
isBindParamdeve ser true (padrão) se o valor a ser filtrado é estático (uma string simples, por exemplo). Deve ser false se o valor a ser filtrado é uma subquery ou uma função SQL, por exemplo.
typeWHERE (padrão) ou HAVING, depende da sua query

Exemplos

Exemplos de filtros e as queries resultantes:

Pessoa::loadAllThatMatches([
['pesID', '=', 1234]
]);
SELECT * FROM pessoas WHERE (pesID=1234);

Pessoa::loadAllThatMatches([
['pesID', '=', 1234],
['pesNome', 'NOT LIKE', 'teste%']
]);
SELECT * FROM pessoas WHERE (pesID=1234 AND pesNome NOT LIKE "teste%");

Pessoa::loadAllThatMatches([
['pesID', '=', 1234, 1],
['pesNome', 'LIKE', 'teste%', 1]
]);
SELECT * FROM pessoas WHERE (pesID=1234 OR pesNome LIKE "teste%");

Pessoa::loadAllThatMatches([
['pesID', '=', 1234, 1],
['pesNome', 'LIKE', 'teste%', 1]
['pesNome', 'NOT LIKE', 'minhoca%', 2]
]);
SELECT * FROM pessoas WHERE (pesID=1234 OR pesNome LIKE "teste%") AND (pesNome NOT LIKE "minhoca%");

Pessoa::loadAllThatMatches([
['pesDataCadastro', '<', 'NOW()'],
]);
**ERRO!**
Funções SQL quando usadas como parâmetros não devem ser "binded". Usar:
Pessoa::loadAllThatMatches([
['pesDataCadastro', '<', 'NOW()', null, false],
]);

FilteringQueryArray

A classe FilteringQueryArray é uma forma de organizar/estruturar/abstrair esses filtros em um formato OOP para simplificar o uso. Por exemplo:

$arrFilters = new FilteringQueryArray();
$arrFilters->addFilter(field: 'pesID', operator: '=', filter: 1234);

A classe tem um método getFiltersAsMultimensionalArray que gera um "array compatível" para ser usado no carregamento de objetos. Os dois trechos a seguir são equivalentes:

Pessoa::loadAllThatMatches([
['pesID', '=', 1234, 1],
['pesNome', 'LIKE', 'teste%', 1]
]);

$arrFilters = new FilteringQueryArray();
$arrFilters->addFilter(field: 'pesID', operator: '=', filter: 1234, level: 1);
$arrFilters->addFilter(field: 'pesNome', operator: '=', filter: 1234, level: 1);
$filters = $arrFilters->getFiltersAsMultimensionalArray();
Pessoa::loadAllThatMatches($filters);

POPULAR MANUALMENTE UM OBJETO ATRAVÉS DE QUERY

Em geral você quer usar os helpers de carregamento para "pegar objetos" (loadByPesID, loadAllThatMatches...). Mas em algumas situações, você pode querer fazer uma query bruta no DB e criar objetos por conta própria. Esses métodos helpers já fazem isso por conta própria, e você pode simular esse comportamento de forma simples:

$query = Accelero::getInjection(DBConnectorInterface::class)->getSelectQuery();
$query->setTablename('pessoas');
$query->addWhere('pesID', '=', 1234);
$result = $query->getArrayResult();
if (count($result) === 0) throw new Exception('Pessoa not found!');

$pes = new Pessoa();
$pes->init(
row: $result[0],
ignoreChanges: true,
setUKey: true
);

Note que o segredo todo está no método init() que recebe um array como primeiro parâmetro. Esse array pode vir de qualquer lugar.

CARREGAMENTO DE MÚLTIPLOS OBJETOS - DICAS

Para carregar múltiplos objetos, você sempre deve usar o método Pessoa::loadAllThatMatches. O retorno será um array de objetos com chaves "iniciadas em zero", algo como:

$arrPes = Pessoa::loadAllThatMatches([]); //ARRAY VAZIO INDICA "SEM FILTROS"
var_dump($arrPes);

/**
array(2) {
[0] object(Accelero\Pessoa)
[1] object(Accelero\Pessoa)
}
**/

Mas pode ser interessante que o retorno seja organizado de forma diferente pra facilitar buscas. Por exemplo, é comum que você queira que as chaves do array de retorno sejam o próprio pesID. Isso pode ser feito passando um segundo parâmetro keyField para o método:

$arrPes = Pessoa::loadAllThatMatches([], keyField: 'pesID'); //ARRAY VAZIO INDICA "SEM FILTROS"
var_dump($arrPes);

/**
array(2) {
[17283] object(Accelero\Pessoa)
[87192] object(Accelero\Pessoa)
}
**/

Isso é especialmente útil quando você quer "pré-carregar dados" pra otimizar buscas. Considere esse seguinte exemplo:

$arrayDePesIDQueVeioDeUmaAPI = [87,8957,12368];
$arrPes = Pessoa::loadAllThatMatches([['pesID', 'IN', $arrayDePesIDQueVeioDeUmaAPI]], keyField: 'pesID');

foreach($arrayDePesIDQueVeioDeUmaAPI as $pesID) {
$pes = $arrPes[$pesID] ?? null;
if ($pes === null) {
var_dump('Pessoa não existe no DB');
} else {
var_dump('Pessoa encontrada!');
}
}

Ao invés de fazer queries em loop pra buscar cada pessoa individualmente (ou fazer um loop dentro do outro loop), você fez uma única query e agora tem uma busca com velocidade O(1). O custo, obviamente, é que usou mais memória pois pré-carregou tudo. Considere isso.

RELACIONAMENTOS

Relacionamentos one-to-one, one-to-many, many-to-many e qualquer outra coisa parecida NÃO SÃO administrador pelo ORM de forma automática. É responsabilidade sua cuidar disso. Em geral, o problema prático é carregar algo como "todas as empresas da pessoa", e isso acontece de forma indireta:

  • existe uma tabela pessoas;
  • existe uma tabela empresas;
  • existe uma tabela linkpessoasempresas.

Essa tabela linkpessoasempresas basicamente tem três colunas: (1) o próprio ID, (2) o pesID, (3) o empID. Nesse cenário o comum é seguir o seguinte pattern:

class Pessoa extends PessoaBase {
protected ?array $arrEmp = null; //ARRAY DAS EMPRESAS DA PESSOA, COM NULL COMO VALOR INICIAL

/**
* Returns all `Empresa` objects indirectly associated to Pessoa.
*
* @param bool $forceReload TRUE forces query to be executed, even if we already memoized results.
* @return Empresa[]
*/
public function getEmp(bool $forceReload = false) : array {
//SE JÁ FIZEMOS A BUSCA UMA VEZ E NÃO HÁ INSTRUÇÃO PRA FORÇAR RELOAD, RETORNA O QUE JÁ TEMOS
if (!$forceReload && ($this->arrEmp !== null)) return $this->arrEmp;

//SE É UMA PESSOA SEM ID (NOVA), NÃO TEM EMPRESA ASSOCIADA, PODEMOS RETORNAR DIRETO.
if ($this->getPesID() === '') {
$this->arrEmp = [];
return [];
}

//BUSCA DO BANCO TODAS AS EMPRESAS INDIRETAMENTE ASSOCIADAS, GUARDA O RESULTADO E RETORNA
$this->emp = Empresa::loadAllThatMatches([
['empID', 'IN', '(SELECT `empID` FROM `linkpessoasempresas` WHERE `pesID`=' . $this->getPesID() . ')', null, false]
]);
return $this->arrEmp;
}
}

EXEMPLOS MAIS REAIS

Salvar dados de pessoa recebidos de fonte externa

$receivedData = [
'nome' => 'João',
'documento' => '1234',
];

$pes = Pessoa::loadFromWhereUnique([
['pesDocumento', '=', $receivedData['documento']]
]);

if ($pes->getIsNew()) {
$logger->debug('Pessoa nova', ['data' => $receivedData]);
} else {
$logger->debug('Pessoa já existente', ['data' => $receivedData, 'pesID' => $pes->getPesID()]);
}

$pes->setPesNome($receivedData['nome']);
$pes->setPesDocumento($receivedData['documento']);
if (!$pes->saveRecord()) throw new Exception('Error saving Pessoa');
$logger->debug('Upsert realizado com sucesso', ['pesID' => $pes->getPesID()]);

Carregar manualmente as categorias de uma pessoa

Na prática, prefira a versão "memoized" detalhada mais acima ao invés dessa aqui, que é só pra reforçar o entendimento.

function getDescricaoOfCategoriasAssociatedToPessoa(Pessoa $pes) : array {
if ($pes->getIsNew()) return [];

$arrLpc = LinkPessoaCategoria::loadAllThatMatches([['pesID', '=', $pes->getPesID()]]);

//SEM ARRAY_MAP
$output = [];
foreach($arrLpc as $lpc) {
$pct = PessoaCategoria::loadByPctID($lpc->getPctID());
$output[] = $pct->getPctDescricao();
}
return $output;

//COM ARRAY_MAP
return array_map(fn(LinkPessoaCategoria $lpc) => PessoaCategoria::loadByPctID($lpc->getPctID())->getPctDescricao(), $arrLpc);
}