Skip to content

refactor(graph): one reader for LadybugDB QueryResult rows - #1369

Merged
jfrench9 merged 1 commit into
mainfrom
refactor/collapse-queryresult-readers
Sep 9, 2026
Merged

refactor(graph): one reader for LadybugDB QueryResult rows#1369
jfrench9 merged 1 commit into
mainfrom
refactor/collapse-queryresult-readers

Conversation

@jfrench9

@jfrench9 jfrench9 commented Sep 9, 2026

Copy link
Copy Markdown
Member

Follow-up to #1368, which removed a reader that silently returned nothing. This removes the pattern that produced it.

What

Four call sites read rows this way:

rows = result.get_as_list() if hasattr(result, "get_as_list") else list(result)

QueryResult has no get_as_list — ladybug 0.18.1 exposes get_all, rows_as_dict, get_as_arrow and a has_next/get_next cursor — so the guard is always false and all four have been taking the fallback since the rename. They work today. Replaced with result_rows() in core/ladybug/results.py, built on the documented cursor, matching the row-to-dict readers already in service.py and engine.py.

  • vector_search.py:263 (build, COUNT), :386 (info, TABLE_INFO), :401 (info, COUNT)
  • tables/materialize.py:153 (_get_target_columns, TABLE_INFO)

No behavior change: the fallback every site was silently taking is now the only path.

Why, given they all work

The shape is the defect, not the four instances. It reads as though either branch might fire, so a fifth copy written without the else looks like a style choice rather than a bug. That fifth copy is exactly what #1368 deleted — the HNSW reader that answered 200 {"results": []} for every query, with nothing logged, because a missing method behind hasattr is not an error. Two files, four sites, one demonstrated failure is enough evidence to collapse it.

The honest limit: a helper only prevents recurrence if the next person reaches for it. It's exported from core.ladybug and documented as the one way in, but that's a convention, not a guarantee.

Deliberately left alone

  • _copy_result_rows in materialize.py guards on get_as_arrow, which QueryResult does have. Live guard, correct as written.
  • service.py and engine.py build column-mapped dicts with row caps for API responses. Different job, correct, and riskier to touch than to leave.

The tests were half the bug

The old tests set get_as_list on a MagicMock, so hasattr was true under test and false against the engine — the mock manufactured the very method whose absence was the defect.

Swapping in a fake with the engine's real surface didn't just flip those tests to passing; it made them hang. MagicMock.has_next() returns a truthy Mock, so the cursor never terminates. That's the same lie pointing the other way, and it's why FakeQueryResult now lives in tests/graph_api/conftest.py for anything reading rows off a result.

test_results.py covers order, cursor exhaustion, unnormalized rows (TABLE_INFO callers distinguish tuple rows from dict rows), and a trap fake that raises if anything reaches for get_as_list again.

1393 graph_api tests pass; just test-code clean.

Verification

On the local stack, against the subgraph from #1368:

  • GET /vectorrow_count: 8 — exercises TABLE_INFO and COUNT through the new reader
  • POST /vector/build backend="hnsw"row_count: 8
  • table with no embedding column → 200 with null, not a 500

The TABLE_INFO read is the same query and the same reader _get_target_columns uses during materialization, so that row shape is covered live rather than only under mocks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HHvoJGP23TsTZZ8njeRTWj

Four call sites read rows as

  rows = result.get_as_list() if hasattr(result, "get_as_list") else list(result)

QueryResult has no get_as_list, so the guard is always false and all four
have been taking the fallback since the ladybug rename. They work. The
problem is the shape: it reads as though either branch might fire, and a
fifth copy written without the `else` is a silent empty result, not an
error. That fifth copy existed -- the vector-search HNSW reader removed in
PR #1368, which answered 200 with zero rows for every query.

Replaced with result_rows(), which uses the engine's documented
has_next()/get_next() cursor, matching the row-to-dict readers already in
service.py and engine.py. No behavior change; the fallback each site was
silently taking is now the only path.

Left alone: _copy_result_rows in materialize.py guards on get_as_arrow,
which QueryResult does have, and service.py/engine.py build column-mapped
dicts rather than positional rows.

The tests are the other half. The old ones set get_as_list on a MagicMock,
so hasattr was true under test and false against the engine -- the mock
manufactured the method whose absence was the bug. Swapping in a fake with
the engine's real surface made those tests hang instead of pass: MagicMock
returns a truthy Mock from has_next(), so the cursor never terminates. That
is the same lie in the other direction, and it is why FakeQueryResult now
lives in tests/graph_api/conftest.py for anything reading rows off a
result. test_results.py covers order, exhaustion, unnormalized rows, and a
trap fake that fails if anything reaches for get_as_list again.

Verified on the local stack: GET /vector (TABLE_INFO + COUNT), POST
/vector/build, and the no-such-table path all behave as before. The
TABLE_INFO read is the same query and reader materialize's
_get_target_columns uses.

Claude-Session: https://claude.ai/code/session_01HHvoJGP23TsTZZ8njeRTWj
@github-actions github-actions Bot added the change:normal Normal change: automated review documented on the pull request before merge label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Change classification: normal — platform or application code changed.

A normal change carries a documented automated review on this pull request before merge, in place of a second human approver. The change-classification job requests that review and passes once it has been posted. Recorded by the change-classification job.

@jfrench9

jfrench9 commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

@claude please review this PR

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @jfrench9's task in 1m 6s —— View job


Review: refactor(graph): one reader for LadybugDB QueryResult rows

  • Gather context (diff, changed files)
  • Review core/ladybug/results.py (new result_rows() reader)
  • Review call site updates (vector_search.py, tables/materialize.py)
  • Review test changes (FakeQueryResult, test_results.py, updated tests)
  • Post final review

Verdict: looks correct, small nit only.

The core change is a straightforward mechanical fix: result.get_as_list() if hasattr(result, "get_as_list") else list(result) was always false-branching (LadybugDB's real QueryResult has no get_as_list), and result_rows() in core/ladybug/results.py:31-33 replaces it with the same has_next()/get_next() cursor loop already used consistently elsewhere in the codebase (app.py:88-89, service.py:468/700, engine.py:351-352, migration_service.py, metrics.py, restore.py). All four call sites (vector_search.py:263/386/401, tables/materialize.py:153) were updated identically — no behavior change, matches the PR description.

_copy_result_rows in materialize.py:50-55 was correctly left alone — it guards on get_as_arrow, which QueryResult genuinely has, so that's a live conditional, not the same defect.

Nit: tests/graph_api/core/ladybug/test_results.py:14-25 defines its own local FakeQueryResult, duplicating the one just added to tests/graph_api/conftest.py:56-70 — which test_materialize_helpers.py and test_vector_hnsw.py both import and use directly in this same PR. Since the PR body says the conftest fake is meant to be "for anything reading rows off a result," importing it here too (or subclassing it for the TrapResult case) would avoid two copies of the same cursor logic drifting apart. Very minor — doesn't affect correctness. Fix this →

Test coverage is good: cursor order, exhaustion, unnormalized tuple/dict/list rows, and a trap fake that asserts get_as_list is never touched again — directly encodes the regression this PR is guarding against. The MagicMock.has_next() hang described in the PR body (truthy Mock never terminates the loop) is a sharp, correctly-diagnosed catch, and swapping to a fake with the engine's real surface is the right fix.

I wasn't able to execute uv run pytest in this sandboxed environment (commands require interactive approval I don't have here), so I verified correctness by reading the diff and cross-checking the cursor pattern against existing call sites rather than by running the suite. The PR description states 1393 graph_api tests pass locally.

@jfrench9
jfrench9 merged commit 134028b into main Sep 9, 2026
8 checks passed
@jfrench9
jfrench9 deleted the refactor/collapse-queryresult-readers branch September 9, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:normal Normal change: automated review documented on the pull request before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant