Skip to content

Return nil from Row#field when the offset is past the end - #366

Open
youdie006 wants to merge 1 commit into
ruby:mainfrom
youdie006:fix/row-field-offset-past-end
Open

Return nil from Row#field when the offset is past the end#366
youdie006 wants to merge 1 commit into
ruby:mainfrom
youdie006:fix/row-field-offset-past-end

Conversation

@youdie006

Copy link
Copy Markdown

The problem

CSV::Row#field(header, offset) raises NoMethodError once the offset goes one past the end of the row, where the docs promise nil.

row = CSV::Row.new(%w{A B C A A}, %w{1 2 3 4 5})

row.field("A", 4)  # => "5"
row.field("A", 5)  # => nil
row.field("A", 6)  # => NoMethodError: undefined method `assoc' for nil:NilClass

Five public entry points reach it — #field, its alias #[], #values_at, #index, and #delete:

field('A', 6)        !! NoMethodError: undefined method `assoc' for nil:NilClass
row['A', 6]          !! NoMethodError: undefined method `assoc' for nil:NilClass
index('A', 6)        !! NoMethodError: undefined method `index' for nil:NilClass
values_at(['A',6])   !! NoMethodError: undefined method `assoc' for nil:NilClass
field('A', -99)      !! NoMethodError: undefined method `assoc' for nil:NilClass

Cause

lib/csv/row.rb:206

pair = @row[minimum_index..-1].public_send(finder, header_or_index)

Array#[start..-1] returns [] when start == length, but nil once start is greater — so the receiver of assoc / [] disappears. lib/csv/row.rb:575 has the same shape and is what #index and #delete go through:

index = headers[minimum_index..-1].index(header)

Why this is the code and not the docs

The documented contract is unconditional:

Returns nil if the header does not exist.
lib/csv/row.rb:202

Returns nil if index is out of range
lib/csv/row.rb:184

and the neighbouring offset already behaves that way: on a 5-field row, field("A", 5) returns nil. Offset 6 returning nil is the only self-consistent reading.

Why it was not caught

test/csv/test_row.rb:70-75 walks the offsets up to exactly the last one that works:

assert_equal(nil, @row.field("A", 4))
assert_equal(nil, @row.field("A", 5))

The row has 5 fields, so it stops one short of the bug.

The change

Guard the slice at both sites. Assertions added to the existing test_field block for offset 6, a far-past offset, and the #[] / #index / #values_at paths.

Verification

Full suite: 527 tests, 4047 assertions, 0 failures, 0 errors — before and after.

Reverting only lib/csv/row.rb and keeping the new assertions fails with NoMethodError: undefined method 'assoc' for nil:NilClass, so they exercise this bug rather than #field in general.


Disclosure: this patch was prepared with AI assistance. The reproduction, the red/green check and the suite runs above were executed against this branch; happy to adjust anything on request.

Array#[start..-1] returns [] when start == length but nil once start is
greater, so the slice in Row#field and Row#index vanishes and the
:assoc / :index send lands on nil:

    row = CSV::Row.new(%w{A B C A A}, %w{1 2 3 4 5})
    row.field("A", 5)  # => nil
    row.field("A", 6)  # => NoMethodError: undefined method `assoc' for nil

The docs promise nil unconditionally ("Returns +nil+ if the header does
not exist", "Returns +nil+ if +index+ is out of range"), and offset 5 on
a 5-field row already returns nil, so offset 6 returning nil is the only
self-consistent behaviour. Negative offsets past the start break the
same way.

Five entry points reach it: #field, its alias #[], #values_at, #index,
and #delete.

The existing assertions stop at the last offset that happens to work
(field("A", 5)), one short of the bug.
@kou

kou commented Sep 1, 2026

Copy link
Copy Markdown
Member

Could you share your motivation for this? You don't have any real-world problem of this, right?

@youdie006

Copy link
Copy Markdown
Author

No, I don't have a real-world problem. I should say that plainly rather than invent one.

I found it by checking documented contracts against behaviour, not from a production incident. I also went looking for a natural way to reach it before answering you, and did not find a convincing one — ragged CSV does not do it, because CSV.parse pads short rows to the header count, so offsets stay in range:

long  = ["1", "2", "3", "4", "5"]
short = ["1", "2", nil, nil, nil]
short.field("A", 4) = nil

So it takes an offset the caller supplied or computed against different data.

What is left, on CSV::Row.new(%w{A B C A A}, %w{1 2 3 4 5}):

field("A", 5)          => nil
field("A", 6)          !! NoMethodError: undefined method `assoc' for nil:NilClass
row["A", 6]            !! NoMethodError: undefined method `assoc' for nil:NilClass
index("A", 6)          !! NoMethodError: undefined method `index' for nil:NilClass
values_at(["A",6])     !! NoMethodError: undefined method `assoc' for nil:NilClass
field("A", -99)        !! NoMethodError: undefined method `assoc' for nil:NilClass
field("Z")             => nil

Two things I would still argue for, and then I'll drop it:

  1. Offset 5 returns nil and offset 6 raises. If out-of-range offsets are meant to be the caller's error, then 5 should raise too — the current line is drawn by Array#[start..-1] returning [] at exactly length and nil past it, not by any decision in row.rb.
  2. undefined method assoc' for nil:NilClasstells the caller nothing about what they did. If the answer is "that's a caller bug", anArgumentErrornaming the offset would say so;nil` is just the option that matches what the docs already promise and costs no new behaviour.

If neither of those moves you, close it — it is a small thing and you have the better view of what CSV users actually hit.

For transparency: I use AI assistance to find and prepare these, and I run and verify everything before sending it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants