<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Join on Tarragon</title><link>https://tarrragon.github.io/blog/tags/join/</link><description>Recent content in Join on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 02 Sep 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/join/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL.3 連接的起手式：配對條件的位置與連接種類的選擇</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/join-starts-from-the-relationship/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/join-starts-from-the-relationship/</guid><description>&lt;p>寫一個連接要先決定的是&lt;strong>哪兩列該配在一起&lt;/strong>，而 SQL 給了這個決定一個專屬的位置：&lt;code>ON&lt;/code>。本篇把這件事叫&lt;strong>配對&lt;/strong>——關係還在組的那個過程，哪一列跟哪一列湊成一列；這是為了指認它而取的稱呼，SQL 的標準術語裡沒有這個詞。條件寫進那裡，配對關係就成為查詢結構的一部分；條件留在 &lt;code>WHERE&lt;/code>，配對關係就退化成一道篩子。&lt;/p>
&lt;p>本篇的查詢跑在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫&lt;/a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。&lt;/p>
&lt;p>這一篇處理的是&lt;strong>起手式&lt;/strong>——寫連接時第一個下筆的地方，而它決定了條件之後能放哪裡。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a> 處理的是條件寫下之後放哪一邊——那一篇會把配對與&lt;strong>成品&lt;/strong>（關係組完之後得到的那一批列）並排；本篇兩個詞都會用到，而判斷一個條件屬於配對還是成品的完整判定表在那一篇。順序上這一篇在前：先有配對關係，才有條件該放哪裡的問題。&lt;/p>
&lt;h2 id="從全部配對縮小的起手式與關聯代數的定義">從全部配對縮小的起手式與關聯代數的定義&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>把連接定義成兩個運算的組合：先取&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積&lt;/a>，再用條件選取。所以下面這個寫法是把定義翻成 SQL：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">CROSS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>它讀起來自然是有來由的——每一種配對先擺出來，再挑走不要的。而它是&lt;strong>語意層的定義&lt;/strong>：代數用它說明連接的結果集是什麼，沒有規定寫法要照這個順序展開。引擎也沒有照著做，這一段的計畫與 &lt;code>JOIN ... ON&lt;/code> 寫同一個條件逐字相同（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a>）。&lt;/p>
&lt;p>這個起手式在內連接底下一直行得通，因為條件放 &lt;code>ON&lt;/code> 與放 &lt;code>WHERE&lt;/code> 在內連接底下回同一批列。行得通的經驗會累積，直到問題需要保留配不到的列。&lt;/p>
&lt;h2 id="從全部配對縮小與寫進-on-在外連接處的差別">從全部配對縮小與寫進 ON 在外連接處的差別&lt;/h2>
&lt;p>書店要一份名單：每位顧客與他 300 元以上的訂單，而沒有這種訂單的顧客也要列出來。&lt;/p>
&lt;p>從全部配對縮小：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">CROSS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 101 / 佳穎 102&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>宗翰與雅文不在名單上。&lt;code>CROSS JOIN&lt;/code> 這個關鍵字沒有外連接的形式，所以要讓宗翰與雅文這種沒有訂單的顧客也留在結果裡（這是外連接做的事，下文叫&lt;strong>保護&lt;/strong>：&lt;strong>被保護的表&lt;/strong>裡配不到對象的列照樣留下，另一張表——下文叫&lt;strong>補 &lt;code>NULL&lt;/code> 的表&lt;/strong>——的欄位在那一列補上 &lt;code>NULL&lt;/code>），這個起手式本身就得換掉。&lt;/p>
&lt;p>把 &lt;code>CROSS JOIN&lt;/code> 換成 &lt;code>LEFT JOIN&lt;/code> 而條件留在原處：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 101 / 佳穎 102&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩人仍然不在。&lt;code>LEFT&lt;/code> 保護下來的那些列右邊全是 &lt;code>NULL&lt;/code>，而 &lt;code>WHERE&lt;/code> 那一步把它們篩掉了（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a>）。&lt;strong>換掉關鍵字補不回那些列，因為要動的是條件的位置。&lt;/strong>&lt;/p>
&lt;p>把配對關係整個寫進 &lt;code>ON&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 101 / 佳穎 102 / 宗翰 （空）/ 雅文 （空）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三位顧客都在。&lt;strong>從全部配對縮小與把配對關係寫進 &lt;code>ON&lt;/code> 這兩個起手式，差別在這裡從「寫法偏好」變成「答得出來與答不出來」&lt;/strong>：從全部配對縮小的起手式把所有條件都放進 &lt;code>WHERE&lt;/code>，在成品上篩，而外連接的保護正是在成品那一步被抵銷的。&lt;/p>
&lt;h2 id="連接種類的選擇配不到的列在答案裡有沒有位置">連接種類的選擇：配不到的列在答案裡有沒有位置&lt;/h2>
&lt;p>哪兩列該配在一起說完之後，接著要決定的是配不到對象的列怎麼處理。判斷標準是一句話：&lt;strong>這些列在答案裡有沒有位置。&lt;/strong>&lt;/p>
&lt;p>有位置就用外連接，而哪一側有位置決定寫 &lt;code>LEFT&lt;/code>、&lt;code>RIGHT&lt;/code> 還是 &lt;code>FULL&lt;/code>；沒有位置就用內連接（&lt;code>JOIN&lt;/code>）（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>那張卡寫三者各保護誰）。「哪些顧客一張單都沒下」這個問題的答案整個在補 &lt;code>NULL&lt;/code> 的那幾列上，內連接會讓它無從回答。&lt;/p>
&lt;p>還有第三種情形：只問配不配得到，右表的欄位一個都不輸出。那時要的運算是半連接——配起來之後只帶走左邊那一份，右邊只當條件（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join&lt;/a>）。寫成 &lt;code>JOIN&lt;/code> 會讓配得到多列的那些左列重複出現，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN&lt;/a> 寫這三種寫法在列數上的差別，以及選哪一種的判斷標準。&lt;/p></description><content:encoded><![CDATA[<p>寫一個連接要先決定的是<strong>哪兩列該配在一起</strong>，而 SQL 給了這個決定一個專屬的位置：<code>ON</code>。本篇把這件事叫<strong>配對</strong>——關係還在組的那個過程，哪一列跟哪一列湊成一列；這是為了指認它而取的稱呼，SQL 的標準術語裡沒有這個詞。條件寫進那裡，配對關係就成為查詢結構的一部分；條件留在 <code>WHERE</code>，配對關係就退化成一道篩子。</p>
<p>本篇的查詢跑在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫</a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。</p>
<p>這一篇處理的是<strong>起手式</strong>——寫連接時第一個下筆的地方，而它決定了條件之後能放哪裡。<a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a> 處理的是條件寫下之後放哪一邊——那一篇會把配對與<strong>成品</strong>（關係組完之後得到的那一批列）並排；本篇兩個詞都會用到，而判斷一個條件屬於配對還是成品的完整判定表在那一篇。順序上這一篇在前：先有配對關係，才有條件該放哪裡的問題。</p>
<h2 id="從全部配對縮小的起手式與關聯代數的定義">從全部配對縮小的起手式與關聯代數的定義</h2>
<p><a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>把連接定義成兩個運算的組合：先取<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積</a>，再用條件選取。所以下面這個寫法是把定義翻成 SQL：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">CROSS</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p>它讀起來自然是有來由的——每一種配對先擺出來，再挑走不要的。而它是<strong>語意層的定義</strong>：代數用它說明連接的結果集是什麼，沒有規定寫法要照這個順序展開。引擎也沒有照著做，這一段的計畫與 <code>JOIN ... ON</code> 寫同一個條件逐字相同（<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>）。</p>
<p>這個起手式在內連接底下一直行得通，因為條件放 <code>ON</code> 與放 <code>WHERE</code> 在內連接底下回同一批列。行得通的經驗會累積，直到問題需要保留配不到的列。</p>
<h2 id="從全部配對縮小與寫進-on-在外連接處的差別">從全部配對縮小與寫進 ON 在外連接處的差別</h2>
<p>書店要一份名單：每位顧客與他 300 元以上的訂單，而沒有這種訂單的顧客也要列出來。</p>
<p>從全部配對縮小：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">CROSS</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">300</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 101 / 佳穎 102</span></span></span></code></pre></div><p>宗翰與雅文不在名單上。<code>CROSS JOIN</code> 這個關鍵字沒有外連接的形式，所以要讓宗翰與雅文這種沒有訂單的顧客也留在結果裡（這是外連接做的事，下文叫<strong>保護</strong>：<strong>被保護的表</strong>裡配不到對象的列照樣留下，另一張表——下文叫<strong>補 <code>NULL</code> 的表</strong>——的欄位在那一列補上 <code>NULL</code>），這個起手式本身就得換掉。</p>
<p>把 <code>CROSS JOIN</code> 換成 <code>LEFT JOIN</code> 而條件留在原處：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">300</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 101 / 佳穎 102</span></span></span></code></pre></div><p>兩人仍然不在。<code>LEFT</code> 保護下來的那些列右邊全是 <code>NULL</code>，而 <code>WHERE</code> 那一步把它們篩掉了（<a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>）。<strong>換掉關鍵字補不回那些列，因為要動的是條件的位置。</strong></p>
<p>把配對關係整個寫進 <code>ON</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">300</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 101 / 佳穎 102 / 宗翰 （空）/ 雅文 （空）</span></span></span></code></pre></div><p>三位顧客都在。<strong>從全部配對縮小與把配對關係寫進 <code>ON</code> 這兩個起手式，差別在這裡從「寫法偏好」變成「答得出來與答不出來」</strong>：從全部配對縮小的起手式把所有條件都放進 <code>WHERE</code>，在成品上篩，而外連接的保護正是在成品那一步被抵銷的。</p>
<h2 id="連接種類的選擇配不到的列在答案裡有沒有位置">連接種類的選擇：配不到的列在答案裡有沒有位置</h2>
<p>哪兩列該配在一起說完之後，接著要決定的是配不到對象的列怎麼處理。判斷標準是一句話：<strong>這些列在答案裡有沒有位置。</strong></p>
<p>有位置就用外連接，而哪一側有位置決定寫 <code>LEFT</code>、<code>RIGHT</code> 還是 <code>FULL</code>；沒有位置就用內連接（<code>JOIN</code>）（<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>那張卡寫三者各保護誰）。「哪些顧客一張單都沒下」這個問題的答案整個在補 <code>NULL</code> 的那幾列上，內連接會讓它無從回答。</p>
<p>還有第三種情形：只問配不配得到，右表的欄位一個都不輸出。那時要的運算是半連接——配起來之後只帶走左邊那一份，右邊只當條件（<a href="/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join</a>）。寫成 <code>JOIN</code> 會讓配得到多列的那些左列重複出現，<a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN</a> 寫這三種寫法在列數上的差別，以及選哪一種的判斷標準。</p>
<p><strong>配不到的列要不要保護，這個決定由問題決定，不由現有資料決定。</strong> 「翻遍現在的訂單表，每一列都配得到顧客」只證明此刻如此，而查詢要活得比這一批資料久。反過來說，資料保證配得到的時候寫 <code>LEFT</code> 也有代價——它宣告了一個從未發生的情況，下一個讀的人會以為那些列存在（<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>）。要判斷配對有沒有保證，去看<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>，而那條保證要真的生效才算數（<a href="/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證</a>）。</p>
<h2 id="業務描述對應到查詢的位置on連接種類與-where">業務描述對應到查詢的位置：ON、連接種類與 WHERE</h2>
<p>「每位顧客與他 300 元以上的訂單，沒有這種訂單的顧客也要列出來」這句話裡有三個決定，各自對應查詢的一個位置。</p>
<p><strong>哪兩列該配在一起。</strong> 顧客的那一列與訂單的那一列，靠顧客編號對上，而且只跟 300 元以上的訂單配。這一整句進 <code>ON</code>——「只跟 300 元以上的訂單配」描述的是配對，所以它屬於那裡。</p>
<p><strong>配不到的列要不要。</strong> 「沒有這種訂單的顧客也要列出來」說了要，而要保護的是顧客那一側，所以是 <code>LEFT JOIN</code>，左邊放顧客。<strong>兩張表的時候這樣判就夠</strong>；三張以上時「左邊」是前面幾個連接累積出來的結果，鏈中間放一個 <code>RIGHT</code> 會把前段保護下來的列整批丟掉。累積的判讀，以及同一組資料上四列變一列的實跑，在 <a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍</a>。</p>
<p><strong>成品裡再篩什麼。</strong> 這句話沒有再提條件，所以 <code>WHERE</code> 是空的。業務描述裡沒有針對顧客那一側的限制時，空的 <code>WHERE</code> 是一個合理的結果。</p>
<p>順序反過來走也能檢查已經寫好的查詢：<code>WHERE</code> 裡引用右表欄位的條件，回頭問它描述的是配對，還是成品——多數形態上它描述的是配對，而它待在那裡正在抵銷某個保護。<strong>有一個形態剛好相反</strong>：<code>WHERE 右表.某欄 IS NULL</code> 挑的正是外連接補上 <code>NULL</code> 的那些列，它描述的是成品，待在 <code>WHERE</code> 是對的位置（<a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a> 有完整的判定表，<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a> 用它示範查孤兒訂單）。</p>
<h2 id="只提到被保護的表的條件該放的位置">只提到被保護的表的條件該放的位置</h2>
<p>上一節那句業務描述裡，「哪兩列該配在一起」與「配不到的列要不要」都在問條件與連接種類該怎麼配。而條件提到哪一側，會讓「哪兩列該配在一起」的答案整個不同。換一對表看得清楚。「每張訂單與它的評價，只要三月五日以後下的單」——被保護的是訂單，而「三月五日以後」只提到訂單。條件寫進 <code>ON</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">下單日</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">下單日</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-03-05&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 101  2026-03-02  （空）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 102  2026-03-09  （空）</span></span></span></code></pre></div><p>三月二日那張照樣回來，篩選沒有發生——<code>LEFT</code> 的定義是左邊每一列都留著，而 <code>ON</code> 只決定右邊配不配得上，配不上就補 <code>NULL</code>。<strong>這個條件改變的是配對</strong>：101 本來配得上那筆五星評價，多了這個條件之後整個 <code>ON</code> 對它判假，於是連評價一起丟了。同一個條件改放 <code>WHERE</code> 才回到要的那批列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">下單日</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">下單日</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="s1">&#39;2026-03-05&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 102  2026-03-09  （空）</span></span></span></code></pre></div><p>只提到被保護的表的條件描述的是成品，屬於 <code>WHERE</code>。判斷標準因此要分兩步：先問這個條件提到哪幾張表，只提被保護的表的直接進 <code>WHERE</code>；兩張表都提到、或只提補 <code>NULL</code> 的表的，才問它描述配對還是成品。<strong>自連接寫成外連接時也是這樣</strong>。自連接指同一張表在一段查詢裡出現兩次、各自扮演一個角色，例如同一張訂單表一次代表較早的訂單、一次代表較晚的訂單；兩次出現裡被保護的那一次，它自己的過濾條件寫進 <code>ON</code> 同樣篩不掉列。配對的兩側是同一張表時，兩次出現要各自有名字才分得開，<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a> 用三個引擎的錯誤訊息示範為什麼非取名不可，並把自連接處理的關係分成前後配對、同組比較與層級關係三種形態。放右表的條件另有一組判定，以及它為什麼對左表條件恆為真，在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>。</p>
<p>所以空的 <code>WHERE</code> 不是一個要去追求的形狀。上一節那句業務描述留下空的 <code>WHERE</code>，是因為它確實沒有再提條件；把一個只提被保護的表的條件塞進 <code>ON</code> 去換一個空的 <code>WHERE</code>，換來的是一個篩不掉列、還會丟掉本來成立的配對的條件。</p>
]]></content:encoded></item><item><title>Outer Join（外連接）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/</guid><description>&lt;p>Outer join 保留配不到對象的列。它解的問題是內連接（&lt;code>INNER JOIN&lt;/code>，也就是只寫 &lt;code>JOIN&lt;/code> 時的預設）會讓沒有對應資料的那些列整個消失，而那些列常常正是要找的對象。產出的仍然是一個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a>，只是比來源表多了一些補 NULL 的列。&lt;/p>
&lt;p>書店有三位顧客——佳穎下過兩張訂單、宗翰與雅文一張都沒下。要一份「每位顧客與他的訂單」的名單，用內連接得到：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">佳穎 101
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 102&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>宗翰與雅文不在名單上。他們沒有訂單可以配對，於是內連接把他們整個丟掉了。換成 &lt;code>LEFT JOIN&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">佳穎 101
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 102
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">宗翰 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">雅文 NULL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>這就是「保護」的意思&lt;/strong>：左邊那張表的每一列都留著，右邊配不上的欄位補 NULL。而「哪些顧客一張單都沒下」這個問題，答案正是那兩列補 NULL 的——內連接會讓它無從回答。&lt;/p>
&lt;p>三種寫法差在保護哪一側：&lt;code>LEFT&lt;/code> 保護左運算元，&lt;code>RIGHT&lt;/code> 保護右運算元，&lt;code>FULL&lt;/code> 兩側都保護。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>外連接產出的仍然是一個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a>，所以它可以繼續當下一個連接的輸入，而 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 在特定條件下可以把它降級成內連接——前提是查詢的其他部分已經讓補 NULL 的那些列不可能出現在結果裡。&lt;/p>
&lt;p>補上的 NULL 是這個機制的產物，與資料裡本來就有的空值來源不同。這一點決定了後續怎麼判斷：&lt;code>IS NULL&lt;/code> 問的可能是「這一列沒配到」，也可能是「配到了而那個欄位本來就空」。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>保護會被 &lt;code>WHERE&lt;/code> 抵銷。上面那個 &lt;code>LEFT JOIN&lt;/code> 加上 &lt;code>WHERE 訂單.金額 &amp;gt; 100&lt;/code> 之後，宗翰與雅文那兩列的金額是 NULL、答不出「大於 100」，於是被篩掉，結果與內連接相同。&lt;/p>
&lt;p>&lt;strong>外連接寫了卻拿到內連接的結果，多半就是這件事&lt;/strong>：&lt;code>WHERE&lt;/code> 裡引用了右表的欄位。判斷保不保得住的方法是把補 NULL 的那一列代進條件裡：判成真才留得下來。&lt;code>IS NULL&lt;/code> 判成真所以保住，&lt;code>IS NOT NULL&lt;/code> 判成假所以取消——兩者看起來成對而落在相反的一側。而反查「沒配到的那些」要挑一個&lt;strong>不可為空的欄位&lt;/strong>（通常是連接鍵）來問 &lt;code>IS NULL&lt;/code>，用可為空的欄位會把「配到了而那個欄位本來就空」的列一起挑進來。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>要在保留左側全部列的前提下限制右側的配對範圍，條件寫進 &lt;code>ON&lt;/code>；要從成品裡篩，條件寫進 &lt;code>WHERE&lt;/code>。兩者在內連接底下同值，所以這個差別只在外連接現形。&lt;/p>
&lt;p>鏈式查詢裡混用 &lt;code>LEFT&lt;/code> 與 &lt;code>RIGHT&lt;/code> 會讓保護的方向中途反轉。&lt;code>RIGHT&lt;/code> 的右運算元通常只是一張表，而左運算元是前面累積出來的全部，所以保護範圍與多數人的本意相反。把需要保護的表移到最前面、其餘一律 &lt;code>LEFT&lt;/code>，方向就一致了。&lt;/p></description><content:encoded><![CDATA[<p>Outer join 保留配不到對象的列。它解的問題是內連接（<code>INNER JOIN</code>，也就是只寫 <code>JOIN</code> 時的預設）會讓沒有對應資料的那些列整個消失，而那些列常常正是要找的對象。產出的仍然是一個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>，只是比來源表多了一些補 NULL 的列。</p>
<p>書店有三位顧客——佳穎下過兩張訂單、宗翰與雅文一張都沒下。要一份「每位顧客與他的訂單」的名單，用內連接得到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">佳穎  101
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  102</span></span></code></pre></div><p>宗翰與雅文不在名單上。他們沒有訂單可以配對，於是內連接把他們整個丟掉了。換成 <code>LEFT JOIN</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">佳穎  101
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  102
</span></span><span class="line"><span class="ln">3</span><span class="cl">宗翰  NULL
</span></span><span class="line"><span class="ln">4</span><span class="cl">雅文  NULL</span></span></code></pre></div><p><strong>這就是「保護」的意思</strong>：左邊那張表的每一列都留著，右邊配不上的欄位補 NULL。而「哪些顧客一張單都沒下」這個問題，答案正是那兩列補 NULL 的——內連接會讓它無從回答。</p>
<p>三種寫法差在保護哪一側：<code>LEFT</code> 保護左運算元，<code>RIGHT</code> 保護右運算元，<code>FULL</code> 兩側都保護。</p>
<h2 id="概念位置">概念位置</h2>
<p>外連接產出的仍然是一個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>，所以它可以繼續當下一個連接的輸入，而 <a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer</a> 在特定條件下可以把它降級成內連接——前提是查詢的其他部分已經讓補 NULL 的那些列不可能出現在結果裡。</p>
<p>補上的 NULL 是這個機制的產物，與資料裡本來就有的空值來源不同。這一點決定了後續怎麼判斷：<code>IS NULL</code> 問的可能是「這一列沒配到」，也可能是「配到了而那個欄位本來就空」。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>保護會被 <code>WHERE</code> 抵銷。上面那個 <code>LEFT JOIN</code> 加上 <code>WHERE 訂單.金額 &gt; 100</code> 之後，宗翰與雅文那兩列的金額是 NULL、答不出「大於 100」，於是被篩掉，結果與內連接相同。</p>
<p><strong>外連接寫了卻拿到內連接的結果，多半就是這件事</strong>：<code>WHERE</code> 裡引用了右表的欄位。判斷保不保得住的方法是把補 NULL 的那一列代進條件裡：判成真才留得下來。<code>IS NULL</code> 判成真所以保住，<code>IS NOT NULL</code> 判成假所以取消——兩者看起來成對而落在相反的一側。而反查「沒配到的那些」要挑一個<strong>不可為空的欄位</strong>（通常是連接鍵）來問 <code>IS NULL</code>，用可為空的欄位會把「配到了而那個欄位本來就空」的列一起挑進來。</p>
<h2 id="設計責任">設計責任</h2>
<p>要在保留左側全部列的前提下限制右側的配對範圍，條件寫進 <code>ON</code>；要從成品裡篩，條件寫進 <code>WHERE</code>。兩者在內連接底下同值，所以這個差別只在外連接現形。</p>
<p>鏈式查詢裡混用 <code>LEFT</code> 與 <code>RIGHT</code> 會讓保護的方向中途反轉。<code>RIGHT</code> 的右運算元通常只是一張表，而左運算元是前面累積出來的全部，所以保護範圍與多數人的本意相反。把需要保護的表移到最前面、其餘一律 <code>LEFT</code>，方向就一致了。</p>
]]></content:encoded></item><item><title>SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/</guid><description>&lt;p>&lt;code>FROM&lt;/code> 子句由上往下把表併進來，每一個 &lt;code>JOIN&lt;/code> 的左運算元是它上面全部合起來的結果。第一個 &lt;code>JOIN&lt;/code> 的左邊剛好是一張表，從第二個開始就不是了。&lt;/p>
&lt;p>左運算元會累積這件事決定三件事：每個 &lt;code>ON&lt;/code> 引用得到哪些欄位、每個 &lt;code>LEFT&lt;/code> &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">保護&lt;/a>的是哪些列（保護指 &lt;code>LEFT&lt;/code> 左邊配不到對象的列也留在結果裡、右邊那幾欄補 &lt;code>NULL&lt;/code>）、以及在鏈中間換成 &lt;code>RIGHT&lt;/code> 會發生什麼。&lt;/p>
&lt;p>累積這件事在只有一個 &lt;code>JOIN&lt;/code> 的查詢裡不現形，從第二個開始才有分量：某個 &lt;code>LEFT&lt;/code> 保護了哪些列，取決於它上面累積出來的是什麼。&lt;/p>
&lt;h2 id="左運算元的累積與判讀方法">左運算元的累積與判讀方法&lt;/h2>
&lt;p>書店的顧客有三位——佳穎下過兩張單，宗翰與雅文一張都沒有，而兩張單裡只有一張被評價過。要一份完整名單：每位顧客連同他的訂單與那張訂單的評價，沒下單的顧客與沒被評價的訂單也要出現。三張表往下接：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>回來四列：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">佳穎 101 5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 102 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">宗翰 NULL NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">雅文 NULL NULL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第二個 &lt;code>LEFT JOIN&lt;/code> 的左邊是顧客與訂單併出來的結果，包含宗翰與雅文那兩列補 NULL 的訂單欄。問第二個 &lt;code>LEFT JOIN&lt;/code>「左邊是哪一張表」得不到答案，因為左邊已經不是一張表。&lt;strong>判讀的作法回到查詢的文字：在要判讀的那個 &lt;code>JOIN&lt;/code> 所在的那一行上方畫一條橫線，橫線以上寫到的表合起來是左運算元，那一行自己掛的那張表是右運算元。&lt;/strong>&lt;/p>
&lt;p>由此得到兩個可以直接用的結論。第二個 &lt;code>ON&lt;/code> 寫得出顧客表的欄位，因為顧客在左運算元裡面。第二個 &lt;code>JOIN&lt;/code> 若換成 &lt;code>INNER&lt;/code>，砍掉的是橫線以上那四列裡配不到評價的三列，而不只是「沒有評價的訂單」——宗翰與雅文會一起消失，儘管他們與評價這張表毫無關係：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 橫線以上：佳穎 101、佳穎 102、宗翰、雅文四列
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 內連接：四列裡配不到評價的三列一起砍掉
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎 101 5&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>他們那兩列訂單欄裡的 &lt;code>NULL&lt;/code> 是連接的產物，不是資料裡本來的空值，而後續對這欄做的計數與比較分不出這件事。&lt;code>LEFT&lt;/code> 保護左側、&lt;code>RIGHT&lt;/code> 保護右側、&lt;code>FULL&lt;/code> 兩側都保護，這三種方向與這些 &lt;code>NULL&lt;/code> 的來源定義在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">Outer Join（外連接）&lt;/a>；這些 &lt;code>NULL&lt;/code> 怎麼讓聚合算錯、為什麼不能用等號比，由 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a> 接手。&lt;/p>
&lt;h2 id="鏈中間的-right-join-保護的對象">鏈中間的 RIGHT JOIN 保護的對象&lt;/h2>
&lt;p>把最後一個 &lt;code>LEFT&lt;/code> 換成 &lt;code>RIGHT&lt;/code>，其餘一個字不動：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">RIGHT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>結果從四列變成一列，只剩 &lt;code>佳穎 101 5&lt;/code>。&lt;/p>
&lt;p>&lt;code>RIGHT&lt;/code> 保護的是右運算元，而右運算元是評價這一張表。左運算元——顧客與訂單累積出來的那四列——整批降級成可選的，配不上評價的就丟掉。宗翰、雅文、以及佳穎那張沒被評價的訂單，全部消失。&lt;/p>
&lt;p>&lt;strong>語意上這等於宣告「每一則評價都要留著，而顧客與訂單可有可無」。&lt;/strong> 書店大概不會想問這個問題。它幾乎不會是任何人在鏈尾接上評價那個 &lt;code>JOIN&lt;/code> 時想表達的事，而它讀起來只像是把 &lt;code>LEFT&lt;/code> 打成了 &lt;code>RIGHT&lt;/code>。&lt;/p>
&lt;h2 id="只用-left-join-的鏈保護方向的判讀與-full-join-的例外">只用 LEFT JOIN 的鏈：保護方向的判讀與 FULL JOIN 的例外&lt;/h2>
&lt;p>不用 &lt;code>RIGHT&lt;/code> 的話，鏈裡每個 &lt;code>JOIN&lt;/code> 都是同一個方向：要保護的那張表寫在最上面，往下一行一行掛可選的表。要判斷查詢裡任何一個 &lt;code>JOIN&lt;/code> 保護了什麼，在它那一行上方畫橫線就夠了。&lt;/p>
&lt;p>真正需要保護的表放到 &lt;code>FROM&lt;/code> 之後、寫成鏈最上面那一張就好。&lt;/p>
&lt;p>&lt;strong>兩張表都需要保護的時候這個做法不適用&lt;/strong>，那時要的是 &lt;code>FULL JOIN&lt;/code>——它兩側都保護，配不上的兩邊各補一次 &lt;code>NULL&lt;/code>。鏈裡出現它，「最上面那張表受保護」這個規律就不成立了，判讀要回到逐個連接看保護的方向。要「每一則評價都在、順便看得到它屬於誰」，就從評價開始往下掛訂單與顧客：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 101 5，與鏈尾換成 RIGHT 的那段查詢是同一個列集合&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段查詢的每個 &lt;code>JOIN&lt;/code> 都是 &lt;code>LEFT&lt;/code>，鏈的方向沒有中途反轉。&lt;/p></description><content:encoded><![CDATA[<p><code>FROM</code> 子句由上往下把表併進來，每一個 <code>JOIN</code> 的左運算元是它上面全部合起來的結果。第一個 <code>JOIN</code> 的左邊剛好是一張表，從第二個開始就不是了。</p>
<p>左運算元會累積這件事決定三件事：每個 <code>ON</code> 引用得到哪些欄位、每個 <code>LEFT</code> <a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">保護</a>的是哪些列（保護指 <code>LEFT</code> 左邊配不到對象的列也留在結果裡、右邊那幾欄補 <code>NULL</code>）、以及在鏈中間換成 <code>RIGHT</code> 會發生什麼。</p>
<p>累積這件事在只有一個 <code>JOIN</code> 的查詢裡不現形，從第二個開始才有分量：某個 <code>LEFT</code> 保護了哪些列，取決於它上面累積出來的是什麼。</p>
<h2 id="左運算元的累積與判讀方法">左運算元的累積與判讀方法</h2>
<p>書店的顧客有三位——佳穎下過兩張單，宗翰與雅文一張都沒有，而兩張單裡只有一張被評價過。要一份完整名單：每位顧客連同他的訂單與那張訂單的評價，沒下單的顧客與沒被評價的訂單也要出現。三張表往下接：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span></span></span></code></pre></div><p>回來四列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">佳穎  101   5
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  102   NULL
</span></span><span class="line"><span class="ln">3</span><span class="cl">宗翰  NULL  NULL
</span></span><span class="line"><span class="ln">4</span><span class="cl">雅文  NULL  NULL</span></span></code></pre></div><p>第二個 <code>LEFT JOIN</code> 的左邊是顧客與訂單併出來的結果，包含宗翰與雅文那兩列補 NULL 的訂單欄。問第二個 <code>LEFT JOIN</code>「左邊是哪一張表」得不到答案，因為左邊已經不是一張表。<strong>判讀的作法回到查詢的文字：在要判讀的那個 <code>JOIN</code> 所在的那一行上方畫一條橫線，橫線以上寫到的表合起來是左運算元，那一行自己掛的那張表是右運算元。</strong></p>
<p>由此得到兩個可以直接用的結論。第二個 <code>ON</code> 寫得出顧客表的欄位，因為顧客在左運算元裡面。第二個 <code>JOIN</code> 若換成 <code>INNER</code>，砍掉的是橫線以上那四列裡配不到評價的三列，而不只是「沒有評價的訂單」——宗翰與雅文會一起消失，儘管他們與評價這張表毫無關係：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">   </span><span class="c1">-- 橫線以上：佳穎 101、佳穎 102、宗翰、雅文四列
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span><span class="w">       </span><span class="c1">-- 內連接：四列裡配不到評價的三列一起砍掉
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 佳穎  101  5</span></span></span></code></pre></div><p>他們那兩列訂單欄裡的 <code>NULL</code> 是連接的產物，不是資料裡本來的空值，而後續對這欄做的計數與比較分不出這件事。<code>LEFT</code> 保護左側、<code>RIGHT</code> 保護右側、<code>FULL</code> 兩側都保護，這三種方向與這些 <code>NULL</code> 的來源定義在 <a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">Outer Join（外連接）</a>；這些 <code>NULL</code> 怎麼讓聚合算錯、為什麼不能用等號比，由 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a> 接手。</p>
<h2 id="鏈中間的-right-join-保護的對象">鏈中間的 RIGHT JOIN 保護的對象</h2>
<p>把最後一個 <code>LEFT</code> 換成 <code>RIGHT</code>，其餘一個字不動：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w">  </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">RIGHT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span></span></span></code></pre></div><p>結果從四列變成一列，只剩 <code>佳穎 101 5</code>。</p>
<p><code>RIGHT</code> 保護的是右運算元，而右運算元是評價這一張表。左運算元——顧客與訂單累積出來的那四列——整批降級成可選的，配不上評價的就丟掉。宗翰、雅文、以及佳穎那張沒被評價的訂單，全部消失。</p>
<p><strong>語意上這等於宣告「每一則評價都要留著，而顧客與訂單可有可無」。</strong> 書店大概不會想問這個問題。它幾乎不會是任何人在鏈尾接上評價那個 <code>JOIN</code> 時想表達的事，而它讀起來只像是把 <code>LEFT</code> 打成了 <code>RIGHT</code>。</p>
<h2 id="只用-left-join-的鏈保護方向的判讀與-full-join-的例外">只用 LEFT JOIN 的鏈：保護方向的判讀與 FULL JOIN 的例外</h2>
<p>不用 <code>RIGHT</code> 的話，鏈裡每個 <code>JOIN</code> 都是同一個方向：要保護的那張表寫在最上面，往下一行一行掛可選的表。要判斷查詢裡任何一個 <code>JOIN</code> 保護了什麼，在它那一行上方畫橫線就夠了。</p>
<p>真正需要保護的表放到 <code>FROM</code> 之後、寫成鏈最上面那一張就好。</p>
<p><strong>兩張表都需要保護的時候這個做法不適用</strong>，那時要的是 <code>FULL JOIN</code>——它兩側都保護，配不上的兩邊各補一次 <code>NULL</code>。鏈裡出現它，「最上面那張表受保護」這個規律就不成立了，判讀要回到逐個連接看保護的方向。要「每一則評價都在、順便看得到它屬於誰」，就從評價開始往下掛訂單與顧客：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎  101  5，與鏈尾換成 RIGHT 的那段查詢是同一個列集合</span></span></span></code></pre></div><p>這段查詢的每個 <code>JOIN</code> 都是 <code>LEFT</code>，鏈的方向沒有中途反轉。</p>
<h2 id="書寫的左右與引擎的執行順序">書寫的左右與引擎的執行順序</h2>
<p>左右是書寫位置決定的語意，與引擎實際先處理誰無關。同一個連接寫成 <code>FROM 訂單 JOIN 顧客</code> 與 <code>FROM 顧客 JOIN 訂單</code>，在跑過 <code>ANALYZE</code> 的資料庫上，SQLite 兩次給出完全相同的 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">query plan</a>——它掃二十萬列的訂單、對五十列的顧客做查找，與誰寫在前面無關。沒有統計時它會改照文字順序走，而那只是它沒有別的資訊時的退路——<a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價</a> 對照有無統計資訊時引擎怎麼選計畫。</p>
<p><strong>左右決定哪一側的列被保護，執行順序決定誰先被掃，兩者互不相干。</strong> 混用這兩個直覺會讓人以為調換書寫順序可以調效能，或以為從查詢文字看得出引擎的動作。</p>
<h2 id="where-引用鏈尾那張表時被撤掉保護的列">WHERE 引用鏈尾那張表時被撤掉保護的列</h2>
<p><code>WHERE</code> 篩的是整條鏈累積完的那四列。條件引用鏈尾評價表的欄位時，佳穎的 102、宗翰與雅文那三列的評價欄是補出來的 <code>NULL</code>，一起被篩掉——宗翰與雅文跟評價表毫無關係，他們的保護是第一個 <code>LEFT JOIN</code> 給的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span><span class="w">   </span><span class="c1">-- 補出來的 NULL 判成未知，那三列被篩掉
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- 佳穎  101  5</span></span></span></code></pre></div><p>在這份資料上，消失的正好是把第二個 <code>JOIN</code> 換成 <code>INNER</code> 時消失的同一批列。所以判讀一個 <code>WHERE</code> 條件撤掉了誰，同樣回到累積：它引用的表掛在鏈的哪一行，那一行以上累積進來、在那張表上配不到的列都在它的射程裡。條件對 <code>NULL</code> 判成什麼決定撤不撤得掉，哪幾種條件形態保得住保護，在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>。</p>
]]></content:encoded></item><item><title>SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/</guid><description>&lt;p>&lt;code>ON&lt;/code> 說的是 &lt;code>JOIN&lt;/code> 左右兩邊的列怎麼配成一個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a>，&lt;code>WHERE&lt;/code> 說的是從配好的關係裡篩掉什麼。這兩件事落在求值順序的不同步：&lt;code>FROM&lt;/code> 那一步把關係組出來，&lt;code>WHERE&lt;/code> 那一步才開始篩。&lt;/p>
&lt;p>本篇要反覆用到這兩件事的名字。&lt;strong>配對&lt;/strong>指關係還在組的那個過程——哪一列跟哪一列湊成一列；&lt;strong>成品&lt;/strong>指關係組完之後得到的那一批列——已經湊好、包含補了 NULL 的那些列在內的整批資料。這兩個詞是本篇為了指認這兩個時刻而取的稱呼，SQL 的標準術語裡沒有它們。&lt;/p>
&lt;p>內連接底下，條件寫在 &lt;code>ON&lt;/code> 與寫在 &lt;code>WHERE&lt;/code> 的結果相同，於是它們看起來可以互換。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>底下不同，而不同的地方正好是外連接存在的理由。條件擺在哪一邊不影響查詢跑不跑得動，只影響外連接剛保護下來的那些列留不留得住。保護指 &lt;code>LEFT JOIN&lt;/code> 把左表配不到對象的列也留在結果裡、右表那幾欄補上 &lt;code>NULL&lt;/code>。&lt;code>LEFT JOIN&lt;/code> 退化成內連接的名單，來源就是把條件放進 &lt;code>WHERE&lt;/code> 這一步。&lt;/p>
&lt;h2 id="同一個條件放-on-與放-where-的結果">同一個條件放 ON 與放 WHERE 的結果&lt;/h2>
&lt;p>書店想要一份名單：全部顧客都要列出來，而評價欄只顯示五星的那些。條件寫進 &lt;code>ON&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>回來四列：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">佳穎 5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">佳穎 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">宗翰 NULL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">雅文 NULL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個顧客都在。佳穎出現兩次，因為她有兩張訂單——第一張有五星評價，第二張沒有評價可配，補 NULL。（一位顧客變成兩列是連接在改變列數，本篇只處理條件決定的留不留，連接怎麼改變列數在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a>。）&lt;/p>
&lt;p>同一個星等條件改放 &lt;code>WHERE&lt;/code>，其餘不動：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">星等&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 5&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>宗翰、雅文與佳穎的第二張訂單全部消失，結果與內連接相同。書店要的是全部顧客，所以這一版的答案錯了，而查詢照樣跑、不報錯——這一類錯的共同性質在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>。需求若恰好只要有五星評價的那些，答案就對了，剩下的是文字的問題：&lt;code>LEFT&lt;/code> 仍然寫在那裡，照字面讀的人會以為沒配到的顧客都在，那是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a> 的主題。&lt;/p>
&lt;h2 id="條件放-on-與放-where-在外連接底下分岔的原因">條件放 ON 與放 WHERE 在外連接底下分岔的原因&lt;/h2>
&lt;p>&lt;code>LEFT JOIN&lt;/code> 的定義是左邊每一列都留著，右邊配不上的補 NULL。條件寫在 &lt;code>ON&lt;/code> 裡的時候，它參與的是「配不配得上」這個判斷——宗翰沒有訂單所以配不上任何評價，佳穎的第二張訂單配不上五星評價，照定義都補 NULL 留下來。&lt;/p>
&lt;p>條件寫在 &lt;code>WHERE&lt;/code> 裡的時候，關係已經組完了，補上的那些 NULL 也已經在裡面。這時候問「星等等於 5」，那些補 NULL 的列答不出 5，於是被篩掉。&lt;strong>外連接剛剛保護下來的列，&lt;code>WHERE&lt;/code> 這一步又把它們拿掉了。&lt;/strong>&lt;/p>
&lt;p>內連接底下看不出差別，是因為它本來就不保留配不上的列。沒有被保護的列可以被抵銷，所以兩種寫法自然同值。&lt;/p>
&lt;p>開頭那段顧客接訂單、再接評價的查詢有三張表，第二個 &lt;code>LEFT JOIN&lt;/code> 保護的「左邊」因此是顧客與訂單累積出來的結果；鏈越長，保護的範圍怎麼跟著累積走，是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元&lt;/a> 的題目。&lt;/p>
&lt;h2 id="保護存不存活的測試把補了-null-的那一列代進條件">保護存不存活的測試：把補了 NULL 的那一列代進條件&lt;/h2>
&lt;p>&lt;code>WHERE&lt;/code> 只留下判成真的列。而條件的答案有三種——真、假、以及&lt;strong>未知&lt;/strong>：拿 &lt;code>NULL&lt;/code> 去跟任何值比較，答案既不是真也不是假（三值邏輯的推導，以及它讓 &lt;code>NOT IN&lt;/code> 整段失效的形態，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。&lt;/p>
&lt;p>外連接補上的那些列，右表欄位全是 &lt;code>NULL&lt;/code>，所以問題變成一句話：&lt;strong>這個條件對「什麼都沒有」這一列給出什麼答案。&lt;/strong> 真的留下，假與未知都被丟掉。&lt;/p></description><content:encoded><![CDATA[<p><code>ON</code> 說的是 <code>JOIN</code> 左右兩邊的列怎麼配成一個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a>，<code>WHERE</code> 說的是從配好的關係裡篩掉什麼。這兩件事落在求值順序的不同步：<code>FROM</code> 那一步把關係組出來，<code>WHERE</code> 那一步才開始篩。</p>
<p>本篇要反覆用到這兩件事的名字。<strong>配對</strong>指關係還在組的那個過程——哪一列跟哪一列湊成一列；<strong>成品</strong>指關係組完之後得到的那一批列——已經湊好、包含補了 NULL 的那些列在內的整批資料。這兩個詞是本篇為了指認這兩個時刻而取的稱呼，SQL 的標準術語裡沒有它們。</p>
<p>內連接底下，條件寫在 <code>ON</code> 與寫在 <code>WHERE</code> 的結果相同，於是它們看起來可以互換。<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>底下不同，而不同的地方正好是外連接存在的理由。條件擺在哪一邊不影響查詢跑不跑得動，只影響外連接剛保護下來的那些列留不留得住。保護指 <code>LEFT JOIN</code> 把左表配不到對象的列也留在結果裡、右表那幾欄補上 <code>NULL</code>。<code>LEFT JOIN</code> 退化成內連接的名單，來源就是把條件放進 <code>WHERE</code> 這一步。</p>
<h2 id="同一個條件放-on-與放-where-的結果">同一個條件放 ON 與放 WHERE 的結果</h2>
<p>書店想要一份名單：全部顧客都要列出來，而評價欄只顯示五星的那些。條件寫進 <code>ON</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span></span></span></code></pre></div><p>回來四列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">佳穎  5
</span></span><span class="line"><span class="ln">2</span><span class="cl">佳穎  NULL
</span></span><span class="line"><span class="ln">3</span><span class="cl">宗翰  NULL
</span></span><span class="line"><span class="ln">4</span><span class="cl">雅文  NULL</span></span></code></pre></div><p>三個顧客都在。佳穎出現兩次，因為她有兩張訂單——第一張有五星評價，第二張沒有評價可配，補 NULL。（一位顧客變成兩列是連接在改變列數，本篇只處理條件決定的留不留，連接怎麼改變列數在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>。）</p>
<p>同一個星等條件改放 <code>WHERE</code>，其餘不動：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎  5</span></span></span></code></pre></div><p>宗翰、雅文與佳穎的第二張訂單全部消失，結果與內連接相同。書店要的是全部顧客，所以這一版的答案錯了，而查詢照樣跑、不報錯——這一類錯的共同性質在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>。需求若恰好只要有五星評價的那些，答案就對了，剩下的是文字的問題：<code>LEFT</code> 仍然寫在那裡，照字面讀的人會以為沒配到的顧客都在，那是 <a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a> 的主題。</p>
<h2 id="條件放-on-與放-where-在外連接底下分岔的原因">條件放 ON 與放 WHERE 在外連接底下分岔的原因</h2>
<p><code>LEFT JOIN</code> 的定義是左邊每一列都留著，右邊配不上的補 NULL。條件寫在 <code>ON</code> 裡的時候，它參與的是「配不配得上」這個判斷——宗翰沒有訂單所以配不上任何評價，佳穎的第二張訂單配不上五星評價，照定義都補 NULL 留下來。</p>
<p>條件寫在 <code>WHERE</code> 裡的時候，關係已經組完了，補上的那些 NULL 也已經在裡面。這時候問「星等等於 5」，那些補 NULL 的列答不出 5，於是被篩掉。<strong>外連接剛剛保護下來的列，<code>WHERE</code> 這一步又把它們拿掉了。</strong></p>
<p>內連接底下看不出差別，是因為它本來就不保留配不上的列。沒有被保護的列可以被抵銷，所以兩種寫法自然同值。</p>
<p>開頭那段顧客接訂單、再接評價的查詢有三張表，第二個 <code>LEFT JOIN</code> 保護的「左邊」因此是顧客與訂單累積出來的結果；鏈越長，保護的範圍怎麼跟著累積走，是 <a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a> 的題目。</p>
<h2 id="保護存不存活的測試把補了-null-的那一列代進條件">保護存不存活的測試：把補了 NULL 的那一列代進條件</h2>
<p><code>WHERE</code> 只留下判成真的列。而條件的答案有三種——真、假、以及<strong>未知</strong>：拿 <code>NULL</code> 去跟任何值比較，答案既不是真也不是假（三值邏輯的推導，以及它讓 <code>NOT IN</code> 整段失效的形態，在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<p>外連接補上的那些列，右表欄位全是 <code>NULL</code>，所以問題變成一句話：<strong>這個條件對「什麼都沒有」這一列給出什麼答案。</strong> 真的留下，假與未知都被丟掉。</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>對補 NULL 的那一列</th>
          <th>保護</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>評價.星等 = 5</code></td>
          <td>NULL 比不出來，判成未知</td>
          <td>取消</td>
      </tr>
      <tr>
          <td><code>評價.星等 IS NOT NULL</code></td>
          <td>判成假</td>
          <td>取消</td>
      </tr>
      <tr>
          <td><code>評價.星等 IS NULL</code></td>
          <td>判成真</td>
          <td>保住</td>
      </tr>
      <tr>
          <td><code>評價.星等 IS NULL OR 評價.星等 = 5</code></td>
          <td>前半判成真，整串就是真</td>
          <td>保住</td>
      </tr>
      <tr>
          <td><code>COALESCE(評價.星等, 0) &gt;= 0</code></td>
          <td>NULL 先被換成 0，判成真</td>
          <td>保住</td>
      </tr>
  </tbody>
</table>
<p><strong>能不能保住不看用了哪個運算子。</strong> <code>IS NOT NULL</code> 看起來與 <code>IS NULL</code> 成對，而在表上的「保護」欄一個是取消、一個是保住，正是因為它對空缺判成假；而 <code>OR</code> 與 <code>COALESCE</code> 這兩種寫法把未知轉成真，保護就留住了。</p>
<p><strong>這張表上的條件全部提到右表，而左表的條件進不了這張表。</strong> 補 <code>NULL</code> 的那一列上左表欄位仍然是原本的值，拿它去問什麼都判得出真假，所以保護一定保得住——這個測試對左表條件恆為真，什麼都判別不出來。左表條件實際的問題出在寫進 <code>ON</code> 的時候：它篩不掉左表的任何一列，還會讓本來配得上的那些配對判假。同一個條件 <code>顧客.姓名 &lt;&gt; '佳穎'</code> 放在三個位置：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 放 WHERE：佳穎被篩掉，宗翰與雅文的保護完好
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;佳穎&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰  NULL  NULL
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">-- 雅文  NULL  NULL
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 放進顧客接訂單的 ON：佳穎與她的兩張訂單判假、配不上，照 LEFT 的定義補 NULL 留下
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;佳穎&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎  NULL  NULL     三位顧客全回來，佳穎的兩張訂單一起消失
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- 宗翰  NULL  NULL
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">-- 雅文  NULL  NULL
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="c1">-- 放進接評價的 ON：顧客與訂單在上一步已經配好，佳穎的兩張訂單留著，只是配不到評價
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">5</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;佳穎&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎  101   NULL
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1">-- 佳穎  102   NULL
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="c1">-- 宗翰  NULL  NULL
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="c1">-- 雅文  NULL  NULL</span></span></span></code></pre></div><p>所以左表條件根本不需要走這個測試，它一開始就屬於 <code>WHERE</code>。</p>
<p><strong>這個測試回答的是保護存不存活，不回答條件該放哪一邊。</strong> 歸屬要另外問一句：這個條件描述的是配對，還是成品。「只跟五星的評價配」描述的是配對，屬於 <code>ON</code>；「最後的名單裡只要五星那幾列」描述的是成品，屬於 <code>WHERE</code>。兩句話說的是不同的事，而內連接讓這兩種寫法碰巧回同一批列。</p>
<p>多數形態上「保護存不存活」與「條件該放哪一邊」這兩個問題同向——取消了保護的條件，通常正是因為它在描述成品。<code>IS NULL</code> 是這兩個問題答案不同向的那一個形態：它保住了保護，而它要的正好是那些被保護下來的列，所以它同時是「保護存活」與「屬於 <code>WHERE</code>」。下一節寫的就是這個形態。</p>
<h2 id="找沒配到的那些列is-null-條件的歸屬">找沒配到的那些列：IS NULL 條件的歸屬</h2>
<p>有時候要的正是「沒配到的那些」。書店想知道哪些顧客一張單都沒下過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>這個條件描述的是成品，所以它屬於 <code>WHERE</code>。它與上面那個「退化成內連接」的形態看起來很像——同樣是在 <code>WHERE</code> 裡引用右表欄位——而要的結果剛好相反：這一個挑的正是外連接補上 NULL 的那些列。</p>
<p><strong>分辨這兩種形態的仍然是那個問題：這個條件描述的是配對，還是成品。</strong> 「一張單都沒下過」是關於成品的敘述，所以它在 <code>WHERE</code> 是對的位置。</p>
<p>同一題「找沒消費過的顧客」換一個切面，走得到同一個結論。<a href="/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象</a> 從「沒消費的人不會出現在訂單表」這個業務事實走到 <code>NOT EXISTS</code>，並說明在外連接之後分組、再數出零的那種寫法，那個零是查詢自己製造再讀回去的產物。</p>
]]></content:encoded></item><item><title>SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/</guid><description>&lt;p>&lt;code>FROM&lt;/code> 走完之後得到的 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a> 已經不是任何一張來源表。它的列數可能變多，而它多出來一些來源表裡沒有的空缺。後面每一個子句都在這個新的關係上操作。本篇從這個關係已經成形的那一刻開始；它是逐個 &lt;code>JOIN&lt;/code> 累積出來的，每個 &lt;code>JOIN&lt;/code> 的左運算元是什麼在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元&lt;/a>。&lt;/p>
&lt;p>列數變多與多出空缺各自帶來一類錯誤，兩類都不報錯。列數變多帶來的錯誤本篇叫&lt;strong>列數膨脹&lt;/strong>：聚合數的是連接之後的列，回了比實際大的數字。空缺帶來的錯誤本篇叫&lt;strong>比較 NULL 的錯誤&lt;/strong>：拿 NULL 做比較，答案是未知而不是真或假，&lt;code>WHERE&lt;/code> 裡的等號與 &lt;code>NOT IN&lt;/code> 因此回了零列，&lt;code>NOT EXISTS&lt;/code> 子查詢裡的不等號則多留了一列。&lt;/p>
&lt;h2 id="列數膨脹與各類聚合的修法">列數膨脹與各類聚合的修法&lt;/h2>
&lt;p>這一節反覆數的是列，也就是橫向的一筆資料——三張訂單就是三列，欄位有幾個與這件事無關。&lt;/p>
&lt;p>書店的訂單表有兩列，而共用資料裡兩張訂單只有 101 被評價過，所以接上評價表之後仍是兩列。替 101 再加一則評價，列數就變成三：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">9002&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">101&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 替 101 再加一則評價之後：3&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訂單一張都沒有增加，而連接後的關係多了一列。原因是配對是逐列做的：訂單 101 配得上兩則評價，於是它在結果裡出現兩次，兩次帶著不同的評價。&lt;/p>
&lt;p>&lt;strong>後續的聚合算的是這個新關係的列，不是訂單表的列。&lt;/strong> 所以數佳穎有幾張訂單，直接數會得到錯的答案：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">看起來的訂單數&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">DISTINCT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">實際訂單數&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 | 3 | 2&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>佳穎實際有兩張訂單，而 &lt;code>count(*)&lt;/code> 回報三。SQLite、DuckDB 與 PostgreSQL 的答案都是這一組。&lt;/p>
&lt;p>列數膨脹最難察覺的地方在於數字看起來合理。count(*) 的三比實際訂單數的二只多一，而且它會隨著評價增加而慢慢變大——沒有任何一刻它會大到讓人起疑。&lt;/p>
&lt;p>&lt;strong>判斷方法是問這個聚合要數的是哪一個層次的單位&lt;/strong>——訂單、還是連接之後的列。決定那個層次的是分組鍵（選鍵的判斷標準在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象&lt;/a>），而把單位拉回來的做法依聚合的性質分三種。&lt;/p>
&lt;p>&lt;strong>數個數的&lt;/strong>用 &lt;code>count(DISTINCT 訂單編號)&lt;/code>，計數的單位就回到訂單。&lt;/p>
&lt;p>&lt;strong>加總的&lt;/strong>不能用 &lt;code>DISTINCT&lt;/code>。直接加總時同一張訂單的金額被加了兩次，而 &lt;code>sum(DISTINCT 金額)&lt;/code> 去掉的是重複的值、不是重複的訂單，兩張金額剛好相同的訂單會被它當成同一筆而少加一次。加總的解法是讓展開的那一層在接上來之前就已經收成一列：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 直接連接再加總：101 配到兩則評價，300 被加了兩次
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 1100
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 括號裡那一段先把每張訂單的評價收成一列，接上來之後每張訂單在結果裡只有一列
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價數&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價數&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價統計&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價統計&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 101 | 300 | 2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 102 | 500 |&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每張訂單在這份結果裡只有一列，對它加總金額時每張訂單也就只算一次。&lt;/p></description><content:encoded><![CDATA[<p><code>FROM</code> 走完之後得到的 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 已經不是任何一張來源表。它的列數可能變多，而它多出來一些來源表裡沒有的空缺。後面每一個子句都在這個新的關係上操作。本篇從這個關係已經成形的那一刻開始；它是逐個 <code>JOIN</code> 累積出來的，每個 <code>JOIN</code> 的左運算元是什麼在 <a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a>。</p>
<p>列數變多與多出空缺各自帶來一類錯誤，兩類都不報錯。列數變多帶來的錯誤本篇叫<strong>列數膨脹</strong>：聚合數的是連接之後的列，回了比實際大的數字。空缺帶來的錯誤本篇叫<strong>比較 NULL 的錯誤</strong>：拿 NULL 做比較，答案是未知而不是真或假，<code>WHERE</code> 裡的等號與 <code>NOT IN</code> 因此回了零列，<code>NOT EXISTS</code> 子查詢裡的不等號則多留了一列。</p>
<h2 id="列數膨脹與各類聚合的修法">列數膨脹與各類聚合的修法</h2>
<p>這一節反覆數的是列，也就是橫向的一筆資料——三張訂單就是三列，欄位有幾個與這件事無關。</p>
<p>書店的訂單表有兩列，而共用資料裡兩張訂單只有 101 被評價過，所以接上評價表之後仍是兩列。替 101 再加一則評價，列數就變成三：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：2
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">9002</span><span class="p">,</span><span class="w"> </span><span class="mi">101</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 替 101 再加一則評價之後：3</span></span></span></code></pre></div><p>訂單一張都沒有增加，而連接後的關係多了一列。原因是配對是逐列做的：訂單 101 配得上兩則評價，於是它在結果裡出現兩次，兩次帶著不同的評價。</p>
<p><strong>後續的聚合算的是這個新關係的列，不是訂單表的列。</strong> 所以數佳穎有幾張訂單，直接數會得到錯的答案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">看起來的訂單數</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">       </span><span class="k">count</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">實際訂單數</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3 | 2</span></span></span></code></pre></div><p>佳穎實際有兩張訂單，而 <code>count(*)</code> 回報三。SQLite、DuckDB 與 PostgreSQL 的答案都是這一組。</p>
<p>列數膨脹最難察覺的地方在於數字看起來合理。count(*) 的三比實際訂單數的二只多一，而且它會隨著評價增加而慢慢變大——沒有任何一刻它會大到讓人起疑。</p>
<p><strong>判斷方法是問這個聚合要數的是哪一個層次的單位</strong>——訂單、還是連接之後的列。決定那個層次的是分組鍵（選鍵的判斷標準在 <a href="/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象</a>），而把單位拉回來的做法依聚合的性質分三種。</p>
<p><strong>數個數的</strong>用 <code>count(DISTINCT 訂單編號)</code>，計數的單位就回到訂單。</p>
<p><strong>加總的</strong>不能用 <code>DISTINCT</code>。直接加總時同一張訂單的金額被加了兩次，而 <code>sum(DISTINCT 金額)</code> 去掉的是重複的值、不是重複的訂單，兩張金額剛好相同的訂單會被它當成同一筆而少加一次。加總的解法是讓展開的那一層在接上來之前就已經收成一列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 直接連接再加總：101 配到兩則評價，300 被加了兩次
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="c1">-- 1100
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 括號裡那一段先把每張訂單的評價收成一列，接上來之後每張訂單在結果裡只有一列
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="p">,</span><span class="w"> </span><span class="err">評價數</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">評價數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">評價統計</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">評價統計</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 101 | 300 | 2
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- 102 | 500 |</span></span></span></code></pre></div><p>每張訂單在這份結果裡只有一列，對它加總金額時每張訂單也就只算一次。</p>
<p><strong>不可加的</strong>（平均、中位數、百分位）不能先在每張訂單這一層算完，再拿到更粗的那一層合併。先算每張訂單的平均星等、再把這些平均值平均一次，得到的是「平均的平均」：評價則數多的訂單與只有一則的訂單權重一樣，結果就偏了。<code>DISTINCT</code> 在這裡也沒有幫助，它去掉的是重複的值，不是重複的列。再替訂單 102 加一則一星評價，讓兩張訂單的則數不同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">9003</span><span class="p">,</span><span class="w"> </span><span class="mi">102</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 平均的平均：101 的兩則平均 4、102 的一則平均 1，再平均一次
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">avg</span><span class="p">(</span><span class="err">每單平均</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="k">avg</span><span class="p">(</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">每單平均</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 2.5</span></span></span></code></pre></div><p>佳穎收到的三則評價是 5、3、1，實際的平均是 3。平均與中位數、百分位的出路不同。</p>
<p><strong>平均可以拆成兩個可加的量。</strong> 子查詢裡同時算 <code>sum</code> 與 <code>count</code>，接上來之後各自加總，最後才相除：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="err">評價統計</span><span class="p">.</span><span class="err">星等總和</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="err">評價統計</span><span class="p">.</span><span class="err">則數</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">平均星等</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="k">sum</span><span class="p">(</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">星等總和</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">則數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">評價統計</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">評價統計</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3.0</span></span></span></code></pre></div><p><code>sum</code> 與 <code>count</code> 在每一層都可以直接相加，所以不管中間經過幾層彙總，最後相除的都是全部評價的總和與總則數。乘上 <code>1.0</code> 是為了避免兩個整數相除時被截成整數。</p>
<p><strong>中位數與百分位拆不成可加的量。</strong> 知道兩張訂單各自的中位數，推不出兩張合起來的中位數，所以它們只能在目標層次上，直接對原始的列計算，而且每一筆資料在那個集合裡只能出現一次。算「每位顧客收到的評價星等中位數」時，要計算的集合是評價的列，而顧客接訂單再接評價，每一則評價正好出現一次，這個集合可以直接用；如果連接裡還接了會讓評價重複的表，就要先把評價收成「每則一列」再算。計算本身各家的寫法不同。SQLite 的 <code>median()</code> 與 <code>percentile()</code> 屬於可選的擴充，手上那一份有沒有，跑一次 <code>SELECT median(1);</code> 就知道；沒有的話用視窗函數替每位顧客的評價排序，取中間那一列，則數是偶數時取中間兩列的平均：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 此時佳穎收到的評價是 5、3、1
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- PostgreSQL：percentile_cont 在每一組裡依 ORDER BY 排好之後取 0.5 分位
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="n">percentile_cont</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span><span class="w"> </span><span class="n">WITHIN</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="p">(</span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">中位數</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite 沒有 median() 時：名次是那位顧客的評價依星等排序後的位置，則數是那位顧客的評價總數
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">WITH</span><span class="w"> </span><span class="err">排序後</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">         </span><span class="n">row_number</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">名次</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">         </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w">     </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">則數</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">  </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="k">avg</span><span class="p">(</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">中位數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">排序後</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">名次</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">((</span><span class="err">則數</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="p">(</span><span class="err">則數</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span><span class="w">   </span><span class="c1">-- 則數是奇數時兩個值相同，偶數時是中間兩列
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">姓名</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 3.0</span></span></span></code></pre></div><p>這一段的例子做完之後把 9003 刪掉：<code>DELETE FROM 評價 WHERE 評價編號 = 9003;</code>。下一節要用到訂單 102 沒有評價的狀態。</p>
<h2 id="外連接補出來的-nullcount-與-count欄位-的差別">外連接補出來的 NULL：count(*) 與 count(欄位) 的差別</h2>
<p>外連接配不上時補的 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>，來源表裡並不存在。<code>LEFT</code> / <code>RIGHT</code> / <code>FULL</code> 各自保護哪一側、因此在哪一側補空缺，定義在 <a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">Outer Join（外連接）</a>。<code>count</code> 對它的處理與一般的值不同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">全部列</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">評價</span><span class="p">.</span><span class="err">星等</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">有星等的列</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">評價</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 3 | 2</span></span></span></code></pre></div><p><code>count(*)</code> 數的是列，<code>count(欄位)</code> 數的是那一欄有值的列。差額就是空缺的數量。這個差別在沒有連接的查詢裡很少現形，因為原始資料多半沒那麼多空值。</p>
<h2 id="null-的比較結果與-is-null">NULL 的比較結果與 IS NULL</h2>
<p>要找出沒下過單的顧客，很自然會寫成「訂單編號等於 NULL」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 0 列，而且沒有任何錯誤訊息
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 同一段改成 IS NULL
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p><code>= NULL</code> 那一段在三個引擎上都回零列、都不報錯。這個 <code>IS NULL</code> 要寫在 <code>WHERE</code> 而不是 <code>ON</code>，理由是它描述的是連接完成之後的那份結果；同一個條件放兩邊為什麼在外連接底下留下不同的列，在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>。</p>
<p>原因是 NULL 表示「這裡沒有值」，而拿一個未知的值去比對任何值，答案也是未知：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="p">(</span><span class="k">NULL</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">NULL</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="k">NULL</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- NULL | 1        SQLite 用 1 表示真</span></span></span></code></pre></div><p><code>NULL = NULL</code> 的結果既不是真也不是假，它是第三種答案。而 <code>WHERE</code> 只留下判斷為真的列，所以「未知」與「假」在這一步的下場相同——列被丟掉。</p>
<p><code>IS NULL</code> 是另一個運算子，它問的是「這個位置有沒有值」，答案只有真或假兩種。<strong>判斷空缺一律用 <code>IS NULL</code> 與 <code>IS NOT NULL</code>，比較運算子在這件事上永遠回零列。</strong></p>
<h2 id="子查詢含-null-時的-not-in-與-not-exists">子查詢含 NULL 時的 NOT IN 與 NOT EXISTS</h2>
<p>同一套三值邏輯（見 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>）製造了一個更難發現的形態。書店要找出沒下過單的顧客，改用 <code>NOT IN</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：宗翰、雅文</span></span></span></code></pre></div><p>答案正確。接著訂單表多了一列顧客編號是 NULL 的資料——比如一筆還沒綁定顧客的訂單。同一段查詢再跑一次，三個引擎都是零列，仍然不報錯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">200</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 0 列
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   子查詢交出 1、1、NULL，NOT IN 對宗翰（顧客編號 2）展開成 2 &lt;&gt; 1 AND 2 &lt;&gt; 1 AND 2 &lt;&gt; NULL
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">--   與 1 比較的兩項為真，與 NULL 比較的那一項是未知，AND 的結果是未知，WHERE 不留</span></span></span></code></pre></div><p><code>NOT IN</code> 展開成一串「不等於」的 AND，與 NULL 比較的那一項答案是未知，整串的結果因此永遠不會是真。<strong>子查詢裡出現任何一個 NULL，<code>NOT IN</code> 就對每一列都回答「未知」。</strong></p>
<p>改成 <code>NOT EXISTS</code> 不受影響：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：宗翰、雅文
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 訂單 103 進來之後：宗翰、雅文
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 刪掉 103：留著的話，金額最高的那段查詢會把它也選進來
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">103</span><span class="p">;</span></span></span></code></pre></div><p><code>NOT EXISTS</code> 問的是「有沒有配得上的列」，那是一個關於存在性的問題，答案只有真假兩種。</p>
<p><strong>這個安全範圍限定在子查詢用等號配對的時候。</strong> 子查詢裡改用不等號比較的話，NULL 一樣會讓結果出錯。「找出每位顧客金額最高的訂單」可以寫成「同一位顧客沒有另一張金額更高的訂單」，而金額為 NULL 的那張訂單會被選成「最高」。原因不是 NULL 比較大：NULL 沒有進到 <code>NOT EXISTS</code> 的答案裡，它進到了子查詢的比較條件裡，比較的答案是未知而不是真，子查詢因此一列都找不到，<code>NOT EXISTS</code> 於是成立。在共用資料上加一張金額未填的訂單 104 就看得到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">104</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-12&#39;</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">a</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">金額</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 102 | 500    子查詢對 102 找不到金額更高的訂單
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">-- 104 |        子查詢比的是 b.金額 &gt; NULL，對每一張 b 都是未知，一列都找不到
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">--   101 沒有出現：102 的 500 &gt; 300 為真，子查詢找得到一列
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="c1">-- 子查詢之外另寫 a.金額 IS NOT NULL，排除金額未填的那一張
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">a</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">b</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="err">金額</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"></span><span class="c1">-- 102 | 500
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">DELETE</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">104</span><span class="p">;</span></span></span></code></pre></div><p>佳穎的「金額最高的訂單」在沒有 <code>IS NOT NULL</code> 的那段查詢裡回了兩張：真正最高的 102，以及金額未填的 104。</p>
<h2 id="列數改變空缺與-null-比較的共同來源">列數改變、空缺與 NULL 比較的共同來源</h2>
<p>列數變了、空缺出現了、空缺的比較是第三種答案——三者都來自同一件事：<strong><code>FROM</code> 產出的是一個新的關係，而後續每一步都在它上面操作。</strong> 拿來源表的直覺去讀後面的子句，就會在這三個地方各撞一次。</p>
<p>列數會變與空缺會出現，在本篇是給定的，而兩者各自在更早的時候就決定了。列數會不會膨脹，由常一起取的資料切在幾張表決定；空缺存不存在，由這一欄允不允許為空決定——兩個都是建表時下的，下的當下看不出代價。<a href="/blog/backend/01-database/design-decisions-price-every-query/" data-link-title="1.16 Schema 設計決定的查詢代價：可空性、排序鍵唯一性、表寬、一對多的切法、比較規則與外鍵執法" data-link-desc="建表時每一個決定有哪些選法、選了之後查詢要多做什麼、代價在什麼條件下才浮現、改回來要付的遷移成本，以及設計當下就能問的問題；輸出為 SQLite 與 PostgreSQL 的實測">backend 1.16 Schema 設計決定的查詢代價</a> 各給一節，並量出雙重展開讓聚合翻倍，以及 <code>DISTINCT</code> 這個修法對不對由資料決定。</p>
<p>列數膨脹與比較 NULL 的錯誤各有各的機制，而共通的是引擎對它們都沒有回報的管道：列數膨脹的結果列數與欄位看不出異常，而計數與總額已經多算；等號比 <code>NULL</code> 與 <code>NOT IN</code> 回零列，而零列與「確實沒有」分不開；<code>NOT EXISTS</code> 子查詢裡的不等號多留了金額未填的那張訂單，多出來的那一列也是一張真實存在的訂單。<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 拿列數膨脹當例子，說明結果的形狀正確為什麼不構成保證。</p>
]]></content:encoded></item><item><title>SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/</guid><description>&lt;p>&lt;code>FROM&lt;/code> 裡寫下的表名指的是那張表在這個查詢裡的一次出現，而那個名字是這次出現的稱呼。標準把這件事叫 table reference，關聯演算（relational calculus）裡的傳統名稱是 range variable，各家文件裡也常寫成 correlation name——三個名字說的是同一件事。同一張表因此可以出現兩次、各自扮演不同角色——這就是自連接。&lt;/p>
&lt;p>一張表只出現一次的時候，這個區別沒有作用。出現兩次就有：兩次出現共用同一個名字，引擎分不出某個欄位屬於哪一次，於是擋下來。&lt;/p>
&lt;p>引擎把這件事講得很明白，而擋下來的時機分兩種。PostgreSQL 與 DuckDB 看到重複的名字就停，訊息分別是 &lt;code>table name &amp;quot;訂單&amp;quot; specified more than once&lt;/code> 與 &lt;code>Duplicate alias &amp;quot;訂單&amp;quot; in query!&lt;/code>；SQLite 讓查詢往下走，等到某個欄位引用無從分辨時才回 &lt;code>ambiguous column name&lt;/code>。&lt;strong>表名本身就是預設的別名&lt;/strong>，所以同一張表的兩次出現共用同一個名字時，等於兩次出現叫同一個稱呼，之後每一次引用都無從分辨。&lt;/p>
&lt;h2 id="兩張表共用欄名時的欄位歸屬">兩張表共用欄名時的欄位歸屬&lt;/h2>
&lt;p>欄位的歸屬要靠名字決定，所以兩張不同的表共用一個欄名時同樣分不開。書店的顧客表與訂單表都有 &lt;code>顧客編號&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- MySQL: ERROR 1052: Column &amp;#39;顧客編號&amp;#39; in field list is ambiguous
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite: ambiguous column name: 顧客編號
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- DuckDB: Ambiguous reference to column name &amp;#34;顧客編號&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>id&lt;/code> 是這件事最常撞到的欄位名，因為多數表都有一個。&lt;strong>連接之後每個欄位引用都要能追回它屬於哪一次出現&lt;/strong>，加上表名或別名當前綴就解決。&lt;/p>
&lt;p>自連接只是這個規則的極端情形：兩次出現連表名都一樣，所以連前綴都救不了，非取別名不可。&lt;/p>
&lt;p>表一多，要判讀的就變成每個 &lt;code>ON&lt;/code> 引用得到哪些出現——寫在它上面的那些出現都已經併進左運算元，寫在它下面的還沒進來。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元&lt;/a> 寫左運算元隨著往下走而變大的過程。&lt;/p>
&lt;h2 id="用別名分開同一張表的兩次出現">用別名分開同一張表的兩次出現&lt;/h2>
&lt;p>訂單表裡的兩張單都掛在佳穎名下。要找出同一位顧客的訂單先後配對——哪一張在前、哪一張在後。這件事需要把訂單表的兩列擺在一起比較，而一列一列處理的查詢做不到，除非讓訂單表出現兩次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 101 | 2026-03-02 | 102 | 2026-03-09&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>早&lt;/code> 與 &lt;code>晚&lt;/code> 是同一張表的兩次出現。連接條件在做兩件事：&lt;code>顧客編號&lt;/code> 相同把配對限制在同一個人身上，&lt;code>下單日&lt;/code> 的大小關係決定哪一次出現扮演「早」、哪一次扮演「晚」。&lt;/p>
&lt;p>&lt;strong>條件裡的第二項是這個寫法的重點。&lt;/strong> 少了它，每張訂單會跟同一位顧客的每張訂單配對一次，包括跟自己配。加上嚴格的大小比較之後，每一對只留下一個方向，而同一張訂單跟它自己組成的那一對，因為兩邊日期相等也被排除。同一位顧客有三張訂單時，每兩張各配一次，不多不少：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 拿掉日期的比較，只留顧客編號相同
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：101|101、101|102、102|101、102|102（兩個方向各一次，加上各自跟自己配）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 日期的比較放回去
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">早&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">晚&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">下單日&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：101|102
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎多一張 103（下單日 2026-03-15）之後：101|102、101|103、102|103&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="自連接的適用問題前後配對同組比較與層級關係">自連接的適用問題：前後配對、同組比較與層級關係&lt;/h2>
&lt;p>自連接處理的是&lt;strong>同一批資料裡兩筆資料之間的關係&lt;/strong>——兩列，不是兩個欄位。一列一列的處理看不到這種關係，因為每一列在被判斷的時候不知道其他列長什麼樣子。&lt;/p>
&lt;p>自連接常見的形態有三種。&lt;strong>前後配對&lt;/strong>：同一位顧客的訂單先後配對，把時間上相鄰或有先後關係的兩列擺在一起。&lt;strong>同組比較&lt;/strong>：找出訂單金額高於同一位顧客其他訂單的那些，比較的對象是同一張表裡的兄弟列。&lt;strong>層級關係&lt;/strong>：一張員工表裡每個人有一個主管欄位指向同一張表的另一列，要把人與他的主管一起列出來時，員工表出現兩次，一次當員工、一次當主管。&lt;/p>
&lt;p>前後配對、同組比較與層級關係三種的共同點是&lt;strong>要比較的兩列住在同一張表裡&lt;/strong>。需不需要自連接由兩個獨立的問題決定：這個問題要不要拿一列去跟同一張表的另一列比——不比的問題不屬於這一族；以及要不要把另一列的欄位輸出出來——這個問題決定族裡選哪一種寫法。&lt;/p>
&lt;p>要不要輸出另一列的欄位，就是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理&lt;/a> 那條判斷標準——自連接也是一種 &lt;code>JOIN&lt;/code>，所以它同樣適用。只是判斷「有沒有另一列符合條件」而不輸出它，用 &lt;code>EXISTS&lt;/code> 比自連接省一次配對展開；要把兩列並排列出來（像上面的訂單先後配對），才非自連接不可。&lt;/p></description><content:encoded><![CDATA[<p><code>FROM</code> 裡寫下的表名指的是那張表在這個查詢裡的一次出現，而那個名字是這次出現的稱呼。標準把這件事叫 table reference，關聯演算（relational calculus）裡的傳統名稱是 range variable，各家文件裡也常寫成 correlation name——三個名字說的是同一件事。同一張表因此可以出現兩次、各自扮演不同角色——這就是自連接。</p>
<p>一張表只出現一次的時候，這個區別沒有作用。出現兩次就有：兩次出現共用同一個名字，引擎分不出某個欄位屬於哪一次，於是擋下來。</p>
<p>引擎把這件事講得很明白，而擋下來的時機分兩種。PostgreSQL 與 DuckDB 看到重複的名字就停，訊息分別是 <code>table name &quot;訂單&quot; specified more than once</code> 與 <code>Duplicate alias &quot;訂單&quot; in query!</code>；SQLite 讓查詢往下走，等到某個欄位引用無從分辨時才回 <code>ambiguous column name</code>。<strong>表名本身就是預設的別名</strong>，所以同一張表的兩次出現共用同一個名字時，等於兩次出現叫同一個稱呼，之後每一次引用都無從分辨。</p>
<h2 id="兩張表共用欄名時的欄位歸屬">兩張表共用欄名時的欄位歸屬</h2>
<p>欄位的歸屬要靠名字決定，所以兩張不同的表共用一個欄名時同樣分不開。書店的顧客表與訂單表都有 <code>顧客編號</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- MySQL:  ERROR 1052: Column &#39;顧客編號&#39; in field list is ambiguous
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- SQLite: ambiguous column name: 顧客編號
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- DuckDB: Ambiguous reference to column name &#34;顧客編號&#34;</span></span></span></code></pre></div><p><code>id</code> 是這件事最常撞到的欄位名，因為多數表都有一個。<strong>連接之後每個欄位引用都要能追回它屬於哪一次出現</strong>，加上表名或別名當前綴就解決。</p>
<p>自連接只是這個規則的極端情形：兩次出現連表名都一樣，所以連前綴都救不了，非取別名不可。</p>
<p>表一多，要判讀的就變成每個 <code>ON</code> 引用得到哪些出現——寫在它上面的那些出現都已經併進左運算元，寫在它下面的還沒進來。<a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a> 寫左運算元隨著往下走而變大的過程。</p>
<h2 id="用別名分開同一張表的兩次出現">用別名分開同一張表的兩次出現</h2>
<p>訂單表裡的兩張單都掛在佳穎名下。要找出同一位顧客的訂單先後配對——哪一張在前、哪一張在後。這件事需要把訂單表的兩列擺在一起比較，而一列一列處理的查詢做不到，除非讓訂單表出現兩次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">下單日</span><span class="p">,</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">下單日</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">早</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">晚</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">下單日</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">下單日</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 101 | 2026-03-02 | 102 | 2026-03-09</span></span></span></code></pre></div><p><code>早</code> 與 <code>晚</code> 是同一張表的兩次出現。連接條件在做兩件事：<code>顧客編號</code> 相同把配對限制在同一個人身上，<code>下單日</code> 的大小關係決定哪一次出現扮演「早」、哪一次扮演「晚」。</p>
<p><strong>條件裡的第二項是這個寫法的重點。</strong> 少了它，每張訂單會跟同一位顧客的每張訂單配對一次，包括跟自己配。加上嚴格的大小比較之後，每一對只留下一個方向，而同一張訂單跟它自己組成的那一對，因為兩邊日期相等也被排除。同一位顧客有三張訂單時，每兩張各配一次，不多不少：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 拿掉日期的比較，只留顧客編號相同
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">早</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">晚</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：101|101、101|102、102|101、102|102（兩個方向各一次，加上各自跟自己配）
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- 日期的比較放回去
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">早</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">晚</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">下單日</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">下單日</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：101|102
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">-- 佳穎多一張 103（下單日 2026-03-15）之後：101|102、101|103、102|103</span></span></span></code></pre></div><h2 id="自連接的適用問題前後配對同組比較與層級關係">自連接的適用問題：前後配對、同組比較與層級關係</h2>
<p>自連接處理的是<strong>同一批資料裡兩筆資料之間的關係</strong>——兩列，不是兩個欄位。一列一列的處理看不到這種關係，因為每一列在被判斷的時候不知道其他列長什麼樣子。</p>
<p>自連接常見的形態有三種。<strong>前後配對</strong>：同一位顧客的訂單先後配對，把時間上相鄰或有先後關係的兩列擺在一起。<strong>同組比較</strong>：找出訂單金額高於同一位顧客其他訂單的那些，比較的對象是同一張表裡的兄弟列。<strong>層級關係</strong>：一張員工表裡每個人有一個主管欄位指向同一張表的另一列，要把人與他的主管一起列出來時，員工表出現兩次，一次當員工、一次當主管。</p>
<p>前後配對、同組比較與層級關係三種的共同點是<strong>要比較的兩列住在同一張表裡</strong>。需不需要自連接由兩個獨立的問題決定：這個問題要不要拿一列去跟同一張表的另一列比——不比的問題不屬於這一族；以及要不要把另一列的欄位輸出出來——這個問題決定族裡選哪一種寫法。</p>
<p>要不要輸出另一列的欄位，就是 <a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a> 那條判斷標準——自連接也是一種 <code>JOIN</code>，所以它同樣適用。只是判斷「有沒有另一列符合條件」而不輸出它，用 <code>EXISTS</code> 比自連接省一次配對展開；要把兩列並排列出來（像上面的訂單先後配對），才非自連接不可。</p>
<p><strong>省下的量由配對會不會膨脹決定，所以這條判斷標準要連著連接鍵唯不唯一一起看。</strong> 連接鍵在對面那一次出現裡唯一的時候，一列只配得到一列，兩種寫法產出的列數相同，<code>EXISTS</code> 省下的是零——那時選哪一個回到讀起來哪一個貼合意圖。一列配得到多列的時候展開才會發生，而那正是 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a> 寫的列數膨脹。判斷的動作是問這個連接鍵在對面那一次出現裡唯不唯一，而唯一與否要由<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>回答，不由現在這批資料回答。</p>
<p><strong>同組比較最常見的題型是每組最大值，而它套用「要不要輸出另一列的欄位」這條判斷標準時，答案是不輸出。</strong> 「找出每位顧客金額最高的訂單」輸出的是那張訂單自己的欄位，同顧客的其他訂單只拿來比大小，所以寫成 <code>NOT EXISTS</code> 就夠。前後配對的「找出每筆訂單與同顧客下一筆的差額」則要用到另一列的金額，那就非自連接不可。</p>
<p>每組最大值的條件是比大小，而比大小自己會帶出兩個邊界：<strong>並列的最大值</strong>與<strong>可為空的比較欄</strong>。<code>NOT EXISTS</code> 會把並列的那幾筆全部留下，要只留一筆得另外加一個決勝的條件，通常取編號最小的那筆。<code>NULL</code> 跟誰比都判不成真，所以「同顧客裡沒有比它大的」對金額是 <code>NULL</code> 的那一張恆成立，它會被選成最大；要排除它，<code>IS NOT NULL</code> 得寫在外層查詢——子查詢裡的比較對它本來就判不成真，寫在那裡攔不住（機制見 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">-- 每位顧客金額最高的訂單：同顧客裡找不到比它大的那一張
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">o</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">i</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：102 | 500
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1">-- 佳穎多一張 103（金額 500）之後：102 | 500、103 | 500（並列的兩張都留下）
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">-- 佳穎多一張 104（金額 NULL）之後：102 | 500、104 | NULL（NULL 那一張被選成最大）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"></span><span class="c1">-- 決勝的條件：金額相同時，編號較小的那一張也算「比它大」
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">o</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">i</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="k">AND</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎多一張 103（金額 500）之後：102 | 500
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w"></span><span class="c1">-- 排除金額是 NULL 的那一張：條件寫在外層
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">o</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">    </span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="n">i</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">    </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">i</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="w">  </span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎多一張 104（金額 NULL）之後：102 | 500
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="c1">-- 同一句改寫進子查詢（AND i.金額 IS NOT NULL）：仍回 102 | 500、104 | NULL</span></span></span></code></pre></div><h2 id="表只出現一次時別名的作用">表只出現一次時別名的作用</h2>
<p>同一張表只出現一次的時候，別名的作用是縮短欄位引用。長表名在有三四張表的查詢裡會讓每個欄位引用都變得很長，取一個短別名之後可讀性差很多。</p>
<p>而作用不只是省字。取了別名之後原本的表名在這個查詢裡就不再是它的稱呼，三個引擎都會擋下來：PostgreSQL 回 <code>invalid reference to FROM-clause entry for table &quot;訂單&quot;</code> 並附上一句提示要改用別名，SQLite 回 <code>no such column</code>，DuckDB 回 <code>Referenced table &quot;訂單&quot; not found</code>。<code>FROM 訂單 AS 早</code> 宣告的是「這次出現叫早」，而宣告過後就只有這一個稱呼。</p>
<h2 id="別名的取名標示這次出現扮演的角色">別名的取名：標示這次出現扮演的角色</h2>
<p>自連接的兩次出現在問題裡扮演不同角色，而別名是查詢裡唯一標示角色的地方。同一位顧客的訂單先後配對：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">金額</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">早</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">晚</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">下單日</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">下單日</span><span class="p">;</span></span></span></code></pre></div><p>把兩個別名換成 <code>a</code> 與 <code>b</code>，查詢的行為一模一樣，而 <code>b.金額 - a.金額</code> 這一行要回頭看 <code>ON</code> 才知道哪一個是後來那張單。判斷標準落在這裡：<strong>讀到用它的那一行時，方向自己說得出來。</strong> 名字取得對的時候，<code>晚.金額 - 早.金額</code> 讀起來就是它的意思。</p>
<p>引擎對這個名字沒有意見。把兩個別名對調，條件也跟著對調：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">晚</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">早</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">早</span><span class="p">.</span><span class="err">下單日</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">晚</span><span class="p">.</span><span class="err">下單日</span></span></span></code></pre></div><p>叫「早」的那一次出現拿到的是後來那張單。查詢照跑，結果與上面那一段相同，而每一個名字都指反了。別名因此也是一種<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">宣告</a>——它說明這次出現代表什麼，兌現與否由寫的人負責。</p>
<h2 id="表的別名與欄位別名各自可用的時機">表的別名與欄位別名各自可用的時機</h2>
<p><code>SELECT</code> 裡的 <code>AS</code> 替算出來的欄位取名，<code>FROM</code> 裡的 <code>AS</code> 替表的一次出現取名。兩者的作用範圍不同，可用的時機也不同。</p>
<p>表的別名在 <code>FROM</code> 那一步就成立，所以 <code>WHERE</code>、<code>GROUP BY</code>、<code>ON</code> 全部用得到它。欄位別名到 <code>SELECT</code> 那一步才誕生，所以 <code>WHERE</code> 照語意模型用不到。兩個 <code>AS</code> 長得一樣而落在求值順序的兩端。</p>
<p>某些引擎讓 <code>WHERE</code> 用得到欄位別名，那是引擎的放寬，不是模型的規定。<a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序</a> 走過每一步能用到哪些欄位與值，並把模型層擋得住的限制與各家引擎自己放寬的分開——表的別名不在它的範圍裡，因為別名在 <code>FROM</code> 那一步就成立了。</p>
]]></content:encoded></item><item><title>SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/</guid><description>&lt;p>「找出有對應資料的那些」這類問題有 &lt;code>IN&lt;/code>、&lt;code>EXISTS&lt;/code> 與 &lt;code>JOIN&lt;/code> 三種常見寫法，而它們在很多情況下回同樣的答案。這種巧合讓它們看起來可以互換，而三者描述的是不同的事，回的列數也不一定相同。&lt;/p>
&lt;p>分開它們的問題只有一個：&lt;strong>除了判斷有沒有，還要不要用到被比對的那張表的欄位。&lt;/strong>&lt;/p>
&lt;h2 id="同一題用-inexists-與-join-各寫一次">同一題用 IN、EXISTS 與 JOIN 各寫一次&lt;/h2>
&lt;p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出下過單的顧客。&lt;/p>
&lt;p>&lt;code>IN&lt;/code> 問的是成員資格——這個顧客編號在不在括號裡那個&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">子查詢&lt;/a>交出的清單裡：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>EXISTS&lt;/code> 問的是存在性——有沒有至少一張訂單屬於這位顧客：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>JOIN&lt;/code> 做的是另一件事——把兩張表併成一個新的關係：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎、佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一個「找出下過單的顧客」的問題，&lt;code>IN&lt;/code> 與 &lt;code>EXISTS&lt;/code> 回一列而 &lt;code>JOIN&lt;/code> 回兩列——&lt;strong>佳穎在 &lt;code>JOIN&lt;/code> 的結果裡出現了兩次&lt;/strong>。&lt;/p>
&lt;h2 id="列數條件與連接的差別">列數：條件與連接的差別&lt;/h2>
&lt;p>&lt;code>IN&lt;/code> 與 &lt;code>EXISTS&lt;/code> 出現在 &lt;code>WHERE&lt;/code> 裡，它們是條件。條件對每一列只做一件事——留下或丟掉——所以顧客表有三列，過完條件最多剩三列，一列都不會多出來。&lt;/p>
&lt;p>&lt;code>JOIN&lt;/code> 出現在 &lt;code>FROM&lt;/code> 裡，它參與的是關係的組成。佳穎配得上兩張訂單，於是她在新的關係裡出現兩次（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。加上 &lt;code>DISTINCT&lt;/code> 之後結果才與 &lt;code>IN&lt;/code>、&lt;code>EXISTS&lt;/code> 一致：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DISTINCT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>需要 &lt;code>DISTINCT&lt;/code> 這件事本身是一個訊號&lt;/strong>：它表示這個查詢只是要判斷有沒有，而 &lt;code>JOIN&lt;/code> 順便做了不需要的配對。這種時候 &lt;code>IN&lt;/code> 或 &lt;code>EXISTS&lt;/code> 更貼合意圖，因為它們一開始就沒有把列複製出來。&lt;/p>
&lt;p>&lt;code>IN&lt;/code> 與 &lt;code>EXISTS&lt;/code> 做的運算在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>裡叫半連接：顧客表與訂單表配對之後只留下顧客表的列，每一列至多出現一次。否定式的 &lt;code>NOT EXISTS&lt;/code> 留下顧客表裡配不到任何訂單的列，叫反連接（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）&lt;/a>）。&lt;/p>
&lt;h2 id="可取用的欄位被比對的那張表在不在-from-裡">可取用的欄位：被比對的那張表在不在 FROM 裡&lt;/h2>
&lt;p>要列出每位顧客的訂單金額，&lt;code>JOIN&lt;/code> 是最直接的一條路：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎 300 / 佳穎 500&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一件事用 &lt;code>IN&lt;/code> 寫不出來：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- SQLite: no such column: 訂單.金額
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">-- DuckDB: Referenced table &amp;#34;訂單&amp;#34; not found!&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>錯誤訊息說得很直接：&lt;code>訂單&lt;/code> 這張表不在 &lt;code>FROM&lt;/code> 裡面。子查詢是一個獨立的查詢，它算完之後只交出一份值的清單，而訂單表沒有成為外層 &lt;code>FROM&lt;/code> 的一部分——外層因此引用不到它的任何欄位。&lt;/p>
&lt;p>帶欄位出來還有另一條路——把子查詢寫在 &lt;code>SELECT&lt;/code> 裡並讓它引用外層的欄位。引用外層欄位的子查詢叫&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">相關子查詢&lt;/a>（correlated subquery）：&lt;/p></description><content:encoded><![CDATA[<p>「找出有對應資料的那些」這類問題有 <code>IN</code>、<code>EXISTS</code> 與 <code>JOIN</code> 三種常見寫法，而它們在很多情況下回同樣的答案。這種巧合讓它們看起來可以互換，而三者描述的是不同的事，回的列數也不一定相同。</p>
<p>分開它們的問題只有一個：<strong>除了判斷有沒有，還要不要用到被比對的那張表的欄位。</strong></p>
<h2 id="同一題用-inexists-與-join-各寫一次">同一題用 IN、EXISTS 與 JOIN 各寫一次</h2>
<p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出下過單的顧客。</p>
<p><code>IN</code> 問的是成員資格——這個顧客編號在不在括號裡那個<a href="/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">子查詢</a>交出的清單裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎</span></span></span></code></pre></div><p><code>EXISTS</code> 問的是存在性——有沒有至少一張訂單屬於這位顧客：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎</span></span></span></code></pre></div><p><code>JOIN</code> 做的是另一件事——把兩張表併成一個新的關係：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎</span></span></span></code></pre></div><p>同一個「找出下過單的顧客」的問題，<code>IN</code> 與 <code>EXISTS</code> 回一列而 <code>JOIN</code> 回兩列——<strong>佳穎在 <code>JOIN</code> 的結果裡出現了兩次</strong>。</p>
<h2 id="列數條件與連接的差別">列數：條件與連接的差別</h2>
<p><code>IN</code> 與 <code>EXISTS</code> 出現在 <code>WHERE</code> 裡，它們是條件。條件對每一列只做一件事——留下或丟掉——所以顧客表有三列，過完條件最多剩三列，一列都不會多出來。</p>
<p><code>JOIN</code> 出現在 <code>FROM</code> 裡，它參與的是關係的組成。佳穎配得上兩張訂單，於是她在新的關係裡出現兩次（<a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。加上 <code>DISTINCT</code> 之後結果才與 <code>IN</code>、<code>EXISTS</code> 一致：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎</span></span></span></code></pre></div><p><strong>需要 <code>DISTINCT</code> 這件事本身是一個訊號</strong>：它表示這個查詢只是要判斷有沒有，而 <code>JOIN</code> 順便做了不需要的配對。這種時候 <code>IN</code> 或 <code>EXISTS</code> 更貼合意圖，因為它們一開始就沒有把列複製出來。</p>
<p><code>IN</code> 與 <code>EXISTS</code> 做的運算在<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>裡叫半連接：顧客表與訂單表配對之後只留下顧客表的列，每一列至多出現一次。否定式的 <code>NOT EXISTS</code> 留下顧客表裡配不到任何訂單的列，叫反連接（<a href="/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）</a>）。</p>
<h2 id="可取用的欄位被比對的那張表在不在-from-裡">可取用的欄位：被比對的那張表在不在 FROM 裡</h2>
<p>要列出每位顧客的訂單金額，<code>JOIN</code> 是最直接的一條路：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 300 / 佳穎 500</span></span></span></code></pre></div><p>同一件事用 <code>IN</code> 寫不出來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- SQLite: no such column: 訂單.金額
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- DuckDB: Referenced table &#34;訂單&#34; not found!</span></span></span></code></pre></div><p>錯誤訊息說得很直接：<code>訂單</code> 這張表不在 <code>FROM</code> 裡面。子查詢是一個獨立的查詢，它算完之後只交出一份值的清單，而訂單表沒有成為外層 <code>FROM</code> 的一部分——外層因此引用不到它的任何欄位。</p>
<p>帶欄位出來還有另一條路——把子查詢寫在 <code>SELECT</code> 裡並讓它引用外層的欄位。引用外層欄位的子查詢叫<a href="/blog/backend/01-database/sql/knowledge-cards/subquery/" data-link-title="Subquery（子查詢）" data-link-desc="看到括號裡還有一個 SELECT、想知道它什麼時候算、算幾次時">相關子查詢</a>（correlated subquery）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">              </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 300 / 宗翰 NULL / 雅文 NULL</span></span></span></code></pre></div><p>它一次只交得出一個值，所以要多欄或多列時仍然回到 <code>JOIN</code>；而它的執行方式是逐列的，代價怎麼算見 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>。</p>
<p>這時候佳穎出現兩次是對的，因為問的是每一張訂單的金額，而她確實有兩張。<strong>這一題本來就要每張訂單各佔一列，所以列數變多正是這個寫法要交出來的東西。</strong></p>
<h2 id="選用的判斷標準要不要用到被比對那張表的欄位">選用的判斷標準：要不要用到被比對那張表的欄位</h2>
<p><strong>要不要用到被比對的那張表的欄位。</strong> 要用到就得 <code>JOIN</code>，因為只有它把那張表帶進 <code>FROM</code>。這時候要順帶檢查列數是不是預期內的——後面如果接聚合，<code>sum</code> 與 <code>count</code> 算的是配對後的列；這個膨脹怎麼發生、可加與不可加的聚合各自怎麼修，在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>。</p>
<p>只是判斷有沒有，就用 <code>IN</code> 或 <code>EXISTS</code>。它們是條件，對每一列只做留或丟，寫出來的意圖比 <code>JOIN</code> 加 <code>DISTINCT</code> 清楚——而哪一種比較快是另一個問題，答案取決於資料與索引。<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a> 用一道重複值的題目量分組、自連接與 <code>EXISTS</code> 三種寫法，並示範加一個索引之後排名重排——<code>EXISTS</code> 從最慢掉到與分組同一個量級。</p>
<h2 id="in-與-exists-的分岔子查詢含-null-時的否定式">IN 與 EXISTS 的分岔：子查詢含 NULL 時的否定式</h2>
<p><code>IN</code> 與 <code>EXISTS</code> 在「有沒有」這件事上同值，而在一個地方分岔：<strong>子查詢的結果裡有 NULL 的時候。</strong></p>
<p>清單裡只要混了一個 NULL，<code>NOT IN</code> 就再也不會為真：值在清單裡的列判成明確的假，值不在清單裡的列因為那個 NULL 判成未知，兩種都被 <code>WHERE</code> 丟掉，整段查詢回零列且不報錯；<code>NOT EXISTS</code> 不受影響。在共用資料上多一張沒填顧客編號的訂單就看得到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-20&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 訂單 103 沒有填顧客編號
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">IN</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：宗翰、雅文
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 多了訂單 103 之後：零列，不報錯（子查詢交出的清單是 1、1、NULL）
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料與多了訂單 103 之後都是：宗翰、雅文</span></span></span></code></pre></div><p>完整的推導在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>，包括 <code>NOT EXISTS</code> 的安全範圍為什麼限定在等號配對。零列與「確實沒有」在結果上分不開，所以 <code>NOT IN</code> 碰到 NULL 的錯只能靠寫的人預先知道「子查詢有 NULL 時 NOT IN 會失效」那條規則——<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 把它歸成<strong>誤解語意模型</strong>：寫的人對某條規則的預期與語意模型的規定不同，而誤解語意模型修得掉，改對之後永遠對。</p>
<p>所以肯定式的 <code>IN</code> 與 <code>EXISTS</code> 可以按可讀性挑，而<strong>否定式一律用 <code>NOT EXISTS</code></strong>——除非能保證子查詢那一欄不會有 NULL，而那個保證要來自<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>而不是來自習慣：翻遍現在的資料都沒有 NULL 只證明此刻沒有，而查詢要活得比這一批資料久。</p>
<p>還有一個形態上的差別：本篇 <code>IN</code> 的子查詢與外層無關，可以單獨拿出來執行，叫獨立子查詢；<code>EXISTS</code> 的子查詢引用了外層的欄位，離開外層就跑不動，是相關子查詢。讀陌生的查詢時，看子查詢裡有沒有出現外層的表名，就分得出它是算一次的獨立子查詢，還是對外層每一列各問一次的相關子查詢。</p>
<p>「語意相同就按可讀性挑」這個處置的適用範圍不只 <code>IN</code>、<code>EXISTS</code> 與 <code>JOIN</code> 這三種寫法：哪幾類寫法差異是免費的、哪一類會與效能分岔，在 <a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a>。</p>
]]></content:encoded></item><item><title>SQL.9 分組鍵的選擇：鍵與一組所代表的對象</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/</link><pubDate>Mon, 31 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/grouping-key-decides-the-unit/</guid><description>&lt;p>&lt;code>GROUP BY&lt;/code> 把鍵的值相同的列收成一組，所以鍵決定了&lt;strong>一組代表什麼&lt;/strong>，而後續的 &lt;code>HAVING&lt;/code> 與 &lt;code>SELECT&lt;/code> 全部是對那個對象提問。&lt;/p>
&lt;p>選錯鍵的時候查詢照樣跑得動，只是每一組代表的對象與寫查詢的人預期的不同。&lt;/p>
&lt;h2 id="外連接之後用右表的欄位當分組鍵">外連接之後用右表的欄位當分組鍵&lt;/h2>
&lt;p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出一張單都沒下的顧客，用 &lt;code>LEFT JOIN&lt;/code> 接起來之後按訂單那一側的顧客編號分組：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">分組鍵&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">這組幾列&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">有值幾列&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">group_concat&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">裝了誰&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- group_concat 把一組裡的名字串起來
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>MySQL、SQLite 與 DuckDB 的答案一致：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">分組鍵 這組幾列 有值幾列 裝了誰
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">NULL 2 0 宗翰,雅文
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">1 2 2 佳穎,佳穎&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>宗翰與雅文被併進同一組。&lt;/strong> 他們沒有配到訂單，外連接給他們的 &lt;code>訂單.顧客編號&lt;/code> 都是 &lt;code>NULL&lt;/code>，而 &lt;code>GROUP BY&lt;/code> 把值相同的列收成一組——&lt;code>GROUP BY&lt;/code> 對 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL&lt;/a> 的處理與比較運算不同：鍵同樣是 &lt;code>NULL&lt;/code> 的那些列會被收進同一組，所有沒配到的列因此共用同一個鍵。分組是少數 &lt;code>NULL&lt;/code> 表現得像一般值的地方，它在比較與 &lt;code>NOT IN&lt;/code> 上的表現剛好相反，那兩處的表現寫在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a>；這些 &lt;code>NULL&lt;/code> 是連接補上的、不是資料裡本來的空值，兩種來源的分別在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">Outer Join（外連接）&lt;/a>。&lt;/p>
&lt;p>接著用 &lt;code>HAVING count(訂單.顧客編號) = 0&lt;/code> 挑出沒下過單的那一組，這個條件本身是對的：它確實選中了那一組，因為 &lt;code>count(欄位)&lt;/code> 只數有值的列而那一組一列都沒有。&lt;strong>選中的是一組，而這一組底下是宗翰與雅文兩列。&lt;/strong>&lt;/p>
&lt;h2 id="輸出非分組欄時各家引擎的反應">輸出非分組欄時各家引擎的反應&lt;/h2>
&lt;p>輸出顧客姓名時，姓名不在分組鍵裡，於是三家引擎在這裡分岔：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">HAVING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>MySQL 8 預設拒絕：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">nonaggregated column ... incompatible with sql_mode=only_full_group_by&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DuckDB 同樣拒絕。SQLite 接受，從分組鍵是 &lt;code>NULL&lt;/code> 的那一組裡任意挑一列回傳——只回宗翰，&lt;strong>雅文消失了，而且沒有任何錯誤訊息&lt;/strong>。&lt;/p>
&lt;p>而拒絕的 MySQL 與 DuckDB 擋下的是輸出非分組欄這個寫法，不是選錯的鍵：把姓名補進 &lt;code>GROUP BY&lt;/code> 之後三家都照跑，在共用資料上答案也是對的，因為三位顧客不同名。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 鍵仍是訂單表的顧客編號，只補上姓名
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">HAVING&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">count&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 共用資料：宗翰、雅文
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;宗翰&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 第二位宗翰，同樣沒下過單
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 重跑同一段查詢：宗翰、雅文
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 沒下過單的顧客有三位而結果只有兩列：兩位宗翰的鍵都是 (NULL, 宗翰)，被併成一組&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>鍵錯在它讓不同的顧客共用一個值，而這件事要等資料裡出現同名的人才看得到。選錯鍵的查詢在語法上完全合法，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a> 把它歸進問錯了問題的那一類。&lt;/p></description><content:encoded><![CDATA[<p><code>GROUP BY</code> 把鍵的值相同的列收成一組，所以鍵決定了<strong>一組代表什麼</strong>，而後續的 <code>HAVING</code> 與 <code>SELECT</code> 全部是對那個對象提問。</p>
<p>選錯鍵的時候查詢照樣跑得動，只是每一組代表的對象與寫查詢的人預期的不同。</p>
<h2 id="外連接之後用右表的欄位當分組鍵">外連接之後用右表的欄位當分組鍵</h2>
<p>書店的三位顧客裡只有佳穎下過單，兩張都是她的。要找出一張單都沒下的顧客，用 <code>LEFT JOIN</code> 接起來之後按訂單那一側的顧客編號分組：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">分組鍵</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">這組幾列</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">       </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">有值幾列</span><span class="p">,</span><span class="w"> </span><span class="n">group_concat</span><span class="p">(</span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">裝了誰</span><span class="w">   </span><span class="c1">-- group_concat 把一組裡的名字串起來
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p>MySQL、SQLite 與 DuckDB 的答案一致：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">分組鍵  這組幾列  有值幾列  裝了誰
</span></span><span class="line"><span class="ln">2</span><span class="cl">NULL    2         0         宗翰,雅文
</span></span><span class="line"><span class="ln">3</span><span class="cl">1       2         2         佳穎,佳穎</span></span></code></pre></div><p><strong>宗翰與雅文被併進同一組。</strong> 他們沒有配到訂單，外連接給他們的 <code>訂單.顧客編號</code> 都是 <code>NULL</code>，而 <code>GROUP BY</code> 把值相同的列收成一組——<code>GROUP BY</code> 對 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a> 的處理與比較運算不同：鍵同樣是 <code>NULL</code> 的那些列會被收進同一組，所有沒配到的列因此共用同一個鍵。分組是少數 <code>NULL</code> 表現得像一般值的地方，它在比較與 <code>NOT IN</code> 上的表現剛好相反，那兩處的表現寫在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>；這些 <code>NULL</code> 是連接補上的、不是資料裡本來的空值，兩種來源的分別在 <a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">Outer Join（外連接）</a>。</p>
<p>接著用 <code>HAVING count(訂單.顧客編號) = 0</code> 挑出沒下過單的那一組，這個條件本身是對的：它確實選中了那一組，因為 <code>count(欄位)</code> 只數有值的列而那一組一列都沒有。<strong>選中的是一組，而這一組底下是宗翰與雅文兩列。</strong></p>
<h2 id="輸出非分組欄時各家引擎的反應">輸出非分組欄時各家引擎的反應</h2>
<p>輸出顧客姓名時，姓名不在分組鍵裡，於是三家引擎在這裡分岔：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span></span></span></code></pre></div><p>MySQL 8 預設拒絕：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains
</span></span><span class="line"><span class="ln">2</span><span class="cl">nonaggregated column ... incompatible with sql_mode=only_full_group_by</span></span></code></pre></div><p>DuckDB 同樣拒絕。SQLite 接受，從分組鍵是 <code>NULL</code> 的那一組裡任意挑一列回傳——只回宗翰，<strong>雅文消失了，而且沒有任何錯誤訊息</strong>。</p>
<p>而拒絕的 MySQL 與 DuckDB 擋下的是輸出非分組欄這個寫法，不是選錯的鍵：把姓名補進 <code>GROUP BY</code> 之後三家都照跑，在共用資料上答案也是對的，因為三位顧客不同名。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">,</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">      </span><span class="c1">-- 鍵仍是訂單表的顧客編號，只補上姓名
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：宗翰、雅文
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">4</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;宗翰&#39;</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 第二位宗翰，同樣沒下過單
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- 重跑同一段查詢：宗翰、雅文
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- 沒下過單的顧客有三位而結果只有兩列：兩位宗翰的鍵都是 (NULL, 宗翰)，被併成一組</span></span></span></code></pre></div><p>鍵錯在它讓不同的顧客共用一個值，而這件事要等資料裡出現同名的人才看得到。選錯鍵的查詢在語法上完全合法，<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 把它歸進問錯了問題的那一類。</p>
<h2 id="選分組鍵的判斷標準一組代表的對象">選分組鍵的判斷標準：一組代表的對象</h2>
<p>一組要代表一位顧客，鍵就是顧客的識別欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">,</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>現在三家引擎都給出兩列。分組鍵換掉之後，沒有任何一組的鍵是 <code>NULL</code> 了——宗翰與雅文各自有不同的顧客編號，所以各自成組，而每一組的訂單計數都是零。</p>
<p><strong>判斷方法：先說出「我要每一組代表什麼」，鍵就是那個對象的識別欄位。</strong> 說不出來的時候，多半是這一題根本不需要分組。</p>
<p>鍵還要涵蓋一件事：<strong><code>SELECT</code> 裡沒有被聚合的欄位全部要進 <code>GROUP BY</code></strong>。上面補的 <code>姓名</code> 就是為此——少了它 MySQL 與 DuckDB 會拒絕（正是上一節那個錯誤）。</p>
<p><strong>而補進去的欄位必須由鍵決定——鍵的每一個值只對到這個欄位的一個值，否則分組的粒度會跟著變。</strong> 這種關係在資料庫理論裡叫函數相依（functional dependency），MySQL 的 <code>ERROR 1055</code> 完整訊息寫的 <code>not functionally dependent on columns in GROUP BY clause</code> 指的就是它。一個顧客編號只對到一個姓名，所以補姓名不影響分組；要求的方向是從鍵到欄位，反方向不必成立——兩位宗翰就是同一個姓名對到兩個顧客編號，而按顧客編號分組時他們仍然各自成組。下單日不由顧客編號決定：把它補進 <code>GROUP BY</code>，同一位顧客的每一個下單日各自成組，計數從「這位顧客有幾張單」變成「這位顧客在這一天下了幾張單」，而引擎不會報錯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">下單日</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">張數</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">,</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">下單日</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 | 2026-03-02 | 1
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 佳穎 | 2026-03-09 | 1      佳穎被拆成兩組
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- 宗翰 |            | 0
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">-- 雅文 |            | 0
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- 拿掉下單日、只按顧客編號與姓名分組時：佳穎 | 2、宗翰 | 0、雅文 | 0</span></span></span></code></pre></div><p>所以不由鍵決定的欄位不能靠補進鍵來輸出，要用聚合包起來（<code>max(下單日)</code> 這種），讓它回到「這一組的某個值」而不是「這一組的分法」。</p>
<p>鍵只決定一組由誰構成；算完之後原本的列還留不留，是分組與視窗函數分開的地方，<a href="/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/" data-link-title="SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列" data-link-desc="GROUP BY 與視窗函數算完之後原本的列還在不在、依結果的一列代表什麼選用哪一個、視窗函數的值怎麼拿來當篩選條件，以及 LAG 取相鄰列時分區、排序鍵與距離條件各自要確認的事">SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列</a> 用同一批訂單並排兩種產出。</p>
<h2 id="不需要分組的問題存在性判斷">不需要分組的問題：存在性判斷</h2>
<p>把分組鍵換成顧客編號、再補上姓名的那個修正讓查詢回出正確答案，而更前面還有一個問題沒被問：這一題需要分組嗎。</p>
<p>從業務事實推一次。<strong>沒消費過的顧客，在訂單表裡不會有任何一列。</strong> 這句話有一個可以直接驗證的推論——「他的訂單數是零」這件事，從訂單表本身問不出來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- (1, 2)</span></span></span></code></pre></div><p>只有佳穎。宗翰與雅文不在結果裡，因為訂單表沒有他們的列，所以沒有一組可以代表他們，也就沒有計數可以是零。</p>
<p><strong>那個零是外連接造出來的。</strong> <code>LEFT JOIN</code> 替他們各生一列全是 <code>NULL</code> 的假訂單，<code>count(欄位)</code> 數那一列得到零。所以「計數等於零」是查詢自己製造出來再讀回去的產物，而不是資料本身帶著的性質。</p>
<p>計數預設有列可以數，而這一題的對象恰好是什麼都沒有的那些人。<strong>這就是分組那條路從一開始就對不上的來源</strong>，比鍵選錯更上一層。</p>
<p>翻成 SQL 最貼字面的形式是存在性判斷：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>一句話對一句話——「找不到對應訂單的顧客」。沒有分組、沒有計數。同一件事也寫得成外連接，兩者都對：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">   </span><span class="c1">-- 訂單編號只有在外連接補出來的列上是 NULL
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 宗翰、雅文</span></span></span></code></pre></div><p>差別在描述的路徑——<code>NOT EXISTS</code> 直接說「找不到」，外連接那一版先讓每位顧客都保留下來、再挑出補了 <code>NULL</code> 的那些。<a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a> 從條件該放哪一邊的角度走同一題，兩篇的結論一致。</p>
<p><strong><code>GROUP BY</code> 是換單位的工具</strong>，用在單看一列答不出來的問題上——「這位顧客下了幾張單」「哪張訂單被評價超過兩次」那一類。判斷沿用 <a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序</a>：這個條件需不需要看到組裡的其他列才能回答。不需要就留在 <code>WHERE</code>，連 <code>GROUP BY</code> 都不必寫。求值順序那一篇走過從 <code>FROM</code> 到 <code>LIMIT</code> 每一步處理的單位，分組前後從列換成組是其中變化最大的一步。</p>
<p>三種寫法各自描述什麼、以及 <code>NOT IN</code> 為什麼在這裡是危險的那一個，在 <a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a>。</p>
]]></content:encoded></item><item><title>Cartesian Product（笛卡兒積）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/</guid><description>&lt;p>笛卡兒積把兩個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a> 的每一列與另一邊的每一列各配一次，得到的關係列數是兩邊列數的乘積。它是&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>的一個運算，而連接由它與選取組合而成：全部組合先在語意上成立，條件再從裡面挑出要留的那些。&lt;/p>
&lt;p>SQL 寫成 &lt;code>CROSS JOIN&lt;/code>，也可以在 &lt;code>FROM&lt;/code> 裡用逗號把兩張表隔開。&lt;/p>
&lt;h2 id="條件決定它會不會被展開">條件決定它會不會被展開&lt;/h2>
&lt;p>代數說連接從積開始，而那是語意層的定義，執行方式由&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器&lt;/a>按代價選。條件把兩邊綁起來的時候，它用索引或雜湊直接找出配得上的那些列，全部組合一次都沒有被造出來。&lt;/p>
&lt;p>條件缺席的時候展開才會發生。一張逐日的觀測表跟自己做沒有條件的 &lt;code>CROSS JOIN&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Weather&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">recordDate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">temperature&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WITH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">RECURSIVE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNION&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ALL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">3890&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Weather&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;2015-01-01&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">||&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39; day&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">%&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">40&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">n&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">UNIQUE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ix&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">Weather&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">recordDate&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">-- SQLite 3.51，Weather 表 3891 列
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SELECT count(*) FROM Weather a CROSS JOIN Weather b;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">-- 15139881
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">|--SCAN a USING COVERING INDEX ix
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">`--SCAN b USING COVERING INDEX ix&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>15139881 是 3891 的平方。外層每掃一列，內層就把整張表再走一遍，而這一千五百萬對配對在後面的子句拿到資料之前就要先產生出來。這張表加到 13891 列之後，列數變成 3.6 倍而配對數變成 192959881——&lt;strong>將近十三倍&lt;/strong>。成長是平方而非線性，這正是這個運算與其他多出來的列的差別。&lt;/p>
&lt;p>&lt;strong>要看的是條件把不把兩邊綁起來，而不是用了哪個關鍵字。&lt;/strong> &lt;code>CROSS JOIN&lt;/code> 加一個等值條件，與 &lt;code>JOIN ... ON&lt;/code> 寫同一個條件，對引擎是同一段查詢（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a>）；反過來，&lt;code>JOIN&lt;/code> 寫了而 &lt;code>ON&lt;/code> 的條件只提到單邊的欄位，展開照樣發生。&lt;/p>
&lt;h2 id="與列數膨脹是兩種不同的多">與列數膨脹是兩種不同的多&lt;/h2>
&lt;p>結果比預期多的時候有兩個成因，分辨它們決定要改什麼。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">列數膨脹&lt;/a>是條件正確而一列配到了多列，於是它被複製那麼多次——多出來的量由資料的重複程度決定，改法是把展開的那一層先收成一列，或改用不展開的寫法。笛卡兒積是條件從一開始就沒有把兩邊綁住——多出來的量由兩張表各自的大小決定，改法是補上那個條件。&lt;/p>
&lt;p>分辨的問句落在條件上而非結果上：&lt;strong>這個條件把一列綁到對面幾列。&lt;/strong> 綁到常數量級（幾列、幾十列）是配對條件，多出來的量由重複程度決定，那是膨脹；綁到與表的大小同量級，那個條件篩掉了一些列，而每一列對面掛著的對象數仍然隨表一起長，多出來的量由兩張表的大小決定，那是積。&lt;/p>
&lt;p>「有沒有同時引用兩邊」不足以分辨。&lt;code>ON a.v &amp;gt; b.v&lt;/code> 同時引用了兩邊，而它把左邊每一列綁到右邊將近一半——&lt;code>v&lt;/code> 的值互不相同時，一千列得 499500 對、兩千列得 1999000 對，仍然是平方。低基數的等值鍵同理：&lt;code>ON a.區域 = b.區域&lt;/code> 只有幾個區域時，一千列對一千列得五十萬對。&lt;/p>
&lt;p>這裡刻意不問「這一列是靠哪個條件配起來的」——&lt;code>JOIN&lt;/code> 寫了而 &lt;code>ON&lt;/code> 只提單邊欄位的那個形態，讀者答得出「靠那個 &lt;code>ON&lt;/code> 條件」而它並沒有把兩邊配起來，問句會把積判成膨脹。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>笛卡兒積是連接的下限——條件寫得越少，結果越靠近它，而條件寫滿的時候結果落在兩張表配得上的那些列。所以「連接的結果集會不會整個換一個量級」這個問題等於「條件綁住了多少」，與用了 &lt;code>JOIN&lt;/code> 還是 &lt;code>CROSS JOIN&lt;/code> 無關。而條件綁不綁得住，由它引用到幾個&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">表的出現&lt;/a>決定——與那個分辨問句是同一件事。&lt;/p>
&lt;p>它在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>裡的位置說明了漏寫連接條件的後果為什麼這麼大：查詢退回它的定義起點，全部組合重新成立。這是結果集大小的整個量級換掉，與逐步變慢是不同的一件事。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>條件正確而列數仍然變多的那一種，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a>。同一批配對用自連接與 &lt;code>EXISTS&lt;/code> 兩種寫法的實測對照，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價&lt;/a>。條件只用來判斷存在時結果的上限落在哪裡，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）&lt;/a>。&lt;code>CROSS JOIN&lt;/code> 這個關鍵字對讀的人宣告了什麼，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a>。production 查詢的結果集大小怎麼治理，在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>笛卡兒積把兩個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 的每一列與另一邊的每一列各配一次，得到的關係列數是兩邊列數的乘積。它是<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的一個運算，而連接由它與選取組合而成：全部組合先在語意上成立，條件再從裡面挑出要留的那些。</p>
<p>SQL 寫成 <code>CROSS JOIN</code>，也可以在 <code>FROM</code> 裡用逗號把兩張表隔開。</p>
<h2 id="條件決定它會不會被展開">條件決定它會不會被展開</h2>
<p>代數說連接從積開始，而那是語意層的定義，執行方式由<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>按代價選。條件把兩邊綁起來的時候，它用索引或雜湊直接找出配得上的那些列，全部組合一次都沒有被造出來。</p>
<p>條件缺席的時候展開才會發生。一張逐日的觀測表跟自己做沒有條件的 <code>CROSS JOIN</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">Weather</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="n">recordDate</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="n">temperature</span><span class="w"> </span><span class="nb">INT</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">n</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">3890</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">Weather</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="p">,</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="s1">&#39;2015-01-01&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39; day&#39;</span><span class="p">),</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">40</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">n</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">UNIQUE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">ix</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">Weather</span><span class="p">(</span><span class="n">recordDate</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- SQLite 3.51，Weather 表 3891 列
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT count(*) FROM Weather a CROSS JOIN Weather b;
</span></span><span class="line"><span class="ln">3</span><span class="cl">-- 15139881
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">|--SCAN a USING COVERING INDEX ix
</span></span><span class="line"><span class="ln">6</span><span class="cl">`--SCAN b USING COVERING INDEX ix</span></span></code></pre></div><p>15139881 是 3891 的平方。外層每掃一列，內層就把整張表再走一遍，而這一千五百萬對配對在後面的子句拿到資料之前就要先產生出來。這張表加到 13891 列之後，列數變成 3.6 倍而配對數變成 192959881——<strong>將近十三倍</strong>。成長是平方而非線性，這正是這個運算與其他多出來的列的差別。</p>
<p><strong>要看的是條件把不把兩邊綁起來，而不是用了哪個關鍵字。</strong> <code>CROSS JOIN</code> 加一個等值條件，與 <code>JOIN ... ON</code> 寫同一個條件，對引擎是同一段查詢（<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>）；反過來，<code>JOIN</code> 寫了而 <code>ON</code> 的條件只提到單邊的欄位，展開照樣發生。</p>
<h2 id="與列數膨脹是兩種不同的多">與列數膨脹是兩種不同的多</h2>
<p>結果比預期多的時候有兩個成因，分辨它們決定要改什麼。</p>
<p><a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">列數膨脹</a>是條件正確而一列配到了多列，於是它被複製那麼多次——多出來的量由資料的重複程度決定，改法是把展開的那一層先收成一列，或改用不展開的寫法。笛卡兒積是條件從一開始就沒有把兩邊綁住——多出來的量由兩張表各自的大小決定，改法是補上那個條件。</p>
<p>分辨的問句落在條件上而非結果上：<strong>這個條件把一列綁到對面幾列。</strong> 綁到常數量級（幾列、幾十列）是配對條件，多出來的量由重複程度決定，那是膨脹；綁到與表的大小同量級，那個條件篩掉了一些列，而每一列對面掛著的對象數仍然隨表一起長，多出來的量由兩張表的大小決定，那是積。</p>
<p>「有沒有同時引用兩邊」不足以分辨。<code>ON a.v &gt; b.v</code> 同時引用了兩邊，而它把左邊每一列綁到右邊將近一半——<code>v</code> 的值互不相同時，一千列得 499500 對、兩千列得 1999000 對，仍然是平方。低基數的等值鍵同理：<code>ON a.區域 = b.區域</code> 只有幾個區域時，一千列對一千列得五十萬對。</p>
<p>這裡刻意不問「這一列是靠哪個條件配起來的」——<code>JOIN</code> 寫了而 <code>ON</code> 只提單邊欄位的那個形態，讀者答得出「靠那個 <code>ON</code> 條件」而它並沒有把兩邊配起來，問句會把積判成膨脹。</p>
<h2 id="概念位置">概念位置</h2>
<p>笛卡兒積是連接的下限——條件寫得越少，結果越靠近它，而條件寫滿的時候結果落在兩張表配得上的那些列。所以「連接的結果集會不會整個換一個量級」這個問題等於「條件綁住了多少」，與用了 <code>JOIN</code> 還是 <code>CROSS JOIN</code> 無關。而條件綁不綁得住，由它引用到幾個<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">表的出現</a>決定——與那個分辨問句是同一件事。</p>
<p>它在<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>裡的位置說明了漏寫連接條件的後果為什麼這麼大：查詢退回它的定義起點，全部組合重新成立。這是結果集大小的整個量級換掉，與逐步變慢是不同的一件事。</p>
<h2 id="往下走">往下走</h2>
<p>條件正確而列數仍然變多的那一種，在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>。同一批配對用自連接與 <code>EXISTS</code> 兩種寫法的實測對照，在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。條件只用來判斷存在時結果的上限落在哪裡，在 <a href="/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/" data-link-title="Semi-join 與 Anti-join（半連接與反連接）" data-link-desc="看到 EXISTS 或 NOT EXISTS 時，查它在代數裡對應哪個運算、以及列數上限為什麼是左邊那張表的列數">Semi-join 與 Anti-join（半連接與反連接）</a>。<code>CROSS JOIN</code> 這個關鍵字對讀的人宣告了什麼，在 <a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a>。production 查詢的結果集大小怎麼治理，在 <a href="/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion</a>。</p>
]]></content:encoded></item><item><title>Semi-join 與 Anti-join（半連接與反連接）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semi-join-and-anti-join/</guid><description>&lt;p>半連接把兩個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a> 配起來之後只留下左邊那一份：結果的欄位全部來自左邊，而左邊的每一列至多出現一次。留下的是在右邊找得到對象的那些列。反連接留下的是另一半——左邊那些在右邊配不到任何對象的列，結果的欄位同樣只有左邊的。&lt;/p>
&lt;p>SQL 用 &lt;code>EXISTS&lt;/code> 與 &lt;code>NOT EXISTS&lt;/code> 表達這兩個運算，語言本身沒有為它們保留關鍵字：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 半連接：下過單的顧客
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 反連接：從未下單的顧客
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NOT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="名字裡的半指結果只帶走一邊">名字裡的「半」指結果只帶走一邊&lt;/h2>
&lt;p>三個運算作用在同一對關係上，差別在結果帶走什麼。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>運算&lt;/th>
 &lt;th>結果的欄位&lt;/th>
 &lt;th>結果的列數&lt;/th>
 &lt;th>SQL 寫法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>連接&lt;/td>
 &lt;td>左右兩邊都有&lt;/td>
 &lt;td>可能超過左邊的列數&lt;/td>
 &lt;td>&lt;code>JOIN ... ON&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>半連接&lt;/td>
 &lt;td>只有左邊的&lt;/td>
 &lt;td>至多左邊的列數&lt;/td>
 &lt;td>&lt;code>EXISTS&lt;/code>、&lt;code>IN&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>反連接&lt;/td>
 &lt;td>只有左邊的&lt;/td>
 &lt;td>至多左邊的列數&lt;/td>
 &lt;td>&lt;code>NOT EXISTS&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>右邊那份資料在這兩個運算裡的角色是條件，它的欄位到不了結果。要把右邊的欄位列出來就得用連接，那條判斷標準在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理&lt;/a>。&lt;/p>
&lt;h2 id="列數上限省掉去重這一步">列數上限省掉去重這一步&lt;/h2>
&lt;p>半連接問的是配不配得到，所以配到幾筆不影響結果——一位顧客手上有兩張訂單或二十張訂單，&lt;code>EXISTS&lt;/code> 都只讓她出現一次。同一個問題用連接寫，她會照訂單張數各出現一次，要補 &lt;code>DISTINCT&lt;/code> 把複製出來的列收掉（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。&lt;/p>
&lt;p>需要 &lt;code>DISTINCT&lt;/code> 因此是一個訊號：這個查詢問的是有沒有，而連接順便做了它不需要的配對。&lt;/p>
&lt;h2 id="反連接有兩條描述路徑">反連接有兩條描述路徑&lt;/h2>
&lt;p>&lt;code>NOT EXISTS&lt;/code> 直接說「找不到」。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>那一版先讓左邊每一列都保留下來，再挑出補了 &lt;code>NULL&lt;/code> 的那些：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩段回同一批列，路徑不同——一條由條件直接表達，另一條靠外連接補出來的 &lt;code>NULL&lt;/code> 當標記。&lt;/p>
&lt;p>&lt;code>NOT IN&lt;/code> 的行為與這兩條分岔：子查詢的結果裡出現一個 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL&lt;/a>，整段就回零列且不報錯。反連接的安全寫法因此落在 &lt;code>NOT EXISTS&lt;/code> 與外連接這兩條上（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>）。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>半連接與反連接跟連接一樣是導出運算：半連接等於連接之後只投影左邊的欄位再去重，反連接等於左邊減去半連接的結果。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數&lt;/a>的正典運算清單因此多半只列到連接，而「有沒有對應資料」這一族問題全部落在這兩個運算上——找出下過單的顧客、找出從未下單的顧客、找出比前一天熱的日子，題目與寫法各異而運算只有這兩個。&lt;/p>
&lt;p>計畫上看不看得到這兩個運算的名字，各家不同。DuckDB 1.5.5 把它印成 &lt;code>Join Type: SEMI&lt;/code>；SQLite 3.51.0 沒有對應的運算子，它把 &lt;code>EXISTS&lt;/code> 的子查詢編成帶一個 &lt;code>LIMIT 1&lt;/code> 計數器的迴圈，找到第一筆就跳出去——語意相同，而&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>上只看得到一個子查詢節點。&lt;/p>
&lt;p>它們與&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">積&lt;/a>分別是連接結果大小的兩個極端。積是條件缺席時的上限，列數是兩邊相乘；半連接把上限壓到左邊的列數，因為右邊配到幾列都只算一次。中間那一段是連接，結果的大小由配對的重複程度決定。&lt;/p></description><content:encoded><![CDATA[<p>半連接把兩個 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 配起來之後只留下左邊那一份：結果的欄位全部來自左邊，而左邊的每一列至多出現一次。留下的是在右邊找得到對象的那些列。反連接留下的是另一半——左邊那些在右邊配不到任何對象的列，結果的欄位同樣只有左邊的。</p>
<p>SQL 用 <code>EXISTS</code> 與 <code>NOT EXISTS</code> 表達這兩個運算，語言本身沒有為它們保留關鍵字：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 半連接：下過單的顧客
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 反連接：從未下單的顧客
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">);</span></span></span></code></pre></div><h2 id="名字裡的半指結果只帶走一邊">名字裡的「半」指結果只帶走一邊</h2>
<p>三個運算作用在同一對關係上，差別在結果帶走什麼。</p>
<table>
  <thead>
      <tr>
          <th>運算</th>
          <th>結果的欄位</th>
          <th>結果的列數</th>
          <th>SQL 寫法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>連接</td>
          <td>左右兩邊都有</td>
          <td>可能超過左邊的列數</td>
          <td><code>JOIN ... ON</code></td>
      </tr>
      <tr>
          <td>半連接</td>
          <td>只有左邊的</td>
          <td>至多左邊的列數</td>
          <td><code>EXISTS</code>、<code>IN</code></td>
      </tr>
      <tr>
          <td>反連接</td>
          <td>只有左邊的</td>
          <td>至多左邊的列數</td>
          <td><code>NOT EXISTS</code></td>
      </tr>
  </tbody>
</table>
<p>右邊那份資料在這兩個運算裡的角色是條件，它的欄位到不了結果。要把右邊的欄位列出來就得用連接，那條判斷標準在 <a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a>。</p>
<h2 id="列數上限省掉去重這一步">列數上限省掉去重這一步</h2>
<p>半連接問的是配不配得到，所以配到幾筆不影響結果——一位顧客手上有兩張訂單或二十張訂單，<code>EXISTS</code> 都只讓她出現一次。同一個問題用連接寫，她會照訂單張數各出現一次，要補 <code>DISTINCT</code> 把複製出來的列收掉（<a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<p>需要 <code>DISTINCT</code> 因此是一個訊號：這個查詢問的是有沒有，而連接順便做了它不需要的配對。</p>
<h2 id="反連接有兩條描述路徑">反連接有兩條描述路徑</h2>
<p><code>NOT EXISTS</code> 直接說「找不到」。<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>那一版先讓左邊每一列都保留下來，再挑出補了 <code>NULL</code> 的那些：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span></span></span></code></pre></div><p>兩段回同一批列，路徑不同——一條由條件直接表達，另一條靠外連接補出來的 <code>NULL</code> 當標記。</p>
<p><code>NOT IN</code> 的行為與這兩條分岔：子查詢的結果裡出現一個 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL</a>，整段就回零列且不報錯。反連接的安全寫法因此落在 <code>NOT EXISTS</code> 與外連接這兩條上（<a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。</p>
<h2 id="概念位置">概念位置</h2>
<p>半連接與反連接跟連接一樣是導出運算：半連接等於連接之後只投影左邊的欄位再去重，反連接等於左邊減去半連接的結果。<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的正典運算清單因此多半只列到連接，而「有沒有對應資料」這一族問題全部落在這兩個運算上——找出下過單的顧客、找出從未下單的顧客、找出比前一天熱的日子，題目與寫法各異而運算只有這兩個。</p>
<p>計畫上看不看得到這兩個運算的名字，各家不同。DuckDB 1.5.5 把它印成 <code>Join Type: SEMI</code>；SQLite 3.51.0 沒有對應的運算子，它把 <code>EXISTS</code> 的子查詢編成帶一個 <code>LIMIT 1</code> 計數器的迴圈，找到第一筆就跳出去——語意相同，而<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>上只看得到一個子查詢節點。</p>
<p>它們與<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">積</a>分別是連接結果大小的兩個極端。積是條件缺席時的上限，列數是兩邊相乘；半連接把上限壓到左邊的列數，因為右邊配到幾列都只算一次。中間那一段是連接，結果的大小由配對的重複程度決定。</p>
<h2 id="往下走">往下走</h2>
<p><code>IN</code>、<code>EXISTS</code> 與 <code>JOIN</code> 在列數與可取用欄位上的差別、以及選哪一種只問一句話，在 <a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a>。從一句業務描述推到反連接、以及為什麼計數那條路走不通，在 <a href="/blog/backend/01-database/sql/grouping-key-decides-the-unit/" data-link-title="SQL.9 分組鍵的選擇：鍵與一組所代表的對象" data-link-desc="外連接之後拿右表欄位當鍵的後果、輸出非分組欄時 MySQL、DuckDB 與 SQLite 的反應、補進鍵的欄位要滿足的函數相依，以及改用 NOT EXISTS 回答的那一類問題">SQL.9 分組鍵的選擇：鍵與一組所代表的對象</a>。同一張表的兩列互相比較時這兩個運算長什麼樣，在 <a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>。同一組寫法的快慢隨索引與資料分布重排的實測，在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。</p>
]]></content:encoded></item><item><title>SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/</guid><description>&lt;p>一段查詢有兩個讀者。引擎讀的是行為——這段文字要求它算出哪一批列；人讀的是宣告——寫的人選了 &lt;code>LEFT&lt;/code> 而不選 &lt;code>INNER&lt;/code>，選了 &lt;code>CROSS&lt;/code> 而不選 &lt;code>JOIN ... ON&lt;/code>，每一個選擇都在說明他預期資料長什麼樣。&lt;strong>引擎執行行為，而它從不檢查宣告有沒有兌現。&lt;/strong>&lt;/p>
&lt;p>宣告與行為分岔的時候，查詢照常回正確答案，測試照常通過，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>上也看不出異常。付出代價的是下一個改它的人：他讀到的是宣告，於是他手上的前提從一開始就是錯的。宣告與行為之外，還有一種落差在查詢與要問的問題之間：查詢在語法上完全合法、引擎照實執行，而它回答的不是要問的那一題，那一種在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>；同一段文字換一家引擎會做什麼，是跨引擎的那一種，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a>。本篇的宣告落空時答案仍然正確，錯的是文字對下一個讀它的人說的話。&lt;/p>
&lt;p>本篇的查詢跑在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫&lt;/a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。讀這一篇需要先有三件事：&lt;code>LEFT&lt;/code> 保護了哪些列在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元&lt;/a>，它與條件位置的互動在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a>，連接怎麼把一列變成多列在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a>。那三篇答的是「這個關鍵字做什麼」，這一篇不重寫那些機制，它問的是同一段查詢對下一個讀它的人說了什麼。&lt;/p>
&lt;h2 id="各個關鍵字的宣告與落空的形態">各個關鍵字的宣告與落空的形態&lt;/h2>
&lt;p>&lt;code>LEFT JOIN&lt;/code> 說的是「右邊配不到對象的左邊那些列也要留著」。寫的人選它，等於宣告他預期有配不到的列，而且那些列對答案有意義。&lt;/p>
&lt;p>&lt;code>CROSS JOIN&lt;/code> 說的是「我要每一種配對」。&lt;code>DISTINCT&lt;/code> 說的是「這一組值本身會重複，而重複對我沒有意義」。&lt;code>COALESCE&lt;/code> 說的是「這裡會有空值」。表的別名也是一種宣告：&lt;code>FROM 訂單 AS 早&lt;/code> 說的是「這一次出現代表較早的那張單」。這些宣告是免費的，寫下去沒有人會查證。&lt;/p>
&lt;p>落空的樣子不只一種。宣告可能&lt;strong>被同一段查詢的別的部分撤銷&lt;/strong>（&lt;code>LEFT&lt;/code> 加上一個問右表的 &lt;code>WHERE&lt;/code>）、可能&lt;strong>描述一個引擎並沒有執行的動作&lt;/strong>（&lt;code>CROSS&lt;/code> 加等值條件）、可能&lt;strong>描述一個從一開始就不存在的情況&lt;/strong>（配對已有保證時的 &lt;code>LEFT&lt;/code>），也可能&lt;strong>指向錯的成因而答案仍然正確&lt;/strong>（&lt;code>DISTINCT&lt;/code> 收掉連接製造的重複），還可能&lt;strong>名字與它代表的那一次出現對調&lt;/strong>（叫「早」的別名拿到後來那張單，條件跟著對調，結果不變，這個例子在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>）。這幾種落空在現有資料上都回正確答案，代價都落在下一個照著宣告動手的人身上：&lt;code>DISTINCT&lt;/code> 指向錯成因，他多要一個欄位就回出錯的結果；被撤銷的 &lt;code>LEFT&lt;/code> 讓他改了 &lt;code>SELECT&lt;/code> 之後仍然少了沒有訂單的顧客，而原因在他沒有動的 &lt;code>WHERE&lt;/code>；其餘幾種連錯的結果都不會出現，只讓他下錯判斷。&lt;/p>
&lt;h2 id="被-where-撤銷的-left-join">被 WHERE 撤銷的 LEFT JOIN&lt;/h2>
&lt;p>書店想找出下過大單的顧客。查詢寫成這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎、佳穎
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 同一段把 LEFT JOIN 換成 JOIN，其餘不動
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 佳穎、佳穎&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>LEFT&lt;/code> 在這裡宣告「沒有訂單的顧客也要留著」。而下一行的 &lt;code>WHERE&lt;/code> 問了一個右表欄位，宗翰與雅文那兩列右邊全是 &lt;code>NULL&lt;/code>，&lt;code>NULL &amp;gt; 200&lt;/code> 答不出真，於是被篩掉，外連接與內連接兩段因此回同樣兩列。&lt;/p>
&lt;p>機制在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a>：條件放 &lt;code>ON&lt;/code> 參與配對，放 &lt;code>WHERE&lt;/code> 篩成品，而外連接補上的那些列過不了成品這一關。這裡要看的是另一件事——&lt;strong>這段查詢的行為是內連接，而它的文字說的是外連接&lt;/strong>。&lt;/p>
&lt;p>下一個維護的人讀到 &lt;code>LEFT&lt;/code>，會以為沒下單的顧客在結果裡。他想加一個「顯示零元」的欄位：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">姓名&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">COALESCE&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 他加的欄位：沒有訂單的顧客顯示 0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LEFT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">顧客&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">顧客編號&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">200&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 他沒有動的那一行
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎 300
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 佳穎 500&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑出來仍然只有佳穎，而他找不出原因——因為問題在他沒有動的那一行。&lt;/p></description><content:encoded><![CDATA[<p>一段查詢有兩個讀者。引擎讀的是行為——這段文字要求它算出哪一批列；人讀的是宣告——寫的人選了 <code>LEFT</code> 而不選 <code>INNER</code>，選了 <code>CROSS</code> 而不選 <code>JOIN ... ON</code>，每一個選擇都在說明他預期資料長什麼樣。<strong>引擎執行行為，而它從不檢查宣告有沒有兌現。</strong></p>
<p>宣告與行為分岔的時候，查詢照常回正確答案，測試照常通過，<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>上也看不出異常。付出代價的是下一個改它的人：他讀到的是宣告，於是他手上的前提從一開始就是錯的。宣告與行為之外，還有一種落差在查詢與要問的問題之間：查詢在語法上完全合法、引擎照實執行，而它回答的不是要問的那一題，那一種在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>；同一段文字換一家引擎會做什麼，是跨引擎的那一種，在 <a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a>。本篇的宣告落空時答案仍然正確，錯的是文字對下一個讀它的人說的話。</p>
<p>本篇的查詢跑在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫</a>上：三位顧客佳穎、宗翰、雅文，而只有佳穎下過兩張訂單（101 與 102，金額 300 與 500）。讀這一篇需要先有三件事：<code>LEFT</code> 保護了哪些列在 <a href="/blog/backend/01-database/sql/join-left-operand-accumulates/" data-link-title="SQL.4 鏈式連接的左運算元：累積方式與外連接的保護範圍" data-link-desc="每個 JOIN 的左運算元由哪些表合成、ON 因此引用得到哪些欄位、LEFT 與 RIGHT 各自保護哪些列，以及 WHERE 引用鏈尾的表時哪些列會被撤掉保護">SQL.4 鏈式連接的左運算元</a>，它與條件位置的互動在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>，連接怎麼把一列變成多列在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>。那三篇答的是「這個關鍵字做什麼」，這一篇不重寫那些機制，它問的是同一段查詢對下一個讀它的人說了什麼。</p>
<h2 id="各個關鍵字的宣告與落空的形態">各個關鍵字的宣告與落空的形態</h2>
<p><code>LEFT JOIN</code> 說的是「右邊配不到對象的左邊那些列也要留著」。寫的人選它，等於宣告他預期有配不到的列，而且那些列對答案有意義。</p>
<p><code>CROSS JOIN</code> 說的是「我要每一種配對」。<code>DISTINCT</code> 說的是「這一組值本身會重複，而重複對我沒有意義」。<code>COALESCE</code> 說的是「這裡會有空值」。表的別名也是一種宣告：<code>FROM 訂單 AS 早</code> 說的是「這一次出現代表較早的那張單」。這些宣告是免費的，寫下去沒有人會查證。</p>
<p>落空的樣子不只一種。宣告可能<strong>被同一段查詢的別的部分撤銷</strong>（<code>LEFT</code> 加上一個問右表的 <code>WHERE</code>）、可能<strong>描述一個引擎並沒有執行的動作</strong>（<code>CROSS</code> 加等值條件）、可能<strong>描述一個從一開始就不存在的情況</strong>（配對已有保證時的 <code>LEFT</code>），也可能<strong>指向錯的成因而答案仍然正確</strong>（<code>DISTINCT</code> 收掉連接製造的重複），還可能<strong>名字與它代表的那一次出現對調</strong>（叫「早」的別名拿到後來那張單，條件跟著對調，結果不變，這個例子在 <a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>）。這幾種落空在現有資料上都回正確答案，代價都落在下一個照著宣告動手的人身上：<code>DISTINCT</code> 指向錯成因，他多要一個欄位就回出錯的結果；被撤銷的 <code>LEFT</code> 讓他改了 <code>SELECT</code> 之後仍然少了沒有訂單的顧客，而原因在他沒有動的 <code>WHERE</code>；其餘幾種連錯的結果都不會出現，只讓他下錯判斷。</p>
<h2 id="被-where-撤銷的-left-join">被 WHERE 撤銷的 LEFT JOIN</h2>
<p>書店想找出下過大單的顧客。查詢寫成這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">200</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 同一段把 LEFT JOIN 換成 JOIN，其餘不動
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">200</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎、佳穎</span></span></span></code></pre></div><p><code>LEFT</code> 在這裡宣告「沒有訂單的顧客也要留著」。而下一行的 <code>WHERE</code> 問了一個右表欄位，宗翰與雅文那兩列右邊全是 <code>NULL</code>，<code>NULL &gt; 200</code> 答不出真，於是被篩掉，外連接與內連接兩段因此回同樣兩列。</p>
<p>機制在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>：條件放 <code>ON</code> 參與配對，放 <code>WHERE</code> 篩成品，而外連接補上的那些列過不了成品這一關。這裡要看的是另一件事——<strong>這段查詢的行為是內連接，而它的文字說的是外連接</strong>。</p>
<p>下一個維護的人讀到 <code>LEFT</code>，會以為沒下單的顧客在結果裡。他想加一個「顯示零元」的欄位：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="n">COALESCE</span><span class="p">(</span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">金額</span><span class="w">   </span><span class="c1">-- 他加的欄位：沒有訂單的顧客顯示 0
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">200</span><span class="p">;</span><span class="w">                              </span><span class="c1">-- 他沒有動的那一行
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 佳穎 300
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 佳穎 500</span></span></span></code></pre></div><p>跑出來仍然只有佳穎，而他找不出原因——因為問題在他沒有動的那一行。</p>
<h2 id="cross-join-加等值條件與內連接的計畫">CROSS JOIN 加等值條件與內連接的計畫</h2>
<p>要比較同一批資料的兩列時，常見的寫法是把表跟自己配對再篩：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">昨天</span><span class="w"> </span><span class="k">CROSS</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">今天</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">金額</span><span class="p">;</span></span></span></code></pre></div><p><code>CROSS JOIN</code> 宣告「我要每一種配對」，讀起來像是先造出所有配對再挑。而等值條件寫進去之後，<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>產出的計畫與內連接的計畫一模一樣。改寫成 <code>JOIN ... ON</code> 的版本把同一個條件搬進 <code>ON</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">昨天</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="err">今天</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="k">ON</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">今天</span><span class="p">.</span><span class="err">金額</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">昨天</span><span class="p">.</span><span class="err">金額</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 102（CROSS JOIN 那一段也回 102）</span></span></span></code></pre></div><p>同一組資料上兩種寫法的計畫逐字相同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- CROSS JOIN 加 WHERE 等值（SQLite 3.51，共用的範例資料庫，沒有另外建索引）
</span></span><span class="line"><span class="ln">2</span><span class="cl">|--SCAN 昨天
</span></span><span class="line"><span class="ln">3</span><span class="cl">|--BLOOM FILTER ON 今天 (顧客編號=?)
</span></span><span class="line"><span class="ln">4</span><span class="cl">`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">-- JOIN ... ON 同一個條件
</span></span><span class="line"><span class="ln">7</span><span class="cl">|--SCAN 昨天
</span></span><span class="line"><span class="ln">8</span><span class="cl">|--BLOOM FILTER ON 今天 (顧客編號=?)
</span></span><span class="line"><span class="ln">9</span><span class="cl">`--SEARCH 今天 USING AUTOMATIC COVERING INDEX (顧客編號=?)</span></span></code></pre></div><p><code>AUTOMATIC</code> 表示這個索引是引擎為了這一次查詢臨時建的——訂單表上沒有 <code>顧客編號</code> 的索引，而它判斷建一個比逐列掃描划算。兩段查詢連這個決定都一樣。</p>
<p><code>CROSS JOIN</code> 在代數上確實對應<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積</a>，而那張卡寫明展開由條件決定：條件把兩邊綁起來時全部組合一次都沒有被造出來。</p>
<p><code>CROSS JOIN</code> 這個宣告的誤導方向與 <code>LEFT</code> 相反。<code>LEFT</code> 那一節是宣告承諾了引擎沒做的事，這一節是宣告讓讀的人誤判了代價——看到 <code>CROSS</code> 就以為它是列數的平方，於是花力氣改寫一段本來就是內連接的查詢。兩段文字比不出快慢，快慢要向引擎要計畫——<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a> 在有無索引的兩種狀態下各量一次；而在這裡要先分清楚：<code>CROSS JOIN</code> 加等值條件與 <code>JOIN ... ON</code> 是同一段查詢的兩種寫法，換掉它省下的是讀的人的疑慮，不是執行時間。</p>
<h2 id="配對已有保證時的-left-join">配對已有保證時的 LEFT JOIN</h2>
<p>每一張訂單都帶著一個有效的顧客編號時，這樣寫也會過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span></span></span></code></pre></div><p><code>LEFT</code> 宣告「訂單可能找不到顧客」。而兩列訂單都配到了，這個宣告一次都沒有生效過：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">),</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="p">)</span><span class="w">   </span><span class="c1">-- count(*) 數列數，count(欄位) 只數那一欄不是 NULL 的列
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 2  2：沒有任何一列是 LEFT 補出來的</span></span></span></code></pre></div><p>它的代價要等到有人相信它才出現。下一個人想查有沒有孤兒訂單（指不到任何顧客的訂單），順手接上這段查詢：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">訂單編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">姓名</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">;</span><span class="w">            </span><span class="c1">-- 配不到顧客的訂單，姓名會是 LEFT 補上的 NULL
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 零列</span></span></span></code></pre></div><p>拿到零列，於是回報「資料乾淨」。零列在這裡有兩種來源：確實沒有孤兒訂單，或者這個宣告本來就沒有內容。這段查詢分不出兩者。</p>
<p>真正保證配對的是<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>——外鍵存在時每一張訂單都指得到顧客，那時 <code>LEFT</code> 描述的是一個資料庫已經排除掉的情況。宣告與約束衝突時，讀的人多半相信宣告，因為它就在眼前，而約束在另一個檔案裡。</p>
<h2 id="收掉連接重複的-distinct">收掉連接重複的 DISTINCT</h2>
<p>顧客與訂單連起來之後，佳穎出現兩次，因為她有兩張訂單。要一份「下過單的顧客」名單，加 <code>DISTINCT</code> 就對了：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="err">姓名</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎</span></span></span></code></pre></div><p><code>DISTINCT</code> 宣告「姓名這一欄有重複值」。而顧客表裡佳穎只有一列——重複是連接製造出來的（<a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。宣告指向了錯的成因，答案卻是對的。</p>
<p>這種宣告落空的代價由下一次修改觸發，而觸發它的動作很小——多要一個欄位就夠：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="err">姓名</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">顧客</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="p">.</span><span class="err">顧客編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">顧客</span><span class="p">.</span><span class="err">顧客編號</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="c1">-- 佳穎 300
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- 佳穎 500</span></span></span></code></pre></div><p><code>DISTINCT</code> 作用在整列上，兩列的金額不同，於是它一列都收不掉。上一個人的修法在這裡失效，而失效的理由要回到「重複從哪裡來」這個問題——那個問題當初被 <code>DISTINCT</code> 跳過了。</p>
<h2 id="查證宣告的動作把關鍵字換成最弱的版本再跑一次">查證宣告的動作：把關鍵字換成最弱的版本再跑一次</h2>
<p>文字本身看不出宣告有沒有兌現——<code>LEFT</code> 寫在那裡，而它保護下來的列可能一列都不剩。差別要現形，得讓 <code>LEFT</code> 這類關鍵字消失一次：<strong>把它換成最弱的版本再跑一次，結果變不變就是它有沒有在做事。</strong> 這個動作在軟體測試裡叫 <a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">mutation testing</a>：在現有資料上結果不變，是那張卡說的突變「存活」；它是不是等價突變（改寫之後行為在任何資料上都相同），要看造不造得出一筆讓改寫前後回不同答案的反例資料（<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 用的名字與造法）。</p>
<table>
  <thead>
      <tr>
          <th>宣告</th>
          <th>換成</th>
          <th>結果不變表示</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>LEFT JOIN</code> / <code>RIGHT JOIN</code></td>
          <td><code>JOIN</code></td>
          <td>在現有資料上行為是內連接</td>
      </tr>
      <tr>
          <td><code>CROSS JOIN</code> 加 <code>WHERE</code> 等值</td>
          <td><code>JOIN ... ON</code> 同一個條件</td>
          <td>這段本來就是內連接（限等值）</td>
      </tr>
      <tr>
          <td><code>DISTINCT</code></td>
          <td>拿掉</td>
          <td>這一組值原本就各不相同</td>
      </tr>
      <tr>
          <td><code>COALESCE(欄位, 預設值)</code></td>
          <td>直接用該欄位</td>
          <td>這一欄在現有資料上沒有空值</td>
      </tr>
  </tbody>
</table>
<p>結果變了的時候，換掉關鍵字這個動作只證明被換掉的那個關鍵字在做事，還沒證明宣告是對的。<code>DISTINCT</code> 那一格就是這種：拿掉之後結果會變，而變的理由是連接把列複製了，與「這一欄有重複值」無關。所以結果變了之後還要問：<strong>變化的成因與宣告說的是不是同一件事</strong>。</p>
<p>配對已有保證時的 <code>LEFT</code>（〈配對已有保證時的 LEFT JOIN〉那一節）是換掉關鍵字再跑一次這個動作測不出來的：配對由外鍵保證的時候，<code>LEFT</code> 與 <code>JOIN</code> 在現有資料上結果相同，而換掉之後仍然看不出宣告當初有沒有意義。那一種要去查<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>，判斷標準是資料庫有沒有替這個配對背書——而約束寫進 schema 也不一定生效，各家在哪一步把寫下與生效分開，在 <a href="/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證</a>。</p>
<p>對讀別人查詢的人來說，這個寫下與生效是否一致的差別回答的是「我可以相信這段文字說的話嗎」——而那個問題在讀的當下沒有別的入口。文字該先為哪一個讀者而寫——人還是引擎——是 <a href="/blog/backend/01-database/sql/readable-and-fast-mostly-align/" data-link-title="SQL.21 可讀性與執行代價：兩者一致與分岔的情形" data-link-desc="寫法差異在什麼情形下免費、什麼情形下分岔，以及分岔時該動查詢還是動 schema">SQL.21 可讀性與執行代價：兩者一致與分岔的情形</a> 的題目。</p>
]]></content:encoded></item></channel></rss>