From b0df9540f564b28cb53e97e2c020ef7a750df586 Mon Sep 17 00:00:00 2001
From: Brad <1676751+Sinkleberg@users.noreply.github.com>
Date: Fri, 6 Nov 2026 01:05:11 -0500
Subject: [PATCH 1/2] Document pattern matching on parsed CSV rows
---
doc/csv/recipes/filtering.rdoc | 33 +++++++++++++++++++++
lib/csv/row.rb | 2 ++
test/csv/test_patterns.rb | 52 ++++++++++++++++++++++++++++++++++
3 files changed, 87 insertions(+)
diff --git a/doc/csv/recipes/filtering.rdoc b/doc/csv/recipes/filtering.rdoc
index d92afb72..af0a4bc0 100644
--- a/doc/csv/recipes/filtering.rdoc
+++ b/doc/csv/recipes/filtering.rdoc
@@ -9,6 +9,7 @@ All code snippets on this page assume that the following has been executed:
=== Contents
+- {Filtering Rows with Pattern Matching}[#label-Filtering+Rows+with+Pattern+Matching]
- {Source and Output Formats}[#label-Source+and+Output+Formats]
- {Filtering String to String}[#label-Filtering+String+to+String]
- {Recipe: Filter String to String parsing Headers}[#label-Recipe-3A+Filter+String+to+String+parsing+Headers]
@@ -27,6 +28,38 @@ All code snippets on this page assume that the following has been executed:
- {Recipe: Filter IO Stream to IO Stream parsing and writing Headers}[#label-Recipe-3A+Filter+IO+Stream+to+IO+Stream+parsing+and+writing+Headers]
- {Recipe: Filter IO Stream to IO Stream Without Headers}[#label-Recipe-3A+Filter+IO+Stream+to+IO+Stream+Without+Headers]
+=== Filtering Rows with Pattern Matching
+
+This example uses Ruby 3.0 or later, where +in+ returns +true+ or +false+.
+Hash patterns use symbol keys. When parsing known headers for a hash pattern,
+use header_converters: :symbol:
+
+ source = <<~CSV
+ Name,Department,Salary
+ Bob,Engineering,1000
+ Jane,Sales,2000
+ John,Management,5000
+ CSV
+ data = CSV.parse(source, headers: true, header_converters: :symbol)
+ data.headers # => [:name, :department, :salary]
+ selected = data.select { _1 in name: /^J/ }
+ selected.map(&:fields)
+ # => [["Jane", "Sales", "2000"], ["John", "Management", "5000"]]
+
+Header conversion is opt-in: CSV::Row preserves the header key types supplied
+by the parser. Without conversion, these headers remain strings and a
+following symbol-key pattern matches no rows:
+
+ data = CSV.parse(source, headers: true)
+ data.headers # => ["Name", "Department", "Salary"]
+ data.select { _1 in Name: /^J/ } # => []
+
+An array pattern matches the row's fields independently of header key types:
+
+ selected = data.select { _1 in [/^J/, _, _] }
+ selected.map(&:fields)
+ # => [["Jane", "Sales", "2000"], ["John", "Management", "5000"]]
+
=== Source and Output Formats
You can use a Unix-style "filter" for \CSV data.
diff --git a/lib/csv/row.rb b/lib/csv/row.rb
index 40988001..45b75cb0 100644
--- a/lib/csv/row.rb
+++ b/lib/csv/row.rb
@@ -688,6 +688,8 @@ def to_h
#
# Returns the new \Hash suitable for pattern matching containing only the
# keys specified as an argument.
+ # Hash pattern keys are symbols. To match parsed headers, explicitly use
+ # header_converters: :symbol when parsing the \CSV data.
def deconstruct_keys(keys)
if keys.nil?
to_h
diff --git a/test/csv/test_patterns.rb b/test/csv/test_patterns.rb
index 881f03a3..08de81ec 100644
--- a/test/csv/test_patterns.rb
+++ b/test/csv/test_patterns.rb
@@ -18,10 +18,62 @@ def test_hash_rest
end
end
+ def test_hash_with_converted_headers
+ data = CSV.parse(csv_source, headers: true, header_converters: :symbol)
+ selected = data.select do |row|
+ case row
+ in name: /^J/
+ true
+ else
+ false
+ end
+ end
+
+ assert_equal([:name, :department, :salary], data.headers)
+ assert_equal([["Jane", "Sales", "2000"], ["John", "Management", "5000"]],
+ selected.map(&:fields))
+ end
+
+ def test_patterns_with_string_headers
+ data = CSV.parse(csv_source, headers: true)
+ selected = data.select do |row|
+ case row
+ in Name: /^J/
+ true
+ else
+ false
+ end
+ end
+ assert_empty(selected)
+ assert_equal(["Name", "Department", "Salary"], data.headers)
+
+ selected = data.select do |row|
+ case row
+ in [/^J/, _, _]
+ true
+ else
+ false
+ end
+ end
+ assert_equal([["Jane", "Sales", "2000"], ["John", "Management", "5000"]],
+ selected.map(&:fields))
+ end
+
def test_array
case CSV::Row.new(%i{A B C}, [1, 2, 3])
in *, matched
assert_equal(3, matched)
end
end
+
+ private
+
+ def csv_source
+ <<~CSV
+ Name,Department,Salary
+ Bob,Engineering,1000
+ Jane,Sales,2000
+ John,Management,5000
+ CSV
+ end
end
From 2000d30a1725ba912eef576e4c37121381085e9a Mon Sep 17 00:00:00 2001
From: Brad <1676751+Sinkleberg@users.noreply.github.com>
Date: Mon, 9 Nov 2026 06:23:48 -0500
Subject: [PATCH 2/2] Move the row pattern recipe to parsing and clarify header
conversion
---
doc/csv/recipes/filtering.rdoc | 33 ---------------------
doc/csv/recipes/parsing.rdoc | 41 +++++++++++++++++++++++++++
lib/csv/row.rb | 6 ++--
test/csv/test_patterns.rb | 52 ----------------------------------
4 files changed, 45 insertions(+), 87 deletions(-)
diff --git a/doc/csv/recipes/filtering.rdoc b/doc/csv/recipes/filtering.rdoc
index af0a4bc0..d92afb72 100644
--- a/doc/csv/recipes/filtering.rdoc
+++ b/doc/csv/recipes/filtering.rdoc
@@ -9,7 +9,6 @@ All code snippets on this page assume that the following has been executed:
=== Contents
-- {Filtering Rows with Pattern Matching}[#label-Filtering+Rows+with+Pattern+Matching]
- {Source and Output Formats}[#label-Source+and+Output+Formats]
- {Filtering String to String}[#label-Filtering+String+to+String]
- {Recipe: Filter String to String parsing Headers}[#label-Recipe-3A+Filter+String+to+String+parsing+Headers]
@@ -28,38 +27,6 @@ All code snippets on this page assume that the following has been executed:
- {Recipe: Filter IO Stream to IO Stream parsing and writing Headers}[#label-Recipe-3A+Filter+IO+Stream+to+IO+Stream+parsing+and+writing+Headers]
- {Recipe: Filter IO Stream to IO Stream Without Headers}[#label-Recipe-3A+Filter+IO+Stream+to+IO+Stream+Without+Headers]
-=== Filtering Rows with Pattern Matching
-
-This example uses Ruby 3.0 or later, where +in+ returns +true+ or +false+.
-Hash patterns use symbol keys. When parsing known headers for a hash pattern,
-use header_converters: :symbol:
-
- source = <<~CSV
- Name,Department,Salary
- Bob,Engineering,1000
- Jane,Sales,2000
- John,Management,5000
- CSV
- data = CSV.parse(source, headers: true, header_converters: :symbol)
- data.headers # => [:name, :department, :salary]
- selected = data.select { _1 in name: /^J/ }
- selected.map(&:fields)
- # => [["Jane", "Sales", "2000"], ["John", "Management", "5000"]]
-
-Header conversion is opt-in: CSV::Row preserves the header key types supplied
-by the parser. Without conversion, these headers remain strings and a
-following symbol-key pattern matches no rows:
-
- data = CSV.parse(source, headers: true)
- data.headers # => ["Name", "Department", "Salary"]
- data.select { _1 in Name: /^J/ } # => []
-
-An array pattern matches the row's fields independently of header key types:
-
- selected = data.select { _1 in [/^J/, _, _] }
- selected.map(&:fields)
- # => [["Jane", "Sales", "2000"], ["John", "Management", "5000"]]
-
=== Source and Output Formats
You can use a Unix-style "filter" for \CSV data.
diff --git a/doc/csv/recipes/parsing.rdoc b/doc/csv/recipes/parsing.rdoc
index 63673072..f9571161 100644
--- a/doc/csv/recipes/parsing.rdoc
+++ b/doc/csv/recipes/parsing.rdoc
@@ -61,6 +61,8 @@ All code snippets on this page assume that the following has been executed:
- {Using Multiple Header Converters}[#label-Using+Multiple+Header+Converters]
- {Recipe: Specify Multiple Header Converters in Option :header_converters}[#label-Recipe-3A+Specify+Multiple+Header+Converters+in+Option+-3Aheader_converters]
- {Recipe: Specify Multiple Header Converters in a Custom Header Converter List}[#label-Recipe-3A+Specify+Multiple+Header+Converters+in+a+Custom+Header+Converter+List]
+- {Pattern Matching}[#label-Pattern+Matching]
+ - {Recipe: Match Parsed Rows by Header or Position}[#label-Recipe-3A+Match+Parsed+Rows+by+Header+or+Position]
- {Diagnostics}[#label-Diagnostics]
- {Recipe: Capture Unconverted Fields}[#label-Recipe-3A+Capture+Unconverted+Fields]
- {Recipe: Capture Field Info}[#label-Recipe-3A+Capture+Field+Info]
@@ -522,6 +524,45 @@ Apply multiple header converters by defining and registering a custom header con
parsed = CSV.parse(source, headers: true, header_converters: :my_header_converters)
parsed.headers # => [:name, :value]
+=== Pattern Matching
+
+==== Recipe: Match Parsed Rows by Header or Position
+
+This example requires Ruby 3.0 or later.
+Hash patterns use symbol keys. The built-in +:symbol+ header converter
+also downcases headers and replaces spaces with underscores:
+
+ source = <<~CSV
+ First Name,Department,Salary
+ Bob,Engineering,1000
+ Jane,Sales,2000
+ John,Management,5000
+ CSV
+ data = CSV.parse(source, headers: true, header_converters: :symbol)
+ data.headers # => [:first_name, :department, :salary]
+ selected = data.select { _1 in first_name: /^J/ }
+ selected.map(&:fields)
+ # => [["Jane", "Sales", "2000"], ["John", "Management", "5000"]]
+
+Headers are strings by default. So a symbol key doesn't match:
+
+ data = CSV.parse(source, headers: true)
+ data.headers # => ["First Name", "Department", "Salary"]
+ data.select { _1 in first_name: /^J/ } # => []
+
+To keep the original case, use a custom converter that calls +to_sym+:
+
+ case_sensitive = CSV.parse("Name\nJane\n", headers: true,
+ header_converters: ->(header) { header.to_sym })
+ case_sensitive.headers # => [:Name]
+ case_sensitive.first in Name: /^J/ # => true
+
+An array pattern matches the row's fields independently of header key types:
+
+ selected = data.select { _1 in [/^J/, _, _] }
+ selected.map(&:fields)
+ # => [["Jane", "Sales", "2000"], ["John", "Management", "5000"]]
+
=== Diagnostics
==== Recipe: Capture Unconverted Fields
diff --git a/lib/csv/row.rb b/lib/csv/row.rb
index 45b75cb0..414f59f5 100644
--- a/lib/csv/row.rb
+++ b/lib/csv/row.rb
@@ -688,8 +688,10 @@ def to_h
#
# Returns the new \Hash suitable for pattern matching containing only the
# keys specified as an argument.
- # Hash pattern keys are symbols. To match parsed headers, explicitly use
- # header_converters: :symbol when parsing the \CSV data.
+ # Hash pattern keys are symbols. The +:symbol+ header converter downcases
+ # headers and replaces spaces with underscores: "First Name"
+ # becomes +:first_name+. To keep the original case, use
+ # header_converters: ->(header) { header.to_sym } instead.
def deconstruct_keys(keys)
if keys.nil?
to_h
diff --git a/test/csv/test_patterns.rb b/test/csv/test_patterns.rb
index 08de81ec..881f03a3 100644
--- a/test/csv/test_patterns.rb
+++ b/test/csv/test_patterns.rb
@@ -18,62 +18,10 @@ def test_hash_rest
end
end
- def test_hash_with_converted_headers
- data = CSV.parse(csv_source, headers: true, header_converters: :symbol)
- selected = data.select do |row|
- case row
- in name: /^J/
- true
- else
- false
- end
- end
-
- assert_equal([:name, :department, :salary], data.headers)
- assert_equal([["Jane", "Sales", "2000"], ["John", "Management", "5000"]],
- selected.map(&:fields))
- end
-
- def test_patterns_with_string_headers
- data = CSV.parse(csv_source, headers: true)
- selected = data.select do |row|
- case row
- in Name: /^J/
- true
- else
- false
- end
- end
- assert_empty(selected)
- assert_equal(["Name", "Department", "Salary"], data.headers)
-
- selected = data.select do |row|
- case row
- in [/^J/, _, _]
- true
- else
- false
- end
- end
- assert_equal([["Jane", "Sales", "2000"], ["John", "Management", "5000"]],
- selected.map(&:fields))
- end
-
def test_array
case CSV::Row.new(%i{A B C}, [1, 2, 3])
in *, matched
assert_equal(3, matched)
end
end
-
- private
-
- def csv_source
- <<~CSV
- Name,Department,Salary
- Bob,Engineering,1000
- Jane,Sales,2000
- John,Management,5000
- CSV
- end
end