PostgreSQL
 sql >> Database >  >> RDS >> PostgreSQL

La "O" in ORDBMS:Ereditarietà PostgreSQL

In questo post del blog esamineremo l'ereditarietà di PostgreSQL, tradizionalmente una delle principali funzionalità di PostgreSQL sin dalle prime versioni. Alcuni usi tipici dell'ereditarietà in PostgreSQL sono:

  • partizionamento delle tabelle
  • multi-locazione

PostgreSQL fino alla versione 10 ha implementato il partizionamento delle tabelle utilizzando l'ereditarietà. PostgreSQL 10 fornisce un nuovo modo di partizionamento dichiarativo. Il partizionamento di PostgreSQL usando l'ereditarietà è una tecnologia piuttosto matura, ben documentata e testata, tuttavia l'ereditarietà in PostgreSQL dal punto di vista del modello di dati non è (secondo me) così diffusa, quindi ci concentreremo su casi d'uso più classici in questo blog. Abbiamo visto dal blog precedente (opzioni multi-tenancy per PostgreSQL) che uno dei metodi per ottenere la multi-tenancy è utilizzare tabelle separate e quindi consolidarle tramite una vista. Abbiamo anche visto gli svantaggi di questo design. In questo blog miglioreremo questo design usando l'ereditarietà.

Introduzione all'ereditarietà

Guardando indietro al metodo multi-tenancy implementato con tabelle e viste separate, ricordiamo che il suo principale svantaggio è l'impossibilità di eseguire inserimenti/aggiornamenti/eliminazioni. Nel momento in cui proviamo un aggiornamento sul noleggio visualizza otterremo questo ERRORE:

ERROR:  cannot insert into view "rental"
DETAIL:  Views containing UNION, INTERSECT, or EXCEPT are not automatically updatable.
HINT:  To enable inserting into the view, provide an INSTEAD OF INSERT trigger or an unconditional ON INSERT DO INSTEAD rule.

Quindi, dovremmo creare un attivatore o una regola sul noleggio view specificando una funzione per gestire l'inserimento/aggiornamento/eliminazione. L'alternativa è usare l'ereditarietà. Cambiamo lo schema del blog precedente:

template1=# create database rentaldb_hier;
template1=# \c rentaldb_hier
rentaldb_hier=# create schema boats;
rentaldb_hier=# create schema cars;

Ora creiamo la tabella principale principale:

rentaldb_hier=# CREATE TABLE rental (
    id integer NOT NULL,
    customerid integer NOT NULL,
    vehicleno text,
    datestart date NOT NULL,
    dateend date
); 

In termini OO questa tabella corrisponde alla superclasse (nella terminologia java). Ora definiamo le tabelle figli ereditando da public.rental e aggiungendo anche una colonna per ogni tabella specifica del dominio:es. il numero di patente di guida (cliente) obbligatorio in caso di auto e il certificato di navigazione in barca opzionale.

rentaldb_hier=# create table cars.rental(driv_lic_no text NOT NULL) INHERITS (public.rental);
rentaldb_hier=# create table boats.rental(sail_cert_no text) INHERITS (public.rental);

Le due tabelle cars.rental e boats.rental eredita tutte le colonne dal loro genitore public.rental :
 

rentaldb_hier=# \d cars.rental
                           Table "cars.rental"
     Column     |         Type          | Collation | Nullable | Default
----------------+-----------------------+-----------+----------+---------
 id             | integer               |           | not null |
 customerid     | integer               |           | not null |
 vehicleno      | text                  |           |          |
 datestart      | date                  |           | not null |
 dateend        | date                  |           |          |
 driv_lic_no | text                  |           | not null |
Inherits: rental
rentaldb_hier=# \d boats.rental
                         Table "boats.rental"
    Column    |         Type          | Collation | Nullable | Default
--------------+-----------------------+-----------+----------+---------
 id           | integer               |           | not null |
 customerid   | integer               |           | not null |
 vehicleno    | text                  |           |          |
 datestart    | date                  |           | not null |
 dateend      | date                  |           |          |
 sail_cert_no | text                  |           |          |
Inherits: rental

Notiamo che abbiamo omesso la azienda colonna nella definizione della tabella padre (e di conseguenza anche nelle tabelle figli). Questo non è più necessario poiché l'identificazione dell'inquilino è nel nome completo del tavolo! Vedremo più avanti un modo semplice per scoprirlo nelle query. Ora inseriamo alcune righe nelle tre tabelle (prestiamo clienti schema e dati del blog precedente):

rentaldb_hier=# insert into rental (id, customerid, vehicleno, datestart) VALUES(1,1,'SOME ABSTRACT PLATE NO',current_date);
rentaldb_hier=# insert into cars.rental (id, customerid, vehicleno, datestart,driv_lic_no) VALUES(2,1,'INI 8888',current_date,'gr690131');
rentaldb_hier=# insert into boats.rental (id, customerid, vehicleno, datestart) VALUES(3,2,'INI 9999',current_date);

Ora vediamo cosa c'è nelle tabelle:

rentaldb_hier=# select * from rental ;
 id | customerid |       vehicleno        | datestart  | dateend
----+------------+------------------------+------------+---------
  1 |          1 | SOME ABSTRACT PLATE NO | 2018-08-31 |
  2 |          1 | INI 8888               | 2018-08-31 |
  3 |          2 | INI 9999               | 2018-08-31 |
(3 rows)
rentaldb_hier=# select * from boats.rental ;
 id | customerid | vehicleno | datestart  | dateend | sail_cert_no
----+------------+-----------+------------+---------+--------------
  3 |          2 | INI 9999  | 2018-08-31 |         |
(1 row)
rentaldb_hier=# select * from cars.rental ;
 id | customerid | vehicleno | datestart  | dateend | driv_lic_no
----+------------+-----------+------------+---------+-------------
  2 |          1 | INI 8888  | 2018-08-31 |         | gr690131
(1 row)

Quindi le stesse nozioni di ereditarietà che esistono nei linguaggi Object Oriented (come Java) esistono anche in PostgreSQL! Possiamo pensare a questo come segue:
public.rental:superclass
cars.rental:subclass
boats.rental:subclass
riga public.rental.id =1:istanza di public.rental
row cars.rental.id =2:istanza di cars.rental e public.rental
rowboats.rental.id =3:istanza di boats.rental e public.rental

Poiché anche le file di barche.noleggio e noleggio auto sono istanze di noleggio pubblico, è naturale che appaiano come file di noleggio pubblico. Se vogliamo solo righe esclusive di public.rental (in altre parole le righe inserite direttamente in public.rental) lo facciamo utilizzando la parola chiave SOLO come segue:

rentaldb_hier=# select * from ONLY rental ;
 id | customerid |       vehicleno        | datestart  | dateend
----+------------+------------------------+------------+---------
  1 |          1 | SOME ABSTRACT PLATE NO | 2018-08-31 |
(1 row)

Una differenza tra Java e PostgreSQL per quanto riguarda l'ereditarietà è questa:Java non supporta l'ereditarietà multipla mentre PostgreSQL lo fa, è possibile ereditare da più di una tabella, quindi a questo proposito potremmo pensare a tabelle più simili a interfacce in Java.

Se vogliamo scoprire la tabella esatta nella gerarchia a cui appartiene una riga specifica (l'equivalente di obj.getClass().getName() in java) possiamo farlo specificando la colonna speciale tableoid (oid della rispettiva tabella in pgclass ), eseguito il cast su regclass che fornisce il nome completo della tabella:

rentaldb_hier=# select tableoid::regclass,* from rental ;
   tableoid   | id | customerid |       vehicleno        | datestart  | dateend
--------------+----+------------+------------------------+------------+---------
 rental       |  1 |          1 | SOME ABSTRACT PLATE NO | 2018-08-31 |
 cars.rental  |  2 |          1 | INI 8888               | 2018-08-31 |
 boats.rental |  3 |          2 | INI 9999               | 2018-08-31 |
(3 rows)

Da quanto sopra (tableoid diverso) possiamo dedurre che le tabelle nella gerarchia sono semplicemente vecchie tabelle PostgreSQL, collegate con una relazione di ereditarietà. Ma oltre a questo, si comportano praticamente come i normali tavoli. E questo verrà ulteriormente sottolineato nella sezione seguente.

Fatti importanti e avvertenze sull'ereditarietà di PostgreSQL

La tabella figlio eredita:

  • vincoli NOT NULL
  • CONTROLLA i vincoli

La tabella figlio NON eredita:

  • Limiti CHIAVE PRIMARIA
  • vincoli UNICI
  • vincoli CHIAVE STRANIERA

Quando le colonne con lo stesso nome compaiono nella definizione di più tabelle nella gerarchia, tali colonne devono avere lo stesso tipo e vengono unite in un'unica colonna. Se esiste un vincolo NOT NULL per un nome di colonna in un punto qualsiasi della gerarchia, questo viene ereditato nella tabella figlio. Anche i vincoli CHECK con lo stesso nome vengono uniti e devono avere la stessa condizione.

Le modifiche allo schema alla tabella padre (tramite ALTER TABLE) vengono propagate in tutta la gerarchia che esiste al di sotto di questa tabella padre. E questa è una delle caratteristiche interessanti dell'ereditarietà in PostgreSQL.

Le politiche di sicurezza e sicurezza (RLS) vengono decise in base alla tabella effettiva che utilizziamo. Se utilizziamo una tabella padre, verranno utilizzati Security e RLS di tali tabelle. È implicito che la concessione di un privilegio sulla tabella padre dia il permesso anche alle tabelle figlie, ma solo quando si accede tramite la tabella padre. Per poter accedere direttamente alla tabella figlio, allora dobbiamo dare esplicito GRANT direttamente alla tabella figlio, il privilegio sulla tabella genitore non sarà sufficiente. Lo stesso vale per RLS.

Per quanto riguarda l'attivazione dei trigger, i trigger a livello di istruzione dipendono dalla tabella denominata dell'istruzione, mentre i trigger a livello di riga verranno attivati ​​a seconda della tabella a cui appartiene la riga effettiva (quindi potrebbe essere una tabella figlio).

Cose a cui prestare attenzione:

  • La maggior parte dei comandi funziona sull'intera gerarchia e supporta l'UNICA notazione. Tuttavia, alcuni comandi di basso livello (REINDEX, VACUUM, ecc.) funzionano solo sulle tabelle fisiche denominate dal comando. Assicurati di leggere la documentazione ogni volta in caso di dubbio.
  • I vincoli FOREIGN KEY (la tabella padre si trova sul lato di riferimento) non vengono ereditati. Questo è facilmente risolvibile specificando lo stesso vincolo FK in tutte le tabelle figlie della gerarchia.
  • A partire da questo punto (PostgreSQL 10), non c'è modo di avere un INDICE UNICO globale (CHIAVI PRIMARIE o vincoli UNIQUE) su un gruppo di tabelle. Di conseguenza:
    • CHIAVE PRIMARIA e vincoli UNIQUE non vengono ereditati e non esiste un modo semplice per imporre l'univocità a una colonna in tutti i membri della gerarchia
    • Quando la tabella padre si trova sul lato referenziato di un vincolo FOREIGN KEY, il controllo viene effettuato solo per i valori della colonna su righe realmente (fisicamente) appartenenti alla tabella padre, non a qualsiasi tabella figlio.

L'ultima limitazione è grave. Secondo i documenti ufficiali non esiste una buona soluzione per questo. Tuttavia, FK e unicità sono fondamentali per qualsiasi progettazione di database seria. Cercheremo un modo per affrontare questo problema.

Scarica il whitepaper oggi Gestione e automazione di PostgreSQL con ClusterControlScopri cosa devi sapere per distribuire, monitorare, gestire e ridimensionare PostgreSQLScarica il whitepaper

Ereditarietà in pratica

In questa sezione, convertiremo un design classico con tabelle semplici, vincoli PRIMARY KEY/UNIQUE e FOREIGN KEY, in un design multi-tenant basato sull'ereditarietà e cercheremo di risolvere i problemi (previsti come nella sezione precedente) che abbiamo viso. Consideriamo la stessa attività di noleggio che abbiamo utilizzato come esempio nel blog precedente e immaginiamo che all'inizio l'attività si occupi solo di autonoleggio (niente barche o altri tipi di veicoli). Consideriamo il seguente schema, con i veicoli dell'azienda e lo storico dei tagliandi su quei veicoli:

create table vehicle (id SERIAL PRIMARY KEY, plate_no text NOT NULL, maker TEXT NOT NULL, model TEXT NOT NULL,vin text not null);
create table vehicle_service(id SERIAL PRIMARY KEY, vehicleid INT NOT NULL REFERENCES vehicle(id), service TEXT NOT NULL, date_performed DATE NOT NULL DEFAULT now(), cost real not null);
rentaldb=# insert into vehicle (plate_no,maker,model,vin) VALUES ('INI888','Hyundai','i20','HH999');
rentaldb=# insert into vehicle_service (vehicleid,service,cost) VALUES(1,'engine oil change/filters',50);

Ora immaginiamo che il sistema sia in produzione, e poi l'azienda acquisisca una seconda società che si occupa di noleggio barche e le deve integrare nel sistema, facendo in modo che le due società operino indipendentemente per quanto riguarda l'operazione, ma in modo unificato per utilizzo da parte dei vertici. Inoltre, immaginiamo che i dati veicolo_servizio non debbano essere divisi poiché tutte le righe devono essere visibili a entrambe le società. Quindi quello che stiamo cercando è fornire una soluzione multi-tenancy basata sull'eredità sulla tabella del veicolo. Per prima cosa, dovremmo creare un nuovo schema per le auto (la vecchia attività) e uno per le barche e quindi migrare i dati esistenti su cars.vehicle:

rentaldb=# create schema cars;
rentaldb=# create table cars.vehicle (CONSTRAINT vehicle_pkey PRIMARY KEY(id) ) INHERITS (public.vehicle);
rentaldb=# \d cars.vehicle
                              Table "cars.vehicle"
  Column  |  Type   | Collation | Nullable |               Default               
----------+---------+-----------+----------+-------------------------------------
 id       | integer |           | not null | nextval('vehicle_id_seq'::regclass)
 plate_no | text    |           | not null |
 maker    | text    |           | not null |
 model    | text    |           | not null |
 vin      | text    |           | not null |
Indexes:
    "vehicle_pkey" PRIMARY KEY, btree (id)
Inherits: vehicle
rentaldb=# create schema boats;
rentaldb=# create table boats.vehicle (CONSTRAINT vehicle_pkey PRIMARY KEY(id) ) INHERITS (public.vehicle);
rentaldb=# \d boats.vehicle
                              Table "boats.vehicle"
  Column  |  Type   | Collation | Nullable |               Default               
----------+---------+-----------+----------+-------------------------------------
 id       | integer |           | not null | nextval('vehicle_id_seq'::regclass)
 plate_no | text    |           | not null |
 maker    | text    |           | not null |
 model    | text    |           | not null |
 vin      | text    |           | not null |
Indexes:
    "vehicle_pkey" PRIMARY KEY, btree (id)
Inherits: vehicle

Notiamo che le nuove tabelle condividono lo stesso valore predefinito per la colonna id (stessa sequenza) della tabella padre. Sebbene questa sia tutt'altro che una soluzione al problema dell'unicità globale spiegato nella sezione precedente, è una soluzione alternativa, a condizione che non venga mai utilizzato alcun valore esplicito per inserimenti o aggiornamenti. Se tutte le tabelle figli (cars.vehicle e boats.vehicle) sono definite come sopra e non modifichiamo mai esplicitamente l'id, allora saremo al sicuro.

Poiché manterremo solo la tabella veicoli_servizio pubblica e questa farà riferimento a righe di tabelle figli, dobbiamo eliminare il vincolo FK:

rentaldb=# alter table vehicle_service drop CONSTRAINT vehicle_service_vehicleid_fkey ;

Ma poiché abbiamo bisogno di mantenere la coerenza equivalente nel nostro database, dobbiamo trovare una soluzione per questo. Implementeremo questo vincolo usando i trigger. Dobbiamo aggiungere un trigger a veicolo_servizio che controlli che per ogni INSERT o UPDATE l'ID veicolo punti a una riga valida da qualche parte nella gerarchia public.vehicle* e un trigger su ciascuna delle tabelle di questa gerarchia che lo controlli per ogni DELETE o UPDATE su id, non esiste alcuna riga in vehicle_service che punti al vecchio valore. (nota dalla notazione veicolo* PostgreSQL implica questa e tutte le tabelle figli)

CREATE OR REPLACE FUNCTION public.vehicle_service_fk_to_vehicle() RETURNS TRIGGER
        LANGUAGE plpgsql
AS $$
DECLARE
tmp INTEGER;
BEGIN
        IF (TG_OP = 'DELETE') THEN
          RAISE EXCEPTION 'TRIGGER : % called on unsuported op : %',TG_NAME, TG_OP;
        END IF;
        SELECT vh.id INTO tmp FROM public.vehicle vh WHERE vh.id=NEW.vehicleid;
        IF NOT FOUND THEN
          RAISE EXCEPTION '%''d % (id=%) with NEW.vehicleid (%) does not match any vehicle ',TG_OP, TG_TABLE_NAME, NEW.id, NEW.vehicleid USING ERRCODE = 'foreign_key_violation';
        END IF;
        RETURN NEW;
END
$$
;
CREATE CONSTRAINT TRIGGER vehicle_service_fk_to_vehicle_tg AFTER INSERT OR UPDATE ON public.vehicle_service FROM public.vehicle DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE public.vehicle_service_fk_to_vehicle();

Se proviamo ad aggiornare o inserire con un valore per la colonna vehicleid che non esiste nel veicolo*, riceveremo un errore:

rentaldb=# insert into vehicle_service (vehicleid,service,cost) VALUES(2,'engine oil change/filters',50);
ERROR:  INSERT'd vehicle_service (id=2) with NEW.vehicleid (2) does not match any vehicle
CONTEXT:  PL/pgSQL function vehicle_service_fk_to_vehicle() line 10 at RAISE

Ora se inseriamo una riga in qualsiasi tabella nella gerarchia, ad es. barche.vehicle (che normalmente richiederà id=2) e riprova:

rentaldb=# insert into boats.vehicle (maker, model,plate_no,vin) VALUES('Zodiac','xx','INI000','ZZ20011');
rentaldb=# select * from vehicle;
 id | plate_no |  maker  | model |   vin   
----+----------+---------+-------+---------
  1 | INI888   | Hyundai | i20   | HH999
  2 | INI000   | Zodiac  | xx    | ZZ20011
(2 rows)
rentaldb=# insert into vehicle_service (vehicleid,service,cost) VALUES(2,'engine oil change/filters',50);

Quindi il precedente INSERT ora riesce. Ora dovremmo anche proteggere questa relazione FK dall'altro lato, dobbiamo assicurarci che nessun aggiornamento/cancellazione sia consentito su nessuna tabella nella gerarchia se la riga da eliminare (o aggiornare) è referenziata da veicolo_servizio:

CREATE OR REPLACE FUNCTION public.vehicle_fk_from_vehicle_service() RETURNS TRIGGER
        LANGUAGE plpgsql
AS $$
DECLARE
tmp INTEGER;
BEGIN
        IF (TG_OP = 'INSERT') THEN
          RAISE EXCEPTION 'TRIGGER : % called on unsuported op : %',TG_NAME, TG_OP;
        END IF;
        IF (TG_OP = 'DELETE' OR OLD.id <> NEW.id) THEN
          SELECT vhs.id INTO tmp FROM vehicle_service vhs WHERE vhs.vehicleid=OLD.id;
          IF FOUND THEN
            RAISE EXCEPTION '%''d % (OLD id=%) matches existing vehicle_service with id=%',TG_OP, TG_TABLE_NAME, OLD.id,tmp USING ERRCODE = 'foreign_key_violation';
          END IF;
        END IF;
        IF (TG_OP = 'UPDATE') THEN
                RETURN NEW;
        ELSE
                RETURN OLD;
        END IF;
END
$$
;
CREATE CONSTRAINT TRIGGER vehicle_fk_from_vehicle_service AFTER DELETE OR UPDATE
ON public.vehicle FROM vehicle_service DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE vehicle_fk_from_vehicle_service();
CREATE CONSTRAINT TRIGGER vehicle_fk_from_vehicle_service AFTER DELETE OR UPDATE
ON cars.vehicle FROM vehicle_service DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE vehicle_fk_from_vehicle_service();
CREATE CONSTRAINT TRIGGER vehicle_fk_from_vehicle_service AFTER DELETE OR UPDATE
ON boats.vehicle FROM vehicle_service DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE vehicle_fk_from_vehicle_service();

Proviamolo:

rentaldb=# delete from vehicle where id=2;
ERROR:  DELETE'd vehicle (OLD id=2) matches existing vehicle_service with id=3
CONTEXT:  PL/pgSQL function vehicle_fk_from_vehicle_service() line 11 at RAISE

Ora dobbiamo spostare i dati esistenti in public.vehicle in cars.vehicle.

rentaldb=# begin ;
rentaldb=# set constraints ALL deferred ;
rentaldb=# set session_replication_role TO replica;
rentaldb=# insert into cars.vehicle select * from only public.vehicle;
rentaldb=# delete from only public.vehicle;
rentaldb=# commit ;

L'impostazione di session_replication_role TO replica impedisce l'attivazione dei normali trigger. Nota che, dopo aver spostato i dati, potremmo voler disabilitare completamente la tabella padre (public.vehicle) per accettare gli inserimenti (molto probabilmente tramite una regola). In questo caso, nell'analogia OO, tratteremmo public.vehicle come una classe astratta, cioè senza righe (istanze). L'uso di questo design per il multi-tenancy sembra naturale perché il problema da risolvere è un classico caso d'uso per l'ereditarietà, tuttavia i problemi che abbiamo dovuto affrontare non sono banali. Questo è stato discusso dalla comunità degli hacker e speriamo in futuri miglioramenti.