การทำงานกับ SQLite ตั้งแต่พื้นฐานถึง Prepared Statements
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
th/topic_12_sqlite_queries
ภาพรวม
ทำไมหัวข้อนี้จึงสำคัญ
แอปขนาดเล็กจำนวนมากต้องการ persistence จริง ก่อนที่จะต้องการ framework เต็มรูปแบบ SQLite เป็นเครื่องมือสอนที่ดี เพราะผู้เรียนฝึกสร้าง schema, insert, update, query และ prepared statements ได้โดยไม่ต้องตั้ง database server
สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้
เมื่อจบหัวข้อนี้ คุณควรจะ:
- เปิดฐานข้อมูล SQLite และสร้างตารางง่าย ๆ ได้
- insert และอ่านแถวข้อมูลผ่าน Ruby
sqlite3driver ได้ - อธิบายความต่างระหว่าง SQL query ธรรมดา กับ query ที่ส่งค่าผ่าน parameter ได้
- เขียน query object ที่โฟกัสงานของตัวเอง แทนที่จะปน SQL ไว้ทั่วระบบ
- อธิบายได้ว่าทำไม prepared statements จึงสำคัญทั้งเรื่องความปลอดภัยและการนำกลับใช้
จุดที่ผมใช้ดูความเข้าใจ
ผมอยากให้คุณอธิบายได้ว่าทำไมการเตรียม schema, การ query และการ bind parameters จึงเป็นความกังวลคนละแบบ แม้ในโปรเจกต์เล็ก ๆ
โน้ตสั้น
SQLite มีประโยชน์ในการสอน เพราะมันพา persistence จริงเข้ามาโดยมีพิธีรีตองน้อยมาก บทเรียนสำคัญไม่ใช่ "Ruby รัน SQL ได้" แต่คือการเห็นว่าการเก็บข้อมูลจะสะอาดขึ้นเมื่อเรา แยกความรับผิดชอบออกจากกัน:
- object หนึ่งดูแลหรือเตรียม schema
- อีก object หนึ่งถามคำถามทางธุรกิจที่โฟกัส
- SQL แบบ parameterized ช่วยคุ้มครองเส้นแบ่งระหว่างข้อมูลกับโค้ด
จุดที่ Ruby ทำได้ดีในหัวข้อนี้:
sqlite3gem เปิดทางให้เข้าถึง SQL ได้ตรงและอ่านออก- hashes กับ repository objects ขนาดเล็ก ทำให้ฝั่ง Ruby เปิดดูได้ง่าย
- prepared statement อาจสั้นมาก แต่สอนนิสัยด้านความปลอดภัยที่สำคัญได้
จุดที่ต้องระวังในหัวข้อนี้:
- raw SQL ที่กระจายไปทั่วโค้ดจะเริ่มดูแลยากเร็วมาก
- การแทรกค่าลงใน SQL ตรง ๆ เป็นความพลาดที่น่าดึงดูดมาก
- โค้ดตั้งค่าฐานข้อมูลไม่ควรถูกคัดลอกซ้ำแบบไม่คิด
คำถามชวนคิด:
- ทำไม parameter binding จึงเป็นการปรับปรุงด้านการออกแบบ ไม่ใช่แค่ลูกเล่นเรื่อง security
ตัวอย่างแบบลงมือดู
Example 1: local catalog หรือ admin tool
เครื่องมือแอดมินขนาดเล็กหรือโปรแกรมแบบ desktop มักต้องการ persistence แต่ยังไม่จำเป็น ต้องมี database server เต็มรูปแบบ SQLite จึงเป็นตัวเลือกที่สมจริงสำหรับ:
- รายการ inventory
- snapshot ของ CSV ที่นำเข้าไว้
- prototypes ในเครื่อง
- state ของแอปแบบฝังอยู่ภายใน
นั่นทำให้มันเป็นตัวอย่างสอนที่ดีกว่า SQL snippets ลอย ๆ ที่ไม่มีบริบท
Example 2: Query service ที่ใช้ parameter binding
แอปพลิเคชันมักถามคำถามที่โฟกัสชัดแบบนี้อยู่เสมอ:
- หาสินค้าจาก SKU
- แสดงสินค้าทั้งหมดในหมวดหนึ่ง
- คืนรายการสินค้าที่ราคาต่ำกว่าเกณฑ์
prepared statements มีประโยชน์เพราะ:
- รูปร่างของ SQL ยังนิ่งอยู่
- ค่าข้อมูลถูกส่งแยกจากตัว query
- โค้ดนี้ฝึกนิสัยที่ปลอดภัยกว่า ตั้งแต่วันแรกที่เริ่มเขียน
โพยสั้น
เปิดฐานข้อมูล
db = SQLite3::Database.new(path)
db.results_as_hash = trueruby
สร้างตาราง
db.execute <<~SQL
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
category TEXT NOT NULL
)
SQLruby
รัน query ง่าย ๆ
db.execute("SELECT * FROM products ORDER BY id")ruby
prepared statement
statement = db.prepare("SELECT * FROM products WHERE sku = ?")
row = statement.execute("B100").next_hash
statement.closeruby
วิธีคิดแบบ sprint
- Sprint 1: schema และ inserts
- Sprint 2: query object ขนาดเล็ก
- Sprint 3: prepared statements และ parameter binding
คู่มือการเรียน
หัวข้อนี้พาเรื่อง persistence เข้ามาทีละขั้น SQL เองสำคัญก็จริง แต่บทเรียนที่ใหญ่กว่า คือโค้ด Ruby ควรมีพฤติกรรมอย่างไรเมื่อข้อมูลไม่ได้อยู่ใน process อีกต่อไป ทันทีที่โปรแกรม เริ่มเขียนลงฐานข้อมูล เรื่องโครงสร้าง ความปลอดภัย และความรับผิดชอบของแต่ละ object จะสำคัญขึ้นทันที
ลำดับการเรียนที่แนะนำ
เดินหัวข้อนี้ตามลำดับนี้:
- อ่าน
overview.md - อ่าน
shortnote.md - อ่าน
worked_examples.md - เปิด
cheatsheet.mdไว้ใกล้ ๆ ตอนอ่านexample.rb - ทำแบบฝึกหัดพื้นฐาน
- ทำแบบฝึกหัดขั้นสูง
อย่าเพิ่งรีบกระโดดไปที่แบบฝึกหัดก่อนอ่านโน้ต หัวข้อนี้ต่างจากหลายหัวข้อก่อนหน้า เพราะ การคิดเรื่องการออกแบบสักเล็กน้อยตั้งแต่ต้น จะช่วยลดความสับสนทีหลังได้มาก
แผนแบบ sprint
Sprint 1: งานพื้นฐาน
ใน sprint แรก ผมอยากให้ฐานข้อมูลกลายเป็นของธรรมดา คุณควรทำสิ่งเหล่านี้ได้:
- เปิดฐานข้อมูล
- สร้างตารางถ้ายังไม่มี
- insert แถวข้อมูล
- อ่านแถวข้อมูลกลับออกมา
เป้าหมายยังไม่ใช่การทำ abstraction แต่คือการทำให้คุณคุ้นกับทรงของลำดับการทำงานพื้นฐาน
Sprint 2: Query Object ขนาดเล็ก
เมื่อการทำงานพื้นฐานเริ่มนิ่งแล้ว ให้แยกงาน setup ออกจากงานรายงาน คุณควรทำสิ่งเหล่านี้ได้:
- เขียน SQL queries ที่โฟกัสกับคำถามที่พบได้บ่อย
- แยก queries เหล่านั้นออกจาก object ที่ดูแล schema หรือ setup
- คืนผลลัพธ์เป็น Ruby hashes ธรรมดาให้ส่วนอื่นของแอปใช้ต่อ
นี่คือก้าวแรกของการสร้างเส้นแบ่งที่ดี การสร้าง schema กับการถามคำถามทางธุรกิจ มักไม่ ควรอยู่ใน object เดียวกัน
Sprint 3: Prepared Statements
ตอนนี้ให้ทำโค้ดให้ปลอดภัยขึ้นและชัดขึ้น คุณควรทำสิ่งเหล่านี้ได้:
- ส่งค่าผ่าน parameters แทนการแทรกค่าลงใน SQL โดยตรง
- อธิบายได้ว่าการ parameterize ช่วยเรื่องความปลอดภัยและความชัดอย่างไร
- ทำให้การเตรียมและใช้งาน statement ยังเล็กและตรงไปตรงมา
Prepared statements สำคัญเพราะเรื่อง security ก็จริง แต่ยังสำคัญเพราะเรื่องความอ่าน ง่ายด้วย มันทำให้เห็นชัดว่าส่วนไหนของ query คือโครงคงที่ และส่วนไหนมาจากข้อมูล
Sprint 4: แนวทางต่อยอด
ถ้าอยากลองไปอีกนิด ลองอย่างใดอย่างหนึ่งต่อไปนี้:
- update และ delete operations
- transactions
- การจัดการ unique-constraint errors
- query objects ที่ cast ข้อมูลกลับเป็น domain objects
ความพลาดที่พบได้บ่อย
ความพลาดที่เจอบ่อยที่สุดในหัวข้อนี้คือเอาค่ามาแทรกตรง ๆ ลงใน SQL เพราะรู้สึกว่าไวและ ไม่เห็นมีอะไร แต่ทางลัดนี้มักทำให้โค้ดแย่ลงสองชั้นพร้อมกัน: มันปลอดภัยน้อยลง และมันซ่อน เส้นแบ่งระหว่างโครงของ query กับข้อมูลนำเข้า
ถ้า query รับค่าจากข้างนอกเข้ามา ให้ถือว่านี่คือส่วนหนึ่งของการออกแบบ ไม่ใช่แค่รายละเอียด ย่อยของ implementation
คำถามชวนคิด
เมื่อจบหัวข้อนี้ ผมอยากให้คุณตอบคำถามพวกนี้ได้:
- ทำไม object ที่ bootstrap schema กับ object ที่ทำ reporting queries ไม่ควรเป็นตัวเดียวกัน
- ความเสี่ยงอะไรเกิดขึ้นเมื่อเอาค่าไปแทรกลงใน SQL ตรง ๆ
- แม้ไม่มองเรื่อง security อย่างเดียว prepared statement ยังมีประโยชน์ตรงไหนอีก
Source Files and Tests
โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน
# EXAMPLE CODE
# Topic: topic_12_sqlite_queries
#
# Purpose:
# - This file demonstrates basic SQLite operations through the Ruby driver.
# - It should pass tests from the beginning.
# - Read it before solving the query and prepared-statement exercises.
require "sqlite3"
class ProductStore
def initialize(path)
@db = SQLite3::Database.new(path)
@db.results_as_hash = true
end
def setup_schema
@db.execute <<~SQL
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
category TEXT NOT NULL
)
SQL
end
def add_product(sku:, name:, price_cents:, category:)
@db.execute(
"INSERT INTO products (sku, name, price_cents, category) VALUES (?, ?, ?, ?)",
[sku, name, price_cents, category]
)
end
def all_products
@db.execute("SELECT sku, name, price_cents, category FROM products ORDER BY id")
.map { |row| normalize_row(row) }
end
private
def normalize_row(row)
{
"sku" => row["sku"],
"name" => row["name"],
"price_cents" => row["price_cents"],
"category" => row["category"]
}
end
end
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_12_sqlite_queries
#
# What to do:
# - Build a focused query object on top of the product table.
# - Keep the class about asking questions, not about schema creation.
# - Use plain SQL queries first before moving to prepared statements in the advanced step.
#
# Expected outcome:
# - You can answer a couple of small business questions from stored data.
require "sqlite3"
class ProductQueries
def initialize(path)
@db = SQLite3::Database.new(path)
@db.results_as_hash = true
end
def names_in_category(category)
@db.execute(
"SELECT name FROM products WHERE category = '#{category}' ORDER BY name"
).map { |row| row["name"] }
end
def average_price_in_category(category)
row = @db.get_first_row(
"SELECT AVG(price_cents) AS average_price FROM products WHERE category = '#{category}'"
)
row["average_price"]&.to_f
end
end
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_12_sqlite_queries
#
# Academic purpose:
# - Move from simple SQL strings to prepared statements with parameter binding.
# - Learn that database safety and clarity improve when values are passed separately from SQL text.
#
# Real-world use case:
# - Production systems often look up records by identifiers such as SKU, email, or external ID.
# - Prepared statements make those repeated lookups safer and easier to reason about.
#
# Why Ruby is beautiful here:
# - The `sqlite3` gem exposes prepared statements directly without heavy ceremony.
# - The method stays compact while still teaching a serious database practice.
# - A tiny repository object can make low-level SQL behavior feel approachable.
#
# What to do:
# - Build lookup methods using prepared statements.
# - Close statements explicitly after use.
# - Return ordinary Ruby hashes so the caller stays decoupled from statement objects.
#
# Expected outcome:
# - Advanced tests pass and you can explain why parameter binding is better than interpolation.
require "sqlite3"
class PreparedProductQueries
def initialize(path)
@db = SQLite3::Database.new(path)
@db.results_as_hash = true
end
def find_by_sku(sku)
statement = @db.prepare("SELECT sku, name, price_cents, category FROM products WHERE sku = ?")
result = statement.execute(sku)
row = result.next_hash
normalize_row(row)
ensure
safely_close(result)
safely_close(statement)
end
def cheaper_than(max_price_cents)
statement = @db.prepare(
"SELECT sku, name, price_cents, category FROM products WHERE price_cents < ? ORDER BY price_cents ASC"
)
result = statement.execute(max_price_cents)
rows = result.map { |row| normalize_row(row) }
rows
ensure
safely_close(result)
safely_close(statement)
end
private
def normalize_row(row)
return nil unless row
{
"sku" => row["sku"],
"name" => row["name"],
"price_cents" => row["price_cents"],
"category" => row["category"]
}
end
def safely_close(resource)
resource&.close
rescue SQLite3::Exception
nil
end
end
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_12_sqlite_queries
#
# Solution idea:
# - Keep the setup/store object separate from the reporting-style query object.
# - Start with straightforward SQL so the student can focus on what each query asks.
# - Return Ruby values rather than database driver objects.
require "sqlite3"
class ProductQueries
def initialize(path)
@db = SQLite3::Database.new(path)
@db.results_as_hash = true
end
def names_in_category(category)
@db.execute(
"SELECT name FROM products WHERE category = '#{category}' ORDER BY name"
).map { |row| row["name"] }
end
def average_price_in_category(category)
row = @db.get_first_row(
"SELECT AVG(price_cents) AS average_price FROM products WHERE category = '#{category}'"
)
row["average_price"]&.to_f
end
end
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_12_sqlite_queries
#
# Solution idea:
# - Replace interpolation with prepared statements and bound parameters.
# - Keep statement lifecycle local to each method.
# - Normalize returned rows so the rest of the code only sees simple hashes.
require "sqlite3"
class PreparedProductQueries
def initialize(path)
@db = SQLite3::Database.new(path)
@db.results_as_hash = true
end
def find_by_sku(sku)
statement = @db.prepare("SELECT sku, name, price_cents, category FROM products WHERE sku = ?")
result = statement.execute(sku)
row = result.next_hash
normalize_row(row)
ensure
safely_close(result)
safely_close(statement)
end
def cheaper_than(max_price_cents)
statement = @db.prepare(
"SELECT sku, name, price_cents, category FROM products WHERE price_cents < ? ORDER BY price_cents ASC"
)
result = statement.execute(max_price_cents)
rows = result.map { |row| normalize_row(row) }
rows
ensure
safely_close(result)
safely_close(statement)
end
private
def normalize_row(row)
return nil unless row
{
"sku" => row["sku"],
"name" => row["name"],
"price_cents" => row["price_cents"],
"category" => row["category"]
}
end
def safely_close(resource)
resource&.close
rescue SQLite3::Exception
nil
end
end
Ruby course source
# This spec is your learning companion for topic_12_sqlite_queries.
#
# How to use this file:
# 1) Run tests and observe failures or successes.
# 2) Keep the EXAMPLE specs green from the beginning.
# 3) Implement the BASIC exercise as sprint 2: simple query methods.
# 4) Implement the ADVANCED exercise as sprint 3: prepared statements.
#
# Expected final result:
# - All examples in this file pass.
# - You understand SQLite work as schema setup, query design, and parameter binding.
require "tempfile"
require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"
RSpec.describe "topic_12_sqlite_queries" do
def with_db_path
file = Tempfile.new(["products", ".sqlite3"])
file.close
yield file.path
ensure
File.delete(file.path) if file && File.exist?(file.path)
end
describe "EXAMPLE purpose: understand basic SQLite operations before adding query objects" do
it "creates schema, inserts rows, and reads them back" do
with_db_path do |path|
store = ProductStore.new(path)
store.setup_schema
store.add_product(sku: "B100", name: "Ruby Book", price_cents: 3000, category: "books")
store.add_product(sku: "A200", name: "Desk Lamp", price_cents: 4500, category: "home")
expect(store.all_products).to eq(
[
{ "sku" => "B100", "name" => "Ruby Book", "price_cents" => 3000, "category" => "books" },
{ "sku" => "A200", "name" => "Desk Lamp", "price_cents" => 4500, "category" => "home" }
]
)
end
end
end
describe "BASIC EXERCISE purpose: ask simple questions from persisted rows" do
it "returns names and average price for a category" do
with_db_path do |path|
store = ProductStore.new(path)
store.setup_schema
store.add_product(sku: "B100", name: "Ruby Book", price_cents: 3000, category: "books")
store.add_product(sku: "B200", name: "Refactoring", price_cents: 5000, category: "books")
store.add_product(sku: "A200", name: "Desk Lamp", price_cents: 4500, category: "home")
queries = ProductQueries.new(path)
expect(queries.names_in_category("books")).to eq(["Refactoring", "Ruby Book"])
expect(queries.average_price_in_category("books")).to eq(4000.0)
end
end
end
describe "ADVANCED EXERCISE purpose: use prepared statements for focused lookups" do
it "finds products by sku and filters by price threshold" do
with_db_path do |path|
store = ProductStore.new(path)
store.setup_schema
store.add_product(sku: "B100", name: "Ruby Book", price_cents: 3000, category: "books")
store.add_product(sku: "B200", name: "Refactoring", price_cents: 5000, category: "books")
store.add_product(sku: "A200", name: "Desk Lamp", price_cents: 4500, category: "home")
queries = PreparedProductQueries.new(path)
expect(queries.find_by_sku("B200")).to eq(
{ "sku" => "B200", "name" => "Refactoring", "price_cents" => 5000, "category" => "books" }
)
expect(queries.cheaper_than(4600)).to eq(
[
{ "sku" => "B100", "name" => "Ruby Book", "price_cents" => 3000, "category" => "books" },
{ "sku" => "A200", "name" => "Desk Lamp", "price_cents" => 4500, "category" => "home" }
]
)
end
end
end
end
Ruby course source
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
exec "${ROOT_DIR}/run_tests.sh" "$(basename "${SCRIPT_DIR}")"
Ruby course source
Study Prompts
อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise
ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน