Skip to content

fix: stop full-text search throwing on models with a dictionary - #123

Open
ttu wants to merge 2 commits into
masterfrom
fix-fulltextsearch-dictionary-property
Open

ttu wants to merge 2 commits into
masterfrom
fix-fulltextsearch-dictionary-property

Conversation

@ttu

@ttu ttu commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Problem

Find(string) throws on any model with a populated dictionary property, making full-text search unusable for those models:

public class Owner
{
    public string Name { get; set; }
    public Dictionary<string, string> Meta { get; set; } = new();
}

collection.InsertOne(new Owner { Name = "Bob", Meta = new() { ["plan"] = "gold" } });
collection.Find("Bob");   // throws
RuntimeBinderException: Operator '==' cannot be applied to operands of type
'System.Collections.Generic.KeyValuePair<string,string>' and '<null>'
   at ObjectExtensions.AnyPropertyHasValue(Object current) in ObjectExtensions.cs:line 87

FullTextSearch walks every property of every item. A Dictionary<,> satisfies IsEnumerable, so each element reaches the recursive helper as a KeyValuePair<,> struct typed dynamic, and the helper's first statement is if (current == null). The runtime binder cannot apply == to a struct with no equality operator.

An empty dictionary never enters the loop, which is why the existing FullTextSearch_Typed / FullTextSearch_Dynamic tests pass — they only exercise models without a populated dictionary.

Fix

Two parts:

  1. current == nullcurrent is null. Pattern matching is not dynamically bound, so the null check no longer depends on the runtime type having an equality operator. This is the root cause, and it also covers lists of any other struct without operator ==, which failed identically. (List<int> was fine only because int has a lifted ==.)

  2. Handle IDictionary before the enumerable branch, matching how HandleTyped and HandleExpando in the same file already treat dictionaries, and search keys and values as separate values.

Part 2 is what makes the behaviour intentional rather than incidental. With only part 1 the search "works", but by comparing against the [key, value] text a KeyValuePair stringifies to — so Find("[plan") and Find(", gold") return false positives. Both are now pinned by a test.

Tests

Seven tests added to CollectionQueryTests, written before the fix and each watched failing first:

  • matches a dictionary value
  • matches a dictionary key
  • matches a non-string key/value pair (Dictionary<int, int>)
  • matches an unrelated property while a dictionary is populated
  • matches an unrelated property with an empty dictionary (the case that already passed — kept as a guard)
  • returns nothing when there is no match
  • does not match dictionary entry formatting ("[plan", ", gold")

Full suite: 245 passed, 0 failed (238 before).

Independently confirmed by the stress harness that found the bug: distinct failures went 8 → 7, with a result-JSON diff showing exactly one resolved and zero new.

Note

The same defect exists on newtonsoft-to-system-text-json — the fix should carry over directly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kn184u1sK2zuFMTc4PNdrR

Summary by CodeRabbit

  • New Features

    • Full-text search now matches dictionary keys and values, including non-string dictionary values and dictionary interfaces.
    • Searches still return results from other properties when dictionaries are empty.
    • Dictionary entry formatting characters and separators are excluded from matches.
  • Bug Fixes

    • Full-text search no longer throws for models with dictionary properties.
    • Null handling in full-text searches is more robust, and queries with no true matches return no results.

Find(string) walks every property of every item and hands each element of an
enumerable property to a recursive helper typed as dynamic. A Dictionary
property satisfies IsEnumerable, so each element arrived as a KeyValuePair
struct, and the helper's "current == null" guard made the runtime binder throw
because that struct has no equality operator. Any model with a populated
dictionary was unsearchable:

  RuntimeBinderException: Operator '==' cannot be applied to operands of type
  'System.Collections.Generic.KeyValuePair<string,string>' and '<null>'

An empty dictionary never enters the loop, which is why this was not caught
before.

Replace the guard with "current is null". Pattern matching is not dynamically
bound, so the check no longer depends on the runtime type having an equality
operator. This also covers lists of any other struct without operator ==, which
failed the same way.

Handle IDictionary before the enumerable branch, matching how HandleTyped and
HandleExpando already treat dictionaries, and search keys and values as
separate values. Without this the search only worked by comparing against the
"[key, value]" text a KeyValuePair stringifies to, making the brackets and
separator searchable, so Find("[plan") and Find(", gold") were false positives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kn184u1sK2zuFMTc4PNdrR
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ttu, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7f7aa6fd-58b9-4f03-bc47-7dbd2eabf0c3

📥 Commits

Reviewing files that changed from the base of the PR and between e87983e and 4a73d31.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • JsonFlatFileDataStore.Test/CollectionQueryTests.cs
  • JsonFlatFileDataStore.Test/TestModels.cs
  • JsonFlatFileDataStore/ObjectExtensions.cs
📝 Walkthrough

Walkthrough

Full-text search now recursively checks dictionary keys and values, safely handles dynamic nulls, and adds collection tests for matching, empty dictionaries, non-string values, formatting characters, interface-typed dictionaries, and no-match cases.

Changes

Dictionary full-text search

Layer / File(s) Summary
Dictionary-aware search logic
JsonFlatFileDataStore/ObjectExtensions.cs
FullTextSearch uses pattern-based null checking and separately searches dictionary keys and values recursively.
Dictionary search validation
JsonFlatFileDataStore.Test/CollectionQueryTests.cs, JsonFlatFileDataStore.Test/TestModels.cs, CHANGELOG.md
Tests and supporting model declarations cover dictionary keys and values, interface-typed dictionaries, non-string values, empty dictionaries, other properties, formatting characters, unmatched terms, and the unreleased fix entry.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main fix: preventing full-text search from throwing on models with dictionary properties.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-fulltextsearch-dictionary-property

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@JsonFlatFileDataStore/ObjectExtensions.cs`:
- Around line 103-114: Broaden IsDictionary and the dictionary handling in
AnyPropertyHasValue to recognize generic IDictionary<TKey,TValue> and
IReadOnlyDictionary<TKey,TValue> declarations, while continuing to enumerate
entries as key/value pairs before the enumerable path. Add a regression test
using an interface-typed dictionary property that verifies nested key or value
matches are found without searching KeyValuePair string representations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b39b0162-ce06-49f9-8025-457440dc19cd

📥 Commits

Reviewing files that changed from the base of the PR and between bac5dc9 and d9a7e8a.

📒 Files selected for processing (2)
  • JsonFlatFileDataStore.Test/CollectionQueryTests.cs
  • JsonFlatFileDataStore/ObjectExtensions.cs

Comment thread JsonFlatFileDataStore/ObjectExtensions.cs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
JsonFlatFileDataStore/ObjectExtensions.cs (1)

103-118: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Handle generic dictionary interfaces, not only IDictionary.

propValue is IDictionary misses runtime values that implement only IDictionary<TKey,TValue> or IReadOnlyDictionary<TKey,TValue>, causing them to enter the enumerable branch and be searched through KeyValuePair.ToString(). This can miss key/value matches and reintroduce false positives from entry formatting. Neither generic interface requires the non-generic IDictionary contract. (learn.microsoft.com)

The new test assigns concrete Dictionary<string, string> instances to both interface properties, so it does not cover this runtime case. Detect generic dictionary interfaces and extract each entry’s Key and Value before the enumerable fallback.

🔎 Verify runtime coverage
#!/usr/bin/env bash
set -euo pipefail

rg -n 'IReadOnlyDictionary|IDictionary|ReadOnlyDictionary|ImmutableDictionary|new Dictionary' \
  JsonFlatFileDataStore.Test JsonFlatFileDataStore

Add a regression test using a dictionary implementation that does not also implement System.Collections.IDictionary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@JsonFlatFileDataStore/ObjectExtensions.cs` around lines 103 - 118, Update the
property-value handling around the IDictionary branch to detect runtime
implementations of generic IDictionary<TKey,TValue> and
IReadOnlyDictionary<TKey,TValue>, including implementations that do not
implement non-generic IDictionary. Extract and recursively search each entry’s
Key and Value before the enumerable fallback; preserve the existing
ExpandoObject handling and add a regression test using a generic dictionary
implementation without System.Collections.IDictionary.

Source: MCP tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 8: Update the CHANGELOG entry to use the established hyphenated spelling
“full-text search” consistently.

---

Outside diff comments:
In `@JsonFlatFileDataStore/ObjectExtensions.cs`:
- Around line 103-118: Update the property-value handling around the IDictionary
branch to detect runtime implementations of generic IDictionary<TKey,TValue> and
IReadOnlyDictionary<TKey,TValue>, including implementations that do not
implement non-generic IDictionary. Extract and recursively search each entry’s
Key and Value before the enumerable fallback; preserve the existing
ExpandoObject handling and add a regression test using a generic dictionary
implementation without System.Collections.IDictionary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9f88630-6226-44e9-9e64-9dc2f02abb91

📥 Commits

Reviewing files that changed from the base of the PR and between d9a7e8a and e87983e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • JsonFlatFileDataStore.Test/CollectionQueryTests.cs
  • JsonFlatFileDataStore.Test/TestModels.cs
  • JsonFlatFileDataStore/ObjectExtensions.cs
🚧 Files skipped from review as they are similar to previous changes (1)
  • JsonFlatFileDataStore.Test/CollectionQueryTests.cs

Comment thread CHANGELOG.md Outdated
* FIXED: Retry JSON parse on read to tolerate concurrent partial-file writes
* FIXED: A failing commit action no longer hangs other callers in the same batch
* FIXED: Collection key in file not matching configured case is now matched case-insensitively, instead of reading empty and duplicating the key on save
* FIXED: Full text search no longer throws on models with a dictionary property, and searches dictionary keys and values

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Hyphenate “full-text search”.

Use the established spelling for this compound term.

Suggested wording
- * FIXED: Full text search no longer throws on models with a dictionary property, and searches dictionary keys and values
+ * FIXED: Full-text search no longer throws on models with a dictionary property, and searches dictionary keys and values
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* FIXED: Full text search no longer throws on models with a dictionary property, and searches dictionary keys and values
* FIXED: Full-text search no longer throws on models with a dictionary property, and searches dictionary keys and values
🧰 Tools
🪛 LanguageTool

[uncategorized] ~8-~8: If this is a compound adjective that modifies the following noun, use a hyphen.
Context: ...nd duplicating the key on save * FIXED: Full text search no longer throws on models with ...

(EN_COMPOUND_ADJECTIVE_INTERNAL)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 8, Update the CHANGELOG entry to use the established
hyphenated spelling “full-text search” consistently.

Source: Linters/SAST tools

The dictionary branch of FullTextSearch tested the declared property type via
IsDictionary, which only recognises the non-generic IDictionary. A property
declared as IDictionary<,> or IReadOnlyDictionary<,> therefore fell through to
the enumerable branch and matched the "[key, value]" text a KeyValuePair
stringifies to, so Find("[plan") and Find(", gold") were false positives.

Test the runtime value with "propValue is IDictionary" instead. A concrete
Dictionary implements the non-generic IDictionary regardless of how the property
is declared, while ExpandoObject does not, so it is still recursed as an object.
This keeps the shared IsDictionary helper untouched, avoiding changes to the
CopyProperties merge paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kn184u1sK2zuFMTc4PNdrR
@ttu
ttu force-pushed the fix-fulltextsearch-dictionary-property branch from e87983e to 4a73d31 Compare July 23, 2026 15:28
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