sql >> Databasteknik >  >> RDS >> PostgreSQL

Mer SQL, mindre kod, med PostgreSQL

Med bara lite justeringar och förbättringar av dina Postgres SQL-frågor kan du minska mängden repetitiv, felbenägen applikationskod som krävs för att samverka med din databas. Oftare än inte, en sådan förändring förbättrar också applikationskodens prestanda.

Här är några tips och tricks som kan hjälpa din applikationskod att lägga ut mer arbete på PostgreSQL och göra din applikation smalare och snabbare.

Upsert

Sedan Postgres v9.5 är det möjligt att specificera vad som ska hända när en insättning misslyckas på grund av en "konflikt". Konflikten kan antingen vara en kränkning av ett unikt index (inklusive en primärnyckel) eller någon begränsning (skapad tidigare med CREATE CONSTRAINT).

Denna funktion kan användas för att förenkla infoga-eller-uppdatera applikationslogik i en enda SQL-sats. Till exempel, givet en tabell kv med nyckel och värde kolumner, kommer satsen nedan att infoga en ny rad (om tabellen inte har en rad med key=’host’) eller uppdatera värdet (om tabellen har en rad med key=’host’):

CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT);

INSERT INTO kv (key, value)
VALUES ('host', '10.0.10.1')
    ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value;

Observera att kolumnen key är den enkolumns primärnyckeln i tabellen och anges som konfliktsatsen. Om du har en primärnyckel med flera kolumner, ange namnet på primärnyckelindex här istället.

För avancerade exempel, inklusive specificering av partiella index och begränsningar, se Postgres docs.

Infoga .. returnerar

INSERT-satsen kan också returnera en eller flera rader, som en SELECT-sats. Det kan returnera värden som genereras av funktioner, nyckelord som current_timestamp och seriell /sequence/identity kolumner.

Här är till exempel en tabell med en autogenererad identitetskolumn och en kolumn som innehåller tidsstämpeln för radens skapande:

db=> CREATE TABLE t1 (id int GENERATED BY DEFAULT AS IDENTITY,
db(>                  at timestamptz DEFAULT CURRENT_TIMESTAMP,
db(>                  foo text);

Vi kan använda INSERT .. RETURNING-satsen för att endast ange värdet för kolumnen foo , och låt Postgres returnera värdena som den genererade för id och vid kolumner:

db=> INSERT INTO t1 (foo) VALUES ('first'), ('second') RETURNING id, at, foo;
 id |                at                |  foo
----+----------------------------------+--------
  1 | 2022-01-14 11:52:09.816787+01:00 | first
  2 | 2022-01-14 11:52:09.816787+01:00 | second
(2 rows)

INSERT 0 2

Från applikationskoden, använd samma mönster/API:er som du skulle använda för att köra SELECT-satser och läsa in värden (som executeQuery() i JDBC eller db.Query() i Go).

Här är ett annat exempel, den här har ett automatiskt genererat UUID:

CREATE TABLE t2 (id uuid PRIMARY KEY, foo text);

INSERT INTO t2 (id, foo) VALUES (gen_random_uuid(), ?) RETURNING id;

I likhet med INSERT kan UPDATE- och DELETE-satserna också innehålla RETURNING-satser i Postgres. RETURNING-satsen är en Postgres-tillägg och inte en del av SQL-standarden.

Alla i en uppsättning

Från applikationskoden, hur skulle du skapa en WHERE-sats som måste matcha en kolumns värde mot en uppsättning acceptabla värden? När antalet värden är kända i förväg är SQL:n statisk:

stmt = conn.prepareStatement("SELECT key, value FROM kv WHERE key IN (?, ?)");
stmt.setString(1, key[0]);
stmt.setString(2, key[1]);

Men vad händer om antalet nycklar inte är 2 utan kan vara vilket nummer som helst? Skulle du konstruera SQL-satsen dynamiskt? Ett enklare alternativ är att använda Postgres-arrayer:

SELECT key, value FROM kv WHERE key = ANY(?)

Operatören ANY ovan tar en array som argument. Klausulen nyckel =ANY(?) väljer alla rader där värdet för nyckel är ett av elementen i den medföljande arrayen. Med detta kan applikationskoden förenklas till:

stmt = conn.prepareStatement("SELECT key, value FROM kv WHERE key = ANY(?)");
a = conn.createArrayOf("STRING", keys);
stmt.setArray(1, a);

Detta tillvägagångssätt är genomförbart för ett begränsat antal värden, om du har många värden att matcha med, överväg andra alternativ som att gå med (tillfälliga) tabeller eller materialiserade vyer.

Flytta rader mellan tabeller

Ja, du kan ta bort rader från en tabell och infoga dem i en annan med en enda SQL-sats! En huvudsats INSERT kan dra in raderna för att infoga med hjälp av en CTE, som omsluter en DELETE.

WITH items AS (
       DELETE FROM todos_2021
        WHERE NOT done
    RETURNING *
)
INSERT INTO todos_2021 SELECT * FROM items;

Att göra motsvarande i applikationskoden kan vara väldigt utförligt, vilket innebär att man lagrar hela resultatet av borttagningen i minnet och använder det för att göra flera INSERT. Visst, att flytta rader är kanske inte ett vanligt användningsfall, men om affärslogiken kräver det, gör besparingarna av applikationsminne och databasresor som detta tillvägagångssätt ger det till den idealiska lösningen.

Uppsättningen av kolumner i käll- och destinationstabellerna behöver inte vara identiska, du kan naturligtvis ändra ordning, ordna om och använda funktioner för att manipulera värdena i urvals-/returlistorna.

Koalesce

Att lämna NULL-värden i applikationskoden tar vanligtvis extra steg. I Go, till exempel, skulle du behöva använda typer som sql.NullString; i Java/JDBC, funktioner som resultSet.wasNull() . Dessa är besvärliga och felbenägna.

Om det är möjligt att hantera, säg NULLs som tomma strängar, eller NULL-heltal som 0, i samband med en specifik fråga, kan du använda COALESCE-funktionen. COALESCE-funktionen kan förvandla NULL-värden till vilket specifikt värde som helst. Tänk till exempel på den här frågan:

SELECT invoice_num, COALESCE(shipping_address, '')
  FROM invoices
 WHERE EXTRACT(month FROM raised_on) = 1    AND
       EXTRACT(year  FROM raised_on) = 2022

som får fakturanummer och leveransadresser för fakturor som togs upp i januari 2022. Förmodligen leveransadress är NULL om varor inte måste skickas fysiskt. Om applikationskoden helt enkelt vill visa en tom sträng någonstans i sådana fall är det enklare att bara använda COALESCE och ta bort NULL-hanteringskoden i applikationen.

Du kan också använda andra strängar istället för en tom sträng:

SELECT invoice_num, COALESCE(shipping_address, '* NOT SPECIFIED *') ...

Du kan till och med hämta det första icke-NULL-värdet från en lista, eller använda den angivna strängen istället. Till exempel för att antingen använda faktureringsadressen eller leveransadressen kan du använda:

SELECT invoice_num, COALESCE(billing_address, shipping_address, '* NO ADDRESS GIVEN *') ...

Fall

CASE är en annan användbar konstruktion för att hantera verkliga, ofullkomliga data. Låt oss säga istället för att ha NULLs i shipping_address för icke-leveransbara artiklar har vår inte så perfekta fakturaskapande programvara satt in "NOT-SPECIFICERAD". Du vill mappa detta till en NULL eller en tom sträng när du läser in data. Du kan använda CASE:

-- map NOT-SPECIFIED to an empty string
SELECT invoice_num,
       CASE shipping_address
	     WHEN 'NOT-SPECIFIED' THEN ''
		 ELSE shipping_address
		 END
FROM   invoices;

-- same result, different syntax
SELECT invoice_num,
       CASE
	     WHEN shipping_address = 'NOT-SPECIFIED' THEN ''
		 ELSE shipping_address
		 END
FROM   invoices;

CASE har en otymplig syntax, men är funktionellt lik switch-case-satser i C-liknande språk. Här är ett annat exempel:

SELECT invoice_num,
       CASE
	     WHEN shipping_address IS NULL THEN 'NOT SHIPPING'
	     WHEN billing_address = shipping_address THEN 'SHIPPING TO PAYER'
		 ELSE 'SHIPPING TO ' || shipping_address
		 END
FROM   invoices;

Välj .. union

Data från två (eller flera) separata SELECT-satser kan kombineras med UNION. Om du till exempel har två tabeller, en med nuvarande användare och en borttagen, så här kan du fråga dem båda samtidigt:

SELECT id, name, address, FALSE AS is_deleted 
  FROM users
 WHERE email = ?

UNION

SELECT id, name, address, TRUE AS is_deleted
  FROM deleted_users
 WHERE email = ?

De två frågorna ska ha samma urvalslista, det vill säga ska returnera samma antal och typ av kolumner.

UNION tar också bort dubbletter. Endast unika rader returneras. Om du hellre vill ha kvar dubblettrader, använd "UNION ALL" istället för UNION.

Som komplimang för UNION finns det också INTERSECT och EXCEPT, se PostgreSQL-dokumentationen för mer information.

Välj .. distinct on

Dubblettrader som returneras av en SELECT kan kombineras (det vill säga endast unika rader returneras) genom att lägga till nyckelordet DISTINCT efter SELECT. Även om detta är standard SQL, tillhandahåller Postgres en tillägg, "DISTINCT ON". Det är lite knepigt att använda, men i praktiken är det ofta det mest kortfattade sättet att få de resultat du behöver.

Tänk på en kunder tabell med en rad per kund och en köp tabell med en rad per köp gjorda av (vissa) kunder. Frågan nedan returnerar alla kunder, tillsammans med vart och ett av deras köp:

   SELECT C.id, P.at
     FROM customers C LEFT OUTER JOIN purchases P ON P.customer_id = C.id
 ORDER BY C.id ASC, P.at ASC;

Varje kundrad upprepas för varje köp de har gjort. Vad händer om vi bara vill returnera det första köpet av en kund? Vi vill i princip sortera raderna efter kund, gruppera raderna efter kund, inom varje grupp sortera raderna efter köptid och slutligen returnera endast den första raden från varje grupp. Det är faktiskt kortare att skriva det i SQL med DISTINCT ON:

   SELECT DISTINCT ON (C.id) C.id, P.at
     FROM customers C LEFT OUTER JOIN purchases P ON P.customer_id = C.id
 ORDER BY C.id ASC, P.at ASC;

Den tillagda "DISTINCT ON (C.id)"-satsen gör precis vad som beskrevs ovan. Det är mycket jobb med bara några extra bokstäver!

Använda nummer i ordning efter klausul

Överväg att hämta en lista med kundnamn och riktnummer för deras telefonnummer från en tabell. Vi antar att amerikanska telefonnummer lagras i formatet (123) 456-7890 . För andra länder säger vi bara "NON-US" som riktnummer.

SELECT last_name, first_name,
       CASE country_code
	     WHEN 'US' THEN substr(phone, 2, 3)
		 ELSE 'NON-US'
		 END
FROM   customers;

Det är allt bra, och vi har CASE-konstruktionen också, men tänk om vi behöver sortera det efter riktnummer nu?

Detta fungerar:

SELECT last_name, first_name,
       CASE country_code
	     WHEN 'US' THEN substr(phone, 2, 3)
		 ELSE 'NON-US'
		 END
FROM   customers
ORDER  BY
       CASE country_code
	     WHEN 'US' THEN substr(phone, 2, 3)
		 ELSE 'NON-US'
		 END ASC;

Men usch! Att upprepa fallklausulen är fult och felbenäget. Vi skulle kunna skriva en lagrad funktion som tar landskod och telefon och returnerar riktnummer, men det finns faktiskt ett trevligare alternativ:

SELECT last_name, first_name,
       CASE country_code
	     WHEN 'US' THEN substr(phone, 2, 3)
		 ELSE 'NON-US'
		 END
FROM   customers
ORDER  BY 3 ASC;

"ORDER BY 3" säger order by the 3rd field! Du måste komma ihåg att uppdatera numret när du ordnar om listan, men det brukar vara värt det.


  1. Fix:Åtkomst nekad för användaren 'root'@'localhost' i MariaDB

  2. TSQL e-postvalidering (utan regex)

  3. Returnera en DML-utlösartyp på en tabell i SQL Server

  4. 3 sätt att få schemat för en resultatuppsättning i SQL Server