NULL แถวเดียวทำให้ NOT IN คืนศูนย์แถว และท่าที่ตอบผิดคือท่าที่จ่ายน้อยที่สุด
ทีมการเงินถามคำถามเดียว ออเดอร์ที่ยกเลิกไปแล้วเหลืออีกกี่รายการที่ยังไม่ได้คืนเงินให้ลูกค้า คุณเขียน query ที่อ่านแล้วตรงกับคำถามคำต่อคำ
SELECT idFROM ordersWHERE status = 'Cancelled' AND id NOT IN (SELECT order_id FROM refunds)มันตอบว่า 0
ไม่มีใครสงสัยอะไร ตัวเลข 0 คือข่าวดี ทุกคนปิดวาระนั้นแล้วไปวาระถัดไป เงินไม่เคยออกจากบัญชี และลูกค้าที่รอคืนเงินก็ยังรออยู่
คำตอบที่ถูกคือ 3,003 รายการ ข้อมูลชุดเดิม ตารางใบเดิม
สิ่งเดียวที่ทำให้ผลต่างกันคือในตาราง refunds มีแถวที่ order_id เป็น NULL อยู่ หนึ่งแถว
NOT IN (SELECT …) ที่เจอ NULL ไม่ได้คืนคำตอบที่ขาดไปบางส่วน มันคืนศูนย์แถวเสมอ
· และท่าที่ตอบผิดนั้น จ่ายน้อยกว่า ท่าที่ตอบถูกทั้งบน SQLite และบน PostgreSQL
ใครเลือกท่าด้วยการจับเวลาจะเลือกท่าที่ตอบผิด แล้ว benchmark จะยืนยันให้ด้วยว่าเลือกถูก
NULL แถวเดียวก็พอ
หัวข้อที่มีชื่อว่า “NULL แถวเดียวก็พอ”เพิ่มแถว NULL เข้าไปทีละแถวแล้วนับคำตอบ ท่าอื่นไม่ขยับเลย มีท่าเดียวที่ตกหน้าผา
ตาราง orders มี 200,000 แถว ในนั้นเป็นออเดอร์ที่ยกเลิกแล้ว 5,931 แถว
ตาราง refunds มี 2,928 แถว หนึ่งแถวต่อออเดอร์ที่คืนเงินไปแล้ว
refunds.order_id รับ NULL ได้ เพราะแถวคืนเงินถูกสร้างจาก webhook ของ payment gateway ก่อน
แล้วงาน reconcile ค่อยเติมเลขออเดอร์ทีหลัง ระหว่างนั้นแถวนั้นมี order_id เป็น NULL
ห้าท่าข้างล่างถามคำถามเดียวกัน และตอบเหมือนกันทุกประการตราบใดที่ยังไม่มีแถว NULL
จำนวนแถวที่ order_id เป็น NULL | NOT IN | NOT EXISTS | LEFT JOIN | EXCEPT | NOT IN + IS NOT NULL |
|---|---|---|---|---|---|
| 0 | 3,003 | 3,003 | 3,003 | 3,003 | 3,003 |
| 1 | 0 | 3,003 | 3,003 | 3,003 | 3,003 |
| 2 | 0 | 3,003 | 3,003 | 3,003 | 3,003 |
| 10 | 0 | 3,003 | 3,003 | 3,003 | 3,003 |
อ่านแถวแรกก่อน ตอนที่ยังไม่มี NULL ทั้งห้าท่าคืน 3,003 เท่ากันหมด นั่นคือสิ่งที่ทำให้เทียบกันได้ต่อจากนี้ ทุกท่าทำงานเดียวกันอยู่
จากแถวที่สองลงไป NOT IN เป็น 0 และอยู่ที่ 0 ตลอด
ไม่ว่าจะมีแถว NULL หนึ่งแถวหรือสิบแถวก็ให้ผลเท่ากัน
นี่ไม่ใช่เส้นที่ลาดลง มันคือหน้าผาที่แถวแรก ถ้าคำตอบค่อย ๆ หายไปตามจำนวนแถว NULL กลไกจะเป็นอย่างอื่น การที่มันตกถึงศูนย์ทันทีตั้งแต่แถวแรก บอกว่ากลไกคือตรรกะสามค่า
IN กับ NOT IN ไม่ใช่นิเสธของกัน
หัวข้อที่มีชื่อว่า “IN กับ NOT IN ไม่ใช่นิเสธของกัน”ตารางค่าความจริงสี่บรรทัดนี้อธิบายทั้งหน้า และไม่มีตารางฐานข้อมูลไหนเกี่ยวข้องเลย
SQL ไม่ได้มีสองค่าความจริง มันมีสามค่า จริง เท็จ และ NULL ที่แปลว่า ไม่รู้
| expression | ผลลัพธ์ |
|---|---|
1 IN (1, 2, NULL) | จริง |
5 IN (1, 2, NULL) | NULL |
1 NOT IN (1, 2, NULL) | เท็จ |
5 NOT IN (1, 2, NULL) | NULL |
5 IN (1, 2) | เท็จ |
5 NOT IN (1, 2) | จริง |
สองบรรทัดล่างคือสิ่งที่ทุกคนคาดไว้ ไม่มี NULL เมื่อไร IN กับ NOT IN ก็เป็นนิเสธของกัน
บรรทัดที่สี่คือเหตุผลที่หน้านี้มีอยู่ เอนจินตอบว่า ไม่รู้ ไม่ได้ตอบว่าจริง เพราะมันเทียบ 5 กับ NULL แล้วได้ NULL ซึ่งแปลว่า “ค่าที่ซ่อนอยู่ตัวนั้นอาจเป็น 5 ก็ได้”
ความไม่สมมาตรอยู่ตรงนี้ IN ยังตอบว่า จริง ได้ ถ้ามันเจอตัวที่ตรงจริง ๆ ก่อน
แต่ NOT IN ไม่มีทางตอบว่าจริงได้เลย เมื่อในรายการมี NULL อยู่แม้แต่ตัวเดียว
เพราะมันต้องพิสูจน์ว่า ไม่ตรงกับทุกตัว และตัวที่เป็น NULL พิสูจน์ไม่ได้
WHERE เก็บเฉพาะแถวที่เงื่อนไขเป็นจริง มันทิ้งทั้งเท็จและ NULL
เมื่อไม่มีแถวไหนเป็นจริงได้เลย ผลลัพธ์จึงว่างเปล่า
NOT IN ที่รายการมี NULL ไม่ได้กรองผิดบางแถว มันกรองทิ้งทุกแถว
และมันทำแบบนั้นเงียบ ๆ ไม่มี error ไม่มี warning ไม่มีอะไรในผลลัพธ์ที่บอกว่าเกิดอะไรขึ้น
ท่าที่ตอบผิดคือท่าที่จ่ายน้อยที่สุด
หัวข้อที่มีชื่อว่า “ท่าที่ตอบผิดคือท่าที่จ่ายน้อยที่สุด”นับ opcode ที่ SQLite เดินจริง ท่าที่คืนศูนย์แถวจ่ายน้อยกว่าทุกท่าที่คืนคำตอบถูก
หน่วยที่ใช้ตรงนี้คือ opcode ที่ VDBE เดินจริง ไม่ใช่วินาที เวลาเปลี่ยนตามเครื่องและตามภาระของเครื่อง จำนวนนับไม่เปลี่ยน
| ท่า | คืนกี่แถว | opcode |
|---|---|---|
NOT IN | 0 | 67,557 |
NOT EXISTS | 3,003 | 100,213 |
LEFT JOIN | 3,003 | 106,290 |
NOT IN + IS NOT NULL | 3,003 | 120,866 |
EXCEPT | 3,003 | 132,720 |
ท่าที่ตอบผิดจ่ายน้อยกว่าท่าที่ตอบถูกที่สุดอยู่ 33%
ผลข้างเคียงของเรื่องนี้ร้ายกว่าตัวเลข สมมติมีคนเทียบสองท่านี้ด้วยการจับเวลา
เขาจะเห็นว่า NOT IN ชนะ แล้วเขาจะเลือกมัน และเขาจะมีตัวเลขยืนยันว่าเขาเลือกถูก
benchmark ที่ไม่ได้ตรวจว่าคำตอบตรงกัน ไม่ได้วัดว่าท่าไหนดีกว่า มันวัดว่าท่าไหนเลิกทำงานเร็วกว่า
probe จะโยนทิ้งทันทีถ้าห้าท่าคืนคนละชุดกันตอนที่ยังไม่มีแถว NULL ถ้าไม่บังคับข้อนี้ ตารางราคาข้างบนจะเป็นการเทียบราคาของงานคนละงาน แล้วมันจะไม่มีความหมาย
เอนจินไม่ได้ลัด
หัวข้อที่มีชื่อว่า “เอนจินไม่ได้ลัด”คำตอบเป็นศูนย์แถว แต่เอนจินไม่ได้หยุดตั้งแต่เห็น NULL มันเดินครบแล้วทิ้งผลทั้งหมด
คนอ่านจะเดาว่าคืน 0 แถวก็ต้องเร็วสิ ไม่มีอะไรให้ส่งกลับ และถ้าเอนจินฉลาดพอ มันควรรู้ตั้งแต่เห็นแถว NULL ว่า query นี้ไม่มีทางคืนอะไรได้เลย
มันไม่รู้
ตอนที่ยังไม่มี NULL ท่านี้จ่าย 76,566 opcode เพื่อคืน 3,003 แถว ตอนที่มี NULL แล้ว มันจ่าย 67,557 opcode เพื่อคืน 0 แถว คิดเป็น 88% ของงานเดิม
ถ้ามันลัดได้ ราคาจะเหลือหลักร้อย ไม่ใช่หลักหมื่น สิ่งที่มันทำจริงคือเดินครบทั้ง 5,931 แถว เทียบทีละแถวกับรายการที่มี NULL อยู่ ได้ผลเป็น ไม่รู้ ทุกแถว แล้วทิ้งทั้งหมด
ที่มันจ่ายน้อยกว่าตอนตอบถูกนิดหน่อย เป็นเพราะมันไม่ต้องเรียงและไม่ต้องส่งแถวกลับ ไม่ใช่เพราะมันข้ามงานส่วนไหนไป
แล้ว optimiser จะจัดการให้เองไหม
หัวข้อที่มีชื่อว่า “แล้ว optimiser จะจัดการให้เองไหม”ลบสถิติทิ้งทั้งหมด ราคาขึ้นสิบเท่า แผนเปลี่ยนจาก SEARCH เป็น SCAN แต่คำตอบไม่ขยับเลย
ข้อโต้แย้งแรกที่ทุกคนยกคือเดี๋ยว optimiser ก็จัดการให้เอง
หน้านี้จึงวัดช่องเดิมซ้ำบนฐานที่ลบ sqlite_stat1 และ sqlite_stat4 ทิ้งหมด บน connection ใหม่
| ท่า | opcode เมื่อมีสถิติ | opcode เมื่อลบสถิติ | คืนกี่แถว เมื่อมีสถิติ | คืนกี่แถว เมื่อลบสถิติ |
|---|---|---|---|---|
NOT IN | 67,557 | 641,691 | 0 | 0 |
NOT EXISTS | 100,213 | 659,332 | 3,003 | 3,003 |
LEFT JOIN | 106,290 | 665,410 | 3,003 | 3,003 |
EXCEPT | 132,720 | 677,200 | 3,003 | 3,003 |
ราคาขึ้นราว 9 เท่าทุกท่า แผนของ NOT IN เปลี่ยนจาก SEARCH เป็น SCAN จริง
แต่ช่องขวาสองช่องไม่ขยับแม้แต่ช่องเดียว
คำตอบที่ผิดไม่ได้มาจากแผนที่แย่ มันมาจากความหมายของ operator optimiser เปลี่ยนได้แค่ วิธี ที่เอนจินเดินไปหาคำตอบ มันเปลี่ยน คำตอบ ไม่ได้ และไม่ควรเปลี่ยนด้วย
หลักฐานที่ตรงที่สุดของข้อนี้อยู่ในแผนเอง แผนของ NOT IN เหมือนกันทุกตัวอักษร
ทั้งตอนที่ตาราง refunds มีแถว NULL และตอนที่ไม่มี optimiser มองไม่เห็นความต่างตรงนั้นเลย
และท่านี้ก็ไม่ได้ถูกบังคับให้ materialise รายการทั้งชุดขึ้นมาด้วย
แผนบอกว่ามันใช้ index ix_refunds_order อยู่แล้ว เรื่องนี้จึงไม่ใช่เรื่องแผนที่ยังปรับไม่ดีพอ
ไม่ใช่นิสัยของ SQLite ตัวเดียว
หัวข้อที่มีชื่อว่า “ไม่ใช่นิสัยของ SQLite ตัวเดียว”ยกข้อมูลชุดเดิมทั้งชุดเข้า PostgreSQL แล้วถามคำถามเดิม หน้าผาเดียวกันเกิดขึ้นอีก
ข้อโต้แย้งที่สองคือ SQLite เป็นฐานข้อมูลตัวเล็ก เอาไปเทียบกับตัวจริงไม่ได้
ตัววัดของหน้านี้จึงไม่ได้สร้างข้อมูลใหม่ฝั่ง PostgreSQL มันยกแถวทุกแถวจาก fixture เดิม
เข้าไปด้วย COPY ทั้งสองเอนจินจึงตอบจากข้อมูลชุดเดียวกันจริง ไม่ใช่จากตารางคนละใบที่หน้าตาคล้ายกัน
| ท่า | คืนกี่แถว เมื่อไม่มี NULL | คืนกี่แถว เมื่อมี NULL หนึ่งแถว | buffers |
|---|---|---|---|
NOT IN | 3,003 | 0 | 2,748 |
NOT EXISTS | 3,003 | 3,003 | 2,939 |
LEFT JOIN | 3,003 | 3,003 | 2,975 |
EXCEPT | 3,003 | 3,003 | 2,936 |
ผลเหมือนกันทุกช่อง และ NOT IN ยังเป็นท่าที่แตะ buffer น้อยที่สุดเหมือนฝั่ง SQLite
ท่าที่ตอบผิดจ่ายน้อยที่สุดบนทั้งสองเอนจิน
ความต่างอยู่ที่แผน NOT EXISTS ได้ Hash Anti Join ซึ่งเป็นวิธีที่ planner เตรียมไว้
สำหรับคำถามรูปนี้โดยเฉพาะ ส่วน NOT IN ได้ hashed SubPlan ทุกครั้ง ไม่เคยได้ anti-join เลย
ประกาศ NOT NULL แล้วก็ยังไม่ช่วย
หัวข้อที่มีชื่อว่า “ประกาศ NOT NULL แล้วก็ยังไม่ช่วย”คนที่รู้เรื่องนี้อยู่แล้วจะเถียงทันทีว่าถ้ารู้ว่า column ไม่มี NULL ก็ประกาศ NOT NULL สิ
แล้ว planner จะรู้ว่ามันแปลงเป็น anti-join ได้อย่างปลอดภัย
หน้านี้จึงวัดข้อนั้นแทนที่จะเดา ข้อมูลชุดเดิมทุกแถว query เดิมทุกตัวอักษร ต่างกันแค่คำประกาศบน column เดียว
คำประกาศบน refunds.order_id | buffers | แผน |
|---|---|---|
| รับ NULL ได้ | 2,748 | hashed SubPlan |
NOT NULL | 2,748 | hashed SubPlan |
ไม่ขยับเลยแม้แต่ buffer เดียว planner ยังไม่ยอมแปลง NOT IN เป็น anti-join
ต่อให้ประกาศไว้ชัดเจนแล้วว่า column นั้นไม่มี NULL
ผลที่ออกมา “เหมือนกันเป๊ะทั้งสองฝั่ง” เป็นผลที่เกิดได้จากอีกสาเหตุหนึ่งด้วย คือคำสั่ง DDL ไม่ได้ลง แล้วเราวัดของอย่างเดียวกันสองครั้งโดยไม่รู้ตัว
ตัววัดจึงถาม information_schema.columns ก่อนสรุปทุกครั้ง ว่า column นั้นประกาศไว้อย่างไรจริง ๆ
ได้ YES ในรอบแรกและ NO ในรอบหลัง ถ้าไม่ได้สองค่านี้ probe จะหยุดและไม่รายงานอะไรเลย
การประกาศ NOT NULL ยังคุ้มที่จะทำ แต่คุ้มด้วยเหตุผลอื่น
มันทำให้แถวที่เป็นต้นเหตุ ใส่ลงตารางไม่ได้ตั้งแต่แรก ไม่ใช่ทำให้ query ทนต่อแถวนั้น
ORM กันไว้ให้แล้ว
หัวข้อที่มีชื่อว่า “ORM กันไว้ให้แล้ว”ถาม EF Core ด้วย ToQueryString แล้วรันจริงบนข้อมูลชุดเดิม LINQ ทุกท่าตอบถูกหมด
ถึงตรงนี้คนเขียน C# ควรจะกลัวแล้ว เพราะ !list.Contains(x) คือ LINQ ที่เขียนกันทุกวัน
หน้านี้จึงไม่ได้อ่านเอกสารแล้วรายงาน มันถาม EF Core 10.0.10.0 ตรง ๆ ว่าแปลออกมาเป็นอะไร แล้วรัน query นั้นบนฐานข้อมูลที่ probe สร้างให้ ซึ่งเป็นข้อมูลชุดเดียวกับตารางทุกตารางข้างบน
| LINQ ที่เขียน | คืนกี่แถว |
|---|---|
!db.Refunds.Select(r => r.OrderId).Contains(o.Id) | 3,003 |
!refundedIds.Contains(o.Id) | 3,003 |
!db.Refunds.Any(r => r.OrderId == o.Id) | 3,003 |
.Except(db.Refunds.Select(r => r.OrderId)) | 3,003 |
ทุกท่าตอบ 3,003 ไม่มีท่าไหนตกหลุมเลยสักท่า
EF Core กันไว้ สองชั้น และสองชั้นนี้เป็นคนละกลไกกัน
ชั้นแรกอยู่ในตัวแปล ท่าที่เป็น subquery ไม่ได้ออกมาเป็น NOT IN ดิบ ๆ มันออกมาแบบนี้
NOT (COALESCE("o"."id" IN ( SELECT "r"."order_id" FROM "refunds" AS "r"), 0))COALESCE(…, 0) แปลง NULL ของตรรกะสามค่าให้เป็นเท็จก่อน แล้ว NOT จึงได้จริงตามที่ควรเป็น
นั่นคือการแก้กับดักที่หน้านี้ทั้งหน้าพูดถึง เขียนไว้ในตัวแปลของ ORM
ชั้นที่สองอยู่ฝั่ง client ท่าที่โหลด id ขึ้นมาก่อนแล้วค่อยกรอง
รายการที่โหลดมามีสมาชิก 2,929 ตัว และในนั้นเป็น null อยู่ 1 ตัว
EF ทิ้ง null ตัวนั้นออกจากรายการก่อนปล่อย SQL ออกไป
SQL ที่ได้เป็น NOT IN ธรรมดา มี parameter 2,999 ตัว
และไม่มี parameter ตัวไหนเป็น NULL เลยแม้แต่ตัวเดียว
ทีมหนึ่งไม่เขียน code กันสองชั้นให้กับปัญหาที่ไม่มีจริง
การที่ตัวแปลไล่แปะ COALESCE รอบ IN ทุกตัว และ ยังกรอง null ทิ้งจากฝั่ง client อีกชั้น
คือหลักฐานว่ากับดักนี้เคยกัดคนมาแล้วมากพอที่จะคุ้มกับการเขียน code กันเอาไว้
กับดักไม่ได้หายไป มันย้ายที่
หัวข้อที่มีชื่อว่า “กับดักไม่ได้หายไป มันย้ายที่”ORM กันให้เฉพาะตอนที่คุณเขียน LINQ พอคุณเปิดหน้าต่าง SQL มันตามไปด้วยไม่ได้
ข่าวดีของหัวข้อที่แล้วคือข่าวร้ายของหัวข้อนี้
คนเขียน C# ที่ใช้ EF Core มาห้าปีจะไม่เคยเจอกับดักนี้เลยสักครั้ง
เขาจึงสร้างสัญชาตญาณขึ้นมาว่า NOT IN ปลอดภัย เพราะในประสบการณ์ของเขามันปลอดภัยจริง
แล้ววันหนึ่งเขาต้องเขียน query รายงานให้ฝ่ายการเงิน งานนั้นไม่ผ่าน ORM มันเป็น SQL ที่พิมพ์ลงไปในหน้าต่าง query ตรง ๆ
FromSql ก็เป็นอีกช่องหนึ่ง stored procedure ก็อีกช่อง
เครื่องมือทำ dashboard ที่ต่อตรงเข้าฐานข้อมูลก็อีกช่อง
งาน ETL ที่เขียนเป็น SQL ล้วนก็อีกช่อง ทุกช่องเหล่านี้ไม่มีตัวแปลของ ORM คอยแปะ COALESCE ให้
และช่องเหล่านี้คือช่องที่มักถือคำถามที่แพงที่สุด คำถามเรื่องเงิน คำถามเรื่องยอดคงเหลือ คำถามที่คนเอาคำตอบไปตัดสินใจ ไม่ใช่คำถามที่ใช้วาดหน้ารายการให้ผู้ใช้กดดู
เขียน
NOT EXISTSและปิดต้นเหตุที่ schema ทั้งสองอย่าง ไม่ใช่เลือกอย่างใดอย่างหนึ่ง
เขียน NOT EXISTS แทน มันตอบถูกไม่ว่า column จะมี NULL หรือไม่
และบน PostgreSQL มันเป็นท่าเดียวในสี่ท่าที่ได้ Hash Anti Join
SELECT o.idFROM orders oWHERE o.status = 'Cancelled' AND NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)เหตุผลที่มันรอดไม่ใช่เพราะมันฉลาดกว่า แต่เพราะมันถามคนละคำถาม
EXISTS ถามว่ามีแถวที่เข้าเงื่อนไขไหม ซึ่งตอบได้แค่มีหรือไม่มี ไม่มีช่องให้ ไม่รู้ โผล่มา
ถ้าต้องใช้ NOT IN จริง ๆ ให้กรอง NULL ทิ้งในตัว subquery อย่ากรองที่อื่น
AND id NOT IN (SELECT order_id FROM refunds WHERE order_id IS NOT NULL)ท่านี้ตอบถูกทุกช่องในตารางแรกของหน้านี้ แต่มันเป็นการจำ ไม่ใช่การป้องกัน วันที่มีคนเขียน query ใหม่แล้วลืมบรรทัดนี้ ไม่มีอะไรจะฟ้อง
ประกาศ column เป็น NOT NULL ถ้าโดเมนบอกว่าอย่างนั้นจริง จำไว้ว่ามันไม่ได้ช่วย planner
อย่างที่วัดไว้ข้างบน สิ่งที่มันทำคือทำให้แถวที่เป็นต้นเหตุใส่ลงตารางไม่ได้ตั้งแต่แรก
นั่นเป็นการปิดต้นเหตุ ไม่ใช่การเลี่ยงอาการ
ถ้าโดเมนบอกว่า column นั้นว่างได้จริง อย่าฝืนประกาศ NOT NULL
ให้ยอมรับว่ามี NULL อยู่ แล้วเลิกใช้ NOT IN กับ column นั้นแทน
หา NOT IN (SELECT ใน code ทั้งหมด แล้วดูทีละที่ว่า column ใน subquery ประกาศ NOT NULL ไว้ไหม
ที่ไหนไม่ได้ประกาศ ที่นั่นคือ query ที่คืนศูนย์แถวได้ทุกเมื่อ โดยไม่มีอะไรฟ้อง
ตรวจ view และ CTE ด้วย column ที่มาจาก LEFT JOIN เป็น NULL ได้เสมอ
ต่อให้ column ต้นทางประกาศ NOT NULL ไว้ก็ตาม
วิธีวัด
หัวข้อที่มีชื่อว่า “วิธีวัด”ทุกตัวเลขในหน้านี้มาจาก script ที่รันซ้ำได้ และมีด่านที่พิสูจน์แล้วว่ามันแดงได้
ฝั่ง SQLite ใช้ Python stdlib ล้วน ไม่ต้องติดตั้งอะไร ไม่ต้องมีเซิร์ฟเวอร์ มันอยู่ในเกต unit ของ repo นี้ จึงถูกวัดซ้ำทุกครั้งที่มีคน push
python3 scripts/not-in-null/probe.py --check # วัดซ้ำแล้วตรวจข้ออ้างpython3 scripts/not-in-null/probe.py --selftest # พิสูจน์ว่าด่านแดงได้--check ไม่ได้ตรึงจำนวน opcode ทีละตัว เพราะจำนวน opcode ขึ้นกับบิลด์ของ SQLite ที่รัน
สิ่งที่มันตรวจคือรูปของผลลัพธ์ ซึ่งเป็นสิ่งที่หน้านี้อ้างจริง ๆ
--selftest ทำให้ข้ออ้างทุกข้อผิดทีละข้อ แล้วบังคับว่าด่านต้องจับได้ทุกครั้ง
ด่านที่ไม่เคยถูกทำให้แดง คือด่านที่ยังไม่มีใครรู้ว่ามันตรวจอะไร
ฝั่ง PostgreSQL และฝั่ง EF Core ต้องมี Docker และ dotnet จึงอยู่นอกเกตนั้น ผลของทั้งสองฝั่งถูก commit ไว้เป็น JSON และเกตเทียบตัวเลขในหน้านี้กับ JSON นั้นทุกครั้ง
ตาราง orders เป็นตารางใบเดียวกับที่บทความเรื่อง index และเรื่อง keyset pagination ใช้
ตัววัดของหน้านี้ import ตัวสร้างของมันมาใช้ซ้ำ ไม่ได้คัดลอก code มา
ข้ออ้างว่าเป็นตารางใบเดียวกันจึงผิดเงียบ ๆ ไม่ได้
เอนจินที่ผลิตตัวเลขชุดนี้คือ SQLite 3.53.4 และ PostgreSQL 18.6
อ่านต่อจากต้นทาง
หัวข้อที่มีชื่อว่า “อ่านต่อจากต้นทาง”- SQLite · The IN and NOT IN operators — ตรวจแล้ว 2026-08-22
- PostgreSQL 18 · Subquery Expressions — ตรวจแล้ว 2026-08-22