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 BYGROUP BYHAVINGWINDOW 子句時會再討論。

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 表的資訊(resultsposition 欄位,冠軍是 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)

這個查詢在 driversresults 之間使用了 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 也支援,但對最佳化(尤其是索引運用)更困難。
  • notnot 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.driveridr.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 種狀態中的任何一種。