11

ไฟล์ CSV, งานข้อมูลขนาดเล็ก และการค้นข้อมูลแบบง่าย

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

th/topic_11_csv_files_queries

ภาพรวม

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

งานกับ CSV โผล่ขึ้นมาในซอฟต์แวร์สายธุรกิจตลอดเวลา ทั้งการ import, export, รายงาน ฝั่งแอดมิน และการส่งต่อข้อมูลระหว่างระบบ หัวข้อนี้จึงอยากให้ผู้เรียนมองงาน CSV เป็น งานข้อมูลขนาดเล็กที่มีลำดับชัด: อ่านข้อมูล เขียนข้อมูล แล้วค่อยค้นหามันผ่าน service ที่ โฟกัสงานเดียว

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

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

  • อ่านไฟล์ CSV เข้าเป็นโครงสร้างข้อมูลของ Ruby ได้
  • เขียนแถวข้อมูลที่มีโครงสร้างกลับไปเป็น CSV พร้อม headers ที่ชัด
  • อธิบายได้ว่าทำไมการจัดการ CSV ควรถูกแยกเป็นเรื่องอ่าน เรื่องเขียน และเรื่องค้นข้อมูล
  • ส่ง reader เข้าไปใน service สำหรับค้นข้อมูล เพื่อให้ทดสอบง่ายขึ้น
  • ใช้ Enumerable เพื่อเขียนการค้นข้อมูลแบบรายงานง่าย ๆ บนข้อมูลที่นำเข้าแล้ว

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

ผมอยากให้คุณอธิบายได้ว่าทำไมการเข้าถึงไฟล์กับ logic การค้นข้อมูล จึงไม่ควรถูกรวมไว้ใน method ใหญ่อันเดียว

โน้ตสั้น

หลายคนเริ่มต้นมอง CSV เป็นงาน utility ง่าย ๆ แค่ว่า "เปิดไฟล์แล้ววนอ่านทีละบรรทัด" มุมมองนั้นแคบเกินไป งาน CSV มักมีความกังวลอยู่สามชั้น:

  • เอาแถวข้อมูลเข้ามาไว้ในหน่วยความจำ
  • เอาแถวข้อมูลที่มีโครงสร้างแล้วเขียนกลับเป็นไฟล์
  • ถามคำถามทางธุรกิจกับข้อมูลที่นำเข้าแล้ว

Ruby ทำงานกับเรื่องนี้ได้สบาย เพราะ:

  • standard library มี CSV ให้อยู่แล้ว
  • hashes และ arrays ทำให้ข้อมูลรายแถวเปิดดูได้ง่าย
  • Enumerable ช่วยให้การค้นข้อมูลแบบรายงานเล็ก ๆ อ่านชัด

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

  • การไหลของข้อมูลเป็นขั้น ๆ ยังอ่านง่ายได้มาก
  • โค้ดค่อย ๆ เคลื่อนจาก file IO ไปสู่ข้อมูลที่มีโครงสร้างอย่างเป็นธรรมชาติ
  • service สำหรับค้นข้อมูลอาจเล็กมาก แต่ยังมีประโยชน์จริง

จุดที่ต้องระวังในหัวข้อนี้:

  • ข้อมูล CSV จริงมักเลอะกว่าที่แบบฝึกหัดทำให้เห็น
  • ถ้าการ parse, การเขียน และการ query ถูกยัดไว้ใน class เดียว โค้ดจะขุ่นเร็วมาก

คำถามชวนคิด:

  • ถ้าแหล่งข้อมูลหยุดเป็นไฟล์ แล้วกลายเป็น API response การออกแบบส่วนไหนจะเปลี่ยนก่อน

ตัวอย่างแบบลงมือดู

Example 1: นำเข้า inventory จากไฟล์ CSV

การนำเข้า inventory เป็นตัวอย่างสอนที่ดี เพราะโครงสร้างของไฟล์เข้าใจง่าย และผลลัพธ์ก็ แปลงเป็น arrays ของ hashes ได้ตรงไปตรงมา

reader.load("inventory.csv")
# => [{"sku"=>"B100", "name"=>"Ruby Book", "price"=>"30", "category"=>"books"}]
worked_examples.md
ruby

นี่เป็น sprint แรกที่ดี เพราะมันแยกการอ่านไฟล์ออกจาก business logic ที่จะตามมา

Example 2: ค้นข้อมูลที่นำเข้าแล้ว

พอโหลดแถวข้อมูลเข้ามาแล้ว ทีมก็มักถามคำถามแบบนี้ต่อ:

  • หาแถวข้อมูลจาก SKU
  • รายการสินค้าทั้งหมดในหมวดหนึ่ง
  • คำนวณค่าเฉลี่ยหรือยอดรวมแบบง่าย

เหตุผลที่ตัวอย่างนี้มีประโยชน์:

  • มันทำให้เห็นว่าการ parse ควรเกิดครั้งเดียว แล้วการค้นข้อมูลควรถูกแยกออกมา
  • มันทำให้ CSV ดูเป็นงานข้อมูลที่มีลำดับขั้น ไม่ใช่แค่แบบฝึกเรื่องรูปแบบของไฟล์
  • มันคล้ายบริการรายงานขนาดเล็กที่เจอได้จริงในแอปพลิเคชัน

โพยสั้น

อ่าน CSV แบบมี headers

CSV.read(path, headers: true).map(&:to_h)
cheatsheet.md
ruby

เขียน CSV แบบมี headers

CSV.open(path, "w") do |csv|
  csv << %w[sku name price]
  rows.each { |row| csv << [row[:sku], row[:name], row[:price]] }
end
cheatsheet.md
ruby

ค้นข้อมูลด้วย Enumerable

rows.select { |row| row["category"] == "books" }
rows.find { |row| row["sku"] == "B100" }
rows.map { |row| row["name"] }
cheatsheet.md
ruby

วิธีคิดแบบ sprint

  • Sprint 1: อ่านแถวข้อมูล
  • Sprint 2: เขียนแถวข้อมูล
  • Sprint 3: ค้นข้อมูลที่นำเข้าแล้วผ่าน service

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

จุดประสงค์ของหัวข้อนี้

หัวข้อนี้อยากให้คุณวางลำดับงานกับ CSV แบบค่อยเป็นค่อยไป โดยเน้นการออกแบบเป็นหลัก: อ่านก่อน เขียนทีหลัง แล้วค่อยค้นข้อมูล ผมอยากให้ผู้เรียนเห็นคุณค่าของการแยกการเข้าถึง ไฟล์ ออกจากคำถามทางธุรกิจ

ลำดับที่ผมแนะนำ

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

แผนแบบ sprint

Sprint 1: Reader

  • อ่านแถวข้อมูลจาก file path
  • ใช้ headers เพื่อให้ผลลัพธ์ออกมาเป็น collection ของ hashes
  • ให้ reader โฟกัสแค่การโหลดข้อมูล ไม่ใช่ตีความความหมายของมัน

Sprint 2: Writer

  • เขียนแถวข้อมูลที่มีโครงสร้างกลับลงไฟล์ CSV
  • รักษาลำดับของ header ให้คงที่
  • คืนอะไรบางอย่างที่คนเรียกใช้ต่อได้ เช่น path

Sprint 3: Service สำหรับค้นข้อมูล

  • ถามคำถามง่าย ๆ กับข้อมูลที่นำเข้าแล้ว
  • ส่ง reader เข้ามา เพื่อให้ service ไม่ผูกกับ file APIs โดยตรง
  • ใช้ Enumerable เพื่อให้ logic สำหรับการรายงานยังอ่านชัด

Sprint 4: แนวทางต่อยอด

  • แปลงค่าตัวเลขของราคาและจำนวน
  • รายงานมูลค่ารวมของ inventory
  • จัดการแถวข้อมูลที่ไม่ถูกต้อง
  • รองรับแหล่งข้อมูลแบบอื่น เช่น StringIO หรือ API payloads

คำถามชวนคิด

  • ทำไม service สำหรับค้นข้อมูลควรพึ่ง reader แทนที่จะเรียก CSV.read ตรง ๆ
  • อะไรจะทำให้ writer ดูแลต่อยาก
  • เมื่อไรการ parse CSV ควรมี type conversion อยู่ด้วย และเมื่อไรควรแยกออกไปอีกชั้น

Source Files and Tests

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

# EXAMPLE CODE
# Topic: topic_11_csv_files_queries
#
# Purpose:
# - This file demonstrates a reference implementation for reading CSV data from a file.
# - It should pass tests from the beginning.
# - Read it before solving the writer and query exercises.

require "csv"

class InventoryCsvReader
  def load(path)
    CSV.read(path, headers: true).map(&:to_h)
  end
end
en/topic_11_csv_files_queries/example.rb
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_11_csv_files_queries
#
# What to do:
# - Write structured rows to a CSV file.
# - Keep header order stable so tests and downstream readers are predictable.
# - Return the path so the caller knows what was written.
#
# Expected outcome:
# - You can export rows to CSV in a small, focused writer object.

require "csv"

class InventoryCsvWriter
  HEADERS = %w[sku name price category].freeze

  def write(path, rows)
    CSV.open(path, "w") do |csv|
      csv << HEADERS
      rows.each do |row|
        csv << [row[:sku], row[:name], row[:price], row[:category]]
      end
    end

    path
  end
end
en/topic_11_csv_files_queries/basic_exercise.rb
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_11_csv_files_queries
#
# Academic purpose:
# - Practice turning imported CSV rows into a small query service.
# - Learn to keep file reading separate from business questions.
#
# Real-world use case:
# - Admin tools and back-office systems often import CSV files and then need quick lookups
#   such as "find this SKU" or "list all rows in this category."
# - A query object keeps those questions away from low-level file handling.
#
# Why Ruby is beautiful here:
# - Arrays of hashes are easy to query with Enumerable.
# - An injected reader makes the service easier to test and replace later.
# - The code stays compact while still describing a real workflow.
#
# What to do:
# - Build a service that loads rows through the reader.
# - Provide a couple of focused query methods.
# - Keep the service about querying, not parsing.
#
# Expected outcome:
# - Advanced tests pass and you can explain the reader/query separation.

class InventoryQueryService
  def initialize(reader:)
    @reader = reader
  end

  def find_by_sku(path, sku)
    @reader.load(path).find { |row| row["sku"] == sku }
  end

  def names_in_category(path, category)
    @reader.load(path)
           .select { |row| row["category"] == category }
           .map { |row| row["name"] }
  end
end
en/topic_11_csv_files_queries/adv_exercise.rb
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_11_csv_files_queries
#
# Solution idea:
# - Use a dedicated writer object so file export stays separate from parsing and queries.
# - Fix the header order up front.
# - Write one row at a time from the hash values in that order.

require "csv"

class InventoryCsvWriter
  HEADERS = %w[sku name price category].freeze

  def write(path, rows)
    CSV.open(path, "w") do |csv|
      csv << HEADERS
      rows.each do |row|
        csv << [row[:sku], row[:name], row[:price], row[:category]]
      end
    end

    path
  end
end
en/topic_11_csv_files_queries/answer_basic_exercise.rb
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_11_csv_files_queries
#
# Solution idea:
# - Inject the reader so the query service asks for rows instead of reading files directly.
# - Use `find` for a single lookup and `select` + `map` for category listing.
# - Keep the methods small and clearly tied to one business question each.

class InventoryQueryService
  def initialize(reader:)
    @reader = reader
  end

  def find_by_sku(path, sku)
    @reader.load(path).find { |row| row["sku"] == sku }
  end

  def names_in_category(path, category)
    @reader.load(path)
           .select { |row| row["category"] == category }
           .map { |row| row["name"] }
  end
end
en/topic_11_csv_files_queries/answer_adv_exercise.rb
Ruby course source
# This spec is your learning companion for topic_11_csv_files_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: writing rows.
# 4) Implement the ADVANCED exercise as sprint 3: querying imported rows.
#
# Expected final result:
# - All examples in this file pass.
# - You understand CSV work as a small pipeline rather than isolated syntax.

require "tempfile"

require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"

RSpec.describe "topic_11_csv_files_queries" do
  describe "EXAMPLE purpose: read rows from a CSV file before adding more behavior" do
    it "loads rows as hashes using headers" do
      file = Tempfile.new(["inventory", ".csv"])
      file.write("sku,name,price,category\nB100,Ruby Book,30,books\nA200,Desk Lamp,45,home\n")
      file.flush

      reader = InventoryCsvReader.new

      expect(reader.load(file.path)).to eq(
        [
          { "sku" => "B100", "name" => "Ruby Book", "price" => "30", "category" => "books" },
          { "sku" => "A200", "name" => "Desk Lamp", "price" => "45", "category" => "home" }
        ]
      )
    ensure
      file.close!
    end
  end

  describe "BASIC EXERCISE purpose: write structured rows in a small export sprint" do
    it "writes rows with a stable header order" do
      file = Tempfile.new(["inventory_export", ".csv"])
      rows = [
        { sku: "B100", name: "Ruby Book", price: 30, category: "books" },
        { sku: "A200", name: "Desk Lamp", price: 45, category: "home" }
      ]

      writer = InventoryCsvWriter.new
      writer.write(file.path, rows)

      expect(File.read(file.path)).to eq(
        "sku,name,price,category\nB100,Ruby Book,30,books\nA200,Desk Lamp,45,home\n"
      )
    ensure
      file.close!
    end
  end

  describe "ADVANCED EXERCISE purpose: query imported data through a focused service" do
    it "uses an injected reader to find rows and list names by category" do
      file = Tempfile.new(["inventory_query", ".csv"])
      file.write("sku,name,price,category\nB100,Ruby Book,30,books\nB200,Refactoring,50,books\nA200,Desk Lamp,45,home\n")
      file.flush

      service = InventoryQueryService.new(reader: InventoryCsvReader.new)

      expect(service.find_by_sku(file.path, "B200")).to eq(
        { "sku" => "B200", "name" => "Refactoring", "price" => "50", "category" => "books" }
      )
      expect(service.names_in_category(file.path, "books")).to eq(["Ruby Book", "Refactoring"])
    ensure
      file.close!
    end
  end
end
en/topic_11_csv_files_queries/tests/topic_11_csv_files_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_11_csv_files_queries/run_topic_tests.sh
Ruby course source

Study Prompts

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

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