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.
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.

1 participant