<?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>Constraint on Tarragon</title><link>https://tarrragon.github.io/blog/tags/constraint/</link><description>Recent content in Constraint 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/constraint/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/</guid><description>&lt;p>一段查詢送進&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">引擎&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;strong>「這批列是不是那個問題的答案」不在引擎檢查的範圍裡&lt;/strong>，因為問題不在查詢裡——查詢是問題的一種表達，而表達得對不對，只有提問的人知道。&lt;/p>
&lt;p>本篇要用到這兩層的名字。&lt;strong>合法&lt;/strong>指上一段那三項檢查都通過——判它不必讀任何一列資料，但要知道是哪一家引擎在判：各家收與不收的界線不同，標準禁止的寫法有的引擎照收，而那條界線劃在哪裡、各家怎麼劃，在 &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;strong>問對了&lt;/strong>指算出來的那份&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">關係&lt;/a>（欄位固定、由若干列構成的集合，查詢的結果就是一份關係）正好是那個問題的答案，這個詞是本篇為了指認「這批列是不是那個問題的答案」這一層而取的，SQL 的標準術語裡沒有它。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型&lt;/a>指這個語言規定的、由查詢的文字算出結果的那套規則，它的每一項規則都有一處與直覺相反（子句的先後、連接的左右、條件擺哪一邊、分組的鍵各是一例）。本篇處理直覺錯掉之後會發生什麼：引擎照樣算完、照樣回一批結果，而那批結果不帶任何出錯的訊號。&lt;/p>
&lt;h2 id="正解與錯的寫法不等於五漏掉星等與not-in">正解與錯的寫法：「不等於五」「漏掉星等」與「NOT IN」&lt;/h2>
&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 沒有評價，所以答案是 102。&lt;/p>
&lt;p>正確的寫法把星等的條件放進 &lt;code>ON&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> 的「找沒配到的那些列：IS NULL 條件的歸屬」一節）：&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 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="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 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">-- 102&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>「不等於五」的錯法把「沒有五星」寫成「星等不等於五」。&lt;/strong> 其餘不動，只把星等條件挪進 &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="p">...&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;lt;&amp;gt;&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">3&lt;/span>&lt;span class="cl">&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">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 101 唯一那則評價是五星，5 &amp;lt;&amp;gt; 5 判成假
&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">-- 102 沒有評價，外連接替它補一列、星等是 NULL，NULL &amp;lt;&amp;gt; 5 的真值是未知，WHERE 只留判成真的列
&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">-- 訂單 101 多一則三星之後：101&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/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接&lt;/a>替配不到評價的訂單補出來、評價欄位全是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">&lt;code>NULL&lt;/code>&lt;/a> 的那一列，MySQL 手冊叫 NULL-complemented row，本篇寫成&lt;strong>補出來的列&lt;/strong>；對補出來的列判不成真的篩選條件叫&lt;strong>拒絕 NULL 的條件&lt;/strong>（null-rejected condition），&lt;code>評價.星等 &amp;lt;&amp;gt; 5&lt;/code> 就是一例（&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> 的「NULL 的比較結果與 IS NULL」一節）。&lt;strong>問錯了問題，加上拒絕 NULL 的條件把補出來的列全數濾掉，兩件疊在一起，所以這段查詢回哪些列由資料決定&lt;/strong>：訂單 101 多一則三星之後，正解仍然是 102，而它交出了恰好相反的那一列。&lt;/p>
&lt;p>補一個 &lt;code>IS 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="p">...&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;lt;&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OR&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 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">-- 共用資料：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">-- 訂單 101 多一則三星之後：101 與 102&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>補上的只是補出來的列，問題仍然是「至少有一則非五星評價」。&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="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 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">-- 共用資料：102
&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">-- 訂單 102 多一則三星評價之後：0 列（正解仍然是 102）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一段問的是「沒有任何評價的訂單」。它與正解在共用資料上是同一批列，因為有評價的那張訂單剛好只有五星那一則；兩段查詢要到某張訂單的評價全部不是五星時才分開。&lt;/p></description><content:encoded><![CDATA[<p>一段查詢送進<a href="/blog/backend/01-database/sql/knowledge-cards/database-engine/" data-link-title="Database Engine（資料庫引擎）" data-link-desc="文章說「引擎決定」「換一家引擎就不同」時，查那是哪一個層次的決定">引擎</a>，它檢查的是這段文字合不合法：名字解析得到、型別配得上、子句的組合合乎<a href="/blog/backend/01-database/sql/knowledge-cards/relational-algebra/" data-link-title="Relational Algebra（關聯代數）" data-link-desc="看到「子句其實是某個運算」或想知道 SQL 的規則從哪裡來時，查這一層在底下的是什麼">關聯代數</a>的規則。通過之後它照著算，回一批列。<strong>「這批列是不是那個問題的答案」不在引擎檢查的範圍裡</strong>，因為問題不在查詢裡——查詢是問題的一種表達，而表達得對不對，只有提問的人知道。</p>
<p>本篇要用到這兩層的名字。<strong>合法</strong>指上一段那三項檢查都通過——判它不必讀任何一列資料，但要知道是哪一家引擎在判：各家收與不收的界線不同，標準禁止的寫法有的引擎照收，而那條界線劃在哪裡、各家怎麼劃，在 <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>；<strong>問對了</strong>指算出來的那份<a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">關係</a>（欄位固定、由若干列構成的集合，查詢的結果就是一份關係）正好是那個問題的答案，這個詞是本篇為了指認「這批列是不是那個問題的答案」這一層而取的，SQL 的標準術語裡沒有它。</p>
<p><a href="/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型</a>指這個語言規定的、由查詢的文字算出結果的那套規則，它的每一項規則都有一處與直覺相反（子句的先後、連接的左右、條件擺哪一邊、分組的鍵各是一例）。本篇處理直覺錯掉之後會發生什麼：引擎照樣算完、照樣回一批結果，而那批結果不帶任何出錯的訊號。</p>
<h2 id="正解與錯的寫法不等於五漏掉星等與not-in">正解與錯的寫法：「不等於五」「漏掉星等」與「NOT IN」</h2>
<p>書店要找出沒有五星評價的訂單。<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料</a>裡訂單 101 有一則五星、訂單 102 沒有評價，所以答案是 102。</p>
<p>正確的寫法把星等的條件放進 <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> 的「找沒配到的那些列：IS 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 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">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">-- 102</span></span></span></code></pre></div><p><strong>「不等於五」的錯法把「沒有五星」寫成「星等不等於五」。</strong> 其餘不動，只把星等條件挪進 <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="p">...</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">&lt;&gt;</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">3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：0 列
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">--   101 唯一那則評價是五星，5 &lt;&gt; 5 判成假
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   102 沒有評價，外連接替它補一列、星等是 NULL，NULL &lt;&gt; 5 的真值是未知，WHERE 只留判成真的列
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- 訂單 101 多一則三星之後：101</span></span></span></code></pre></div><p>這一段問的是「至少有一則非五星評價的訂單」，與「沒有五星評價的訂單」是兩個不同的問題。<a href="/blog/backend/01-database/sql/knowledge-cards/outer-join/" data-link-title="Outer Join（外連接）" data-link-desc="想確認 LEFT / RIGHT / FULL 各自保護哪一側、或連接之後有些人從名單上消失時">外連接</a>替配不到評價的訂單補出來、評價欄位全是 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時"><code>NULL</code></a> 的那一列，MySQL 手冊叫 NULL-complemented row，本篇寫成<strong>補出來的列</strong>；對補出來的列判不成真的篩選條件叫<strong>拒絕 NULL 的條件</strong>（null-rejected condition），<code>評價.星等 &lt;&gt; 5</code> 就是一例（<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> 的「NULL 的比較結果與 IS NULL」一節）。<strong>問錯了問題，加上拒絕 NULL 的條件把補出來的列全數濾掉，兩件疊在一起，所以這段查詢回哪些列由資料決定</strong>：訂單 101 多一則三星之後，正解仍然是 102，而它交出了恰好相反的那一列。</p>
<p>補一個 <code>IS 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="p">...</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">&lt;&gt;</span><span class="w"> </span><span class="mi">5</span><span class="w"> </span><span class="k">OR</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">3</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：102，看起來完全正確
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 訂單 101 多一則三星之後：101 與 102</span></span></span></code></pre></div><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="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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 共用資料：102
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">-- 訂單 102 多一則三星評價之後：0 列（正解仍然是 102）</span></span></span></code></pre></div><p>這一段問的是「沒有任何評價的訂單」。它與正解在共用資料上是同一批列，因為有評價的那張訂單剛好只有五星那一則；兩段查詢要到某張訂單的評價全部不是五星時才分開。</p>
<p><strong>「NOT IN」的錯法用 <code>NOT IN</code> 表達否定的成員判斷。</strong> 星等條件回到<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></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="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="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">5</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">-- 共用資料：102
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">-- 評價表多一則訂單編號還沒填的五星評價之後：0 列
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">--   子查詢交出 101 與 NULL，NOT IN 對訂單 102 展開成 102 &lt;&gt; 101 AND 102 &lt;&gt; NULL
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">--   前一項為真、後一項的真值是未知，AND 的結果是未知，WHERE 不留
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></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">-- 同一個問題寫成 NOT EXISTS，加了那則評價之後仍然回 102
</span></span></span><span class="line"><span class="ln"> 9</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">10</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></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="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="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><code>NOT IN</code> 展開成一串「不等於」的 AND，子查詢只要交出一個 <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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節）。<code>NOT EXISTS</code> 逐列問「有沒有一則配得上的五星評價」，訂單編號是 <code>NULL</code> 的那則評價配不上任何一張訂單，所以不影響結果。</p>
<h2 id="答案錯掉的成因分類與引擎不報錯的原因">答案錯掉的成因分類與引擎不報錯的原因</h2>
<p>從要問的那一題到算出來的那批列，中間經過三個環節：提問的人把問題寫成查詢文字、語意模型規定這段文字算出什麼、引擎照它的實作去算。答案錯掉的成因按斷在哪一個環節分類：斷在問題寫成文字的，本篇叫<strong>問錯了問題</strong>；寫的人對某條規則的預期與語意模型的規定不同，叫<strong>誤解語意模型</strong>；引擎的行為偏離標準，叫<strong>引擎寬鬆</strong>；另有一類不斷在任何環節，而是要問的那一題本身還沒成形，叫<strong>問題還沒決定</strong>。</p>
<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 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a> 叫它宣告落空：查詢的答案是對的，錯的是文字對下一個讀它的人說的話，例如 <code>LEFT</code> 宣告了一個從未發生的情況。問錯了問題的落差在問題與文字之間，宣告落空在文字與行為之間。</p>
<p>「NOT IN」的錯法誤解語意模型。<code>NOT IN</code> 的展開方式由模型定死，而寫的人預期它表現得像「不在這個集合裡」。誤解語意模型修得掉，改對之後永遠對。分頁是誤解語意模型的另一個實例：同分的列排在哪裡模型沒有規定，而逐頁取的人預期它固定，<a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記</a> 寫它怎麼變成重複與遺漏。</p>
<p>「不等於五」的錯法同時屬於問錯了問題與誤解語意模型——它問的那個問題不是要問的，而它的星等條件又是拒絕 NULL 的條件、把補出來的列全數濾掉——所以它回的列隨資料變動，這是它比「漏掉星等」與「NOT IN」難診斷的原因。</p>
<p>引擎寬鬆的實例不在這三種錯法裡。引擎接受了標準禁止的寫法並自己補一個值：分組之後把非分組欄放進 <code>SELECT</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> 與 <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>，換一家引擎就浮現。SQL.2 除了求值順序，也寫了哪些違反求值順序的寫法會被引擎當場報錯、哪些會被照收——同一個放錯邊的條件在一家報錯、在另一家回一份錯的清單。問題還沒決定的實例是「所有訂單都被評價過的顧客」：它在一張訂單都沒下過的人身上為真（邏輯裡叫空真，vacuous truth），而提問的人多半還沒想過那算不算。問題還沒決定連換誰來寫都不會浮現，因為要問的那一題還沒成形。</p>
<p>這些成因有一個共通的地方：引擎回給寫查詢的人的只有兩樣，錯誤訊息與算出來的那批列，而這些錯誤在兩樣裡都不現形。<strong>引擎保證的是兩件事：這段文字合法，而算出來的關係符合語意模型。文字合法與關係符合語意模型這兩項都不決定答案對不對，而答案對不對也不影響這兩項，所以錯誤沒有任何一條經由引擎回報的管道。</strong> 三段查詢一則錯誤訊息都沒有，沒有型別衝突，沒有一列長得奇怪。</p>
<p>回來的那批列本身也看不出答案錯了，而看不出來的情形不只一種。「漏掉星等」的錯法在原本那批資料上回了正確答案，看不出問題；「不等於五」與「NOT IN」兩種回零列，而零列既可能是「確實沒有」也可能是條件整段失效——分不出這兩者，正是 <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> 把否定式一律交給 <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="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></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="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">-- 共用資料：800（101 是 300、102 是 500）
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 替 101 再加一則評價之後：1100
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   101 配到兩則評價，連接後那張訂單變成兩列，300 算了兩次</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>）。</p>
<p>「漏掉星等」的錯法另有一個性質：它在寫下的當時通得過任何檢驗。拿現有資料驗它，答案是對的；逐句讀它，每一個子句都符合語法與模型。</p>
<h2 id="問對了沒有的判定靜態分析約束與反例資料">「問對了沒有」的判定：靜態分析、約束與反例資料</h2>
<p>「問對了沒有」需要一個答案來比對，而那個答案不在查詢裡，也不在資料裡——它在提問的人手上。所以這一層交不出一條<strong>可以機械執行的判定</strong>——任何規則都得先知道要問的是什麼，才說得出這批列對不對。</p>
<p><strong>「問對了沒有」這整條判定交不出去，而它底下有幾項較小的判斷交得出去。</strong> 一項判斷能不能交給機器，看判斷時要讀的資訊，機器在做這項判斷的那一刻讀不讀得到：引擎收到查詢的時候讀得到查詢的文字與表的結構，資料庫寫入一筆資料的時候讀得到那一筆資料與資料庫當下已有的列，而「要問的是什麼」只有提問的人知道。這些判斷因此按它們要讀什麼分開：只讀查詢文字與表結構的是<strong>靜態分析</strong>，只讀寫入那一刻資料庫裡的資料的寫成<strong>約束</strong>，要讀「要問的是什麼」的，機器能接手的只有比對——提問的人先造一筆<strong>反例資料</strong>、給出它對應的正確答案，機器負責比對查詢回的答案與它是否相同。</p>
<p><strong>靜態分析只讀查詢的文字與表的結構，判斷時不需要任何一列資料，也不需要提問的人。</strong> 「不等於五」的錯法是它判得出來的例子：這段查詢寫了外連接，而 <code>WHERE</code> 裡的星等條件是拒絕 NULL 的條件，補出來的列一列都留不住，結果與內連接在任何資料上都相同。這個等價由三值邏輯決定、與資料裡有什麼無關，所以最佳化器規劃查詢時自己就會用到它，MySQL 手冊把這一步叫 outer join simplification。最佳化器做這個改寫時不產生任何警告，要讀<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</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">EXPLAIN</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="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"> 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">&lt;&gt;</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">-- 星等條件拒絕 NULL：補出來的列星等是 NULL，NULL &lt;&gt; 5 判成未知，全數被濾掉
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">-- PostgreSQL 16 印出 Hash Join：最佳化器已經把 LEFT JOIN 改成內連接
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">-- MySQL 8 的 SHOW WARNINGS 印出改寫後的查詢，連接寫的是 join
</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">-- 正解
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="k">EXPLAIN</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="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">=</span><span class="w"> </span><span class="mi">5</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">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">15</span><span class="cl"><span class="w"></span><span class="c1">-- 星等條件在 ON 裡；WHERE 的 評價編號 IS NULL 對補出來的列判成真，補出來的列留下
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- PostgreSQL 16 印出 Hash Left Join：外連接保留下來</span></span></span></code></pre></div><p>外連接被最佳化器改成內連接，代表查詢裡的 <code>LEFT</code> 沒有作用，而寫下 <code>LEFT</code> 的人要的正是保留沒有評價的訂單。</p>
<p>「NOT IN」的錯法是另一個例子，靜態分析在它身上判出來的是風險而不是確定的錯。風險由表結構與查詢文字兩處合起來決定，兩處都不必讀任何一列資料：</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">-- 表結構：評價表的訂單編號沒有宣告 NOT NULL，這一欄可能是 NULL
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="p">(</span><span class="err">評價編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">星等</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">3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- 查詢文字：NOT IN 的子查詢讀的正是那一欄
</span></span></span><span class="line"><span class="ln">5</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">6</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">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="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">5</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，NOT IN 對每一列都判不成真，整段回零列
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- 現有資料裡沒有訂單編號未填的評價，所以這段此刻回的仍是 102</span></span></span></code></pre></div><p>靜態分析判不出「漏掉星等」：那段查詢與正解是同一種寫法，只差 <code>ON</code> 少了星等的條件，而查詢文字與表結構裡沒有任何一處說那個條件該在。</p>
<p><strong>約束只讀寫入那一刻資料庫裡的資料，所以資料庫在寫入的那一刻就判得完。</strong> <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>是寫進資料庫、由它在每次寫入時檢查的規則，每一種讀的範圍由種類固定：<code>NOT NULL</code> 與 <code>CHECK</code> 只讀正要寫入的那一筆，<code>UNIQUE</code> 讀同一欄已有的值，外鍵讀它指向的那一列。<code>NOT NULL</code> 寫下「這一欄不會缺」，外鍵寫下「這個編號一定指得到一列」。讀的範圍超出這幾種的規則（「評價要在出貨之後」要讀另一張表的出貨日）同樣在寫入那一刻判得完，而要改用觸發器或應用層檢查。「NOT IN」的錯法要評價表裡出現一則訂單編號未填的五星評價才會回零列，而評價表的訂單編號那一欄若帶著 <code>NOT NULL</code>，那則評價一開始就寫不進來——這是「否定式一律用 <code>NOT EXISTS</code>」那條規則的豁免條件，不是對 <code>NOT IN</code> 展開方式那個誤解的修法。「不等於五」與「漏掉星等」寫不成約束：兩者錯在問錯了問題，而判斷一個問題有沒有問對要讀「要問的是什麼」，那不是任何一筆資料的性質。<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> 拿參照完整性把約束走完一輪——外鍵擋下哪些寫入、宣告與生效在哪幾個位置分開，以及內連接掉列與 <code>NOT IN</code> 回零列這兩種錯各由哪一條約束管掉；五種約束各保證什麼在 <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint（約束）</a>。</p>
<p><strong>要讀「要問的是什麼」的判斷交不出去，而提問的人的判斷可以記成一筆反例資料。</strong> 反例資料是一筆讓正解與某個錯的寫法回不同答案的資料，名字取自邏輯裡的反例（counterexample）：兩段查詢在某一筆資料上回的答案不同，就證明兩者不等價。上面三種錯法各有一筆——「不等於五」要訂單 101 再多一則非五星評價，「漏掉星等」要訂單 102 多一則非五星評價，「NOT IN」要一則訂單編號未填的五星評價。</p>
<p><strong>反例資料從表的結構與查詢的條件推得出來，不必先知道哪一種寫法是錯的。</strong> 測試領域把這個做法叫<strong>等價類劃分</strong>（equivalence partitioning）：把輸入按程式對它的行為分類，每一類至少放一筆。落在這段查詢上，等價類是一張訂單配到評價的方式——一則都配不到、配到不只一則、配到的全部不是五星——再加上欄位是空值。沒有宣告約束的表全部允許這些等價類，而寫查詢的人容易當成它們不會發生；正解與錯的寫法對這些等價類的假設不同，所以兩者的答案就在這些等價類上分開。</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="c1">-- 從建好的空表開始；101、102 與 9001 同共用資料
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></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">101</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">300</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">102</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-09&#39;</span><span class="p">,</span><span class="mi">500</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="p">(</span><span class="mi">103</span><span class="p">,</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;2026-03-10&#39;</span><span class="p">,</span><span class="mi">200</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">104</span><span class="p">,</span><span class="mi">2</span><span class="p">,</span><span class="s1">&#39;2026-03-11&#39;</span><span class="p">,</span><span class="mi">400</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="p">(</span><span class="mi">105</span><span class="p">,</span><span class="mi">3</span><span class="p">,</span><span class="s1">&#39;2026-03-12&#39;</span><span class="p">,</span><span class="mi">600</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">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></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="p">(</span><span class="mi">9001</span><span class="p">,</span><span class="mi">101</span><span class="p">,</span><span class="mi">5</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">9002</span><span class="p">,</span><span class="mi">101</span><span class="p">,</span><span class="mi">3</span><span class="p">),</span><span class="w">   </span><span class="c1">-- 101：配到不只一則，其中一則五星
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9003</span><span class="p">,</span><span class="mi">103</span><span class="p">,</span><span class="mi">3</span><span class="p">),</span><span class="w">                 </span><span class="c1">-- 103：配到的全部不是五星
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9004</span><span class="p">,</span><span class="mi">104</span><span class="p">,</span><span class="mi">5</span><span class="p">),</span><span class="w">                 </span><span class="c1">-- 104：只配到一則五星
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9005</span><span class="p">,</span><span class="mi">105</span><span class="p">,</span><span class="mi">4</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">9006</span><span class="p">,</span><span class="mi">105</span><span class="p">,</span><span class="mi">2</span><span class="p">),</span><span class="w">   </span><span class="c1">-- 105：配到不只一則，全部不是五星
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="w">  </span><span class="p">(</span><span class="mi">9007</span><span class="p">,</span><span class="k">NULL</span><span class="p">,</span><span class="mi">5</span><span class="p">);</span><span class="w">                </span><span class="c1">-- 訂單編號是空值的五星評價
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- 102 一則評價都配不到
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">-- 正解：102、103、105
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- 「不等於五」：101、103、105、105（105 配到兩則非五星評價，出現兩次）
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- 「漏掉星等」：102
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1">-- 「NOT IN」：0 列</span></span></span></code></pre></div><p>提問的人要出的仍然只有一件事：這五張訂單裡哪幾張是答案。造得出反例資料，代表提問的人已經把「要問的是什麼」說到分得開兩個問題的程度；造不出來，代表兩段查詢在這張表結構允許的每一種資料上都回同樣的答案，兩種寫法等價、選哪一種都對。反例資料與「問對了沒有」交不出去並不牴觸：反例資料與它對應的正確答案仍然由提問的人給出，交給機器的只有比對這一步。</p>
<p><strong>反例資料與約束改變的對象不同。</strong> 約束改變的是資料能長成什麼樣，不改變查詢——加上 <code>NOT NULL</code> 之後「NOT IN」的錯法不再回零列，是因為觸發它的那則評價寫不進來，而那段查詢照樣合法、照樣是同一個誤解，同一個人在下一個可為空的欄位上會再寫一次 <code>NOT IN</code>。反例資料不改變資料庫允許什麼資料，提問的人的判斷也仍然由他給出，改變的是那個判斷的保存形式：反例資料當輸入、他給的答案當預期值，合起來就是一則測試案例，往後每次改動那段查詢都能再跑一次，而讀者多半已經有跑測試案例的工具。<strong>反例資料只證偽、不證成</strong>——「漏掉星等」的錯法在原本那批資料上通過了一切檢驗，而通過從來不是對的證明。反例資料的射程另有一道邊界：它由提問的人依他當時對問題的理解寫下，所以它分得出「想的對而寫錯了」，分不出「想的就不對」。</p>
<p>反例資料與它的預期答案在測試領域有對應的名字，而測試那邊分得比本篇細：預期值從哪裡取得叫 <a href="/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判斷標準的來源，以及每一種來源的射程">test oracle</a>，那張卡列了規格、參照實作、人的判斷與現狀四種來源，以及各自抓不到的那些；「寫斷言的人與寫實作的人共用同一份誤解」叫 <a href="/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">test provenance</a>；而「造不出反例資料」在 <a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">Mutation Testing</a> 裡叫 equivalent mutant。本篇只講這三個概念落在 SQL 查詢上的樣子，三張卡各自涵蓋本篇沒有處理的情境。</p>
<h2 id="正確性之外查詢的代價與由引擎資料庫決定的事">正確性之外：查詢的代價與由引擎、資料庫決定的事</h2>
<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/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a> 量的是一道重複值的題目：加一個索引，最慢的那一種寫法就掉到與最快的同一個量級。</p>
<p>本篇處理的錯誤都發生在查詢的文字與提問的人之間：引擎照語意模型把結果算對了，而那不是要問的那一題。一段查詢的結果還受幾件不寫在查詢文字裡的事影響，它們由收下查詢的引擎與資料庫決定。<strong>同一段查詢合不合法，由執行它的那一家引擎判定</strong>，而標準禁止的寫法有的引擎照收、有的報錯，<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> 逐個寫法比較各家收不收、收了之後回什麼。查詢裡的表名與欄名送進引擎之後，引擎會照自己的規則處理大小寫與引號之後再去找對應的表與欄，<a href="/blog/backend/01-database/sql/identifier-rules/" data-link-title="SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字" data-link-desc="表名與欄名送進引擎之後被怎麼改寫、PostgreSQL 與 SQLite、DuckDB 在引號上的差別、MySQL 的反引號，以及全部不加引號、全部加引號與混用各自在換引擎時的後果">SQL.14 識別字的規則：各家的大小寫摺疊、引號與保留字</a> 寫各家的大小寫規則、引號的作用與保留字。執行查詢的帳號被授予哪些權限、最佳化器選了哪一個執行計畫、表上宣告了哪些約束、字串照哪一套 collation 比較，也各自影響同一段查詢回什麼，分別在 <a href="/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限</a>、<a href="/blog/backend/01-database/sql/declarative-not-procedural/" data-link-title="SQL.1 宣告式的紅利與代價：書寫、求值與執行順序各自由誰決定" data-link-desc="SQL 寫結果而不寫算法帶來的後果：同一個問題換書寫順序之後在有無統計資訊時拿到的計畫、求值順序擋下的寫法，以及代價為什麼要向引擎問">SQL.1 宣告式的紅利與代價</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> 與 <a href="/blog/backend/01-database/sql/string-comparison-and-collation/" data-link-title="SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性" data-link-desc="同一段等值條件在各家回不同的列的原因、LIKE 與等號何時分岔、索引與條件的比較規則怎麼對上，以及規則該寫在哪一層">SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性</a>。</p>
]]></content:encoded></item><item><title>Constraint（約束）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/constraint/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/constraint/</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>常見的幾種各自保證不同的事。&lt;code>NOT NULL&lt;/code> 保證這一欄永遠有值；&lt;code>UNIQUE&lt;/code> 保證這一欄的值不重複；&lt;code>PRIMARY KEY&lt;/code> 是前兩者合起來再加上「這是本表辨識一列的依據」；&lt;code>FOREIGN KEY&lt;/code> 保證這一欄的值在另一張表裡找得到對應的那一列；&lt;code>CHECK&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/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">空值&lt;/a>：&lt;code>NOT IN&lt;/code> 在子查詢那一欄出現 &lt;code>NULL&lt;/code> 的時候會整個回空集合而不報錯，所以否定式的成員判斷一般用 &lt;code>NOT EXISTS&lt;/code>。這條規則有一個豁免——那一欄掛了 &lt;code>NOT NULL&lt;/code>。&lt;strong>豁免的條件是約束而不是觀察&lt;/strong>：翻遍現在的資料都沒有 &lt;code>NULL&lt;/code>，只證明此刻沒有；約束證明的是往後也不會有，而查詢要活得比這一批資料久。&lt;/p>
&lt;p>同一個結構在別處反覆出現：&lt;code>UNIQUE&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>FOREIGN KEY&lt;/code> 讓「這個 &lt;code>JOIN&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/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL&lt;/a> 建立與修改，而不是由日常的讀寫語句。這條歸屬有兩個後果：加約束的權限跟建表的權限走在一起，不跟常態運行的帳號走；加約束到一張已經有資料的表上會失敗，如果既有資料裡有違反它的那幾列。&lt;/p>
&lt;p>它與&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引&lt;/a>在外觀上容易混——&lt;code>UNIQUE&lt;/code> 在多數引擎底下確實靠一個索引實作，而兩者的責任不同：索引買的是查找速度，約束買的是內容的保證。刪掉一個索引查詢會變慢，刪掉一個約束則是往後的寫入不再被擋。&lt;/p>
&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;code>EXISTS&lt;/code> 查詢、&lt;code>recordDate&lt;/code> 上同樣掛著索引，只把索引從 &lt;code>UNIQUE&lt;/code> 換成普通索引，SQLite 3.51.0 產出的位元碼就多一圈內層迴圈——唯一的時候引擎知道那次查找至多回一列，不唯一的時候它得準備好再看下一筆。這個差別在 &lt;code>EXPLAIN QUERY PLAN&lt;/code> 上看不見，兩邊都印同一行 &lt;code>SEARCH y USING INDEX ix (recordDate=?)&lt;/code>，要 &lt;code>EXPLAIN&lt;/code> 到位元碼那一層才分得開；而在這組資料上它沒有量出時間差。同一組查詢在 DuckDB 1.5.5 上的計畫與有沒有 &lt;code>UNIQUE&lt;/code> 無關。所以約束會不會影響代價是各家自己的決定，不是約束本身的性質（&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;h2 id="往下走">往下走&lt;/h2>
&lt;p>&lt;code>NULL&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;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> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節。約束能在寫入時擋下哪些會讓答案錯掉的資料、為什麼問錯問題的那一類錯它擋不下，在 &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>FOREIGN KEY&lt;/code> 擋下哪兩個方向的寫入、同一段宣告在各家引擎為什麼未必生效，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證&lt;/a>。約束由誰有權建立、以及它跟常態運行帳號的權限為什麼分開，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限&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>常見的幾種各自保證不同的事。<code>NOT NULL</code> 保證這一欄永遠有值；<code>UNIQUE</code> 保證這一欄的值不重複；<code>PRIMARY KEY</code> 是前兩者合起來再加上「這是本表辨識一列的依據」；<code>FOREIGN KEY</code> 保證這一欄的值在另一張表裡找得到對應的那一列；<code>CHECK</code> 保證這一欄滿足一個自己寫的條件（例如金額不為負）。</p>
<h2 id="它決定哪些推論可以做">它決定哪些推論可以做</h2>
<p>一個判斷標準能不能省掉某一步，取決於約束有沒有把那一步的前提固定住。最直接的一例是<a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">空值</a>：<code>NOT IN</code> 在子查詢那一欄出現 <code>NULL</code> 的時候會整個回空集合而不報錯，所以否定式的成員判斷一般用 <code>NOT EXISTS</code>。這條規則有一個豁免——那一欄掛了 <code>NOT NULL</code>。<strong>豁免的條件是約束而不是觀察</strong>：翻遍現在的資料都沒有 <code>NULL</code>，只證明此刻沒有；約束證明的是往後也不會有，而查詢要活得比這一批資料久。</p>
<p>同一個結構在別處反覆出現：<code>UNIQUE</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>FOREIGN KEY</code> 讓「這個 <code>JOIN</code> 會不會掉列」有一個不必查資料就成立的答案。</p>
<h2 id="概念位置">概念位置</h2>
<p>約束住在結構那一側，所以它由 <a href="/blog/backend/01-database/sql/knowledge-cards/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL</a> 建立與修改，而不是由日常的讀寫語句。這條歸屬有兩個後果：加約束的權限跟建表的權限走在一起，不跟常態運行的帳號走；加約束到一張已經有資料的表上會失敗，如果既有資料裡有違反它的那幾列。</p>
<p>它與<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>在外觀上容易混——<code>UNIQUE</code> 在多數引擎底下確實靠一個索引實作，而兩者的責任不同：索引買的是查找速度，約束買的是內容的保證。刪掉一個索引查詢會變慢，刪掉一個約束則是往後的寫入不再被擋。</p>
<p>這條分工有一個邊界：約束宣告的內容同時是<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">最佳化器</a>可用的資訊，所以它買到的有時候多於內容的保證。同一張五千列的表、同一個 <code>EXISTS</code> 查詢、<code>recordDate</code> 上同樣掛著索引，只把索引從 <code>UNIQUE</code> 換成普通索引，SQLite 3.51.0 產出的位元碼就多一圈內層迴圈——唯一的時候引擎知道那次查找至多回一列，不唯一的時候它得準備好再看下一筆。這個差別在 <code>EXPLAIN QUERY PLAN</code> 上看不見，兩邊都印同一行 <code>SEARCH y USING INDEX ix (recordDate=?)</code>，要 <code>EXPLAIN</code> 到位元碼那一層才分得開；而在這組資料上它沒有量出時間差。同一組查詢在 DuckDB 1.5.5 上的計畫與有沒有 <code>UNIQUE</code> 無關。所以約束會不會影響代價是各家自己的決定，不是約束本身的性質（<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>
<h2 id="往下走">往下走</h2>
<p><code>NULL</code> 在比較、計數與分組裡各自怎麼表現，以及否定式那條規則的完整推導，在 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">NULL（空值）</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> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節。約束能在寫入時擋下哪些會讓答案錯掉的資料、為什麼問錯問題的那一類錯它擋不下，在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a>。<code>FOREIGN KEY</code> 擋下哪兩個方向的寫入、同一段宣告在各家引擎為什麼未必生效，在 <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>。約束由誰有權建立、以及它跟常態運行帳號的權限為什麼分開，在 <a href="/blog/backend/01-database/sql/privilege-model/" data-link-title="SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限" data-link-desc="新角色的預設權限、讀寫各自授權與收回的方式、建立物件的權限落在 schema 這一層，以及最小權限在應用程式帳號上成立的前提">SQL.16 SQL 的權限模型：角色、GRANT 的授權單位與最小權限</a>。</p>
]]></content:encoded></item><item><title>SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/foreign-key-and-referential-integrity/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/foreign-key-and-referential-integrity/</guid><description>&lt;p>外鍵是一條寫在表結構上的宣告：這一欄的值，在另一張表裡找得到對應的那一列。資料庫在每一次寫入時檢查它，所以它涵蓋的是整張表，以及往後的每一筆資料。外鍵這條保證有一個名字叫&lt;strong>參照完整性&lt;/strong>——「參照」指一欄指向另一張表的某一列，「完整」指那個指向落得到實處。&lt;/p>
&lt;p>查詢的文字之外還有幾樣東西在決定結果——名字實際指向誰、送出的人被允許做什麼、引擎打算花多久。約束介入得比那幾樣都早：它決定資料能長成什麼樣，於是決定一段查詢會遇到哪幾種列。&lt;/p>
&lt;p>本篇走完這條保證的全貌：它擋下哪些寫入、什麼時候才算生效、生效之後查詢省掉哪一步、父列消失時它怎麼維持，以及它涵蓋不到的地方。這條約束該不該加是資料表設計的取捨，本篇只處理加了之後寫入與查詢的行為有什麼改變。&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>上重建，差別是這一次把約束寫進結構裡；顧客表的三個人是佳穎、宗翰、雅文，而只有佳穎下過單。後面談替既有的表補約束時，另外用到一張出貨表。指不到父列的那一列，下文叫&lt;strong>孤兒列&lt;/strong>。&lt;/p>
&lt;h2 id="外鍵擋下的寫入與-null-的放行">外鍵擋下的寫入與 NULL 的放行&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">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="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&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>&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="err">顧客編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&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 class="nb">TEXT&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 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">3&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">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">評價&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">評價編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&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>&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="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&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 class="nb">INT&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">-- PostgreSQL 18
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">ERROR: insert or update on table &amp;#34;評價&amp;#34; violates foreign key constraint &amp;#34;評價_訂單編號_fkey&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DETAIL: Key (訂單編號)=(999) is not present in table &amp;#34;訂單&amp;#34;.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">DELETE FROM 顧客 WHERE 顧客編號 = 1;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">ERROR: update or delete on table &amp;#34;顧客&amp;#34; violates foreign key constraint &amp;#34;訂單_顧客編號_fkey&amp;#34; on table &amp;#34;訂單&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">DETAIL: Key (顧客編號)=(1) is still referenced from table &amp;#34;訂單&amp;#34;.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>上面兩則錯誤訊息裡的約束名不同。第一則是評價表自己那條，第二則是訂單表那條——刪顧客這個動作在訂單表上觸發檢查，所以擋下它的約束掛在訂單表上。&lt;strong>外鍵的錯誤訊息因此要分兩層讀&lt;/strong>：訊息指名的表，未必是剛才那句 SQL 寫到的表。&lt;/p>
&lt;p>有一個值在子表與父表兩邊都放行：&lt;code>NULL&lt;/code>。單欄的外鍵讀作「這一欄有值的時候，那個值找得到對應」，所以空著的那一欄不參與檢查。&lt;strong>跨多欄的外鍵預設另有一種讀法&lt;/strong>——標準的預設 &lt;code>MATCH SIMPLE&lt;/code> 只要其中一欄是 &lt;code>NULL&lt;/code> 就整條放行，另外幾欄有值也不查。要它回到逐欄都算的讀法得寫 &lt;code>MATCH FULL&lt;/code>，而這個寫法只在 PostgreSQL 上生效。同一列 &lt;code>(999, NULL)&lt;/code> 寫進兩欄外鍵、而 &lt;code>999&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">-- 父表以 (a, b) 兩欄為主鍵，999 在父表裡不存在
&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">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="err">父&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&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 class="n">b&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 class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&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">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="err">子&lt;/span>&lt;span class="n">_預設&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&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 class="n">b&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 class="k">FOREIGN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">父&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&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">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">子&lt;/span>&lt;span class="n">_完整&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&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 class="n">b&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 class="k">FOREIGN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">REFERENCES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">父&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MATCH&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FULL&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="c1">-- 預設 MATCH SIMPLE：有一欄是 NULL，整條放行，另一欄不查
&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">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="n">_預設&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">999&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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"> 8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- PostgreSQL 18：收下
&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">&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">-- 宣告成 MATCH FULL：逐欄都算
&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">&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="n">_完整&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">999&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">NULL&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">12&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- PostgreSQL 18：拒絕，MATCH FULL does not allow mixing of null and nonnull key values
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite 3.51（已開 PRAGMA foreign_keys）、MySQL 8.4：收下 MATCH FULL 這個語法，照樣放行同一列&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>在 SQLite 與 MySQL 上要擋下混著空與非空的組合，得在每一欄各自加 &lt;code>NOT NULL&lt;/code>。訂單編號留空的評價插得進去，而它是一則指不到任何訂單的評價——&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> 裡「NOT IN」的錯法要回零列，要的正是這樣一列。要連訂單編號留空的評價也擋掉，評價表的訂單編號另外要有 &lt;code>NOT NULL&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<p>外鍵是一條寫在表結構上的宣告：這一欄的值，在另一張表裡找得到對應的那一列。資料庫在每一次寫入時檢查它，所以它涵蓋的是整張表，以及往後的每一筆資料。外鍵這條保證有一個名字叫<strong>參照完整性</strong>——「參照」指一欄指向另一張表的某一列，「完整」指那個指向落得到實處。</p>
<p>查詢的文字之外還有幾樣東西在決定結果——名字實際指向誰、送出的人被允許做什麼、引擎打算花多久。約束介入得比那幾樣都早：它決定資料能長成什麼樣，於是決定一段查詢會遇到哪幾種列。</p>
<p>本篇走完這條保證的全貌：它擋下哪些寫入、什麼時候才算生效、生效之後查詢省掉哪一步、父列消失時它怎麼維持，以及它涵蓋不到的地方。這條約束該不該加是資料表設計的取捨，本篇只處理加了之後寫入與查詢的行為有什麼改變。</p>
<p>本篇的表在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫</a>上重建，差別是這一次把約束寫進結構裡；顧客表的三個人是佳穎、宗翰、雅文，而只有佳穎下過單。後面談替既有的表補約束時，另外用到一張出貨表。指不到父列的那一列，下文叫<strong>孤兒列</strong>。</p>
<h2 id="外鍵擋下的寫入與-null-的放行">外鍵擋下的寫入與 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">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</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></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</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">3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="p">(</span><span class="err">評價編號</span><span class="w"> </span><span class="nb">INT</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">                   </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">INT</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">-- PostgreSQL 18
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);
</span></span><span class="line"><span class="ln">3</span><span class="cl">ERROR:  insert or update on table &#34;評價&#34; violates foreign key constraint &#34;評價_訂單編號_fkey&#34;
</span></span><span class="line"><span class="ln">4</span><span class="cl">DETAIL:  Key (訂單編號)=(999) is not present in table &#34;訂單&#34;.
</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">DELETE FROM 顧客 WHERE 顧客編號 = 1;
</span></span><span class="line"><span class="ln">7</span><span class="cl">ERROR:  update or delete on table &#34;顧客&#34; violates foreign key constraint &#34;訂單_顧客編號_fkey&#34; on table &#34;訂單&#34;
</span></span><span class="line"><span class="ln">8</span><span class="cl">DETAIL:  Key (顧客編號)=(1) is still referenced from table &#34;訂單&#34;.</span></span></code></pre></div><p>上面兩則錯誤訊息裡的約束名不同。第一則是評價表自己那條，第二則是訂單表那條——刪顧客這個動作在訂單表上觸發檢查，所以擋下它的約束掛在訂單表上。<strong>外鍵的錯誤訊息因此要分兩層讀</strong>：訊息指名的表，未必是剛才那句 SQL 寫到的表。</p>
<p>有一個值在子表與父表兩邊都放行：<code>NULL</code>。單欄的外鍵讀作「這一欄有值的時候，那個值找得到對應」，所以空著的那一欄不參與檢查。<strong>跨多欄的外鍵預設另有一種讀法</strong>——標準的預設 <code>MATCH SIMPLE</code> 只要其中一欄是 <code>NULL</code> 就整條放行，另外幾欄有值也不查。要它回到逐欄都算的讀法得寫 <code>MATCH FULL</code>，而這個寫法只在 PostgreSQL 上生效。同一列 <code>(999, NULL)</code> 寫進兩欄外鍵、而 <code>999</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">-- 父表以 (a, b) 兩欄為主鍵，999 在父表裡不存在
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">父</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</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">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">子</span><span class="n">_預設</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">父</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</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">TABLE</span><span class="w"> </span><span class="err">子</span><span class="n">_完整</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">父</span><span class="w"> </span><span class="p">(</span><span class="n">a</span><span class="p">,</span><span class="w"> </span><span class="n">b</span><span class="p">)</span><span class="w"> </span><span class="k">MATCH</span><span class="w"> </span><span class="k">FULL</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="c1">-- 預設 MATCH SIMPLE：有一欄是 NULL，整條放行，另一欄不查
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></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="n">_預設</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">999</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"> 8</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18：收下
</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">-- 宣告成 MATCH FULL：逐欄都算
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></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="n">_完整</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">999</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">12</span><span class="cl"><span class="w"></span><span class="c1">-- PostgreSQL 18：拒絕，MATCH FULL does not allow mixing of null and nonnull key values
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- SQLite 3.51（已開 PRAGMA foreign_keys）、MySQL 8.4：收下 MATCH FULL 這個語法，照樣放行同一列</span></span></span></code></pre></div><p>在 SQLite 與 MySQL 上要擋下混著空與非空的組合，得在每一欄各自加 <code>NOT NULL</code>。訂單編號留空的評價插得進去，而它是一則指不到任何訂單的評價——<a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 裡「NOT IN」的錯法要回零列，要的正是這樣一列。要連訂單編號留空的評價也擋掉，評價表的訂單編號另外要有 <code>NOT NULL</code>。</p>
<p><code>NULL</code> 放行這件事在一種結構裡是必要的：外鍵指回自己所在的那張表。員工表的主管欄指向同一張表的另一列時，一列的上層是同一張表的另一列，而最頂層那一列的主管欄只能留空。這個結構的查詢要把同一張表擺兩次，兩次出現需要各自的名字，<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a> 寫自連接為什麼非取名不可，並把它處理的關係分成前後配對、同組比較與層級關係三種形態——員工與主管屬於層級關係。</p>
<h2 id="宣告與生效分開的位置連線設定約束狀態語法形態與儲存引擎">宣告與生效分開的位置：連線設定、約束狀態、語法形態與儲存引擎</h2>
<p>同一段 <code>REFERENCES</code> 送進不同的引擎，會落在不同的狀態。宣告與生效在連線的設定、約束自己的狀態、語法的形態、表的儲存引擎這幾個位置各自分開過一次，而它們讓人察覺宣告與生效已經分岔的難易程度差很多。</p>
<p><strong>連線的設定</strong>。SQLite 認得 REFERENCES 這段語法、也把約束記進結構裡，而檢查預設關閉，開關掛在每一條連線上：</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">-- SQLite 3.51，未開 PRAGMA
</span></span><span class="line"><span class="ln">2</span><span class="cl">INSERT INTO 評價 VALUES (9003, 999, 4);   -- 成功，孤兒列進來了
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</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">PRAGMA foreign_keys = ON;
</span></span><span class="line"><span class="ln">6</span><span class="cl">INSERT INTO 評價 VALUES (9004, 888, 4);
</span></span><span class="line"><span class="ln">7</span><span class="cl">Error: stepping, FOREIGN KEY constraint failed (19)</span></span></code></pre></div><p>開關打開之後，開關關著時插進去的那筆孤兒列（9003）仍然在表裡——檢查發生在寫入的當下，對已經在裡面的資料沒有追溯力。把孤兒列找出來的指令是 <code>PRAGMA foreign_key_check</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="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_key_check</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">-- table | rowid | parent | fkid
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- 評價  | 2     | 訂單   | 0
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">--   違反者所在的表、那一列的 rowid、被指向的表，以及那是這張表上的第幾條外鍵（從 0 起算）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">--   評價編號宣告成 INT PRIMARY KEY 而不是 INTEGER PRIMARY KEY，所以 rowid 是另一個隱藏欄位，不等於評價編號</span></span></span></code></pre></div><p><strong>約束自己的狀態</strong>。PostgreSQL 的 <code>NOT VALID</code> 讓一條外鍵掛上去而略過既有的列，往後的寫入照常檢查。這個狀態是給大表補約束用的（既有列的檢查另用 <code>VALIDATE CONSTRAINT</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">-- PostgreSQL 18，出貨表裡已經有一列指向不存在的訂單 777
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="p">(</span><span class="err">出貨編號</span><span class="w"> </span><span class="nb">INT</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="err">訂單編號</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"> 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="err">出貨</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">102</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">777</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="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="err">出貨</span><span class="n">_訂單編號_fkey</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">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</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">NOT</span><span class="w"> </span><span class="k">VALID</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">-- 成功，777 那一列沒有被檢查
</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="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">3</span><span class="p">,</span><span class="w"> </span><span class="mi">888</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">-- ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</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="k">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">出貨</span><span class="w"> </span><span class="n">VALIDATE</span><span class="w"> </span><span class="k">CONSTRAINT</span><span class="w"> </span><span class="err">出貨</span><span class="n">_訂單編號_fkey</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">-- ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- DETAIL:  Key (訂單編號)=(777) is not present in table &#34;訂單&#34;.</span></span></span></code></pre></div><p><strong>語法的形態</strong>。MySQL 認得欄位層的 <code>REFERENCES</code>，解析完把它丟掉，不留下任何約束，<code>SHOW WARNINGS</code> 也是空的。同一段文字在 PostgreSQL 與 SQLite 底下建得出約束，在 MySQL 底下建不出來，而 MySQL 在建表時沒有給任何警告。</p>
<p><strong>表的儲存引擎</strong>。同一家 MySQL 裡還有一條讓宣告消失的路，而它多半不是有人刻意選的——舊系統的建表模板、從舊備份還原回來的表、或複製過來的一段舊 DDL，都會把儲存引擎一起帶進來。表層的 <code>FOREIGN KEY</code> 寫法建在 MyISAM 上，語法照收、警告照樣是空的，約束同樣沒有留下。兩條路要改的地方不同：欄位層的寫法要改寫成表層的 <code>FOREIGN KEY</code>，MyISAM 要換成 InnoDB。</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">-- MySQL 8.4，資料庫名 t；顧客表同共用資料，並以顧客編號為主鍵
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 欄位層的 REFERENCES：建表成功，SHOW WARNINGS 是空的
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</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"> 5</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">103</span><span class="p">,</span><span class="w"> </span><span class="mi">999</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"> 6</span><span class="cl"><span class="w"></span><span class="c1">-- 成功，孤兒列進來了
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></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">-- 表層的 FOREIGN KEY，建在預設的 InnoDB 上
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="n">_表層</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</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="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</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">10</span><span class="cl"><span class="w">                        </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</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="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="n">_表層</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="mi">999</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">12</span><span class="cl"><span class="w"></span><span class="c1">-- ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"></span><span class="c1">-- 同樣的表層寫法建在 MyISAM 上：建表成功，SHOW WARNINGS 是空的
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="n">_MyISAM</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</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="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="p">,</span><span class="w"> </span><span class="err">下單日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</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">16</span><span class="cl"><span class="w">                          </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">顧客編號</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</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">ENGINE</span><span class="o">=</span><span class="n">MyISAM</span><span class="p">;</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">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="n">_MyISAM</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="mi">999</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">18</span><span class="cl"><span class="w"></span><span class="c1">-- 成功，孤兒列進來了
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></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">SELECT</span><span class="w"> </span><span class="k">TABLE_NAME</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">REFERENTIAL_CONSTRAINTS</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">WHERE</span><span class="w"> </span><span class="k">CONSTRAINT_SCHEMA</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;t&#39;</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">-- 訂單_表層    三張表裡只有這一張留下了約束</span></span></span></code></pre></div><p>這幾個位置的共同點是<strong>外鍵只在寫入的那一刻現身</strong>：查詢的文字裡沒有一個字提到約束，讀的時候也沒有差別。所以「這條保證在不在」要另外查，查的地方是系統目錄。<strong>系統目錄記的是宣告存不存在，生不生效要另外查</strong>：SQLite 在 <code>PRAGMA foreign_keys</code> 關著的時候照樣列出那條外鍵，而孤兒列插得進去；PostgreSQL 的 <code>NOT VALID</code> 外鍵同樣列在 <code>information_schema.table_constraints</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">-- PostgreSQL
</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="k">table_name</span><span class="p">,</span><span class="w"> </span><span class="k">constraint_name</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">table_constraints</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="n">constraint_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;FOREIGN KEY&#39;</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">-- MySQL
</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="k">TABLE_NAME</span><span class="p">,</span><span class="w"> </span><span class="k">CONSTRAINT_NAME</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">information_schema</span><span class="p">.</span><span class="n">REFERENTIAL_CONSTRAINTS</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">CONSTRAINT_SCHEMA</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;t&#39;</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">-- SQLite：每張表各查一次
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_key_list</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">-- 0|0|訂單|訂單編號|訂單編號|NO ACTION|NO ACTION|NONE    開關關著的時候也列得出來
</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">-- SQLite：這條連線的開關
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="n">PRAGMA</span><span class="w"> </span><span class="n">foreign_keys</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">-- 0 是關、1 是開
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- PostgreSQL：convalidated 是 f 的外鍵是 NOT VALID，既有的列沒有檢查過
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">conname</span><span class="p">,</span><span class="w"> </span><span class="n">convalidated</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">pg_constraint</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">contype</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;f&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w"></span><span class="c1">-- 出貨_訂單編號_fkey | f
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1">-- 訂單_顧客編號_fkey | t
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1">-- 評價_訂單編號_fkey | t</span></span></span></code></pre></div><p>生效之後還有一個「什麼時候檢查」的維度。預設是每一句寫入當場檢查，而 PostgreSQL 的 <code>DEFERRABLE INITIALLY DEFERRED</code> 把檢查推到交易提交的那一刻——中間的每一句都通過，<code>COMMIT</code> 才回 <code>violates foreign key constraint</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">-- PostgreSQL 18，兩條外鍵都宣告成 DEFERRABLE INITIALLY DEFERRED
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="p">(</span><span class="err">部門編號</span><span class="w"> </span><span class="nb">INT</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="err">主管</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</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"> 3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">員工</span><span class="w"> </span><span class="p">(</span><span class="err">員工編號</span><span class="w"> </span><span class="nb">INT</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">部門編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">DEFERRABLE</span><span class="w"> </span><span class="k">INITIALLY</span><span class="w"> </span><span class="k">DEFERRED</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">ALTER</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">部門</span><span class="w"> </span><span class="k">ADD</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">KEY</span><span class="w"> </span><span class="p">(</span><span class="err">主管</span><span class="p">)</span><span class="w"> </span><span class="k">REFERENCES</span><span class="w"> </span><span class="err">員工</span><span class="w"> </span><span class="k">DEFERRABLE</span><span class="w"> </span><span class="k">INITIALLY</span><span class="w"> </span><span class="k">DEFERRED</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">BEGIN</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="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">1</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 員工 10 還不存在，這一句照樣通過
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></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">10</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">10</span><span class="cl"><span class="w"></span><span class="k">COMMIT</span><span class="p">;</span><span class="w">                            </span><span class="c1">-- 提交時兩條外鍵都滿足
</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="k">BEGIN</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="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">2</span><span class="p">,</span><span class="w"> </span><span class="mi">20</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 通過
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="k">COMMIT</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">-- ERROR:  insert or update on table &#34;部門&#34; violates foreign key constraint &#34;部門_主管_fkey&#34;
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1">-- DETAIL:  Key (主管)=(20) is not present in table &#34;員工&#34;.
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">--   同樣兩張表不宣告 DEFERRABLE 的話，兩句 INSERT 無論哪一句先寫都當場被擋下</span></span></span></code></pre></div><h2 id="約束生效之後查詢可以省掉的判斷not-null-與外鍵各自的保證">約束生效之後查詢可以省掉的判斷：NOT NULL 與外鍵各自的保證</h2>
<p>一條保證的價值落在它讓哪些判斷不必再做。同一組表的兩個版本擺在一起看得清楚：一邊的 <code>訂單.顧客編號</code> 帶著 <code>NOT NULL REFERENCES 顧客(顧客編號)</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">-- SQLite 3.51，已開 PRAGMA foreign_keys
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">-- 有約束的版本
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">                   </span><span class="err">顧客編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</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 class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">金額</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"> 5</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">101</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">300</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">102</span><span class="p">,</span><span class="mi">1</span><span class="p">,</span><span class="s1">&#39;2026-03-09&#39;</span><span class="p">,</span><span class="mi">500</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="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"> 7</span><span class="cl"><span class="w"></span><span class="c1">-- NOT NULL constraint failed: 訂單.顧客編號
</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"></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><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="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">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">12</span><span class="cl"><span class="w"></span><span class="c1">-- 有約束：2，訂單表 2 列
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- 沒有約束：2，訂單表 3 列；103 配不到任何顧客，從結果裡消失
</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">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">16</span><span class="cl"><span class="w"></span><span class="c1">-- 有約束：宗翰、雅文
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1">-- 沒有約束：0 列；子查詢交出 1、1、NULL，與 NULL 比較的那一項是未知</span></span></span></code></pre></div><p>沒有約束的那一版，內連接與 <code>NOT IN</code> 這兩段查詢各自出了一種錯，而兩種都不報錯。內連接掉了一列，而總數看起來仍然像一份完整的訂單清單；<code>NOT IN</code> 整段回空集合，因為子查詢交出的那一欄含一個 <a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時"><code>NULL</code></a>。內連接為什麼丟掉配不到對象的列，在 <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>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> 的「子查詢含 NULL 時的 NOT IN 與 NOT EXISTS」一節。</p>
<p>有約束的那一邊，兩段查詢都對，而它們的寫法一個字都沒改。<strong>改變的是那兩個判斷的前提從觀察變成了保證</strong>：翻遍現在的訂單表沒有一筆顧客編號是空的，只證明此刻如此；<code>NOT NULL</code> 證明的是往後也不會有，而查詢要活得比這一批資料久（<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint</a>）。</p>
<p>這裡有兩條約束在做不同的事，分開記各自買到什麼。<strong><code>NOT NULL</code> 買的是「這一欄有值」</strong>，它管掉上面兩種錯——沒有空缺，內連接就掉不了列，<code>NOT IN</code> 也碰不到那個 <code>NULL</code>。<strong>外鍵買的是「這個值指得到人」</strong>，它管的是另一批推論：這一欄有值的列，連過去必定配得到一列。子表的每一列都歸屬到某個父列、<code>JOIN</code> 與 <code>LEFT JOIN</code> 在這個方向上回同一批列，這兩項要兩條約束都在才成立——只有外鍵的時候，顧客編號留空的訂單照樣寫得進去，<code>LEFT JOIN</code> 留下它而 <code>JOIN</code> 丟掉它。兩條都在的時候，「這個連接會掉列嗎」這個問題不必查資料就答得出來。<code>UNIQUE</code> 保證值不重複、<code>CHECK</code> 保證值滿足一個自己寫的條件、<code>PRIMARY KEY</code> 是 <code>NOT NULL</code> 加 <code>UNIQUE</code> 再加上「這是辨識一列的依據」，而這幾種約束共用同一條性質——豁免一個判斷的條件必須是一條約束，不能是一次觀察；各自買到什麼、以及它們為什麼住在 <a href="/blog/backend/01-database/sql/knowledge-cards/ddl-dml/" data-link-title="DDL 與 DML（定義結構與動資料）" data-link-desc="看到「這個帳號只需要 DML 權限」或遷移文件把兩者分開講時，查這條界線切在哪裡">DDL</a> 那一側，在 <a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">Constraint（約束）</a>。</p>
<p>這些推論的代價落在寫入那一側：每插進一列，資料庫要去父表確認那個值在不在，所以外鍵是拿寫入的工作換讀取的保證。這筆交換要用量測來判，而讀代價的工具在 <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/schema-design/" data-link-title="1.2 Schema Design 與資料建模" data-link-desc="整理 table、index、key、partition、denormalization 與命名規則">Schema Design</a> 的「Table 與 Relation」一節寫這個取捨，並提醒宣告與執法要分開看。</p>
<h2 id="父列被刪除時子列的去向on-delete-的選項">父列被刪除時子列的去向：ON DELETE 的選項</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="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">CASCADE</span><span class="w">      </span><span class="c1">-- 子列一起刪掉
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">NULL</span><span class="w">     </span><span class="c1">-- 子列留著，這一欄清空
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w"> </span><span class="mi">0</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">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">DEFAULT</span><span class="w">  </span><span class="c1">-- 子列留著，改指一個哨兵列</span></span></span></code></pre></div><p>在 PostgreSQL 18 上刪掉訂單 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="c1">-- 三張評價表照上面三行宣告，各有一列 (9001, 102, 4)；訂單 0 是事先備好的哨兵列
</span></span></span><span class="line"><span class="ln">2</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">102</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></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_連帶刪除</span><span class="p">;</span><span class="w">   </span><span class="c1">-- ON DELETE CASCADE：0 列
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_清空</span><span class="p">;</span><span class="w">       </span><span class="c1">-- ON DELETE SET NULL：9001 |   | 4，訂單編號變成空的
</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="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">評價</span><span class="n">_改指哨兵</span><span class="p">;</span><span class="w">   </span><span class="c1">-- ON DELETE SET DEFAULT：9001 | 0 | 4，改指哨兵列</span></span></span></code></pre></div><p>擋下刪除的則有兩種寫法而它們不一樣：<code>RESTRICT</code> 在那一句 <code>DELETE</code> 當場擋下，而且不能推遲；<code>NO ACTION</code>（沒寫 <code>ON DELETE</code> 時的預設）在每一句結束時檢查，約束宣告成 <code>DEFERRABLE</code> 之後才能推到交易提交。同一個交易裡先刪父列、再刪子列，只有宣告成 <code>DEFERRABLE</code> 的 <code>NO ACTION</code> 過得去。</p>
<p><strong>子列離開父列之後還有沒有意義，決定它跟著刪掉還是留下。</strong> 一則評價離開它評的那張訂單之後說不出在評什麼，所以它適合跟著走；一筆出貨紀錄即使訂單被撤銷仍然是發生過的事實，清空指向比刪掉它更貼近實情。</p>
<p><strong>這一列有沒有自己的保留義務，是獨立的另一個問句，而它成立的時候蓋過「還有沒有意義」的答案。</strong> 一筆交易紀錄離開它的帳戶之後同樣說不出在記什麼，只問還有沒有意義會走到 <code>CASCADE</code>——而受稽核或法規保留的紀錄不能被任何一次刪除帶走。保留義務成立的時候，答案落在 <code>RESTRICT</code> 或 <code>SET DEFAULT</code> 上（<code>RESTRICT</code> 不准刪父列，<code>SET DEFAULT</code> 讓子列改指一個「已刪除」的哨兵列，紀錄留著而那一欄不為空），而只問還有沒有意義的時候，這兩個選項不會被想到。保留義務要優先的理由是 <code>CASCADE</code> 的一個性質：<strong>一次刪除實際刪掉多少，由表之間的結構決定，不由那句 <code>DELETE</code> 決定</strong>。它沿著外鍵一路傳下去，刪一個顧客連帶刪掉他的訂單、那些訂單的評價，以及再往下的每一層，而那句 SQL 的文字裡只提到顧客。</p>
<p><code>SET NULL</code> 與同一欄上的 <code>NOT NULL</code> 要求的條件互相排斥，而建表的時候兩者並存不會被擋下。PostgreSQL 18 收下這張表，衝突到刪除父列的那一刻才浮現，訊息指的是那個清空的動作違反了 <code>NOT 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="c1">-- PostgreSQL 18
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">評價</span><span class="w"> </span><span class="p">(</span><span class="err">評價編號</span><span class="w"> </span><span class="nb">INT</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></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">                   </span><span class="err">訂單編號</span><span class="w"> </span><span class="nb">INT</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w"> </span><span class="k">REFERENCES</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">ON</span><span class="w"> </span><span class="k">DELETE</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="k">NULL</span><span class="p">,</span><span class="w"> </span><span class="err">星等</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">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="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">9001</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">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="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">101</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">-- ERROR:  null value in column &#34;訂單編號&#34; of relation &#34;評價&#34; violates not-null constraint
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">-- DETAIL:  Failing row contains (9001, null, 5).
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">-- CONTEXT:  SQL statement &#34;UPDATE ONLY &#34;public&#34;.&#34;評價&#34; SET &#34;訂單編號&#34; = NULL WHERE ...&#34;</span></span></span></code></pre></div><p><strong>宣告與生效那條分界在這裡又出現一次：宣告收下了，做得到做不到要等到執行的那一刻。</strong> 而 <code>NOT NULL</code> 買掉的正是掉列與 <code>NOT IN</code> 那兩類錯誤，所以這裡要選一邊——留下子列的代價是那一欄重新可能為空，關於它的推論回到觀察那一級。</p>
<h2 id="外鍵涵蓋不到的寫入與規則">外鍵涵蓋不到的寫入與規則</h2>
<p>外鍵的檢查發生在寫入的那一刻，所以它的射程由「哪些寫入經過它」決定。</p>
<p><strong>既有的資料不在射程裡。</strong> 補一條外鍵到已經有孤兒列的表上，這個動作本身會失敗：</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">-- PostgreSQL 18，出貨表裡有一列的訂單編號是 777
</span></span><span class="line"><span class="ln">2</span><span class="cl">ALTER TABLE 出貨 ADD FOREIGN KEY (訂單編號) REFERENCES 訂單(訂單編號);
</span></span><span class="line"><span class="ln">3</span><span class="cl">ERROR:  insert or update on table &#34;出貨&#34; violates foreign key constraint &#34;出貨_訂單編號_fkey&#34;
</span></span><span class="line"><span class="ln">4</span><span class="cl">DETAIL:  Key (訂單編號)=(777) is not present in table &#34;訂單&#34;.</span></span></code></pre></div><p>這則錯誤說的是資料裡已經有孤兒列，而約束加得上去的條件，是既有的每一列都已經滿足它。給有流量的大表補約束的完整順序在 <a href="/blog/backend/01-database/database-migration-playbook/" data-link-title="1.6 資料庫轉換實作：雙寫、回填、切流與回滾" data-link-desc="同一個資料庫引擎內 schema 演進與資料變更的分段驗證流程：expand-contract、回填、驗證查詢、切流與回滾；換資料庫引擎的遷移不在範圍內">資料庫轉換實作</a> 的 「Type I：加約束（CHECK / FK / NOT NULL 收緊）」一節。</p>
<p><strong>同一個資料庫之外的指向不在射程裡。</strong> 外鍵認的是另一張表，所以一個指向別的服務的識別碼、或指向物件儲存的一個鍵，資料庫無從檢查。這一類的完整性要在資料庫以外的層落地。應用程式碼內部的落點分幾層、各層違反規則時發生什麼，在 <a href="/blog/software-design/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>——那一篇的作用域是單一物件的規則。跨服務的一致性靠什麼維持（Saga、outbox 這一類），在 <a href="/blog/backend/01-database/transaction-boundary/" data-link-title="1.3 Transaction 與一致性邊界" data-link-desc="交易邊界、isolation level、retry 策略、distributed transaction（2PC、Saga）與跨 region 強一致取捨">Transaction 與一致性邊界</a>，那一篇談的是一致性，參照完整性要落在哪一層它沒有單獨處理。</p>
<p><strong>不經過這條連線的寫入不在射程裡。</strong> 這一點是 SQLite 把 <code>PRAGMA foreign_keys</code> 開關掛在每一條連線上的直接後果——約束的檢查掛在連線上的時候，遷移工具、測試框架與別人的第三方程式各自開自己的連線，而它們未必開了那個開關。射程因此不是「這個資料庫」，是「經過設定正確的連線的那些寫入」。</p>
<p><strong>業務規則不在射程裡。</strong> 外鍵回答的問題只有一個：這個值在那張表裡找不找得到。「評價要在出貨之後」「同一張訂單只能退款一次」沒有這個形狀，而兩條規則各自落在不同的機制上：「同一張訂單只能退款一次」要比對退款表裡同一張訂單的其他列，寫成退款表訂單編號上的 <code>UNIQUE</code>；「評價要在出貨之後」要讀出貨表裡那張訂單的出貨時間，而 <code>CHECK</code> 只讀正要寫入的那一列、不接受子查詢，所以它落在觸發器或應用層。</p>
<p>業務規則那一項接上 <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>——在寫入那一刻就判得完、而且讀的範圍對得上某一種約束的，寫得成約束：<code>CHECK</code> 只讀正要寫入的那一筆，<code>UNIQUE</code> 讀同一欄已有的值，外鍵讀另一張表裡被指向的那一列。外鍵是約束裡讀得最遠的一種，而讀的範圍到被指向的那一列為止；「評價要在出貨之後」要讀另一張表的出貨日，同樣在寫入那一刻判得完，卻超出了每一種約束的範圍。</p>
<h2 id="文字的宣告被執行的程度外鍵引擎補上的決定與-left-join">文字的宣告被執行的程度：外鍵、引擎補上的決定與 LEFT JOIN</h2>
<p>一段文字說的話什麼時候會被執行，外鍵是答案最明確的那一端：它有生效的狀態可查，查得到之後保證就在。<code>LEFT JOIN</code> 的 <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>。夾在中間的是引擎替文字補上的決定：外鍵在某一家被靜默丟掉就是其中一例，這一類差異按發聲的位置從當場報錯分到完全靜默、並依同一段 SQL 要跑在幾種引擎與設定的組合上，決定哪幾種要在寫的當下就處理掉，在 <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>
]]></content:encoded></item></channel></rss>