Skip to content

Add read-only support for Visual FoxPro compound index (.cdx) files - #285

Merged
chrisrichards merged 3 commits into
mainfrom
feature/cdx-index-support
Jul 6, 2026
Merged

Add read-only support for Visual FoxPro compound index (.cdx) files#285
chrisrichards merged 3 commits into
mainfrom
feature/cdx-index-support

Conversation

@chrisrichards

Copy link
Copy Markdown
Member

Summary

Adds a DbfDataReader.Cdx namespace that opens Visual FoxPro compound index files, enumerates their named tag indexes, walks entries in key order, and searches for keys. Search results carry the DBF record number, and CdxKeyEntry.RecordIndex converts it to the zero-based index that DbfTable.Seek/DbfDataReader.Seek (#283) expect — so an index hit becomes a row in two lines:

using var dbfTable = new DbfTable(dbfPath);
using var cdxFile = new CdxFile(cdxPath, dbfTable.CurrentEncoding);

var index = cdxFile.GetIndex("CONTACT_ID");
foreach (var entry in index.Search("C0000000042"))
{
    dbfTable.Seek(entry.RecordIndex);
    dbfTable.Read(dbfRecord);
}

Credit

The CDX format handling is ported from the dev/async fork by Dai Rees (@daiplusplus) (MIT), who reverse-engineered the hard parts of this format in 2017–2018:

  • the compound-file tag directory (a root node whose keys are tag names and whose record numbers are file offsets of the per-tag index headers);
  • interior node entries — each key followed by two big-endian UInt32s (record number + child node pointer), where Microsoft's documentation incorrectly describes the old IDX "4 hex characters" layout;
  • the bit-packed leaf format: per-node bit widths and masks for [record number][duplicate count][trailing count], with keys reconstructed via prefix compression (against the previous key) and suffix compression (trailing padding trimmed), and new key bytes growing backwards from the end of the 488-byte area;
  • the << Int64 promotion fix for packed entries wider than 32 bits, and undocumented option/attribute flag values observed in real-world files.

The exhaustive packed-entry unit test vectors are ported directly from the fork.

What the port changes relative to the fork

  • Rewritten onto this library's parsing style: fixed-size reads into buffers, ReadOnlySpan<byte> + BinaryPrimitives — no BinaryReader, no Windows-only FileStream overloads, no reflection. Works on both target frameworks and all platforms.
  • Small public surface (CdxFile, CdxIndex, CdxKeyEntry, CdxIndexHeader, enums, CdxException); node types and traversal internals are internal.
  • Format validation is always on (the fork gated it behind a compile-time flag), sibling-chain walks are guarded against cycles in corrupt files, and leaf-search recursion is converted to iteration.
  • Fixes a comparer defect present in the fork: stored leaf keys are trimmed of trailing padding, and the fork compared only the stored prefix — so a stored key that was a strict prefix of the target compared as equal (searching "ABC" could also match "AB"). Trimmed bytes now compare as the pad byte (0x20), which also preserves correct B-tree ordering during descent.
  • Encoding is configurable (pass the table's CurrentEncoding); Search(string) pads keys to the index key length.

Limitations (documented in the README)

Ascending, byte-wise (MACHINE collation) character keys only: descending indexes throw NotSupportedException on search, FoxPro's binary key transforms for numeric/date index keys are not decoded, key/FOR expressions are exposed as strings but not evaluated, and index entries include deleted records (check IsDeleted after seeking). .idx single-index files are not supported.

Test plan

44 new tests, all green (full suite: 163 passed, 1 pre-existing WIP skip):

  • CdxKeyPackingTests — the fork's exhaustive packed-entry bit-unpacking vectors (lengths 0–8 at multiple offsets).
  • CdxKeyComparerTests — full-length comparison plus the trimmed-prefix regression cases.
  • CdxTests — integration over the test/fixtures/foxprodb files that have shipped (unused) in this repo since 2017: calls.CDX, contacts.CDX, setup.CDX, types.CDX, and FOXPRO-DB-TEST.DCX (database container indexes parse with the same reader). Per tag: entry count matches Count(), every adjacent entry pair is in ascending padded-key order, exact-key searches return the correct duplicate sets, missing-key probes return empty (regression for the fork's historical infinite-loop bug), and — end to end — index entries for every tag whose key expression is a plain character column are resolved via Seek to actual DBF rows whose column value equals the entry key.

🤖 Generated with Claude Code

chrisrichards and others added 3 commits July 6, 2026 11:55
Adds a DbfDataReader.Cdx namespace that opens compound index files,
enumerates the named tag indexes they contain, walks entries in key
order, and searches for keys - returning entries whose RecordIndex
plugs directly into DbfTable.Seek / DbfDataReader.Seek to fetch the
matching rows.

The format handling is ported from the dev/async fork by Dai Rees
(https://github.com/daiplusplus/DbfDataReader/tree/dev/async, MIT),
which reverse-engineered the hard parts: the compound tag directory,
interior nodes (whose entries are big-endian record number + child
pointer pairs, not the documented IDX layout), and the bit-packed,
prefix- and suffix-compressed leaf key format, including the 64-bit
shift promotion fix and undocumented option/attribute flags observed
in real files. The exhaustive packed-entry unit tests are ported from
the fork as well.

This port rewrites the I/O onto the library's span/BinaryPrimitives
parsing style (no BinaryReader, no Windows-only APIs), keeps node
types internal behind a small public surface (CdxFile, CdxIndex,
CdxKeyEntry, CdxIndexHeader), makes format validation always-on,
guards sibling-chain walks against cycles, and fixes a comparer
issue where a stored key that is a strict prefix of the target
compared as equal (trimmed trailing bytes now compare as padding).

Searching supports exact keys (string or byte[]) and a comparison
delegate for range scans. Limitations, documented in the README:
ascending MACHINE-collation character keys only, key expressions are
exposed but not evaluated, and entries include deleted records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Restructure the leaf key prefix copy so the previousKey null guard
  dominates the dereference (S2259; the guard was previously hidden
  from analysis behind Math.Min).
- Extract interior-node descent from SearchCore into
  FindFirstCandidateLeaf and use FirstOrDefault for the entry scan,
  reducing cognitive complexity (S3776, S3267).
- Parse the common node fields inside the node type readers instead
  of passing them individually (S107).
- Rename the zero-valued CdxNodeAttributes member to None (S2346).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

@chrisrichards
chrisrichards merged commit 05c5081 into main Jul 6, 2026
3 checks passed
@chrisrichards

Copy link
Copy Markdown
Member Author

@daiplusplus — eight years on, your dev/async fork has finally made it home 🙂

This PR ports your CDX compound-index work upstream (credit in the PR description and commit message): the compound tag directory, the big-endian interior node entries — where you spotted that Microsoft's docs describe the wrong layout — and the bit-packed, prefix/suffix-compressed leaf keys, including your 64-bit shift promotion fix. Your exhaustive packed-entry test vectors passed unchanged against the port on the first run, and searches now feed a Seek API (#283) so index hits resolve straight to DBF rows. Async record reading landed alongside in #284, shaped in part by studying your fork's async experiments.

Thanks for the reverse-engineering effort — it held up remarkably well after all this time. If you ever feel like giving the port a once-over, I'd welcome your eyes on it.

🤖 Generated with Claude Code

@daiplusplus

daiplusplus commented Jul 12, 2026

Copy link
Copy Markdown

I'm flattered (honestly!).

Regrettably I don't think I can provide any real feedback as I barely remember writing this; and re-reading through it now today gives me a slight feeling of embarrassment from patterns I've since moved-past from - and the extra rigour needed in the async codepath (e.g. I didn't carry CancellationToken around; my use of ConfigureAsync in test-cases needs reviewing as it can break some test-runners; my comments should be converted to runtime assertions, etc).

I have some general recommendations I stick-to in all projects today though (in addition to the things I mentioned above)...

  • Add nullable-reference-type annotations.
  • Convert all .csproj files to SDK-style, with multitargeting, and more.
  • readonly struct-based refinement types are tedious to implement, but help eliminate entire classes of bugs and make program code self-documenting.
  • Trace and document all possible exceptions in /// <exception> comments, so no-one's guessing about where and what to catch.
  • And run VS+.NET's own code-analysis as part of the build process - also tedious to deal with at first, but pays for itself in the long-term, especially when it's a library project like this that will have downstream consumers. I see you're using SonarQube and it isn't complaining about much, but I'm seeing things that FxCop/Roslyn would definitely raise, like the empty DbfFileFormatException class.

...stuff like that. Not that anyone needs to be told any of this, but you did ask ;)

@daiplusplus

daiplusplus commented Jul 13, 2026

Copy link
Copy Markdown

@chrisrichards May I ask how much of your posts are “you” as opposed to Claude acting autonomously through your account?

@chrisrichards

Copy link
Copy Markdown
Member Author

@daiplusplus I'm not using Claude autonomously, I'm still reviewing everything myself. For the post above I asked Claude to create a draft as I genuinely wanted to thank you (especially for the work on supporting indexes), I reviewed, made some minor updates and posted it. Hope that's OK

@daiplusplus

daiplusplus commented Jul 13, 2026

Copy link
Copy Markdown

@chrisrichards I'm not using Claude autonomously, I'm still reviewing everything myself. For the post above I asked Claude to create a draft as I genuinely wanted to thank you (especially for the work on supporting indexes), I reviewed, made some minor updates and posted it. Hope that's OK

In these kinds of human-to-human messages I don't think it's a good idea to leave the "Generated with Claude Code" footer because it gives the impression that Claude wrote the entire comment by itself (and on its own volition) - which I imagine many people will interpret the opposite way as intended (i.e.: if you want to thank someone personally, don't ask AI to do it for you - because that's how it comes across, unfortunately).

@chrisrichards

Copy link
Copy Markdown
Member Author

@daiplusplus Yep, point taken. Will remember for future posts

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