Skip to content

LibRed: ADO.NET metadata surface, and every stored query kind read and written - #302

Open
ChrisJollyAU wants to merge 6 commits into
CirrusRedOrg:masterfrom
ChrisJollyAU:schemawork
Open

ChrisJollyAU wants to merge 6 commits into
CirrusRedOrg:masterfrom
ChrisJollyAU:schemawork

Conversation

@ChrisJollyAU

Copy link
Copy Markdown
Member

Three commits, all in LibRed, all verified against ACE on Northwind rather than against assumption.

Serve ADO.NET's metadata surface from the catalog

GetSchema served nothing, a reader could not describe its own columns, and a command took only text.

  • 20 metadata collections, in ACE's shapes — verified row for row against its OLE DB provider: tables (41), columns (228), indexes (69), primary and foreign keys, the four constraint rowsets, statistics, views and procedures all match. The two ACE serves through neither GetSchema nor GetOleDbSchemaTable (procedure parameters, view column usage) follow the OLE DB shapes. TABLE_TYPE comes from MSysObjects.Flags; a view's columns come from planning its query, so a passed-through column reports the stored column behind it, facets and all.
  • GetSchemaTable / IDbColumnSchemaGenerator 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 — the round trip that flag exists to avoid is one this provider never makes.
  • CommandType.StoredProcedure and TableDirect, executing the named query with 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 — 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, CloseConnection closes the connection with the reader. Describing needs 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 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.

Read and write every stored query kind Access stores

UPDATE, DELETE, make-table and an append fed by a SELECT were recognised, named and refused, although the engine could already run every one of those statements. Access stores them the way it stores a view — one row per source table, one per join, one for the WHERE, one per declared parameter — and they differ only in the action row and in what the column rows mean. All four now read back as runnable SQL and are written, row for row as ACE writes them, object flags included.

Along the way:

  • A declared parameter now survives the round trip. Its size never reached the type mapper, so Text(50) was stored as a memo; its facets were dropped, so Access rendered Text(255) back. They 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).
  • 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 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.

Record what ACE writes, and when, on a commit

Measured: 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 recorded a commit fsyncing twice; it now records the protocol and what LibRed does against it.

Tests

LibRed.Ado.Tests 111, LibRed.Engine.Tests 4018, LibRed.Core.Tests 639, LibRed.Engine.AccessTests 517, LibRed.Core.AccessTests 985, LibRed.EFCore.Tests 15 — all passing, solution builds clean. New suites cover the schema collections, the reader's column provenance, and each stored query kind both ways: run in ACE and in LibRed on two copies of Northwind and compared, and written by LibRed then read and run by ACE.

The grammar gained updateStatement / deleteStatement as procedure bodies; the parser is regenerated from AccessSql.g4 with the committed script.

🤖 Generated with Claude Code

ChrisJollyAU and others added 6 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>
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