System
Database System
ORM moderno e Schema Builder para OfficeGest — Modelos tipados, Query Builder expressivo, Casting automático e Migrações.
Visão Geral
O Sistema de Base de Dados do OfficeGest fornece uma camada moderna de ORM que encapsula o query builder legado existente (CodeIgniter 2).
📌 Arquitetura
O Model e o Builder não substituem o query builder legado — eles o encapsulam.
Internamente, todas as queries continuam a usar o OGDB_query_builder do CodeIgniter 2.
A integração com o legado é isolada num adaptador interno (LegacyQueryAdapter), mantendo a API do Builder moderna e consistente.
O que adicionamos é uma camada de conveniência com tipagem forte, PHPDoc, PHP 8 Attributes, casting automático e proteções contra operações acidentais.
Modelos Tipados
PHPDoc e autocompletar para todas as propriedades.
Query Builder
Interface fluente com proteções contra operações em massa.
Casting Automático
Conversão automática entre tipos PHP e tipos de banco.
Modules/Common/Database/
├── Model.php # Classe base para modelos
├── Builder.php # Query builder moderno
├── Traits/
│ ├── Castable.php # Sistema de casting
│ ├── HasAttributes.php # Manipulação de atributos
│ ├── HasAccessors.php # PHP 8 Attributes
│ ├── TracksChanges.php # Dirty tracking
│ ├── Persistable.php # CRUD operations
│ └── HasTimestamps.php # Timestamps configuráveis
├── Casts/
│ ├── JsonCast.php # JSON encode/decode
│ ├── DateTimeCast.php # Carbon conversion
│ └── DecimalCast.php # Números com precisão
└── Interfaces/
├── QueueableEntity.php # Serialização em jobs
└── UrlRoutable.php # Route model binding
Modelos de Dados
Um Modelo é uma classe PHP que representa uma tabela da base de dados. Em vez de arrays associativos, trabalhamos com objetos tipados.
Estrutura Básica de um Modelo
<?php
namespace Og\Modules\Tax\Models;
use Og\Modules\Common\Database\Model;
/**
* Modelo de IVA (Taxas de Imposto).
*
* @property-read string $codiva Código do IVA
* @property-read string $designacao Descrição da taxa
* @property-read float $iva Percentagem de IVA
* @property-read Carbon $date_reg Data de registo
*/
class Vat extends Model
{
protected string $table = TABLE_IVAS;
protected string|array $primaryKey = 'CodIVA';
protected array $casts = [
'iva' => 'decimal:2',
'date_reg' => 'datetime',
'date_alter' => 'datetime',
];
}
| Propriedade | Tipo | Default | Descrição |
|---|---|---|---|
| $table | string | — | Nome da tabela |
| $primaryKey | string|array | 'id' | Chave primária (simples ou composta) |
| $timestamps | bool | true | Ativa timestamps automáticos |
| $casts | array | [] | Mapeamento de casts |
| $fillable | array | [] | Campos permitidos em fill() |
| $hidden | array | [] | Campos ocultos em toArray() |
Uso Básico
// Obter todos os IVAs
$vats = Vat::all();
// Obter um IVA específico por primary key
$vat = Vat::find('NOR');
// Acessar propriedades com tipagem
echo $vat->designacao; // string
echo $vat->iva; // string (decimal:2, ex.: "23.00")
echo $vat->date_reg; // Carbon (objeto de data)
// Filtrar com where
$normalVats = Vat::where('tipotaxa', 'NOR')
->where('iva', '>', 0)
->get();
✨ Sintaxe Flexível
O Model suporta várias formas de interação. A forma estática é a recomendada para uso comum:
// ✅ RECOMENDADO: Métodos estáticos (mais limpo e direto)
$vats = Vat::where('iva', '>', 0)->get();
$vat = Vat::find('NOR');
$vat = Vat::findOrFail('NOR');
$count = Vat::where('tipotaxa', 'NOR')->count();
// 🔧 Via query() - acesso ao Builder moderno (API camelCase)
$results = Vat::query()
->where('iva', '>', 0)
->asArray()
->get();
// Escape hatch: acesso ao query builder legado (CodeIgniter 2)
// Use apenas quando precisares de métodos ainda não mapeados no wrapper moderno.
$legacy = Vat::query()->unwrap();
$legacy->where_in('codiva', ['NOR', 'RED'])->get();
// Via instância (para casos específicos)
$model = new Vat();
$vats = $model->newQuery()->where('iva', '>', 0)->get();
💡 Quando usar query()?
Use Model::query() para obter um Builder moderno (API camelCase) e encadear queries.
Se precisares de um método específico do CodeIgniter 2 que ainda não existe no wrapper moderno, usa ->unwrap() para acessar o OGDB_query_builder diretamente.
Sistema de Casting
O sistema de casting converte automaticamente valores entre tipos PHP e tipos de base de dados.
Casts Simples (String)
protected array $casts = [
'is_active' => 'bool', // "T" → true
'price' => 'decimal:2', // "23.000" → "23.00"
'settings' => 'json', // JSON string → array
'created_at' => 'datetime', // String → Carbon
'quantity' => 'int', // "123" → 123
];
| Tipo | Descrição | Exemplo |
|---|---|---|
| int, integer | Inteiro | "123" → 123 |
| float, double | Decimal | "12.5" → 12.5 |
| bool, boolean | Booleano | "T" → true |
| array, json | JSON → array | '{"a":1}' → ['a' => 1] |
| datetime, date | Carbon instance | "2024-01-15" → Carbon |
| timestamp | Unix timestamp | 1705276800 → Carbon |
| collection | Array → Collection | [1,2,3] → Collection |
| decimal:N | Decimal com N casas | "23.000" → "23.00" |
Cast Customizado
use Og\Modules\Common\Database\Casts\CastInterface;
class MoneyCast implements CastInterface
{
public function get(mixed $value, array $attributes = []): Money
{
return new Money((int) $value, $attributes['currency'] ?? 'EUR');
}
public function set(mixed $value, array $attributes = []): int
{
return $value instanceof Money ? $value->cents : (int) $value;
}
}
// Uso no Model
protected array $casts = [
'total_cents' => MoneyCast::class,
];
Accessors e Mutators
Accessors (getters virtuais) e Mutators (setters com lógica) transformam dados ao ler ou escrever. Suporta dois estilos: PHP 8 Attributes e Laravel-style.
✨ Estilo Moderno (PHP 8 Attributes)
use Og\Modules\Common\Database\Attributes\Computed;
class TipoDocumento extends Model
{
#[Computed]
protected function nomeCompleto(): string
{
$nome = $this->langvar
? lang($this->langvar)
: $this->codabreviado;
return "{$nome} [{$this->codabreviado}]";
}
}
// Uso - método convertido para snake_case
echo $doc->nome_completo; // "Fatura [FT]"
Estilo Legacy (Laravel-style)
class TipoDocumento extends Model
{
// Accessor: getXxxAttribute()
protected function getNomeCompletoAttribute(): string
{
return "{$this->designacao} [{$this->codabreviado}]";
}
}
// Uso é o mesmo
echo $doc->nome_completo; // "Fatura [FT]"
Mutator (#[Mutator])
use Og\Modules\Common\Database\Attributes\Mutator;
class User extends Model
{
#[Mutator]
protected function password(string $value): void
{
$this->attributes['password'] = password_hash($value, PASSWORD_DEFAULT);
}
}
$user->password = 'minhasenha'; // Automaticamente hashed!
💡 Dica
Campos password e senha são hashed automaticamente via password_hash() sem precisar de mutator!
Builder (Wrapper do Query Builder)
O Builder fornece uma interface fluente e tipada que encapsula o OGDB_query_builder legado do CodeIgniter 2.
Internamente, todos os métodos delegam para o query builder original, mas com camadas adicionais de segurança e conveniência.
ℹ️ API camelCase
No wrapper moderno, priorizamos métodos em camelCase (ex.: whereIn). Chamadas diretas a métodos snake_case do legado
(ex.: where_in, order_by) devem ser feitas via ->unwrap().
Métodos de Consulta
// WHERE básico
Vat::where('tipotaxa', 'NOR')
->where('iva', '>', 0)
->get();
// WHERE IN
Vat::whereIn('codiva', ['NOR', 'RED', 'ISE'])->get();
// LIMIT
Vat::query()->limit(10)->get();
Métodos de Conveniência
// Encontrar por ID
$vat = Vat::find('NOR');
$vat = Vat::findOrFail('NOR');
// Obter valores de uma coluna
$ids = Vat::pluck('codiva'); // ['NOR', 'RED', 'ISE']
// Verificar existência
if (Vat::where('codiva', 'NEW')->exists()) {
// ...
}
Métodos de Criação
// Encontrar ou criar (NÃO salva automaticamente)
$vat = Vat::firstOrNew(
['codiva' => 'NEW'], // Critério de busca
['iva' => 23, 'designacao' => 'Novo'] // Valores para nova instância
);
// Encontrar ou criar E SALVAR
$vat = Vat::firstOrCreate(
['codiva' => 'NEW'],
['iva' => 23, 'designacao' => 'Novo']
);
// Atualizar ou criar
$vat = Vat::updateOrCreate(
['codiva' => 'NEW'],
['iva' => 25]
);
⚠️ Proteção Contra Operações em Massa
O Builder previne UPDATE/DELETE acidentais sem WHERE por padrão. Para operações em massa, o bypass precisa ser explícito.
// ❌ ERRO: RuntimeException - sem WHERE
Vat::query()->delete();
// ✅ CORRETO: DELETE com WHERE
Vat::where('codiva', 'OLD')->delete();
// ⚠️ Bypass explícito (usar com extrema cautela)
Vat::query()->withoutSafetyGuards()->delete();
// ✅ Segurança extra: whereIn([]) representa conjunto vazio e vira no-op
$affected = Vat::query()->whereIn('codiva', [])->update(['iva' => 0]); // 0
Cache de Queries e Modo Híbrido
// Cache por 5 minutos (300 segundos)
$vats = Vat::where('tipotaxa', 'NOR')
->cached('vats:normal', 300)
->get();
// Modo híbrido para relatórios (sem hidratar Models)
// Usa asArray() para receber arrays
$data = Vat::query()->asArray()->get();
CRUD Operations
CREATE
$vat = new Vat();
$vat->fill(['codiva' => 'NEW', 'iva' => 15]);
$vat->save();
READ
$vat = Vat::find('NOR');
$vats = Vat::where('iva', '>', 0)->get();
UPDATE
$vat->iva = 16;
$vat->save(); // UPDATE só do campo 'iva'
// Ou via update
$vat->update(['iva' => 17]);
DELETE
$vat->delete();
// Via query (com proteção)
Vat::where('codiva', 'OLD')->delete();
Dirty Tracking (Mudanças em Memória)
O Model mantém um snapshot dos valores originais vindos do banco. Isso permite detectar alterações em memória
e garantir que o save() atualize apenas os campos realmente modificados.
Estados
$vat = Vat::find('NOR');
// Modelo vindo do banco
$vat->exists(); // true
// Snapshot do que veio do banco
$vat->getOriginal(); // array
// Mudanças desde o último syncOriginal()
$vat->getDirty(); // array
$vat->isDirty(); // bool
$vat->isClean(); // bool
Mudanças e Reversão
$vat = Vat::find('NOR');
$vat->iva = '9.99';
// Lista apenas os campos alterados
$vat->getDirty(); // ['iva' => '9.99']
// Mudanças com original + current
$vat->getChanges(); // ['iva' => ['original' => '23.00', 'current' => '9.99']]
// Desfaz alterações em memória
$vat->discardChanges();
💡 Por que isso importa?
Em produção, isso reduz writes desnecessários, evita atualizar colunas sem intenção e simplifica auditoria (só muda o que foi alterado).
Mass Assignment Protection
class User extends Model
{
// Whitelist - campos permitidos em fill()
protected array $fillable = ['name', 'email', 'password'];
// Ou Blacklist - bloquear específicos
protected array $guarded = ['id', 'is_admin', 'role'];
}
| Método | Respeita $fillable | Descrição |
|---|---|---|
| fill($data) | ✅ Sim | Ignora não-fillable silenciosamente |
| fillOrFail($data) | ✅ Sim | Lança exceção para não-fillable |
| forceFill($data) | ❌ Não | Bypass total (uso interno) |
| update($data) | ✅ Sim | fill() + save() |
OGDB Forge (Schema Builder)
O OGDB Forge fornece uma API de alto nível para operações de definição e evolução de esquema de banco de dados (DDL).
Criar Tabela
$this->dbforge
->add_field([
'id' => ['type' => 'INT', 'constraint' => 11, 'auto_increment' => true],
'user_id' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'email' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false],
'created_at' => ['type' => 'DATETIME', 'null' => true, 'default' => RawSql::currentTimestamp()],
])
->primary('id')
->unique('email')
->index(['user_id', 'created_at'])
->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE')
->create_table('user_emails', true); // true = IF NOT EXISTS
| Atributo | Tipo | Descrição |
|---|---|---|
| type | string | VARCHAR, INT, BIGINT, DATETIME, TEXT |
| constraint | int | Tamanho/precisão (255, 11, etc.) |
| null | bool | Permite NULL |
| default | mixed | Valor padrão ou RawSql |
| unsigned | bool | Para tipos numéricos |
| auto_increment | bool | Incremento automático |
Padrões de Campos (Atalhos)
// Timestamps: date_reg e date_alter com defaults
$this->dbforge->timestamps();
// Soft deletes: deleted_at + índice
$this->dbforge->softDeletes();
// User tracking: oper_reg e oper_alter com FKs
$this->dbforge->userTracking();
Adicionar Colunas
$this->dbforge->addColumns(
'orders',
[
'status' => [
'type' => 'VARCHAR',
'constraint' => 30,
'default' => 'pending'
],
],
null,
true, // verifyIfExists
['status' => 'index']
);
Modificar/Remover
// Modificar coluna
$this->dbforge->modify_column('users', [
'email' => [
'name' => 'email',
'type' => 'VARCHAR',
'constraint' => 320
]
]);
// Remover coluna
$this->dbforge->drop_column('users', 'legacy');
Modernização: Legado vs. Moderno
| Métrica | Legado | Moderno | Ganho |
|---|---|---|---|
| Linhas de código | ~170 | ~80 | -53% |
| Testabilidade | ❌ Difícil | ✅ Unit tests | 100% |
| Type safety | ❌ Nenhuma | ✅ Forte | 100% |
| Reutilização | ❌ Copy/paste | ✅ Components | Alta |
| API Docs | ❌ Manual | ✅ OpenAPI | Auto |
❌ Código Legado
// 170+ linhas, sem tipagem
public function list_report($request)
{
global $db; // Dependência global
// Parsing manual
if (Funcs::not_empty_verify($request['date'])) {
$search['where']['date >='] = ...;
}
// +50 linhas de condicionais...
// Formatação no loop
foreach ($list as $k => $v) {
$list[$k]['total_f'] = Funcs::number_format($v['total']);
}
return $list; // Array sem tipo
}
✅ Código Moderno
readonly class HandlingList
{
public function execute(
HandlingFilterDTO $filters
): array {
$query = Handling::query();
if ($filters->statusIds) {
$query->whereIn('status_id', $filters->statusIds);
}
if ($filters->dateFrom) {
$query->where('date_reg', '>=', $filters->dateFrom);
}
return $query
->limit($filters->limit)
->asArray()
->get();
}
}
💡 ROI Estimado
Para cada hora investida na migração, economiza-se 3-5 horas em manutenção futura e debugging.
CLI: make:model
# Criar model básico
php og make:model User --module=Auth
# Criar model para tabela legada
php og make:model Vat --module=Tax --legacy --normalize --pk=CodIVA --table=ivas
| Opção | Descrição |
|---|---|
| --module= | Módulo destino |
| --addon= | Addon destino |
| --table= | Nome da tabela |
| --pk= | Primary key |
| --legacy | Timestamps legados (date_reg/date_alter) |
| --normalize | Normalização de keys |
Timestamps Configuráveis
Controle flexível sobre colunas de timestamp para tabelas modernas e legadas.
Tabelas Modernas
class Order extends Model
{
// Padrão: created_at e updated_at
protected bool $timestamps = true;
// Formato (opcional)
protected string $timestampFormat = 'Y-m-d H:i:s';
}
Tabelas Legadas
class Vat extends Model
{
// Para tabelas com date_reg/date_alter
protected string $createdAtColumn = 'date_reg';
protected string $updatedAtColumn = 'date_alter';
}
Desativar Timestamps
// Permanentemente no modelo
class LogEntry extends Model
{
protected bool $timestamps = false;
}
// Temporariamente para uma operação
$model->withoutTimestamps()->save();
Visibilidade de Atributos
Controle quais atributos aparecem na serialização (toArray(), toJson()).
class User extends Model
{
// Ocultar sempre (excluídos de toArray, retornam null no acesso)
protected array $hidden = ['password', 'api_token', 'remember_token'];
// Modo whitelist: SÓ estes aparecem em toArray()
protected array $visible = ['id', 'name', 'email', 'avatar'];
// Computed properties a incluir automaticamente
protected array $appends = ['full_name', 'profile_url'];
}
Alterações Temporárias
// Tornar visível temporariamente
$user->makeVisible(['password'])->toArray();
// Ocultar temporariamente
$user->makeHidden(['email'])->toArray();
Acesso Interno
// Acesso bloqueado (retorna null)
$user->password; // null
// Acesso raw para uso interno
$user->getRawAttribute('password'); // valor real
Normalização de Keys Legadas
Tabelas antigas podem ter colunas duplicadas com case diferente (ex: SAFTTaxCode e safttaxcode). O Model normaliza automaticamente.
class Vat extends Model
{
// Opções: null (desativado), 'lower', 'snake'
protected ?string $normalizeKeys = 'lower';
}
// SAFTTaxCode e safttaxcode → apenas safttaxcode
// Evita duplicação no toArray()
| Opção | Input | Resultado |
|---|---|---|
| null | SAFTTaxCode | SAFTTaxCode |
| 'lower' | SAFTTaxCode | safttaxcode |
| 'snake' | SAFTTaxCode | s_a_f_t_tax_code |
Interfaces Implementadas
QueueableEntity
Serialização eficiente em jobs de queue. Apenas o ID é guardado.
// Dispatch com modelo
Queue::dispatch(new ProcessOrderJob($order));
// No job, carrega automaticamente do banco
class ProcessOrderJob {
public function __construct(
private Order $order // Só ID serializado
) {}
}
UrlRoutable
Route Model Binding — converte parâmetros de URL em modelos.
// Route: /orders/{order}
Route::get('/orders/{order}', [OrderController::class, 'show']);
// Controller - $order já carregado!
public function show(Order $order): Response
{
return view('orders.show', compact('order'));
}
ArrayAccess
Acesso estilo array para compatibilidade com código legado.
$user = User::query()->find(1);
// Acesso por array (equivalente a ->name)
echo $user['name'];
$user['email'] = 'new@example.com';
// Verificação
isset($user['name']); // true
unset($user['temp']); // remove atributo
Entidades Ricas (DDD)
Evolução de modelos anémicos (só dados) para entidades ricas com comportamento de negócio encapsulado.
❌ Modelo Anémico
// Só getters, sem lógica
class Order extends Model {}
// Lógica espalhada pelo código
$total = $order->subtotal + $order->tax;
if ($order->status === 'pending') { ... }
✅ Entidade Rica
class Order extends Model
{
#[Computed]
protected function total(): float {
return $this->subtotal + $this->tax;
}
public function isPending(): bool {
return $this->status === 'pending';
}
}
Exemplo Completo: Entidade Rica
class Order extends Model
{
#[Computed]
protected function total(): float {
return $this->subtotal + $this->tax - $this->discount;
}
public function isPending(): bool { return $this->status === 'pending'; }
public function isPaid(): bool { return $this->status === 'paid'; }
public function canBeCancelled(): bool {
return in_array($this->status, ['pending', 'confirmed']);
}
public function confirm(): void {
if (!$this->isPending()) {
throw new \DomainException('Só pedidos pendentes podem ser confirmados');
}
$this->status = 'confirmed';
$this->confirmed_at = now();
$this->save();
}
public function cancel(string $reason): void {
if (!$this->canBeCancelled()) {
throw new \DomainException('Este pedido não pode ser cancelado');
}
$this->status = 'cancelled';
$this->cancellation_reason = $reason;
$this->save();
}
}
📈 Evolução Gradual
Etapa 1: Arrays (legado) → Etapa 2: Modelos tipados (atual) → Etapa 3: Entidades ricas + Value Objects (futuro)