domenica 19 marzo 2017

DBIx (DBIC) in 10 minuti

Per chi proviene dal mondo "classico" della programmazione ORM (Object Relational Mapping), il componente DBIX può risultare un po' ostico. Il problema principale, a mio avviso, è nella terminologia e nella modalità di utilizzo. Se un ORM classico infatti richiede solitamente un livello intermedio di persistenza (tipicamente tramite una sessione, dao, ecc), DBIX tende a fornire oggetti (proxy-zzati) molto piu' autonomi.
In questo brevissimo articolo voglio mostrare come può essere usato DBIX agilmente nelle operazioni piu' comuni.

1 Creazione del database

Si supponga di creare uno schema SQLite3 molto semplice:
sqlite3 database.db
sqlite> CREATE TABLE events( pk integer primary key autoincrement,
ts timestamp default current_timestamp,
description varchar(200) );

2 Eseguire il dump dello schema

Il programma dbicdump permette il dump di uno schema database, ovvero la creazione degli oggetti result che rappresentano ciascuno una tabella singola del database/schema.
 dbicdump -o dump_directory=./lib -o components='["InflateColumn::DateTime"]' \
  Test::Schema
  dbi:SQLite:database.db
Dumping manual schema for Test::Schema to directory ./lib ...
Schema dump completed.
Dopo aver eseguito questo comando si avrà una directory lib che contiene un file Schema.pm (che dichiara il namespace) e il relativo albero di directory Schema/Result con un file pm per ogni DBIX result, ovvero un file per tabella. In particolare, per la singola tabella event rappresentata dalla classe nel file Event.pm si ha che:
__PACKAGE__->add_columns(
  "pk",
  { data_type => "integer", is_auto_increment => 1, is_nullable => 0 },
  "ts",
  {
    data_type     => "timestamp",
    default_value => \"current_timestamp",
    is_nullable   => 1,
  },
  "description",
  { data_type => "varchar", is_nullable => 1, size => 200 },
);
Si nota come il sistema di gestione dei result sia di tipo dichiarativo: ogni classe (Perl) indica in modo dichiarativo i propri membri, e ciascun membro corrisponde sostanzialmente ad una colonna del database.
L'utilizzo di DBIX risulta veramente veloce e semplice (dal punto di vista della quantità del codice). La classe schema eredita da DBIX::Schema e quindi dispone del metodo connect che consente la connessione al database. Ogni azione che si vuole fare su un database (istanza) deve avvenire attraverso lo schema. Lo schema raccoglie una serie di tuple raggruppate in un result set (inteso qui differentemente rispetto a JDBC, DBI, ecc). Ogni operazione che si vuole svolgere su una tupla deve passare attraverso un result set. In sostanza si potrebbe affermare che l'analogia fra DBI e DBIX è la seguente:
DBI - JDBC - similari DBIX Significato
database handler schema Connessione al database (istanza)
result set array di oggetti Risultati da una query
statement su tabella result set Interazione lettura/scrittura con una tabella

3 Un primo esempio

Ecco quindi un programma di esempio:
use Test::Schema;

my $schema_connection = Test::Schema->connect( 'dbi:SQLite:/home/luca/tmp/dbix/database.db' );


for ( 1..100 ){
    my $event = $schema_connection
 ->resultset( 'Event' )
 ->create( { description => "Evento $_" } );

    # update !
    $event->update();
}

# ricerca di eventi
my $result_set = $schema_connection->resultset( 'Event' )->search( { pk =>{ '>' => 50, '<' => 87 }  } );

while ( ( my $event = $result_set->next() ) ){
    say "Evento " . $event->pk . " = " . $event->description;
}
#+end-perl

La prima parte carica lo schema e gli oggetti creati appositamente da ~dbicdump~.
Successivamente si crea lo schema, ovvero ci si collega al database (istanza).
Poi si effettua un semplice ciclo per inserire 100 righe nella tabella /events/, corrispondente all'oggetto Perl ~Event~.
Si noti che tali oggetti sono creati con il metodo ~create~ sul result set, che a sua volta è agganciato alla tabella /events/.
In altre parole la riga

#+negin_src perl
my $event = $schema_connection
 ->resultset( 'Event' )
 ->create( { description => "Evento $_" } );
esegue:
  1. una connessione al database (istanza) usando lo Schema;
  2. decide di lavorare sul result set Event, ovvero sulla tabella corrispondente events;
  3. chiedendo di creare un oggetto che rappresenta una singola riga nella tabella.
Giunti a questo punto DBIX sa tutto quello che gli serve per memorizzare in modo permanente sul database l'oggetto, e quindi tradurlo in una riga di SQL. Da notare che l'oggetto creato tramite il metodo create è un oggetto Perl a tutti gli effetti, ovvero è possibile usare i metodi accessor per impostare i valori.
Fino a qui DBIX si è solo "preparato", ma non ha eseguito nulla di fatto. Nel momento in cui l'oggetto si aggiorna, ovvero si invoca il metodo update, questo viene inserito/aggiornato nella tabella.
Questo è un punto nodale che rappresenta una differenza fondamentale nell'approccio DBIX rispetto ad altri ORM (es. Hibernate): invece che creare degli oggetti slegati dal database e richiedere ad un servizio DAO di rendere persistente tali oggetti, DBIX crea oggetti collegati alla propria tabella e al proprio database, così che tali oggetti "sappiano" dove andare ad "inserirsi" o "aggiornarsi". Nessuna cosa, entità o servizio di persistenza esterno viene invocato.
Successivamente è possibile rileggere i dati inseriti, ancora una volta passando per il result set:
  1. si usa la connessione al database (istanza), ovvero lo Schema;
  2. si dichiara di voler lavorare sullo schema Event, ovvero la tabella fisica events;
  3. si effettua una ricerca specificando, per ogni colonna che si vuole cercare, i valori da mettere in and-logico. Nell'esempio di cui sopra si ha che si prendono tutte le tuple con pk compreso fra 51 e 86 inclusi. L'oggetto ritornato è un Result Set, da intendersi come collezione di oggetti del tipo Event.
Si può allora iterare sul result set per ottenere tutti gli oggetti di tipo Event richiesti. Da notare che è possibile specificare delle clausole where letterali, il metodo search può accettare diversi formati di parametri. Inoltre esiste un metodo find per la ricerca secca di un record per chiave primaria.

4 Update massivo

Ci sono due strade per effettuare un aggiornamento massivo di record:
  1. iterare su ogni oggetto collegato ad una tupla ed eseguire un update singolo;
  2. eseguire un update sul result set trovato da una query di ricerca.
Il primo metodo potrebbe riassumersi così:
my $result_set = $schema_connection->resultset( 'Event' )->search( { pk =>{ '>' => 50, '<' => 87 }  } );

while ( ( my $event = $result_set->next() ) ){
    $event->description( " AGGIORNATO " . $event->description() );
    $event->update();
}
L'idea è semplice: si effettua una ricerca, si prendono uno ad uno gli oggetti Event (ciascuno dei quali corrisponde ad una tupla) e lo si aggiorna tramite update. DBIX è sufficientemente intelligente per capire che deve eseguire una UPDATE verso il database, siccome la riga proveniva da una query.
Il secondo approccio, sicuramente piu' compatto e simile, come concetto, alla query SQL che si vorrebbe eseguire, è il seguente:
my $result_set = $schema_connection->resultset( 'Event' )->search( { pk =>{ '>' => 50, '<' => 87 }  } );
$result_set->update( { description => 'AGGIORNATO' } );
La differenza, ovvia, è che non si possono chiamare degli accessor nel caso di update di un intero result set, e quindi questa tecnica è effettivamente utile quando si devono impostare tutti i valori ad un letterale prestabilito.

5 Colonne modificate, annullare le modifiche

Un singolo oggetto collegato ad un result set è in grado di sapere quali colonne sono state modificate:
$event->description( " AGGIORNATO " . $event->description() );
    my %cols = $event->get_dirty_columns();
    for my $col ( keys %cols ){
 say "Evento " . $event->pk . " con colonna $col modificata " ;
    }
e quindi è anche possibile evitare di eseguire delle query qualora l'oggetto non sia stato modificato:
$event->update() if ( $event->get_dirty_columns() );

6 Cancellazione

Beh, come ci si puo' aspettare, delete esegue lo scopo:
$event->delete;

7 Insert o Update?

Inizialmente ho mentito: non è necessario chiamare update per eseguire una query INSERT o una query UPDATE. Come ci si può aspettare il metodo update esegue…eh.. UPDATE! Esiste infatti un metodo insert da richiamare su un oggetto risultato per eseguire l'inserimento nella tabella corrispondente. Il motivo per il quale nel primo esempio si è usato update è che lo schema ha creato l'oggetto con create, che inserisce di fatto una tupla nella tabella corrispondete. L'update successiva aggiorna tale tupla. E' possibile creare un oggetto senza inserirlo nel database usando il metodo new sul result set, che restituisce un oggetto sul quale occorre poi fare una insert esplicita:
my $event = $schema_connection
    ->resultset( 'Event' )
    ->new( { description => "!!Evento $_" } );

$event->insert;

8 Aggiungere foreign keys

Supponiamo di voler dotare ogni evento di un "tag":
sqlite> CREATE TABLE tags( pk integer primary key autoincrement, description varchar(20) );
sqlite> INSERT INTO tags( description) VALUES( 'Tag1' ), ( 'Tag2' );
sqlite> DROP TABLE events;
sqlite> CREATE TABLE events( pk integer primary key autoincrement,
   ...>    ts timestamp default current_timestamp,
   ...>    description varchar(200),
   ...>    tag_pk integer,
   ...>    FOREIGN KEY(tag_pk) REFERENCES tags(pk) );
Se si ricostruisce il modello Perl con dbicdump si ha che viene creata una classe Tag che contiene nella definizione il seguente blocco:
=head1 RELATIONS

=head2 events

Type: has_many

Related object: L

=cut

__PACKAGE__->has_many(
  "events",
  "Test::Schema::Result::Event",
  { "foreign.tag_pk" => "self.pk" },
  { cascade_copy => 0, cascade_delete => 0 },
);
che istruisce DBIX sul fatto che Event e Tag sono in relazione attraverso le chiavi esterne. Analogamente Event viene modificato rispetto a prima:
__PACKAGE__->belongs_to(
  "tag_pk",
  "Test::Schema::Result::Tag",
  { pk => "tag_pk" },
  {
    is_deferrable => 0,
    join_type     => "LEFT",
    on_delete     => "NO ACTION",
    on_update     => "NO ACTION",
  },
);
Vediamo ora come è possibile inserire delle tuple che agiscano su entrambe le tabelle:
use Test::Schema;

my $schema_connection = Test::Schema->connect( 'dbi:SQLite:/home/luca/tmp/dbix/database.db' );


for ( 1..100 ){
    my $tag = $schema_connection
 ->resultset( 'Tag' )
 ->find( ( $_ % 2 ) + 1 );

    my $event = $schema_connection
 ->resultset( 'Event' )
 ->new( { description => "Evento $_" } );

    $event->tag_pk( $tag );
    $event->insert;
}
Come si può notare si seleziona in modo "random" un tag dal resultset Tag, e poi lo si imposta nell'oggetto Event che viene poi inserito tramite insert. Da notare una bruttura: DBIX chiama il metodo accessor della relazione come la colonna stessa della tabella, in questo caso tag_pk. Ovviamente non è DBIX, quanto dbicdump, quindi questa cosa va tenuta presente e/o modificata a mano per rendere piu' leggibile il codice Perl (dopotutto tag_pk() agisce su un oggetto Tag e non su un oggetto integer). Se ad esempio si modifica Event.pm come segue:
__PACKAGE__->belongs_to(
  "tag",  # era tag_pk
  "Test::Schema::Result::Tag",
  { pk => "tag_pk" },
  {
    is_deferrable => 0,
    join_type     => "LEFT",
    on_delete     => "NO ACTION",
    on_update     => "NO ACTION",
  },
);
allora il seguente codice usa il metodo mnemonico tag che ricorda meglio l'oggetto risultato Tag:
for ( 1..100 ){
    my $tag = $schema_connection
 ->resultset( 'Tag' )
 ->find( ( $_ % 2 ) + 1 );

    my $event = $schema_connection
 ->resultset( 'Event' )
 ->new( { description => "Evento $_" } );


    $event->tag( $tag ); # era tag_pk
    $event->insert;
}
A questo punto si può procedere in maniera analoga alla ricerca dei dati, il metodo Event::tag fornirà un oggetto di tipo Tag mentre Tag::events fornirà una lista di oggetti Event. Ad esempio:
my $result_set = $schema_connection->resultset( 'Tag' )->find( 1 );

for my $event ( $result_set->events() ){
    say "Evento trovato " . $event->description;
}
da notare che sul result set non si deve effettuare nessuna operazione poiché il metodo find restituisce un solo valore Tag.

9 Riassunto

DBIX è un meccanismo molto flessibile e potente, che opera a livello semi-dichiarativo: come si è potuto notare ogni oggetto collegato ad una tupla database è espresso tramite invocazioni di metodi che ne definiscono struttura e relazioni (es. belongs_to). Il concetto chiave di DBIX è, a mio avviso, il fatto che il result set non rappresenta un set di risultati quanto una tabella fisica (o un join di tabelle), e che di conseguenza ogni singola tupla di tabella/e diventa un oggetto capace di agire autonomamente sulla propria persistenza. Ne consegue che ogni volta che si vuole interagire con una tabella si deve passare per un result set, sia per aggiungere, aggiornare, cancellare o selezionare tuple, che a loro volta diventano oggetti, che includono la logica dao al proprio interno.

Nessun commento: