code ที่เขียนเพื่อดูแถวที่ลบแล้ว ทำให้เห็นข้อมูลของลูกค้ารายอื่นไปด้วย
โปรแกรมมีหน้าจอหนึ่งที่แอดมินกดดู “ออเดอร์ที่ถูกลบไปแล้ว” คนเขียนรู้ว่ามี global query filter
กันแถวที่ลบแล้วอยู่ จึงเติม IgnoreQueryFilters() เข้าไปในบรรทัดนั้น หน้าจอทำงานถูกต้อง
ทุกอย่าง และ code review ผ่าน
หน้านี้รัน EF Core 10.0.10.0 ตัวจริงบนคลังที่มี 2 tenant แล้วนับว่า บรรทัดนั้นคืนแถวของใครมาบ้าง
คำตอบคือ แถวของทุกคน จาก 2 เป็น 8 และจาก tenant เดียวเป็น 2 tenant ทั้งที่คนเขียนขอแค่ให้เห็นแถวที่ลบแล้ว
เรื่องนี้ไม่ใช่ความลับ เอกสารเขียนไว้ตรง ๆ
หัวข้อที่มีชื่อว่า “เรื่องนี้ไม่ใช่ความลับ เอกสารเขียนไว้ตรง ๆ”สิ่งที่หน้านี้เพิ่มไม่ใช่ข้อเท็จจริงใหม่ แต่เป็นตัวเลขว่าข้อเท็จจริงนั้นแปลว่าอะไรในคลังที่มีสอง tenant
เอกสาร Global Query Filters ของ EF เขียนไว้ตรงตัวว่า “If multiple named filters are configured, this disables all of them.” · พฤติกรรมนี้จึงถูกประกาศไว้แล้ว ไม่ได้ซ่อนอยู่
ที่ซ่อนอยู่คือผลของมันในบริบทของคุณ เพราะบรรทัดที่คนตรวจอ่านไม่ได้บอกว่า entity นั้น มีตัวกรองกี่ตัว และไม่ได้บอกว่าตัวที่เหลือกันอะไรอยู่
entity ในคลังที่วัดประกาศตัวกรองไว้สองตัว ตัวหนึ่งชื่อ SoftDelete กันแถวที่ถูกลบ
อีกตัวชื่อ Tenant กันแถวของลูกค้ารายอื่น
ทั้งสองตัวเป็นกลไกเดียวกัน คือ predicate ที่ EF เติมเข้าไปในทุก query โดยอัตโนมัติ
· IgnoreQueryFilters() ที่ไม่ระบุชื่อ จึงถอด ทั้งสองตัว ออกพร้อมกัน ตามที่เอกสารบอก
ผลคือบรรทัดที่เขียนขึ้นเพื่อเรื่องหนึ่ง ไปเปิดอีกเรื่องหนึ่งที่คนเขียนไม่ได้คิดถึง และไม่มีอะไรในโปรแกรมฟ้องเลยสักจุด
วิธีวัด ตารางเดียว สอง tenant ครึ่งหนึ่งถูกลบ
หัวข้อที่มีชื่อว่า “วิธีวัด ตารางเดียว สอง tenant ครึ่งหนึ่งถูกลบ”คลังเล็กพอให้ตรวจด้วยตาได้ทุกแถว และทุกตัวเลขในหน้านี้มาจากการรันจริง
คลังมี 2 tenant แต่ละ tenant มีลูกค้าสองราย ลูกค้าละสองออเดอร์ รวม 8 ออเดอร์ · ครึ่งหนึ่งของทั้งลูกค้าและออเดอร์ถูก soft delete
DbContext ที่ใช้เป็นของ tenant 1 เท่านั้น ตัวกรอง Tenant จึงควรกันแถวของ
tenant อีกรายไว้ทั้งหมด ไม่ว่า query จะเขียนแบบไหน
คำตอบที่ถูกต้องของ “ออเดอร์ที่ยังไม่ถูกลบของ tenant 1” คือ 2 แถว และของ “ออเดอร์ทั้งหมดของ tenant 1 รวมที่ลบแล้ว” คือ 4 แถว
ตัวเลข สี่ท่าที่เขียนได้ และแถวที่แต่ละท่าคืนมา
หัวข้อที่มีชื่อว่า “ตัวเลข สี่ท่าที่เขียนได้ และแถวที่แต่ละท่าคืนมา”สามท่าแรกดูเหมือนกันในสายตาคนอ่าน code แต่คืนข้อมูลของคนละกลุ่มคน
| ท่าที่เขียน | แถว | tenant ที่โผล่ | ที่ลบแล้ว |
|---|---|---|---|
filter ครบ | 2 | 1 | 0 |
IgnoreQueryFilters() | 8 | 1, 2 | 4 |
IgnoreQueryFilters([“SoftDelete”]) | 4 | 1 | 2 |
IgnoreQueryFilters([“Tenant”]) | 4 | 1, 2 | 0 |
แถวที่สองคือท่าที่ทุกคนเขียน มันคืน 8 แถวจาก 2 tenant · ในนั้นมี 4 แถวที่ไม่ใช่ของ tenant 1 เลย
แถวที่สามคือสิ่งที่คนเขียนตั้งใจจริง ๆ · ระบุชื่อตัวกรองที่จะปิด แล้วตัวที่เหลือยังทำงานอยู่ ได้ 4 แถว tenant เดียว ตรงกับคำตอบที่ถูกต้อง
แถวสุดท้ายมีไว้ให้เห็นว่ากลไกนี้ทำงานสองทางจริง ปิดตัวกรอง Tenant อย่างเดียวก็ได้
และผลคือเห็นแถวข้าม tenant โดยที่แถวที่ลบแล้วยังถูกกันอยู่
หลักฐานไม่ได้อยู่ที่จำนวนแถว มันอยู่ที่ WHERE ที่หายไปทั้งท่อน
หัวข้อที่มีชื่อว่า “หลักฐานไม่ได้อยู่ที่จำนวนแถว มันอยู่ที่ WHERE ที่หายไปทั้งท่อน”ถ้าตัวกรองถูกกรองทีหลังในหน่วยความจำ เรื่องนี้จะเป็นแค่เรื่องประสิทธิภาพ แต่มันหายไปตั้งแต่ SQL
| ท่าที่เขียน | WHERE ที่ provider สร้างขึ้น |
|---|---|
filter ครบ | WHERE NOT ("o"."IsDeleted") AND "o"."TenantId" = @ef_filter___tenant |
IgnoreQueryFilters() | — ไม่มีเงื่อนไขเลย — |
IgnoreQueryFilters([“SoftDelete”]) | WHERE "o"."TenantId" = @ef_filter___tenant |
แถวกลางไม่มี WHERE เลยแม้แต่ตัวเดียว · ฐานข้อมูลถูกถามว่า เอาทุกแถวในตารางนี้มา
และมันก็ตอบตามนั้น
นี่คือเหตุผลที่ปัญหานี้ไม่ใช่เรื่องประสิทธิภาพ · ถ้าตารางมีสิบล้านแถวของลูกค้าหมื่นราย บรรทัดเดียวนั้นดึงมาทั้งหมด
query ที่ไม่ได้เอ่ยชื่อ Orders เลยสักครั้ง ก็รั่วเหมือนกัน
หัวข้อที่มีชื่อว่า “query ที่ไม่ได้เอ่ยชื่อ Orders เลยสักครั้ง ก็รั่วเหมือนกัน”ตัวกรองผูกกับ entity ไม่ได้ผูกกับชื่อที่ปรากฏใน LINQ
query ที่วัดในตารางนี้เขียนว่า Customers.Include(c => c.Orders) คำว่า Orders
ปรากฏในฐานะ navigation เท่านั้น ไม่ได้เป็นจุดเริ่มของ query
| ท่าที่เขียน | ลูกค้า | ออเดอร์ที่ติดมาด้วย | tenant ของออเดอร์ |
|---|---|---|---|
filter ครบ | 1 | 1 | 1 |
IgnoreQueryFilters() | 4 | 8 | 1, 2 |
IgnoreQueryFilters([“SoftDelete”]) | 2 | 4 | 1 |
IgnoreQueryFilters() ที่เขียนไว้เพื่อ Customers ปิดตัวกรองของ Orders ไปด้วย
ได้ออเดอร์ 8 ใบจากทั้งสอง tenant คือ ทั้งตาราง
ข้อนี้คือข้อที่ code review จับยากที่สุด เพราะบรรทัดที่ตรวจไม่ได้พูดถึงตารางที่รั่วเลย คนตรวจต้องรู้ว่า entity ปลายทางของทุก navigation ในนั้นประกาศตัวกรองอะไรไว้บ้าง
รายงานที่นับอย่างเดียว รั่วเงียบกว่าเดิมอีกชั้น
หัวข้อที่มีชื่อว่า “รายงานที่นับอย่างเดียว รั่วเงียบกว่าเดิมอีกชั้น”ไม่มีแถวโผล่ออกมาให้ใครเห็น มีแค่ตัวเลขที่ผิด
หน้าจอที่แสดงแถวยังพอมีโอกาสให้คนสังเกตว่ามีชื่อแปลก ๆ ปนมา · รายงานที่นับอย่างเดียว ไม่มีโอกาสนั้นเลย
| สิ่งที่ถาม | filter ครบ | ปิดตัวกรอง |
|---|---|---|
Count() ของออเดอร์ | 2 | 8 |
| จำนวน tenant ที่โผล่ในผล | 1 | 2 |
ตัวเลขที่ขึ้นแดชบอร์ดจึงเป็นยอดรวมของทั้งระบบ ไม่ใช่ของลูกค้าที่กำลังดูอยู่ และมันจะดูสมเหตุสมผลจนกว่าจะมีคนเอาไปเทียบกับยอดจากที่อื่น
ทำไมท่านี้ถึงกลายเป็นท่ามาตรฐาน
หัวข้อที่มีชื่อว่า “ทำไมท่านี้ถึงกลายเป็นท่ามาตรฐาน”ก่อน EF Core 10 มันเป็นท่าเดียวที่มี และเอกสารทางการเขียนแบบนี้ไว้เอง
เอกสารของ EF Core 10 เขียนไว้ตรง ๆ ว่า “up to now EF has only supported a single query
filter per entity type” · ก่อนรุ่นนี้ การเรียก HasQueryFilter ครั้งที่สอง ทับ
ตัวแรกทิ้ง ไม่ได้เพิ่มเข้าไป
คนที่ต้องการทั้ง soft delete และ multi-tenant จึงต้องมัดสองเงื่อนไขไว้ใน predicate ก้อนเดียว และเมื่อมันเป็นก้อนเดียว การปิดทีละตัวก็เป็นไปไม่ได้ตั้งแต่ต้น
IgnoreQueryFilters() แบบไม่ระบุชื่อจึงไม่ใช่ API ที่ออกแบบมาผิด มันคือ API ที่ถูกต้อง
สำหรับโลกที่มีตัวกรองได้ตัวเดียว · สิ่งที่เปลี่ยนคือโลก ไม่ใช่ API
EF Core 10 เพิ่มตัวกรองแบบมีชื่อเข้ามา แต่ไม่ได้ถอดท่าเก่าออก · IgnoreQueryFilters()
ที่ไม่มี argument ยังคอมไพล์ผ่าน ยังไม่มี warning และยังปิดตัวกรองทุกตัวเหมือนเดิม
โปรเจกต์ที่อัปเกรดมาจากรุ่นก่อนจึงได้ความสามารถใหม่มาโดยที่ code เดิมไม่เปลี่ยนพฤติกรรม เลยแม้แต่นิดเดียว — ซึ่งดีสำหรับการอัปเกรด และเป็นเหตุผลที่บรรทัดเหล่านั้นจะอยู่ต่อไป จนกว่าจะมีคนไปหามันเจอ
ตัวกรองเปิดอยู่ SQL ถูกต้อง แต่แถวที่ลบแล้วยังโผล่
หัวข้อที่มีชื่อว่า “ตัวกรองเปิดอยู่ SQL ถูกต้อง แต่แถวที่ลบแล้วยังโผล่”ข้อนี้ไม่ใช่เรื่องของสวิตช์แล้ว มันคือเรื่องของชั้นที่ตัวกรองไปไม่ถึง
query สองอันข้างล่างเหมือนกันทุกตัวอักษร ตัวกรองเปิดครบทั้งสองตัว และ SQL ที่ออกมา
ก็เหมือนกัน · ต่างกันแค่ว่า DbContext เคยโหลดอะไรไว้ก่อนหน้า
| context ก่อนหน้า | ออเดอร์ที่ติดมากับลูกค้า | ในนั้นเป็นแถวที่ลบแล้ว |
|---|---|---|
| ว่าง | 1 | 0 |
เคยเรียก IgnoreQueryFilters() ไปแล้ว | 2 | 1 |
เหตุผลคือ navigation fixup ทำงานที่ identity map ซึ่งเป็นของฝั่ง EF ในหน่วยความจำ
ส่วนตัวกรองถูกแปลลงไปเป็น WHERE ในฐานข้อมูล สองอย่างนี้อยู่คนละชั้นกัน
entity ที่ถูกโหลดเข้ามาแล้วโดย query ก่อนหน้า จะถูกต่อกลับเข้า navigation ของผลลัพธ์ ใหม่โดยอัตโนมัติ ไม่ว่า query ใหม่จะกรองมันออกไปหรือไม่
ผลคือ query ที่ถูกต้องทุกประการ คืนผลไม่เท่ากันตามลำดับที่ code ถูกเรียก และนี่คือ รูปแบบที่ reproduce ในเทสต์ยากที่สุด เพราะเทสต์มักสร้าง context ใหม่ทุกครั้ง
เอาไปใช้ได้ทันที
หัวข้อที่มีชื่อว่า “เอาไปใช้ได้ทันที”สามข้อ เรียงตามจำนวนบรรทัดที่ต้องแก้
หนึ่ง — ค้นทั้งโปรเจกต์ด้วยคำว่า IgnoreQueryFilters แล้วอ่านทีละจุด คำถามที่ต้องตอบ
ของแต่ละจุดคือ entity นั้นมีตัวกรองกี่ตัว และตั้งใจปิดตัวไหน
สอง — ถ้าอยู่บน EF Core 10 ให้ตั้งชื่อตัวกรองทุกตัว แล้วปิดทีละชื่อ ท่าที่ไม่ระบุชื่อ ควรเหลือไว้เฉพาะที่ตั้งใจจะปิดทุกตัวจริง ๆ เช่นงานย้ายข้อมูลที่รันนอกบริบทของผู้ใช้
สาม — ถ้ายังอยู่บนรุ่นเก่า อย่ารวมตัวกรองข้ามความกังวลไว้ก้อนเดียว ตัวกรองที่กันข้อมูล ของคนอื่น ควรอยู่คนละที่กับตัวกรองที่ซ่อนแถวที่ลบแล้ว · ทางที่ทำได้คือให้ขอบเขต tenant อยู่ที่ connection หรือที่ schema แทนที่จะอยู่ใน predicate ของ query
Query Performance ของคอร์ส EF Core พูดถึงราคาของ query ที่ดึงมามากเกินจำเป็น · หน้านี้คืออีกด้านหนึ่งของเรื่องเดียวกัน คือ query ที่ดึงมามากเกินจำเป็น ในทางที่ไม่ควรเห็น ไม่ใช่แค่ในทางที่ช้า
Authorization ของคลัง DevIQ วางกฎว่าอย่าเชื่อ id ที่ส่งมา ให้ตรวจสิทธิ์ที่ฝั่งเซิร์ฟเวอร์เสมอ · กฎนั้นตรวจ แถวที่ถูกขอมาทีละใบ ส่วนหน้านี้พูดถึง query ที่ไม่มี id สักตัว คือรายการ รายงาน และ export
สิ่งที่หน้านี้ยืนยันไม่ได้
หัวข้อที่มีชื่อว่า “สิ่งที่หน้านี้ยืนยันไม่ได้”ขอบเขตของการวัดแคบกว่าที่หัวข้ออาจทำให้เข้าใจ
- หน้านี้ไม่ใช่การประเมินช่องโหว่ของโปรแกรมใคร มันวัดพฤติกรรมของ API ตัวหนึ่งบนคลัง ที่สร้างขึ้นมาเอง ว่าคืนแถวของใครมาบ้าง
- ตัวเลขทุกตัวมาจาก SQLite provider อื่นสร้าง SQL คนละหน้าตา แต่กลไกของตัวกรอง อยู่ในชั้นที่อยู่เหนือ provider จำนวนแถวจึงไม่ควรต่างกัน — ข้อนี้ยังไม่ได้วัด
- คลังมีแค่ 8 ออเดอร์ ตัวเลขที่รายงานคือ ใครโผล่มาบ้าง ไม่ใช่ รั่วกี่แถว ในระบบจริง · ขนาดของคลังไม่ได้เปลี่ยนข้อสรุป มันเปลี่ยนแค่ขนาดของผลเสีย
- ข้อความจากเอกสารเรื่องตัวกรองตัวเดียวต่อ entity เป็นการยกมา ไม่ใช่การวัด หน้านี้ไม่ได้รัน EF Core รุ่นเก่าเทียบ
- ชั้น identity map ที่วัดได้คือกรณี soft delete ในรูปทรงที่วัด ลูกค้ากับออเดอร์อยู่ tenant เดียวกัน จึงไม่มีแถวข้าม tenant รั่วผ่านทางนั้น
ตัวเลขทุกตัวในหน้านี้มาจากคำสั่งเดียวใน repo ของไซต์นี้
npm run gen:query-filters # รันตัววัดแล้วเขียน file ข้อมูลใหม่npx vitest run test/article-query-filters.test.tsตัววัดสร้างฐานข้อมูล SQLite จริงใน file ชั่วคราว วัด แล้วลบทิ้ง · หน้านี้อ่านตัวเลขจาก
src/data/perf/query-filters.ts ทุกตัว แก้ด้วยมือไม่ได้ เพราะเทสต์รันตัววัดซ้ำ
แล้วเทียบทุกช่อง
เทสต์ไม่ได้เทียบแค่ตัวเลข มันตรึง ข้ออ้าง ไว้ด้วย สามข้อ
- ท่าที่ไม่ระบุชื่อต้องคืนแถวข้าม tenant ส่วนท่าที่ระบุชื่อต้องไม่คืน
WHEREของท่าที่ไม่ระบุชื่อต้องหายไปทั้งท่อน- ผลของ query เดียวกันต้องต่างกันเมื่อ context เคยโหลดแถวที่ถูกกรองไว้แล้ว
- What’s New in EF Core 10 — Named query filters (ตรวจแล้ว 2026-08-21) — ที่มาของประโยคว่า “up to now EF has only supported a single query filter per entity type” และของรูปแบบการตั้งชื่อตัวกรอง
- EF Core Documentation — Global Query Filters (ปรับปรุง 2026-06-24)
(ตรวจแล้ว 2026-08-21) — ที่มาของกลไกที่ EF เติม predicate เข้าไปในทุก query และของ
พฤติกรรมของ
IgnoreQueryFilters - EF Core Documentation — Identity Resolution (ตรวจแล้ว 2026-08-21) — ที่มาของ navigation fixup ที่ทำงานบน identity map