ข้าม​ไป​ยัง​เนื้อหา

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() ที่​ไม่​ระบุ​ชื่อ จึง​ถอด ทั้ง​สอง​ตัว ออก​พร้อม​กัน ตาม​ที่​เอกสาร​บอก

ผล​คือ​บรรทัด​ที่​เขียน​ขึ้น​เพื่อ​เรื่อง​หนึ่ง ไป​เปิด​อีก​เรื่อง​หนึ่ง​ที่​คน​เขียน​ไม่​ได้​คิดถึง และ​ไม่มี​อะไร​ใน​โปรแกรม​ฟ้อง​เลย​สัก​จุด

คลัง​เล็ก​พอ​ให้​ตรวจ​ด้วย​ตา​ได้​ทุก​แถว และ​ทุก​ตัวเลข​ใน​หน้า​นี้​มา​จาก​การ​รัน​จริง

คลัง​มี 2 tenant แต่ละ tenant มี​ลูกค้า​สอง​ราย ลูกค้า​ละ​สอ​งอ​อเด​อร์ รวม 8 ออ​เด​อร์ · ครึ่ง​หนึ่ง​ของ​ทั้ง​ลูกค้า​และ​ออ​เด​อร์​ถูก soft delete

DbContext ที่​ใช้​เป็น​ของ tenant 1 เท่านั้น ตัว​กรอง Tenant จึง​ควร​กัน​แถว​ของ tenant อีก​ราย​ไว้​ทั้งหมด ไม่​ว่า query จะ​เขียน​แบบ​ไหน

คำ​ตอบ​ที่​ถูก​ต้อง​ของ “ออ​เด​อร์​ที่​ยัง​ไม่​ถูก​ลบ​ของ tenant 1” คือ 2 แถว และ​ของ “ออ​เด​อร์​ทั้งหมด​ของ tenant 1 รวม​ที่​ลบ​แล้ว” คือ 4 แถว

ตัวเลข สี่​ท่า​ที่​เขียน​ได้ และ​แถว​ที่​แต่ละ​ท่า​คืน​มา

หัวข้อ​ที่​มีชื่อ​ว่า “ตัวเลข สี่​ท่า​ที่​เขียน​ได้ และ​แถว​ที่​แต่ละ​ท่า​คืน​มา”

สาม​ท่า​แรก​ดู​เหมือน​กัน​ใน​สายตา​คน​อ่าน code แต่​คืน​ข้อมูล​ของ​คนละ​กลุ่ม​คน

ท่า​ที่​เขียนแถวtenant ที่​โผล่ที่​ลบ​แล้ว
filter ครบ210
IgnoreQueryFilters()81, 24
IgnoreQueryFilters([“SoftDelete”])412
IgnoreQueryFilters([“Tenant”])41, 20

แถว​ที่​สอง​คือ​ท่า​ที่​ทุก​คน​เขียน มัน​คืน 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 ครบ111
IgnoreQueryFilters()481, 2
IgnoreQueryFilters([“SoftDelete”])241

IgnoreQueryFilters() ที่​เขียน​ไว้​เพื่อ Customers ปิด​ตัว​กรอง​ของ Orders ไป​ด้วย ได้​ออ​เด​อร์ 8 ใบ​จาก​ทั้ง​สอง tenant คือ ทั้ง​ตาราง

ข้อ​นี้​คือ​ข้อ​ที่ code review จับ​ยาก​ที่สุด เพราะ​บรรทัด​ที่​ตรวจ​ไม่​ได้​พูด​ถึง​ตาราง​ที่​รั่ว​เลย คน​ตรวจ​ต้อง​รู้​ว่า entity ปลาย​ทาง​ของ​ทุก navigation ใน​นั้น​ประกาศ​ตัว​กรอง​อะไร​ไว้​บ้าง

รายงาน​ที่​นับ​อย่าง​เดียว รั่ว​เงียบ​กว่า​เดิม​อีก​ชั้น

หัวข้อ​ที่​มีชื่อ​ว่า “รายงาน​ที่​นับ​อย่าง​เดียว รั่ว​เงียบ​กว่า​เดิม​อีก​ชั้น”

ไม่มี​แถว​โผล่​ออก​มา​ให้​ใคร​เห็น มี​แค่​ตัวเลข​ที่​ผิด

หน้า​จอ​ที่​แสดง​แถว​ยัง​พอ​มี​โอกาส​ให้​คน​สังเกต​ว่า​มีชื่อ​แปลก ๆ ปนมา · รายงาน​ที่​นับ​อย่าง​เดียว ไม่มี​โอกาส​นั้น​เลย

สิ่ง​ที่​ถามfilter ครบปิด​ตัว​กรอง
Count() ขอ​งอ​อเด​อร์28
จำนวน tenant ที่​โผล่​ใน​ผล12

ตัวเลข​ที่​ขึ้น​แด​ช​บอร์ด​จึง​เป็น​ยอด​รวม​ของ​ทั้ง​ระบบ ไม่ใช่​ของ​ลูกค้า​ที่​กำลัง​ดู​อยู่ และ​มัน​จะ​ดู​สม​เหตุ​สม​ผล​จนกว่า​จะ​มี​คน​เอา​ไป​เทียบ​กับ​ยอดจาก​ที่​อื่น

ก่อน 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 ก่อน​หน้าออ​เด​อร์​ที่​ติด​มา​กับ​ลูกค้าใน​นั้น​เป็น​แถว​ที่​ลบ​แล้ว
ว่าง10
เคย​เรียก IgnoreQueryFilters() ไป​แล้ว21

เหตุผล​คือ 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 ของ​ไซต์​นี้

Terminal window
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 เคย​โหลด​แถว​ที่​ถูก​กรอง​ไว้​แล้ว
🔗 อ้างอิง​ต้นทาง