Skip to content

LibRed: metadata surface, stored queries, complex columns, and byte-parity writes - #302

Merged
ChrisJollyAU merged 14 commits into
CirrusRedOrg:masterfrom
ChrisJollyAU:schemawork
Sep 22, 2026
Merged

ChrisJollyAU merged 14 commits into
CirrusRedOrg:masterfrom
ChrisJollyAU:schemawork

Conversation

@ChrisJollyAU

@ChrisJollyAU ChrisJollyAU commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

This branch gives LibRed an ADO.NET metadata surface, reads and writes every kind of stored query Access stores, adds complex (multi-value and attachment) columns, and brings its index writes to byte parity with ACE. It also turns the analysers on across src. Nearly every behaviour change was measured against ACE first, and the file-format changes are pinned by whole-file byte comparisons against ACE's own output.

LibRed ADO.NET surface

  • 20 metadata collections, in ACE's shapes: tables, columns, indexes, primary and foreign keys, the four constraint rowsets, statistics, views and procedures. Procedure parameters and view column usage follow the OLE DB shapes, which ACE serves through neither.
  • GetSchemaTable/IDbColumnSchemaGenerator: each output column carries the stored column it came from, so an alias keeps the stored name, a joined column names the other table, and a computed one is read-only. Key and base information is reported whether or not KeyInfo was asked for.
  • GetDataTypeName gives the provider's name, not the CLR type's. Before, every text column read as String.
  • CommandType.StoredProcedure and TableDirect execute the named query with arguments bound by name. The batch splitter no longer breaks an Access PARAMETERS clause away from the query it declares for.
  • CommandBehavior is honoured: SchemaOnly plans a statement and runs none of it, SingleRow stops at the buffered row, CloseConnection closes with the reader.
    • A stored procedure can be described without parameter values.
    • Reporting a view's columns used to sort and buffer the whole of any view with an ORDER BY.

LibRed SQL engine

  • Every stored query kind Access stores reads back as runnable SQL and is written as ACE writes it. UPDATE, DELETE, make-table and append-from-SELECT were recognised, named and refused.
    • A declared parameter survives the round trip; its facets live in the parameter row's LvExtra. Text(50) was stored as a memo and rendered back as Text(255).
    • PARAMETERS names are lowered in an UPDATE and a DELETE too, so a parameterized update no longer compares a column against itself.
    • A body with no FROM no longer throws out of the decomposer.
    • HAVING reads and writes. A totals query carrying one was dropped from the catalog entirely, and selecting from it said the table did not exist.
    • The designer's own qualified column, [Order Details.UnitPrice], binds. A period cannot appear in an Access object name, so the parser splits it.
  • DateDiff gives Null where a count will not fit, as ACE does, instead of throwing; DateDiff_Big counts the same boundaries into an Int64. "ms" is no longer the one interval that counts wide.
    • Extended mode translates the Unix-epoch methods through DateDiff_Big; compatible mode keeps the Jet translator.
  • Two-argument LTRIM/RTRIM, as SQL Server 2022 has them: the second argument is a set of characters. Trim still takes one, since Access's second argument does not exist.
  • STRING_AGG is a second spelling of LISTAGG, sharing DISTINCT, FILTER (WHERE …) and the window form.

LibRed file format — complex columns

  • The in-row value is a 4-byte record id, not the long-value descriptor it decoded as. The values are rows of a per-column flat table keyed by it.
  • The flat table and its two bookkeeping columns are resolved by index shape, never by name, because neither name follows a rename.
  • One id per row, shared by every complex column of the table — the counter is a single TDEF word at 0x1C, separate from the table's own. An empty read is the ordinary answer, not an error.
  • Attachments write, and ACE reads them back. Compression is keyed on extension by Access convention rather than by the format, and the 256 MB ceiling is Access's policy.
  • A complex column carries the 0x04 AutoNumber flag, so a table can have several columns that read as AutoNumber. Three guards enforcing "at most one" threw on any ALTER COLUMN of such a table.
    • The rule is about the header's seed/increment pair, which one column may draw on.
    • The read applied that pair to every flagged column, so each reported the table counter's seed as its own.
  • On a schema surface it is presented as a Memo column is, as ACE presents it. Before, it reported a non-nullable bare varchar.

LibRed file format — writes that now match ACE

  • A delete writes the bytes ACE's delete writes, including one that cascades into a complex column's flat tables. Three things differed, all of them dead-byte handling.
    • A page is zero-filled when allocated and never cleared again, so a rewrite carries the destination's bytes past the new live end.
    • A delete keeps the prefix length the page was stored at. ACE re-compresses only a leaf that fills, and a delete never fills one.
    • The per-index entry counters leave a block whose total reads 0 alone, unique included. Without this the totals went negative.
  • A leaf splits where ACE splits it: the cut is taken over the entries the page held before the insert, and the new entry joins whichever side its key falls on.
  • Left unmatched deliberately, and recorded as open:
    • Page allocation. ACE extends the file by an eight-page chunk where LibRed reuses a free page; matching would grow files that need not grow.
    • A key sorting below everything on the page splits at the same point, but ACE then stores that page uncompressed.

Build and analysers

  • src/Directory.Build.props turns on AnalysisMode=Recommended and EnforceCodeStyleInBuild, so every CA/IDE diagnostic in src is a build error. The test projects stay on the SDK default.
  • Nearly everything is fixed rather than suppressed. A #pragma with a written reason only where the rule's own fix would change behaviour: IDE0028, whose collection expression drops a comparer; CA1711/CA1069 on the transcribed ADO/ADOX/DAO enums; CA1051, CA1010, CA1848, CA2254 on EF Core and ADO.NET shapes.
  • Three behaviour changes: eight throw new Exception(...) in EFCore.Jet.Data become InvalidOperationException; the .mdb extension test in both database creators is case-insensitive, where .MDB never matched; JetDualTable's public static fields become properties.
  • src/Shared/SharedSource.props declares the shared sources and the implicit usings they need. The five projects' <Using> lists had drifted, and IDE0005 is evaluated per project, so formatting one would delete a using another still needs.

Tests and CI

  • New suites cover the schema collections, the reader's column provenance, each stored query kind both ways, complex-column resolution, and an attachment round-trip through DAO.
  • Two byte-parity guards: a delete matches ACE page for page, and a split leaf is cut where ACE cuts it.
  • C# sources check out CRLF on every platform. * text=auto gave the working tree the platform's ending while .editorconfig pins crlf, so every Linux and macOS leg failed the build on IDE0055 once it became an error. The index is unaffected.
  • The Core file-format suites run on the right runners. In pull_request.yml the driver-free LibRed.Core.Tests ran on the Windows ACE job, the five-platform job ran no Core tests, and LibRed.Core.AccessTests ran nowhere and was missing from the libred path filter. push.yml already had this right.
  • The grammar gained updateStatement/deleteStatement as procedure bodies; the parser is regenerated from AccessSql.g4 with the committed script.

Docs

  • Format spec (src/LibRed/docs/format): the index B-tree, TDEF and column pages, the system catalog and the complex-column layout are updated with everything above, including the two parity questions left open.
  • Commits: ACE never forces the OS cache to disk, and its commit-sync settings change only whether a write is issued inside the statement or after it returns. The transactions design note had recorded a commit fsyncing twice.
  • Over-long text: VARCHAR(256) and above fails the whole CREATE TABLE, rather than being promoted to a memo or clamped. LibRed refuses at the same threshold with ACE's wording.
  • CLAUDE.md/AGENTS.md: the analysis settings and the src/Shared formatting trap are described, and the claim that src/LibRed/Directory.Build.props bypasses src/Directory.Build.props is corrected — it imports it.

🤖 Generated with Claude Code

ChrisJollyAU and others added 12 commits September 20, 2026 16:12
ACE never forces the OS cache to disk - no FlushFileBuffers on a
statement, on an explicit commit or at close - and opens the file
without write-through, so its durability is the OS cache plus the
commit-byte protocol. Its commit-sync settings change only whether a
write is issued inside the statement or after it returns. Two orderings
hold in every mode: a page's lock is released only after that page has
been written, and the connection's own commit slot is written
immediately before the first page of a batch and again after the last.
An explicit transaction writes nothing until it commits, and growth
writes the new page's last byte before the page. The transactions design
note claimed a commit fsyncs twice; it now records the measured
protocol, and what LibRed does against it.

Also note that how aggressively freed long-value pages are reclaimed is
an engine setting, RecycleLVs, which ships as 1 on ACE and 0 on Jet 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetSchema served nothing, a reader could not describe its own columns,
and a command took only text. All three now answer from the catalog and
the planner, in the shapes ACE's OLE DB provider uses - verified row for
row against it over Northwind: tables, columns, indexes, primary and
foreign keys, the four constraint rowsets, statistics, views and
procedures all match, and the two ACE serves through neither call
(procedure parameters, view column usage) follow the OLE DB shapes.
TABLE_TYPE comes from MSysObjects.Flags, a view's columns from planning
its query - so a passed-through column reports the stored column behind
it, facets and all - and a table's logical indexes carry the foreign-key
direction byte, which is what lets an incoming relationship stay hidden
as ACE hides it.

The reader implements IDbColumnSchemaGenerator and GetSchemaTable from
the same description: each output column carries the stored column it
came from, so an alias keeps the stored name, a joined column names the
other table, and a computed one stands alone as read-only. Key and base
information is reported whether or not KeyInfo was asked for, since the
round trip that flag exists to avoid is one this provider never makes.

A command now accepts StoredProcedure and TableDirect, executing the
named query with its arguments bound by name. An Access PARAMETERS
clause ends in a semicolon, so the batch splitter no longer breaks it
away from the query it declares for - which is the form every stored
parameterized query reads back as.

CommandBehavior is honoured rather than ignored: SchemaOnly plans a
statement for its shape and runs none of it (measured: ACE leaves an
INSERT's table untouched under it), SingleRow stops at the row already
buffered, and CloseConnection closes the connection with the reader.
Describing needs the executor to build a plan that reads nothing, so
every leaf yields no rows and no seek key, offset or count is evaluated
- which is also why a stored procedure can be described without the
parameter values nobody supplied. Reporting a view's columns goes
through the same path, where it used to sort and buffer the whole of any
view with an ORDER BY.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stored action query was read back as executable SQL for only two of
its kinds, and written for the same two. The rest - UPDATE, DELETE,
make-table, and an append fed by a SELECT - were recognised, named and
refused, although the engine could run every one of those statements
already. They are recognised because Access stores them the way it
stores a view: one row per source table, one per join condition, one for
the WHERE, one per declared parameter. Only the action row and the
meaning of the column rows differ - an assignment names its target
column, qualified when the update runs over a join; an append names the
column it fills; a DELETE keeps the verbatim `table.*` when the
statement names one, and nothing at all when it does not.

So the rebuild now shares the clause builders with the view path, and
every kind reads back and runs by name. Writing them is the same
arrangement in reverse, and what it writes is row for row what ACE
writes for the same statement, object flags included, which is how each
kind is checked. The MSysObjects flags are DAO's QueryDef.Type in the
low byte - not the action row's own numbering, which differs.

A declared parameter now survives the round trip. It could not before:
its size never reached the type mapper, so Text(50) was stored as a
memo, and its facets were dropped, so Access rendered Text(255) back.
Those facets live in the parameter row's LvExtra - a length for text,
precision and scale packed together for a decimal, nothing for the types
that record neither, a sized binary included. Elsewhere in the row set
LvExtra holds uninitialised bytes, so nothing reads or writes it there.
The parser lowered PARAMETERS names in a SELECT and an INSERT but not in
an UPDATE or a DELETE, which left a parameterized update comparing a
column against itself.

A procedure or view body with no FROM at all - `SELECT 1 AS n`, which
Access stores and opens - threw NullReferenceException out of the
decomposer, and would have been dropped silently on the way back in.
Access stores it as any other query minus the table rows; so does
LibRed now. ACE will open such a query but not use it as a source,
on its own files as much as on ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec gave text's 1..255 limit but not what happens when DDL asks
for more, and the bullet next to it is about an over-long value, which
is a different refusal with a different message. Measured: VARCHAR(255)
creates the column, VARCHAR(256) and anything above it fails the whole
CREATE TABLE with "Size of field is too long" - not promoted to a memo,
not clamped. LibRed refuses the same declarations at the same
threshold and opens its message with ACE's wording, which is already
covered by tests on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two-argument LTRIM/RTRIM, as SQL Server 2022 has them: the second
argument is a SET of characters rather than a substring, so every
leading (or trailing) character found in it is removed and the stripping
stops at the first that is not. An empty set strips nothing and either
argument Null gives Null. The one-argument form is untouched - still
Access's, stripping the space and U+3000 alone - and Trim still takes
only the one, since Access's own second argument does not exist.

STRING_AGG turned out to be a second spelling of the LISTAGG already
here, so it shares the implementation, DISTINCT, FILTER (WHERE …) and
the window form. What differs is what each insists on: LISTAGG needs its
WITHIN GROUP and lets the separator go, where STRING_AGG needs the
separator and lets the order go - and without a WITHIN GROUP the values
list in the order the rows arrive, which over a window is window order.
The separator is written as a string, because it is the one value the
whole group shares.

The ACE arity test records LTrim/RTrim as taking one argument or two,
the way it already records Log: ACE rejects the second, deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetDataTypeName answered with the CLR type's name, so every text column
read as "String" whether it was a Char, a VarChar or a LongText - while
the same reader's GetColumnSchema and GetSchemaTable had the provider's
name for it all along. It now gives that name, which is also the one the
DataTypes collection and Columns.TYPE_NAME use, so one type has one name
across the whole surface and the fixed/variable distinction survives.

ACE answers this with the OLE DB spelling instead (DBTYPE_WVARCHAR where
this says VarChar), but its own schema rowsets use these short names, so
matching them is what keeps the provider consistent with itself. A
result with nothing described behind it - a system-variable select - has
no stored column to name and still falls back to the CLR name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/Directory.Build.props turns on AnalysisMode=Recommended and
EnforceCodeStyleInBuild. TreatWarningsAsErrors already applied, so every
diagnostic is a build error. The test projects import the repo-root props
and stay on the SDK's default rule set.

Nearly everything is fixed rather than suppressed: culture arguments on
locale-sensitive conversions and comparisons, Any() -> Count, First() ->
indexer, sealing, GC.SuppressFinalize, ObjectDisposedException.ThrowIf,
missing <param> tags, override parameter names matched to their base, dead
members removed.

A #pragma, each with a written reason, only where the rule's own fix would
change behaviour or fights a fixed external contract: IDE0028, whose
collection expression silently drops a comparer (measured); CA1859 where it
is wrong about what a method returns; CA1711/CA1069 on the ADO/ADOX/DAO
type-library enums, which are transcribed name-for-name; CA1051, CA1010,
CA1848 and CA2254 on EF Core and ADO.NET house shapes.

Three behaviour changes: eight `throw new Exception(...)` in EFCore.Jet.Data
become InvalidOperationException; the .mdb extension test in both database
creators is now case-insensitive, where .MDB never matched before; and
JetDualTable's two public static fields become properties.

src/Shared/SharedSource.props declares the shared sources and the implicit
usings they need, imported by the five projects that compile them. Those
projects' <Using> lists had drifted, so a directive redundant in one was
load-bearing in another, and IDE0005 is evaluated per project - formatting
one project would delete a using the rest still needed.

RowEncoder keeps its JetFormatBase parameter, unread until Jet 3 needs it.

CLAUDE.md and AGENTS.md said src/LibRed/Directory.Build.props bypasses
src/Directory.Build.props. It imports it, which is how the LibRed projects
pick the analyzers up; corrected, and both now describe the analysis
settings and the src/Shared formatting trap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DateDiff counts into Access's Long Integer, so DateTimeOffset.ToUnixTimeSeconds
overflowed it against any date before about 1902 — the BasicTypes data holds
year-0101 ones — and the evaluator threw. ACE does not: measured over OLE DB
against that data, the column reports adInteger for every row while those rows
come back null. SQL Server's DATEDIFF has the same Int32 ceiling and documents
the same limits, and answers them with a separate DATEDIFF_BIG.

So DateDiff now gives Null where a count will not fit, and DateDiff_Big counts
the same boundaries over the same interval table into an Int64. Hours cannot
pass a Long Integer between any two dates Access represents; minutes, seconds
and milliseconds can, and only those are checked.

"ms" is no longer the one interval DateDiff counts wide: it returns an Int32
like the rest, and DateDiff_Big is what counts further.

Extended mode translates both Unix-epoch methods through DateDiff_Big, in a
copy of the Jet translator selected by the same mode ternary the Math ones use.
Compatible mode keeps the Jet translator, because its statements have to run
against ACE, which has no such function — so ToUnixTimeMilliseconds there is
short a row rather than wrong, which is ACE's own ceiling and not ours to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That commit gave DateDiff a different contract - a count that will not fit is
Null rather than an OverflowException, and "ms" counts into a Long Integer like
every other interval instead of being the one that counts wide - but left the
tests that pinned the old one. Six have failed since.

Values_past_their_type_overflow keeps DateSerial and TimeSerial, which still
overflow. The two DateDiff cases move to a test that asserts Null, joined by
"ms", which now has the same ceiling; DateDiff_Big answers the same three
spans in an Int64.

The Int64 expectations in DateDiffMillisecondTests move to DateDiff_Big rather
than going away, so the wide count keeps its coverage: Spans_beyond_int32
asserts Null from DateDiff and the whole 50-year count from DateDiff_Big, and
the declared-column-type theory names the function beside the interval.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A file of saved queries showed two ways a query Access wrote comes back wrong,
both silent.

A totals query with a HAVING was dropped from the catalog entirely. The
reconstruction bails on any attribute outside a known list and 0x0A was not in
it, so the query surfaced neither as a view nor as an action query and selecting
from it said the table did not exist - a missing-object error for an object
plainly in MSysObjects. GROUP BY and ORDER BY were already rendered either side
of it. The engine could always run the statement; only the rendering could not
write it. HAVING now reads and writes like the rest: ViewSpec and ViewDefinition
carry it, ViewCreator writes the row flagless as the WHERE row is, and both the
view and the make-table paths render it between GROUP BY and ORDER BY. The
parser's refusal goes with it.

A test pinned that gap from the other side, asserting an ACE-authored HAVING
view appears as neither an action query nor a view because such queries are
omitted until their attributes reconstruct losslessly. That is the limitation
being lifted here, so it now asserts the view is present and that the HAVING
reaches the rebuilt SQL - reading the GROUP BY while dropping the HAVING would
answer with every country rather than fail.

ViewSpec's summary said aggregates, GROUP BY, HAVING and ORDER BY were "not
permitted" while three of the four were already members; the same stale claim
sat on ViewDefinition and BuildViewDefinition. Corrected.

The second is the designer's own way of writing a qualified column: one
delimited name, [Order Details.UnitPrice], not [Order Details].[UnitPrice].
Nothing resolved it, so Hex([MSysObjectsEXT.Flags]) failed to bind. A period
cannot appear in an Access object name, which makes the split unambiguous, and
it happens in the parser rather than at lookup because the planner reads
ColumnReference.Table to decide which source a predicate belongs to - resolved
later, the reference would find its value but stop being pushed down or
seekable. An undelimited a.b never reaches that path; the grammar has already
split it.

Also recorded: MSysDb's ANSI Query Mode, the per-database half of Access's "SQL
Server Compatible Syntax (ANSI 92)" option. It is a real field - 33 of 38 files
carry 0, five carry 1 - and the engine does not consult it. Measured across nine
combinations: absent/0/1 against OLE DB, ODBC, and ODBC with ExtendedAnsiSQL=1,
for ad-hoc SQL and for a saved query, authored by ACE itself or by LibRed. Every
result identical. The wildcard set belongs to the connection instead: OLE DB is
ANSI-92, plain ODBC reads a saved query's pattern as ANSI-89, and ODBC with
ExtendedAnsiSQL=1 - which JetConnection sets on every ODBC connection - is
ANSI-92 again. Worth the words because the corpus argues the opposite: all 17
extractable LIKE patterns in saved queries are ANSI-89, which makes it look as
though the file's mode must be driving them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A complex column - the type behind multi-value and attachment fields - decoded
as a long-value descriptor and gave back nothing usable. Its in-row value is not
a descriptor at all but a 4-byte record id, and the values themselves are rows
of a per-column flat table keyed by it. Core now resolves the pair and reads
them: JetCatalog walks MSysComplexColumns to the flat table and finds the owner
link and the per-value id by index shape rather than by building names, because
neither name follows a rename - a column now called BK_category can be backed by
f_..._TempField*7. JetTypeCodec decodes the in-row value as the Int32 it is, and
IndexKeyEncoder keys it as one, verified across 43 entries in 9 such indexes.

The id is allocated per row, not per value, and every complex column of a table
shares it, because the counter is a single TDEF word at 0x1C rather than one per
column. So a row carries the same id in all four of complex1's attachment
columns, an empty read is the ordinary answer rather than an error, and
RowInserter advances 0x1C separately from the table's own counter at 0x14.
Attachments write too: ComplexAttachment packs the payload Access expects - an
outer header carrying a compression flag and the uncompressed length, then an
inner header with the extension as null-terminated UTF-16 - and ACE extracts a
file LibRed attached, byte for byte. Whether a file is deflated is Access's
convention keyed on extension, not a rule of the format; both forms read back,
and the 256 MB ceiling is Access's policy, well under the format's own.

That a complex column carries the descriptor's 0x04 AutoNumber flag had a
consequence nothing accounted for. Five columns of complex1's Table1 read as
AutoNumber, and MSysResources in every Northwind has two - so "a table has at
most one AutoNumber column" is false as stated, and three guards enforcing it
rejected tables Access created normally: any ALTER COLUMN on such a table threw.
The rule is about the header's seed/increment pair, which one column may draw
on; a complex column is flagged the same but allocates from 0x1C. Worse, the
read applied that pair to every flagged column, so all five reported the ID
counter's seed as their own. Fixed at the read and in all three guards.

Then the writes themselves. A delete now leaves the bytes ACE's delete leaves,
measured by running the same delete through DAO and through LibRed on two copies
and diffing whole files - an ordinary table and one whose delete cascades into
four flat tables, 5 and 20 pages, every page identical and no page touched that
ACE left alone. Three things differed, all of them dead-byte handling. A page is
zero-filled when allocated and never cleared again, so an in-place rewrite must
carry the destination's bytes past the new live end instead of building in a
clean buffer. A delete keeps the prefix length the page was stored at, because
ACE re-compresses a leaf only when it fills and a delete never fills one. And
the per-index entry counters skip a block whose total already reads 0 - not a
clamp, since unique stays where it is rather than dropping - which is what kept
LibRed's totals from going negative. That last one also reconciles an apparent
contradiction in the spec: an index built empty and filled by inserts has total
0, so the delete skips it, which is why unique looked as though it never fell.

A leaf splits where ACE splits it. The cut is taken over the entries the page
held before the insert, and the new entry joins whichever side its key falls on,
so the original page keeps one more entry for a key landing below the midpoint
than for one landing on or above it; halving the post-insert list instead always
hands the odd entry rightward. Measured on a 602-entry leaf split by a single
further key at positions 1, 50, 301, 302 and 400 - the isolated case, because the
cascade that first showed the difference ran three levels deep over three indexes
and could attribute nothing.

Left alone deliberately: page allocation. Both engines pick the same first free
page, then ACE extends the file by an eight-page chunk while LibRed reuses
another free page - verified genuinely free, with every table's rows intact and
ACE reading the result back. Matching would mean growing files that need not
grow. One split case is still open rather than solved: a key sorting below
everything on the page splits at the same point, but ACE then stores that page
uncompressed, and the two explanations that fit are not yet separable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A complex column reported itself to INFORMATION_SCHEMA and the scaffolder as a
non-nullable "varchar", which is wrong twice and malformed once.

ACE has no way to name such a column over OLE DB, so it flattens it to long
text: an attachment column gives the identical DBSCHEMA_COLUMNS row to a Memo
one - type 130, flags 234, maximum length 0 - and a reader types it
System.String at the Memo ceiling of 536,870,910. Only DAO names it at all,
as type 101, dbAttachment, size 4, that size being the Int32 complex id.
JetStoreType had no Complex case, so the name fell through the default to
"varchar" - which is also the one store type in the map carrying neither a
facet nor a reason to lack one, where every other text type is varchar(n) or
char(n). It now reads longchar, the same as Memo, so a caller sees what it
would see through ACE.

The nullability was the auto-number flag deciding on its own again. A complex
column carries 0x04 without drawing on the table's counter, so the rule that an
AutoNumber column is never nullable does not reach it - and both ACE surfaces
disagreed with us, OLE DB reporting IS_NULLABLE true and DAO Required false.
Only the flag is ignored; nullability still comes from the column's own
IsNullable, which the catalog sets from the LvProp Required property, and those
columns carry no LvProp entry at all.

Both derivations live in JetStoreType precisely so INFORMATION_SCHEMA and
database-first scaffolding cannot drift apart, so both surfaces move together.
The CLR type is left as Int32 rather than ACE's String: a SELECT of a complex
column still yields the record id, and that changes when the values projection
does, not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ChrisJollyAU ChrisJollyAU changed the title LibRed: ADO.NET metadata surface, and every stored query kind read and written LibRed: metadata surface, stored queries, complex columns, and byte-parity writes Sep 22, 2026
ChrisJollyAU and others added 2 commits September 22, 2026 23:04
Every Linux and macOS leg fails the build with IDE0055 on lines nobody has
touched, while Windows builds clean, because two settings that have to agree
do not.

.gitattributes normalises with `* text=auto`, so the index holds LF and the
working tree takes whatever the platform prefers - CRLF on Windows, LF
everywhere else. .editorconfig pins `end_of_line = crlf`, for [*.cs] and again
for [*.{cs,vb}]. On Windows the checkout matches what the rule asks for; on
Linux every line ending violates it. That was a suggestion until
EnforceCodeStyleInBuild arrived alongside TreatWarningsAsErrors, which turned
it into a build error, so the platform the analysers never ran on is the one
that stopped compiling.

Pinning the checkout is the smaller of the two fixes: the index is untouched -
it stores LF either way - no file is renormalised, and the style contract the
repo already states twice is left as it is. Setting end_of_line to lf instead
would have moved the rule to match the accident of how each platform checks
out, and broken Windows in the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull_request.yml had the two halves of the file-format suite the wrong way
round, and lost one of them entirely.

LibRed.Core.Tests ran on LibRedAccess, the Windows-with-ACE job. It needs no
driver at all, and running it there asserts nothing the five-platform LibRed
job could not assert better - reading and writing the on-disk format on Linux,
macOS and ARM64 is precisely the part of the cross-platform claim that was
going unchecked, because that job ran only LibRed.Engine.Tests. It now runs
both, as push.yml already does.

LibRed.Core.AccessTests was not run by this workflow at all. It is the half
that cross-checks LibRed's output against the real engine, so it belongs on
the Windows job, in the slot Core.Tests was occupying. Its path was also
missing from the libred filter, so a change confined to that suite marked the
whole LibRed leg as unaffected and skipped it.

push.yml already had all three of these right; this brings the pull-request
flow level with it, comments included. pull_request_without_ci.yml runs no
suites and needs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ChrisJollyAU
ChrisJollyAU requested a review from a team as a code owner September 22, 2026 15:11
@ChrisJollyAU
ChrisJollyAU merged commit 416a2aa into CirrusRedOrg:master Sep 22, 2026
18 of 25 checks passed
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