ไฟล์ 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"}]ruby
นี่เป็น sprint แรกที่ดี เพราะมันแยกการอ่านไฟล์ออกจาก business logic ที่จะตามมา
Example 2: ค้นข้อมูลที่นำเข้าแล้ว
พอโหลดแถวข้อมูลเข้ามาแล้ว ทีมก็มักถามคำถามแบบนี้ต่อ:
- หาแถวข้อมูลจาก SKU
- รายการสินค้าทั้งหมดในหมวดหนึ่ง
- คำนวณค่าเฉลี่ยหรือยอดรวมแบบง่าย
เหตุผลที่ตัวอย่างนี้มีประโยชน์:
- มันทำให้เห็นว่าการ parse ควรเกิดครั้งเดียว แล้วการค้นข้อมูลควรถูกแยกออกมา
- มันทำให้ CSV ดูเป็นงานข้อมูลที่มีลำดับขั้น ไม่ใช่แค่แบบฝึกเรื่องรูปแบบของไฟล์
- มันคล้ายบริการรายงานขนาดเล็กที่เจอได้จริงในแอปพลิเคชัน
โพยสั้น
อ่าน CSV แบบมี headers
CSV.read(path, headers: true).map(&:to_h)ruby
เขียน CSV แบบมี headers
CSV.open(path, "w") do |csv|
csv << %w[sku name price]
rows.each { |row| csv << [row[:sku], row[:name], row[:price]] }
endruby
ค้นข้อมูลด้วย Enumerable
rows.select { |row| row["category"] == "books" }
rows.find { |row| row["sku"] == "B100" }
rows.map { |row| row["name"] }ruby
วิธีคิดแบบ sprint
- Sprint 1: อ่านแถวข้อมูล
- Sprint 2: เขียนแถวข้อมูล
- Sprint 3: ค้นข้อมูลที่นำเข้าแล้วผ่าน service
คู่มือการเรียน
จุดประสงค์ของหัวข้อนี้
หัวข้อนี้อยากให้คุณวางลำดับงานกับ CSV แบบค่อยเป็นค่อยไป โดยเน้นการออกแบบเป็นหลัก: อ่านก่อน เขียนทีหลัง แล้วค่อยค้นข้อมูล ผมอยากให้ผู้เรียนเห็นคุณค่าของการแยกการเข้าถึง ไฟล์ ออกจากคำถามทางธุรกิจ
ลำดับที่ผมแนะนำ
- อ่าน
overview.md - อ่าน
shortnote.md - อ่าน
worked_examples.md - เปิด
cheatsheet.mdไว้ตอนอ่านexample.rb - ทำแบบฝึกหัดพื้นฐานเรื่อง writer
- ทำแบบฝึกหัดขั้นสูงเรื่อง 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
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
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
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
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
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
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 แล้วจดว่าคำตอบต่างจากวิธีคิดแรกของคุณตรงไหน