Leo

Sobre tecnologia...

Laravel, single table inheritance

2 months ago · 5 MIN READ
#PHP  #Laravel  #Tricks  #Design Patterns 

Introdução

Olá pessoal, nesse artigo gostaria de compartilhar uma técnica de herança que utilizamos na ferramenta de folha de pagamentos da Convenia e que nos ajuda muito quando precisamos calcular coisas como totais e somas em tabelas.

Nossa aplicação de Folha é feita em Laravel então os exemplos que vocês vão ver são feitos em Laravel porém acredito que as técnicas podem ser reproduzidas em qualquer framework ou linguagem, não posso esquecer de ressaltar que para melhor compreensão os exemplos foram bem simplificados.

Implementar herança no seu design de classes é algo bem trivial, talvez uma das primeiras coisas que você deve aprender ao começar estudar orientação a objetos, mas quando essas heranças devem refletir no banco de dados(principalmente bancos de dados relacionais) logo você deve começar a perceber o quão desajeitado isso pode se tornar, isso em parte pelo fato dos bancos relacionais não terem um mecanismo de herança, normalmente o que se faz é linkar tabelas separadas que representam as classes.

Tanto para contextualizar como para aprofundamento acho importante citar as técnicas mais comuns de herança, que são Concrete Table Inheritance, Class Table Inheritance e Single Table Inheritance, esta última será a que iremos explorar nesse artigo, ambas são explicadas com bem mais propriedade no livro Patterns of Enterprise Application Architecture do Martin Fowler.

Mão na massa

Para começar vamos mostrar uma das primeiras telas no passo a passo de fechamento, a tela de seleção de colaboradores.

tela-selecao.png

Nessa tela selecionamos quais colaboradores devem fazer parte do cálculo de fechamento, porém existe uma outra entidade que entra automáticamente no calculo do colaborador, o seu dependente, agora imagine que se esses registros fossem mantidos em tabelas separadas para pegar um simples "total de participantes" ou mesmo os "valores totais", teriamos que fazer muitos rodeios como "Joins" e até mesmo dois procedimentos separados e uma posterior soma já no lado do PHP, desajeitado certo? mas se isso fosse mantido em apenas uma tabela ajudaria bastante, dessa forma a implementação ficaria da seguinte forma:

    public function up()
    {
        Schema::create('participants', function (Blueprint $table) {
            $table->id();
            $table->string('type');
            $table->unsignedInteger('payroll_id');
            $table->unsignedInteger('parent_id')->nullable();
            $table->string('name');
            $table->string('active')->default('inactive');
            $table->string('job_description')->nullable();
            $table->string('department')->nullable();
            $table->timestamps();
        });
    }

Esse é o código da nossa migração que gera a tabela participants, esse código já nos revela algo importante, teremos a entidade mais genérica Participant, além da entidade Employee e Dependent então o raciocínio é trazer todos os registros ao buscar com a model Participant e trazer os registros específicos ao buscas com as models mais específicas.

O colaborador tem todos os campos com exceção do campo parent_id já o dependente tem apenas o nome e o campo parent_id, importante notar que todo campo que é obrigatório apenas para uma entidade, não deve ser marcado como obrigatório no banco, isso torna o seu modelo de banco de dados um pouco mais frágil então a boa prática aqui é sempre manter a disciplina utilizando as mais altas abstrações para manipular os registros, evitar ao máximo alterar o banco diretamente, dessa forma pode ser muito interessante implementar até mesmo uma camada de serviços para as operações, na nossa versão de produção utilizamos serviços de forma opaca para cada operação que precisamos fazer, isso significa que salvo algumas exceções não chamamos a model diretamente.

O campo type é muito importante é ele que discrimina qual é o tipo do registro, é muito importante manter o preenchimento de campo de forma automática, para que não haja chance de esquecer em manipulações futuras.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Participant extends Model
{
    public function payroll(): BelongsTo
    {
        return $this->belongsTo(Payroll::class);
    }
}

A model Participant é bem direta, você deve colocar nela tudo que é referente á todas as models que extendem ela, no caso o relacionamento com Payroll seria uma ótima pedida.

<?php

namespace App;

use Illuminate\Database\Eloquent\Relations\HasMany;

class Employee extends Participant
{
    use InheritanceLock;

    protected $table = 'participants';

    protected $fillable = [
        'payroll_id',
        'name',
        'job_description',
        'department',
        'active'
    ];

    public function dependents(): HasMany
    {
        return $this->hasMany(Dependent::class, 'parent_id');
    }

    public function active()
    {
        $this->active = 'active'; 
    }
}

A model employee define os próprios campos fillable(sem o parent_id que não pertence a ela) e o relacionamento com seus dependentes.

Importante notar também o método active() que não faria sentido em uma model de dependente, uma vez que não selecionamos os dependentes na tela inicial que foi mostrada no inicio do artigo.

<?php

namespace App;

use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Dependent extends Participant
{
    use InheritanceLock;

    protected $table = 'participants';

    protected $hidden = [
        'active',
        'job_description',
        'department'
    ];

    protected $fillable = [
        'payroll_id',
        'parent_id',
        'name',
    ];

    public function employee(): BelongsTo
    {
        return $this->belongsTo(Employee::class, 'parent_id');
    }
}

A model de Dependent é bem parecida com a model Employee com a exceção de que ela declara o relacionamento contrário, ela pertence a um employee.

Bem interessante notar também a declaração do array $hidden ele omite esses valores ao retornar uma model de dependente, os campos omitidos são referentes ao colaborador e não devem ser exibidos nos registros de dependentes

<?php

namespace App;

use App\Scopes\InheritanceLockScope;

trait InheritanceLock
{
    public static function bootInheritanceLock(): void
    {
        $typeSetting = function ($model) {
            $model->type = self::class;
        };

        static::creating($typeSetting);
        static::saving($typeSetting);
        static::addGlobalScope(
            new InheritanceLockScope(self::class)
        );
    }
}

Você deve ter notado essa trait inserida nas models mais específicas, ela é responsável por preencher automáticamente o campo type e aplicar automáticamente o filtro pelo type em um Global Scope

O ciclo de vida e operações de uma model emitem eventos no Laravel, assim é possível escutar e declarar listeners para esses eventos, esse código declara um listener para os eventos de criação que preenche o campo type com self::class, esse é o nome da classe que "usa" a trait.

<?php

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class InheritanceLockScope implements Scope
{
    protected $type;

    public function __construct(string $type)
    {
        $this->type = $type;
    }

    /**
     * Apply the scope to a given Eloquent query builder.
     */
    public function apply(Builder $builder, Model $model): void
    {
        $builder->whereType($this->type);
    }
}

Essa é a classe do global scope, a função de um global scope é adicionar uma condição cada vez que acontece uma consulta, nesse caso cada vez que retornarmos os registros do banco ele deve adicionar um WHERE type = "ModelName" na consulta, isso nos garante que nunca vamos trazer um dependente no meio de colaboradores e vice versa.

Dessa forma nossa implementação está completa, a seguir a utilização:

tinker.png

Como pode ser evidenciado no tinker criamos um colaborador e um dependente em sequencia e ao buscar os registros nas models específicas apenas os registros epecíficos são selecionados

tinker2.png

Ao chamar a model mais generica conseguimos todos os registros

tinker3.png

Os relacionamentos também funcionam como esperado

Conclusão

Conseguimos ver os seguintes pontos positivos na utilização dessa técnica de herança:

  • Existe apenas uma tabela para você se preocupar.
  • Você não precisa fazer joins e unions para retornar os registros em uma única tacada.
  • se você precisar adicionar um campo que ja existia em um tipo de model no outro tipo, não será necessário criar uma nova migração.
  • Somas são bem facilitadas

Mas também devemos nos preocupar com os seguintes possíveis problemas:

  • A confusão gerada ao olhar a tabela uma vez que alguns campos fazem sentido e outros não
  • Colunas utilizadas apenas por um subtipo podem gastar espaço atoa dependente do banco utilizado.
  • A tabela pode acabar ficando muito grande e cheia de indexes, o que pode ser um problema de performance.
  • E principalmente, a modelagem fica mais frágil na parte do banco de dados.

Na nossa experiência colhemos bons frutos com a utilização da técnica, cabe a você avaliar seus ganhos e implementar, ou não.

Espero ter contribuido de alguma forma!!

···

Leonardo Lemos


comments powered by Disqus


Proudly powered by Canvas · Sign In