SELECT 語句的解剖#
PostgreSQL 中最簡單的 select 語句就是:
SELECT 1;其他系統的 FROM 子句是必填的,有些還提供一張單列的假表(dummy table)讓你 select。
投影(輸出):SELECT#
SELECT 子句列出輸出欄位——要送回客戶端應用程式的資料清單。伺服器執行查詢的唯一目的,就是回傳一個結果集,其中每一列都呈現 select 子句指定的欄位;這稱為投影(projection)。
在 select 清單多加一個欄位可能牽涉大量工作:
- 從磁碟讀取資料
- 可能還要解壓縮存放在主表結構之外的資料(TOAST),並載入資料庫伺服器記憶體
- 再透過網路把資料送回客戶端
因此,惡名昭彰的 select * 寫法、以及某些 ORM「不管三七二十一,先把物件完整水合(hydrate)再說」的行為,通常都不受歡迎。以下快捷寫法只適合互動模式使用:
select * from races limit 1;標準語法的 limit 其實稍複雜一點:
select * from races fetch first 1 rows only;SQL 標準還允許更簡便的替代寫法,結果相同:
table races limit 1;為什麼不要 select *#
在應用程式碼中避免 select * 還有另一個理由:一旦來源關聯(relation)的定義改變,同一句查詢的結果集結構就跟著變,你可能得同步修改應用程式的記憶體資料結構。
以一個簡化的 Java 例子來說(略去例外處理與資源釋放):
rs = st.executeQuery("SELECT * FROM races LIMIT 1;");
if (rs.next()) {
System.out.println(rs.getInt("raceid"));
System.out.println(rs.getInt("year"));
// ... 逐欄讀取,共 8 欄
}即使這麼短的程式,也必須知道 races 表的欄位清單、每個欄名與型別。假設 schema 曾有一個 extra 欄位、程式裡有 rs.getString("extra"),當該欄位被移除(例如 schema 變更上線後),程式就掛了:
SEVERE: The column name extra was not found in this ResultSet.
org.postgresql.util.PSQLException: The column name extra was not found ...問題在於程式碼錯了,但 code review 幫不上忙——因為查詢在改動前後都只是 select * ...。若改成明確列出欄位:
rs = st.executeQuery("SELECT name, date, url, extra FROM races LIMIT 1;");SQL 查詢欄名與結果集取值之間的對應一目瞭然。雖然在 review 或編譯期仍無法確認欄位是否存在於生產環境,但至少錯誤訊息變得非常清楚(ERROR: column "extra" does not exist),而且改動的 diff 也容易審查——被移除的欄位會同時出現在查詢字串與取值程式的刪除行裡。
反對
select *的理由總結:
select *隱藏了程式的意圖;明確列出欄位等於宣告開發者的想法。- 欄位清單明確時,程式碼改動更容易 review(若查詢放在獨立的
.sql檔更佳)。- 每次都抓回用不到的位元組並不划算——由於 TOAST(The Oversized-Attribute Storage Technique)機制,有些位元組在伺服器端的讀取成本很高,之後還得經過網路、進入應用程式記憶體。
核心是讓程式碼明確表達它在做什麼:對生產除錯、效能分析與最佳化、新成員上手、code review 等一切維護工作都有巨大幫助。
計算值與別名#
SELECT 子句可以回傳計算值並重新命名欄位:
select code,
format('%s %s', forename, surname) as fullname,
forename,
surname
from drivers;這裡用了 PostgreSQL 的 format 函式,類似 Python 的 print 或 C 的 printf。SQL 標準則提供串接運算子 ||,可寫出符合標準的等價查詢:forename || ' ' || surname as fullname。
本書聚焦 PostgreSQL 而非標準相容性,因為 PostgreSQL 提供大量 SQL 標準與多數競爭對手都沒有的實用函式與寶藏。另外,SELECT 別名的可見範圍值得留意——這在後面談
ORDER BY、GROUP BY、HAVING、WINDOW子句時會再討論。
PostgreSQL 處理函式#
PostgreSQL 內建非常豐富的處理函式,查詢的任何位置都能使用。作者常看到程式只從 RDBMS 抓原始資料、把所有處理都放在應用程式碼裡,因此特別示範用 PostgreSQL 處理日曆資訊——展示 extract()、to_char() 與 CASE 結構:
select date::date,
extract('isodow' from date) as dow,
to_char(date, 'dy') as day,
extract('isoyear' from date) as "iso year",
extract('week' from date) as week,
extract('day' from
(date + interval '2 month - 1 day')
)
as feb,
extract('year' from date) as year,
extract('day' from
(date + interval '2 month - 1 day')
) = 29
as leap
from generate_series(date '2000-01-01',
date '2010-01-01',
interval '1 year')
as t(date);generate_series() 回傳一組資料——這裡是 2000 年代每年的第一天。針對每個日期計算:當天是星期幾(數字與文字形式)、ISO 標準定義的年份與週數、二月最後一天,以及是否為閏年的布林值。
查詢結果與 ISO 週數定義
PostgreSQL 文件對 ISO 年與週數的定義:ISO 週從星期一開始,一年的第一週必須包含該年的 1 月 4 日——換句話說,一年的第一個星期四落在第 1 週。
date │ dow │ day │ iso year │ week │ feb │ year │ leap
════════════╪═════╪═════╪══════════╪══════╪═════╪══════╪══════
2000-01-01 │ 6 │ sat │ 1999 │ 52 │ 29 │ 2000 │ t
2001-01-01 │ 1 │ mon │ 2001 │ 1 │ 28 │ 2001 │ f
2004-01-01 │ 4 │ thu │ 2004 │ 1 │ 29 │ 2004 │ t
2005-01-01 │ 6 │ sat │ 2004 │ 53 │ 28 │ 2005 │ f
2006-01-01 │ 7 │ sun │ 2005 │ 52 │ 28 │ 2006 │ f
2010-01-01 │ 5 │ fri │ 2009 │ 53 │ 28 │ 2010 │ f
(節錄)在 PostgreSQL 裡做複雜的日期計算非常容易,連時區都幫你處理好了。千萬別想自己實作這類邏輯——裡面滿是稀奇古怪的例外。
資料來源:FROM#
FROM 子句引入查詢使用的資料來源,並支援宣告這些來源之間的關係。最基本的形式是從單一資料表讀取:
select code, driverref, forename, surname
from drivers;假設現在想找出史上贏得最多場比賽的前三名車手,就需要 drivers 表與 results 表的資訊(results 有 position 欄位,冠軍是 1):
select code, forename, surname,
count(*) as wins
from drivers
join results using(driverid)
where position = 1
group by driverid
order by wins desc
limit 3; code │ forename │ surname │ wins
══════╪══════════╪════════════╪══════
MSC │ Michael │ Schumacher │ 91
HAM │ Lewis │ Hamilton │ 56
¤ │ Alain │ Prost │ 51
(3 rows)這個查詢在 drivers 與 results 之間使用了 inner join:兩張表都有 driverid 欄位,可作為關聯兩表資料的查找依據。
理解 Join#
書中不逐一詳解每種 join(inner join、left/right outer join、cross join、full outer join、lateral join 等)——PostgreSQL 文件的 FROM 子句章節已寫得很好,請搭配閱讀;這裡聚焦更有趣的進階範例。
知道怎麼取得比賽冠軍之後,可以列出一季某一季度的所有比賽與其冠軍:
\set beginning '2017-04-01'
\set months 3
select date, name, drivers.surname as winner
from races
left join results
on results.raceid = races.raceid
and results.position = 1
left join drivers using(driverid)
where date >= date :'beginning'
and date < date :'beginning'
+ :months * interval '1 month'; date │ name │ winner
════════════╪═══════════════════════╪══════════
2017-04-09 │ Chinese Grand Prix │ Hamilton
2017-04-16 │ Bahrain Grand Prix │ Vettel
2017-04-30 │ Russian Grand Prix │ Bottas
2017-05-14 │ Spanish Grand Prix │ Hamilton
2017-05-28 │ Monaco Grand Prix │ Vettel
2017-06-11 │ Canadian Grand Prix │ Hamilton
2017-06-25 │ Azerbaijan Grand Prix │ ¤
(7 rows)這次改用 left join,是為了保留該季度的每一場比賽,有額外資訊才顯示。left join 的語義是:保留運算子左側資料表的完整結果集,右側資料表只在符合 join 條件時填入欄位值,否則以 NULL 填補。上例中冠軍資訊來自右側的 results 表;Azerbaijan Grand Prix 在本地的 f1db 資料庫還沒有賽果,所以回傳 NULL。
注意
results.position = 1這個限制被移進了 join 條件,而不是放在 where 子句——若放在 where,尚無賽果的比賽會被整列濾掉,但我們仍想顯示它們。
另一種寫法:以子查詢建立中間表
也可以用明確的子查詢先建出只含冠軍的中間結果表,再 join:
select date, name, drivers.surname as winner
from races
left join
( select raceid, driverid
from results
where position = 1
)
as winners using(raceid)
left join drivers using(driverid)
where date >= date :'beginning'
and date < date :'beginning'
+ :months * interval '1 month';PostgreSQL 夠聰明,兩種寫法實際上以同樣方式執行——不過也可能是因為 f1db 的資料集很小。
限制條件:WHERE#
WHERE 子句是查詢的過濾器:條件為 true 就保留該列,為 false 就跳過。實務上的 where 可能相當複雜,允許使用 CASE 等邏輯結構;但我們通常盡量讓 where 保持簡單,好讓 PostgreSQL 能用索引解決過濾條件。
幾個簡單原則:
- where 裡的多個過濾條件通常用
and組合,它支援短路求值——只要其中一個條件為 false,就能確定跳過該列。 or也支援,但對最佳化(尤其是索引運用)更困難。not與not in都有支援,但兩者是完全不同的東西。
小心
not in遇到 NULL 的語義:下面的查詢一列都不會回傳——select x from generate_series(1, 100) as t(x) where x not in (1, 2, 3, null);
最後,where 子句裡也能使用子查詢(SQL 查詢幾乎任何地方都可以),常見於用 not exists 實作**反連接(anti-join)**模式——只保留「未通過測試」的列。例如想列出某一季「參賽卻一場都沒完賽」的倒楣車手:車手完賽時 results.position 會有值(is not null),所以濾掉完賽過的人:
\set season 'date ''1978-01-01'''
select forename,
surname,
constructors.name as constructor,
count(*) as races,
count(distinct status) as reasons
from drivers
join results using(driverid)
join races using(raceid)
join status using(statusid)
join constructors using(constructorid)
where date >= :season
and date < :season + interval '1 year'
and not exists
(
select 1
from results r
where position is not null
and r.driverid = drivers.driverid
and r.resultid = results.resultid
)
group by constructors.name, driverid
order by count(*) desc;這個查詢有趣之處在 where not exists:那個 select 1 是幹嘛的?
- where 是過濾器,
not exists依「子查詢是否回傳列」來過濾。要通過過濾只需回傳任何東西——PostgreSQL 根本不看子查詢選了什麼,只在意有沒有列被回傳。 - 主查詢與 not exists 子查詢之間的 join 條件寫在子查詢的 where 子句裡,可以引用外層查詢,如
r.driverid = drivers.driverid與r.resultid = results.resultid。
1978 賽季查詢結果(前十名)
以「從未完賽的車手數」來看,1978 不是個好賽季,這裡只列前十筆:
forename │ surname │ constructor │ races │ reasons
══════════════╪═══════════╪═════════════╪═══════╪═════════
Arturo │ Merzario │ Merzario │ 16 │ 8
Hans-Joachim │ Stuck │ Shadow │ 12 │ 6
Rupert │ Keegan │ Surtees │ 12 │ 6
Hector │ Rebaque │ Team Lotus │ 12 │ 7
Jean-Pierre │ Jabouille │ Renault │ 10 │ 4
Clay │ Regazzoni │ Shadow │ 10 │ 5
James │ Hunt │ McLaren │ 10 │ 6
Brett │ Lunger │ McLaren │ 9 │ 5
Niki │ Lauda │ Brabham │ 9 │ 4
Rolf │ Stommelen │ Arrows │ 8 │ 5
(10 rows)沒完賽的原因可能是 did not qualify、gearbox,或 f1db 資料庫裡 133 種狀態中的任何一種。