Database Issues
SQLite Performance Query Silently Zeroes Fields
SQLite Performance Query Silently Zeroes Fields
Problem
An integration test (test_merge_tags) failed with usage_count == 0 when 2 was expected, despite the tag being
correctly assigned to 2 pages.
Symptoms:
tag_repo.get_tag_by_id(id).usage_countalways returns 0tag_repo.list_tags()returns the correctusage_countfor the same tag- No error, no warning — the field is silently zero
Investigation
Steps Tried
- Checked
SetPageTagsUseCase— correctly inserts intopage_tagstable - Checked
page_tagstable contents — both assignments present - Compared SQL queries between
get_tag_by_idandlist_tags— found the difference
Root Cause
Two SQL query constants serve different purposes:
-- TAG_SELECT_SIMPLE (used by get_tag_by_id, get_tag_by_name)-- Returns 0 AS usage_count to avoid LEFT JOIN overheadSELECT t.id, t.name, t.slug, t.color, t.group_id, 0 AS usage_count, -- ← hardcoded zero! t.created_at, t.updated_atFROM tags t
-- TAG_SELECT (used by list_tags, search_tags)-- Computes real count via LEFT JOINSELECT t.id, t.name, t.slug, t.color, t.group_id, COUNT(pt.tag_id) AS usage_count, t.created_at, t.updated_atFROM tags tLEFT JOIN page_tags pt ON pt.tag_id = t.idGROUP BY t.idThe performance optimization is documented in the code comment:
“Used by single-entity lookups that only need tag metadata. Avoids the LEFT JOIN overhead. Returns
usage_countas 0 — callers that need real counts should useTAG_SELECT.”
The test used the wrong accessor method.
Solution
// Before (broken) — get_tag_by_id uses TAG_SELECT_SIMPLE, always returns 0let source_before = tag_repo.get_tag_by_id(source.id).unwrap();assert_eq!(source_before.usage_count, 2); // FAILS: left=0
// After (fixed) — list_tags uses TAG_SELECT with real LEFT JOIN countlet all_tags = tag_repo.list_tags().unwrap();let source_before = all_tags.iter().find(|t| t.id == source.id).unwrap();assert_eq!(source_before.usage_count, 2); // PASSESPrevention
Best Practices
- Never assert on fields from “simple” queries — if a query skips expensive joins for performance, its denormalized fields cannot be trusted for correctness assertions
- Document zero-valued fields at the type level — when a struct field may be intentionally zero depending on how it
was loaded, consider using
Option<u32>instead ofu32to make the ambiguity visible - Name query variants explicitly —
TAG_SELECT_SIMPLEvsTAG_SELECTis good, but methods likeget_tag_by_id_with_counts()would make the caller’s choice explicit
Warning Signs
- A struct field that is loaded from different SQL queries with different semantics
- Performance-optimized queries that hardcode
0 AS field_nameorNULL AS field_name - Tests that pass in unit tests (with mocks that return real values) but fail in integration tests (with real SQL)
Pattern: Two-Query Accessor
When a repository has both “fast” and “full” queries, consider:
- Separate return types:
TagSummary(no count) vsTagWithStats(with count) - Or
Option<u32>for fields that may not be populated - At minimum, document which methods return which fields
References
- Commit:
ee03e57e— fix(tests): use list_tags for real usage_count in test_merge_tags - File:
crates/infrastructure/sqlite/src/workspace/tag_repository.rs(TAG_SELECT_SIMPLE vs TAG_SELECT) - File:
tests/core/tests/tag_tests.rs:289
Previous
SQLite GROUP BY/HAVING on Non-Aggregate Columns Is Undefined Next
SQLite Post-Migration Rust Backfill for Application-Generated Values
SQLite GROUP BY/HAVING on Non-Aggregate Columns Is Undefined Next
SQLite Post-Migration Rust Backfill for Application-Generated Values
Was this page helpful?
Thanks for your feedback!