本章要寫一個非常簡單的應用程式,比較「傳統應用程式碼」與「SQL」解決常見問題的差異。目標是實際面對「SQL 作為程式碼庫的一部分」該如何管理,並看清何時該用應用程式碼、何時該用 SQL。

Readme 先行開發#

在寫任何程式或測試之前,作者習慣先寫 README——那個向使用者說明「為什麼要在乎這個應用」以及大致用法的小檔案:

  • cdstore 是架在 Chinook 資料庫上的極簡包裝。
  • Chinook 資料模型代表一間數位媒體商店,包含藝人、專輯、曲目、發票與顧客等資料表。
  • cdstore 提供資料庫上的實用清單與報表,也能產生一些活動。

載入資料集#

作者選擇用 pgloader 從 SQLite 檔載入 Chinook(現在也找得到 PostgreSQL 備份檔,但 pgloader 更順手),額外的好處是載入摘要會列出每張資料表與載入的列數——這是我們與資料集的第一次照面:

$ createdb chinook
$ pgloader https://github.com/lerocha/chinook-database/raw/master \
           /ChinookDatabase/DataSources \
           /Chinook_Sqlite_AutoIncrementPKs.sqlite \
           pgsql:///chinook
延伸輸出:pgloader 載入摘要(節錄)
             table name     errors       rows       bytes     total time
----------------------- --------- --------- --------- --------------
                 artist          0        275      6.8 kB         0.026s
                  album          0        347     10.5 kB         0.090s
               employee          0          8      1.4 kB         0.034s
                invoice          0        412     31.0 kB         0.059s
              mediatype          0          5      0.1 kB         0.083s
          playlisttrack          0       8715     57.3 kB         0.179s
               customer          0         59      6.7 kB         0.010s
                  genre          0         25      0.3 kB         0.019s
            invoiceline          0       2240     43.6 kB         0.090s
               playlist          0         18      0.3 kB         0.056s
                  track          0       3503    236.6 kB         0.192s
----------------------- --------- --------- --------- --------------
      Total import time          ✓      15607    394.5 kB         0.893s

載入後要修正一個 SQLite 那邊定義不良的主鍵:track 資料表只有 UNIQUE 索引 idx_51519_ipk_track,沒有真正的主鍵,因此執行:

alter table track add primary key using index idx_51519_ipk_track;

PostgreSQL 實作了 group by 推導(group by inference),後續部分查詢必須有這個主鍵才能執行。載入資料集後請立刻修好主鍵。

Chinook 資料庫#

Chinook 包含音樂收藏的基本元素:albumartisttrackgenremediatype;播放清單則由 playlist 加上關聯表 playlisttrack 組成(一首曲目可屬於多個清單、一個清單含多首曲目);顧客消費模型由 staffcustomerinvoiceinvoiceline 組成,共 11 張資料表。

用一條簡單查詢開始探索資料集——各曲風的曲目數:

  select genre.name, count(*) as count
    from            genre
         left join track using(genreid)
group by genre.name
order by count desc;
        name        │ count
════════════════════╪═══════
 Rock               │  1297
 Latin              │   579
 Metal              │   374
 Alternative & Punk │   332
 Jazz               │   130
 ...
(25 rows)

音樂目錄#

應用程式用 Python 撰寫(方便在書中瀏覽程式碼),並使用 anosql 函式庫:SQL 乾淨地放在 .sql 檔案裡,Python 端輕鬆載入。artist.sql 長這樣:

-- name: top-artists-by-album
-- Get the list of the N artists with the most albums
  select artist.name, count(*) as albums
    from           artist
         left join album using(artistid)
group by artist.name
order by albums desc
   limit :n;

.sql 檔放進原始碼樹的好處:可以用 git 做版本控制、必要時寫註解,還能在應用程式目錄與互動式 psql shell 之間直接複製貼上。

anosql 的具名變數(如 limit :n)在 psql 裡也能直接受益:

> \set n 3
> \i artist.sql
     name     │ albums
══════════════╪════════
 Iron Maiden  │     21
 Led Zeppelin │     14
 Deep Purple  │     11
(3 rows)

也可以從命令列設定變數,方便整合進 bash 腳本:

psql --variable "n=10" -f artist.sql chinook

依藝人列出專輯#

上一章的查詢也能直接收編,album.sql 如下:

-- name: list-albums-by-artist
-- List the album titles and duration of a given artist
  select album.title as album,
         sum(milliseconds) * interval '1 ms' as duration
    from album
         join artist using(artistid)
         left join track using(albumid)
   where artist.name = :name
group by album
order by album;

依曲風的 Top-N 藝人#

再實作一個經典的 Top-N 問題:每個曲風取出「在播放清單中出現次數最多」的前 N 首曲目。SQL 中實作 Top-N 最好的方式是 lateral join——可以先把理論簡化成「lateral join 讓你在 SQL 裡寫出明確的迴圈」。這是應用程式的 genre-topn.sql

-- name: genre-top-n
-- Get the N top tracks by genre
select genre.name as genre,
       case when length(ss.name) > 15
            then substring(ss.name from 1 for 15) || '…'
            else ss.name
        end as track,
       artist.name as artist
  from genre
       left join lateral
       /*
        * the lateral left join implements a nested loop over
        * the genres and allows to fetch our Top-N tracks per
        * genre, applying the order by desc limit n clause.
        *
        * here we choose to weight the tracks by how many
        * times they appear in a playlist, so we join against
        * the playlisttrack table and count appearances.
        */
       (
             select track.name, track.albumid, count(playlistid)
               from            track
                    left join playlisttrack using (trackid)
              where track.genreid = genre.genreid
           group by track.trackid
           order by count desc
              limit :n
       )
       /*
        * the join happens in the subquery's where clause, so
        * we don't need to add another one at the outer join
        * level, hence the "on true" spelling.
        */
             ss(name, albumid, count) on true
       join album using(albumid)
       join artist using(artistid)
order by genre.name, ss.count desc;

查詢的運作方式:

  • 外層迴圈走訪所有曲風;對每個曲風,內層的相關子查詢(correlated subquery)以 order by count desc limit :n 取出出現次數最高的 n 首曲目。
  • where track.genreid = genre.genreid 就是內外層迴圈之間的相關條件
  • 內層(命名為 ss 的 lateral 子查詢)完成後,再 join albumartist 取得藝人名稱。

寫這種「中等複雜度 SQL」的主因是效率。同樣的事若在應用程式端做:

  1. 取曲風清單(一次 select name from genre
  2. 對每個曲風各執行一次 Top-N 曲目查詢(ss 子查詢 × 曲風數)
  3. 對每首入選曲目(n × 曲風數次)再查藝人名稱

大量資料在應用與資料庫之間來回、大量無謂的處理。讓資料庫直接算出恰好需要的結果集,Python 端就只剩使用者介面:解析命令列選項、印出查詢結果。

另一個常見反駁是「同樣結果 SQL 有別的寫法,不必用 lateral 子查詢」。確實可以,但那些寫法都比 lateral 低效。之後的章節會教你讀 explain 計畫(explain plan),那才是判斷最有效率寫法的方法。

延伸程式碼:cdstore.py 完整應用程式
#! /usr/bin/env python3
# -*- coding: utf-8 -*-

import anosql
import psycopg2
import argparse
import sys

PGCONNSTRING = "user=cdstore dbname=appdev application_name=cdstore"

class chinook(object):
    """Our database model and queries"""
    def __init__(self):
        self.pgconn = psycopg2.connect(PGCONNSTRING)
        self.queries = None

        for sql in ['sql/genre-tracks.sql',
                    'sql/genre-topn.sql',
                    'sql/artist.sql',
                    'sql/album-by-artist.sql',
                    'sql/album-tracks.sql']:
            queries = anosql.load_queries('postgres', sql)
            if self.queries:
                for qname in queries.available_queries:
                    self.queries.add_query(qname, getattr(queries, qname))
            else:
                self.queries = queries

    def genre_list(self):
        return self.queries.tracks_by_genre(self.pgconn)

    def genre_top_n(self, n):
        return self.queries.genre_top_n(self.pgconn, n=n)

    def artist_by_albums(self, n):
        return self.queries.top_artists_by_album(self.pgconn, n=n)

    def album_details(self, albumid):
        return self.queries.list_tracks_by_albumid(self.pgconn, id=albumid)

    def album_by_artist(self, artist):
        return self.queries.list_albums_by_artist(self.pgconn, name=artist)


class printer(object):
    "print out query result data"

    def __init__(self, columns, specs, prelude=True):
        """COLUMNS is a tuple of column titles,
            Specs an tuple of python format strings
        """
        self.columns = columns
        self.specs = specs
        self.fstr = " | ".join(str(i) for i in specs)

        if prelude:
            print(self.title())
            print(self.sep())

    def title(self):
        return self.fstr % self.columns

    def sep(self):
        s = ""
        for c in self.title():
            s += "+" if c == "|" else "-"
        return s

    def fmt(self, data):
        return self.fstr % data


class cdstore(object):
    """Our cdstore command line application. """

    def __init__(self, argv):
        self.db = chinook()

        parser = argparse.ArgumentParser(
            description='cdstore utility for a chinook database',
            usage='cdstore <command> [<args>]')
        subparsers = parser.add_subparsers(help='sub-command help')

        genres = subparsers.add_parser('genres', help='list genres')
        genres.add_argument('--topn', type=int)
        genres.set_defaults(method=self.genres)

        artists = subparsers.add_parser('artists', help='list artists')
        artists.add_argument('--topn', type=int, default=5)
        artists.set_defaults(method=self.artists)

        albums = subparsers.add_parser('albums', help='list albums')
        albums.add_argument('--id', type=int, default=None)
        albums.add_argument('--artist', default=None)
        albums.set_defaults(method=self.albums)

        args = parser.parse_args(argv)
        args.method(args)

    def genres(self, args):
        "List genres and number of tracks per genre"
        if args.topn:
            p = printer(("Genre", "Track", "Artist"),
                        ("%20s", "%20s", "%20s"))

            for (genre, track, artist) in self.db.genre_top_n(args.topn):
                artist = artist if len(artist) < 20 else "%s…" % artist[0:18]
                print(p.fmt((genre, track, artist)))
        else:
            p = printer(("Genre", "Count"), ("%20s", "%s"))
            for row in self.db.genre_list():
                print(p.fmt(row))

    def artists(self, args):
        "List genres and number of tracks per genre"
        p = printer(("Artist", "Albums"), ("%20s", "%5s"))
        for row in self.db.artist_by_albums(args.topn):
            print(p.fmt(row))

    def albums(self, args):
        # we decide to skip parts of the information here
        if args.id:
            p = printer(("Title", "Duration", "Pct"),
                        ("%25s", "%15s", "%6s"))
            for (title, ms, s, e, pct) in self.db.album_details(args.id):
                title = title if len(title) < 25 else "%s…" % title[0:23]
                print(p.fmt((title, ms, pct)))

        elif args.artist:
            p = printer(("Album", "Duration"), ("%25s", "%s"))
            for row in self.db.album_by_artist(args.artist):
                print(p.fmt(row))


if __name__ == '__main__':
    cdstore(sys.argv[1:])

執行 Top-N 查詢,取出每個曲風最常被收進播放清單的一首曲目:

$ ./cdstore.py genres --topn 1 | head
               Genre |                Track |               Artist
---------------------+----------------------+---------------------
         Alternative |        Hunger Strike |    Temple of the Dog
  Alternative & Punk |        Infeliz Natal |            Raimundos
               Blues |     Knockin On Heav… |         Eric Clapton
          Bossa Nova |       Onde Anda Você |  Toquinho & Vinícius
           Classical |     Fantasia On Gre… |  Academy of St. Mar…
              Comedy |      The Negotiation |           The Office
               Drama |           Homecoming |               Heroes
      Easy Listening |     I've Got You Un… |        Frank Sinatra

換成 --topn 3 就是每曲風前三名。若之後想改用別的權重方式選曲,也很容易:在 psql 裡把玩查詢,改好後替換 .sql 檔即可——下一章要介紹的,正是這種以 REPL 工具互動式撰寫 SQL 的方式。