12

การทำงานกับ SQLite ตั้งแต่พื้นฐานถึง Prepared Statements

โค้ด Ruby ใช้ร่วมจากโฟลเดอร์ en/ ของต้นฉบับ เพื่อให้สองภาษาผูกกับชุดทดสอบเดียวกัน

th/topic_12_sqlite_queries

ภาพรวม

ทำไมหัวข้อนี้จึงสำคัญ

แอปขนาดเล็กจำนวนมากต้องการ persistence จริง ก่อนที่จะต้องการ framework เต็มรูปแบบ SQLite เป็นเครื่องมือสอนที่ดี เพราะผู้เรียนฝึกสร้าง schema, insert, update, query และ prepared statements ได้โดยไม่ต้องตั้ง database server

สิ่งที่ผมอยากให้คุณทำได้เมื่อจบหัวข้อนี้

เมื่อจบหัวข้อนี้ คุณควรจะ:

  • เปิดฐานข้อมูล SQLite และสร้างตารางง่าย ๆ ได้
  • insert และอ่านแถวข้อมูลผ่าน Ruby sqlite3 driver ได้
  • อธิบายความต่างระหว่าง SQL query ธรรมดา กับ query ที่ส่งค่าผ่าน parameter ได้
  • เขียน query object ที่โฟกัสงานของตัวเอง แทนที่จะปน SQL ไว้ทั่วระบบ
  • อธิบายได้ว่าทำไม prepared statements จึงสำคัญทั้งเรื่องความปลอดภัยและการนำกลับใช้

จุดที่ผมใช้ดูความเข้าใจ

ผมอยากให้คุณอธิบายได้ว่าทำไมการเตรียม schema, การ query และการ bind parameters จึงเป็นความกังวลคนละแบบ แม้ในโปรเจกต์เล็ก ๆ

โน้ตสั้น

SQLite มีประโยชน์ในการสอน เพราะมันพา persistence จริงเข้ามาโดยมีพิธีรีตองน้อยมาก บทเรียนสำคัญไม่ใช่ "Ruby รัน SQL ได้" แต่คือการเห็นว่าการเก็บข้อมูลจะสะอาดขึ้นเมื่อเรา แยกความรับผิดชอบออกจากกัน:

  • object หนึ่งดูแลหรือเตรียม schema
  • อีก object หนึ่งถามคำถามทางธุรกิจที่โฟกัส
  • SQL แบบ parameterized ช่วยคุ้มครองเส้นแบ่งระหว่างข้อมูลกับโค้ด

จุดที่ Ruby ทำได้ดีในหัวข้อนี้:

  • sqlite3 gem เปิดทางให้เข้าถึง 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 = true
cheatsheet.md
ruby

สร้างตาราง

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
cheatsheet.md
ruby

รัน query ง่าย ๆ

db.execute("SELECT * FROM products ORDER BY id")
cheatsheet.md
ruby

prepared statement

statement = db.prepare("SELECT * FROM products WHERE sku = ?")
row = statement.execute("B100").next_hash
statement.close
cheatsheet.md
ruby

วิธีคิดแบบ sprint

  • Sprint 1: schema และ inserts
  • Sprint 2: query object ขนาดเล็ก
  • Sprint 3: prepared statements และ parameter binding

คู่มือการเรียน

หัวข้อนี้พาเรื่อง persistence เข้ามาทีละขั้น SQL เองสำคัญก็จริง แต่บทเรียนที่ใหญ่กว่า คือโค้ด Ruby ควรมีพฤติกรรมอย่างไรเมื่อข้อมูลไม่ได้อยู่ใน process อีกต่อไป ทันทีที่โปรแกรม เริ่มเขียนลงฐานข้อมูล เรื่องโครงสร้าง ความปลอดภัย และความรับผิดชอบของแต่ละ object จะสำคัญขึ้นทันที

ลำดับการเรียนที่แนะนำ

เดินหัวข้อนี้ตามลำดับนี้:

  1. อ่าน overview.md
  2. อ่าน shortnote.md
  3. อ่าน worked_examples.md
  4. เปิด cheatsheet.md ไว้ใกล้ ๆ ตอนอ่าน example.rb
  5. ทำแบบฝึกหัดพื้นฐาน
  6. ทำแบบฝึกหัดขั้นสูง

อย่าเพิ่งรีบกระโดดไปที่แบบฝึกหัดก่อนอ่านโน้ต หัวข้อนี้ต่างจากหลายหัวข้อก่อนหน้า เพราะ การคิดเรื่องการออกแบบสักเล็กน้อยตั้งแต่ต้น จะช่วยลดความสับสนทีหลังได้มาก

แผนแบบ 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
en/topic_12_sqlite_queries/example.rb
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
en/topic_12_sqlite_queries/basic_exercise.rb
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
en/topic_12_sqlite_queries/adv_exercise.rb
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
en/topic_12_sqlite_queries/answer_basic_exercise.rb
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
en/topic_12_sqlite_queries/answer_adv_exercise.rb
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
en/topic_12_sqlite_queries/tests/topic_12_sqlite_queries_spec.rb
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}")"
en/topic_12_sqlite_queries/run_topic_tests.sh
Ruby course source

Study Prompts

  1. อ่าน test ก่อน แล้วบอกให้ได้ว่าพฤติกรรมใดเป็น example, basic exercise และ advanced exercise

  2. ลองทำแบบฝึกหัดก่อนเปิด answer files แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน