Skip to content

v4.0.0

Choose a tag to compare

@github-actions github-actions released this 05 Sep 06:55
· 15 commits to main since this release

Added

  • Access has the same API as the other hosts. AccessDatabase gains
    module_names(), get_module(), set_module(), add_module() (taking
    VBAModuleKind), vba_project() with add_module / rename_module /
    delete_module, and pull_modules() / push_modules(); the top level
    gains push_access() beside pull_access(), and the command line
    access-push beside access-pull. Forms and reports come back from
    forms(), reports(), form() and add_form() / add_report() as
    AccessForm objects with walk(), control(), properties(),
    set_property(), add_control(), remove_control() and set_code(),
    the calls a UserForm takes; a control is an AccessControl with the
    same properties() / set_property(). Removing a control re-marks the
    ones left in its section and closes the tab order up behind it; the
    live gate has Access read back what remains.

  • Compact and Repair. db.compact_and_repair() builds a new database
    the way DAO's CompactDatabase does: a bare engine skeleton with every
    object copied in -- containers, forms and modules, then tables, queries
    and links in the order of the catalog's Name index, rows in primary-key
    order, indexes built over them, AutoNumber counters reset to the largest
    value present, permission rows and property blobs written in a last
    pass, relationships re-created. Jet 4 .mdb files take their own
    skeleton. Under a frozen engine clock five compactions -- tables with
    deletes, keys and memos; counters and queries; attachments; forms,
    reports, macros, modules, a link and seven query shapes; a Jet 4 file
    -- match the engine's output on every page but page 0. The
    result keeps the source's creation date, because the engine keys its
    encoding of owner and permission SIDs to that date (a 30-bit fold of
    five of its bytes feeds a generator that stayed unknown), so those
    bytes copy across unchanged. A table whose definition runs past one
    page is built column by column as the engine builds it, two writes
    per column onto fresh continuation pages, and the destination grows
    and lets rewritten pages back into use by the engine's rule (64 pages
    ahead, then 8 or 64 at a time, the waiting pages returning once 24
    pages have been taken since they last did), so the stale pages behind
    a wide table and every page after them land where the engine puts
    them: a sixth compaction, two 161-column tables with a memo, a key and
    rows, matches the engine page for page.

  • Fourteen more form property codes are named -- Glow, Shadow,
    QuickStyle, HoverForeColor, PressedForeColor and the hover and
    pressed theme index, tint and shade properties -- by differencing a
    command button and a toggle button in Access, taking the table to 180.
    The four codes still keyed by number, 700-703, turned out not to be
    properties: they are the insets a button keeps for an effect that is
    off, dropped in favour of the paddings when Glow or Shadow is set.

  • SELECT ... INTO makes a table. The make-table query creates the
    table the engine creates: a copied column keeps its definition, an
    expression gets the type the engine's own inference gives it (157
    expressions measured, every definition page byte-identical), the rows
    follow, and the three quirks a made table's headers carry -- computed
    columns variable-length, ordinals from one, a Decimal's precision and
    scale leaking into the columns after it -- are written as the engine
    writes them.

  • UPDATE and DELETE run over a join, writing to either table of
    the join, applying every join row in the engine's order, and refusing
    what the engine refuses: a subquery in a SET clause, a join DELETE that
    does not name its table, a row a DELETE reaches twice.

  • A FROM clause with joins in parentheses reads -- (A INNER JOIN B ON ...) INNER JOIN C ON ..., which is how Access writes every query
    over three tables, and the group on the right side too.

  • A numeric literal with an exponent (1E3, 1.5E-1) reads.

  • A table's rows can be packed onto fewer pages.
    db.rebuild_table(name) reads a table's rows out, drops the table,
    makes it again from the same definition and writes the rows back, so
    they land on as few pages as they need; db.compact(rebuild=True) does
    that for every table it can and then reclaims the file's tail. A
    2000-row table cut to 200 went from 270 pages to 92.

    Deleting rows does not shrink a table -- not here and not in Access,
    which is what Compact and Repair is for -- so until now a large delete
    left the space stranded: free pages, but in the middle, where the
    trailing-run reclaim could not reach them.

    Both halves are writers the engine was measured against, so this adds
    no new way to lay a table out. It refuses a table it cannot carry
    across whole -- one with a complex column, a link, or a relationship
    naming it -- and it compares the rows before and after, putting the
    whole database back if they differ. A rebuild either round-trips or
    changes nothing. The AutoNumber values and their counter are kept,
    where Access's own compact resets the counter.

    db.table_specs(name) is the piece that made it possible: the table
    described as the ColumnSpec and IndexSpec list that would create it
    again, with sizes in the units those take rather than the header's
    bytes.

  • Charts and Edge browsers can be written, and the reader knows two
    more types (navigation buttons and the navigation control's own kind).
    That takes the reader to 28 control types and the writer to 23. Naming
    a record is not needed to write one: a slot whose code has no
    established meaning is keyed by its code, which also expresses the one
    case a name could not -- an Edge browser carries code 450 twice, at two
    ids.

    Three records Access writes on these are deliberately left out. 596,
    597 and 600 put the control in a layout, which is where the designer
    drops a new one; with them written, Access stacks a chart under
    whatever else claims the same layout instead of leaving it where it was
    put. Isolating that took building the same three-control form five ways
    and comparing where Access reported each control.

    A navigation control stays read-only for a reason that is not about
    codes: it is not one control. One of its records names a sibling
    subform by name, and Access builds navigation buttons beside it, so one
    written alone would point at a subform that is not there.

  • A design's properties can be changed.
    db.set_control_property(form, control, "FontSize", 18) and
    db.set_design_property(form, "Caption", "My window") write one
    property of one control, section, or of the form or report itself.
    Access reads every one back exactly as written -- captions, fonts,
    colours, sizes, control sources, tips and tags among them.

    PROPERTY_CODES grew from 40 names to 107 along the way. Codes whose
    values are small integers every other property also uses cannot be
    named by matching values, so those were named by differencing: build
    the same form twice, identical but for one property, and see which
    record moved.

    A record's id is its slot in that object type's own schema, so changing
    a property an object does not already carry means knowing the id Access
    would have given it. PROPERTY_SLOTS holds 959 of those across 26
    object types, every one read off an object Access itself wrote; across
    seven databases every id, code and value type agreed, and so did every
    length except the strings', whose length is their text's. A property
    the object already carries keeps its record where it stands; a name
    that is not in the table is refused rather than written somewhere it
    does not belong.

  • Every control type pyOpenVBA can read, it can now write.
    add_control wrote a Label or a TextBox; it now writes all eighteen --
    CommandButton, ToggleButton, OptionButton, CheckBox, OptionGroup,
    ListBox, ComboBox, Rectangle, Line, Image, PageBreak,
    BoundObjectFrame, ObjectFrame, Subform, Tab and Page as well. Every
    slot's id, code, value type and width was read back from a control
    Access itself made, and each type gets only the slots it has: a page
    break carries a top and nothing else, a tab control no left or top at
    all, an image no overlap flags, a combo box its GUID ahead of its
    name. A button is written with the padding Access gives it, a list or
    combo box with Table/Query as its row source type, and anything that
    takes the focus with the next tab index.

  • Twenty-nine more design property codes named, taking the table from
    40 to 69. Access's SaveAsText writes a design's properties with their
    names, so pairing that against the blob names the codes -- but pair on
    the value, not the position: the blob carries records the text does
    not write, so a straight walk drifts and starts naming codes wrongly a
    few records in. Both runs re-derived every code already named and
    contradicted none, which is what makes the new names evidence rather
    than a guess. docs/research/access_designs/ carries the scripts.

  • Six control types the reader did not recognise at all: custom
    (ActiveX) controls, attachment controls, the web browser, the
    navigation control, charts and the Edge browser. All six now parse and
    report their type; the first three are written as well, and Access
    accepts each. The other three are read but not written: each
    carries records this project cannot name -- 450, 456, 458, 600 among
    them, and a dozen more on a chart -- and on the navigation control and
    the Edge browser one of those, 450, appears twice at two ids, which a
    table keyed by property name cannot express. Writing one is refused
    with the reason rather than attempted.

    That takes the reader to 27 control types and the writer to 21. Two
    more codes Access accepts, 131 and 132, turn out to be alternate ways
    to ask for a subform and a text box: the file records them as 112 and
    109.

  • A control can hold controls. add_control(..., parent="Tabs") puts
    a page on a tab control, which is written as a group of its own right
    after it. Reading a design is now a tree walk rather than a flat scan,
    since a section's count is of its own controls and not of everything
    beneath them. A page must have a parent tab and nothing else may have
    one, both of which are refused rather than written.

    The live gate puts one of every writable type on a single form and has
    Access name them all back, and builds a tab control with two pages
    between a text box and a button -- Access reports the tab as each
    page's parent and the form as the parent of the two beside it.

  • Jet 3 (Access 97) databases read. AccessDatabase opens a 2 KiB
    page .mdb and reads its catalog, tables, rows and long values the
    same way it reads an .accdb. Everything that moved between the two
    versions lives in one Layout record, so there is a single parser, a
    single row splitter and a single set of value decoders rather than a
    second implementation: the page halves, a row counts its columns and
    its variable-column offsets in bytes rather than words, text is stored
    in the code page page 0 names rather than UTF-16, and the definition,
    column, index and name headers all shrink and move.

    Every offset was measured against files the Jet engine wrote, and the
    parser checks that what it consumed equals the length the page
    declares. The live gate has DAO 3.6 -- which still creates Access 97
    files though Access dropped the format in 2013 -- build a database with
    every Jet 3 column type, a 4000-character memo, code page text, a
    deleted row and four hundred rows over many pages; pyOpenVBA reads the
    same file with no COM involved and the two agree cell for cell.

    Writing a Jet 3 file is refused rather than attempted, at the page
    store itself, so nothing can put a Jet 4 shape into one.

  • Compaction. db.compact() gives back the free pages at the end of
    the file and says how many went. That is what a dropped table or a
    large delete leaves behind, and it is the part of compaction that can
    be done without moving a page: nothing moves and no object is
    rewritten, so nothing can be lost. Free pages in the middle keep their
    place, and the first two pages are never dropped.

    A 954 KB database with a dropped 3000-row table came back at 300 KB
    with its other table intact. The live gate has the engine itself read
    a compacted file row for row and then run its own Compact and Repair
    over it, which rebuilds every page it kept.

    This is not Access's Compact and Repair, which also renumbers pages,
    resets AutoNumber counters and drops deleted rows from the middle of a
    table.

  • The domain functions. DLookup, DCount, DSum, DAvg, DMin,
    DMax, DFirst, DLast, DStDev, DStDevP, DVar and DVarP run
    in db.execute(...). Each is a query over another table, so each runs
    as one: DLookup("Total", "Orders", "Id = 1") is
    SELECT Total FROM Orders WHERE Id = 1. A criteria can name a column
    of the row it is evaluated in, which is what makes them worth having.

    These are Access's own rather than the database engine's, so DAO cannot
    answer for them: the gate compares every one against Access's Eval on
    the same database, and all seventeen agree.

  • The password guard reaches Access. db.vba_is_protected() reads
    the PROJECT stream's DPB record, and db.save() refuses to write a
    VBA change into a protected project unless it is told
    allow_protected=True -- which is what the other hosts already do on
    their own save. A change that is not to the VBA project saves as
    before.

  • The project's references. db.references() reads the libraries a
    VBA project points at, db.add_reference(name, guid, major, minor, path=, description=) adds one and db.drop_reference(name) removes
    it. A live gate adds the Scripting Runtime, writes a module that uses
    Scripting.Dictionary, and has Access compile and run it.

    Access keeps them only in the dir stream, three records each --
    REFERENCEORIGINAL, its Unicode twin, and REFERENCEREGISTERED
    holding *\G{GUID}#major.minor#lcid#path#description. PROJECT
    carries no Reference= line, and the two libraries every project has,
    VBA itself and Access, are not in the file at all. The version is
    written in hex: DAO 12.0 is stored as c.0.

  • Forms and reports. db.forms(), db.reports(), db.form(name),
    db.report(name), db.create_form(name), db.create_report(name),
    db.delete_form(name) and db.delete_report(name). A live gate opens
    what this writes in Access's own designer, and a created form also
    opens in form view.

    A design is a stream of property records, <u32 id><u16 code><u32 type><u32 width><u32 length><value>, with the ids ascending inside one
    object. Three ids are not properties but markers that open the next
    object: 0xFE a section, 0xFD the next object at the same level, and
    0xFF a control, which carries a second u16 naming its type. Every
    design measured -- an empty form, a form with a label and a text box,
    and a report with its three sections -- rebuilds byte for byte.

    db.add_control(design, type, name, ...) puts a Label or a TextBox on
    one, and Access reads back every measurement it was given. Thirty-three
    property codes are named, worked out by exporting a design with
    SaveAsText -- which writes the same records with their names, in the
    same order -- and walking the two together.

    A control belongs to a section and is written immediately after it, and
    its marker depends on how many controls that section holds: one is
    a single child, 0xFE; two or more open a group, 0xFF then 0xFD.
    Access writes both in one report -- a page header holding one control
    and a detail band holding two -- and refuses each in the other's place,
    with "saved in an invalid format" for one of them.

    db.set_design_code(name, code) puts code behind one, creating the
    module when the design has none, and Access runs it. A document module
    belongs to its design rather than to Modules: no storage folder, no
    catalog row, and a DocClass= line in PROJECT where a class module
    gets Class=. Without that line Access loads the module and the form
    still does not answer to it. The design's TypeInfo and the module's
    VB_Base share a CLSID, and a byte in the design folder's PropData
    records that it has a module at all.

    Creating cuts from a captured empty design with a GUID of its own
    patched in, since the catalog row repeats the one the design carries.

  • Macros. db.macros(), db.macro(name), db.create_macro(name, actions) and db.delete_macro(name), with MacroAction(name, arguments) for each step. A live gate creates a macro, has Access run
    it with DoCmd.RunMacro, and reads back the value it set.

    Access stores a macro as a binary blob, not as the XML its designer
    shows: a 32-byte header, a length-prefixed "33", then one record per
    action carrying the action id, the row number, fourteen u16 slots
    holding byte offsets into a string area, and the strings themselves.
    Arguments occupy slots from 4 upward and an empty one takes no slot,
    so a gap in the middle reads back as an empty string. Every blob in
    the fixture rebuilds byte for byte.

    Twenty-four action ids, measured by loading one macro each through
    LoadFromText and pairing storage folders with MSysObjects rows in
    id order. A macro's object id steps by one where a module's steps
    by four, and a macro gets no navigation-pane group row where a module
    does -- so the step is what an object reserves for itself rather than
    a global stride.

  • Attachments and multi-valued columns. db.complex_columns() finds
    them, table.attachments(column, key) and
    table.multi_values(column, key) read them, set_attachments /
    set_multi_values write them, and
    table.add_complex_column(name, kind) creates one. An inserted row is
    given its complex id automatically. A live gate builds a database, a
    table and both kinds of column from nothing and has the ACE engine read
    the bytes back.

    Creating one costs four things beyond the column itself, and three were
    invisible until the engine refused the result. The flat table keeps its
    two Long bookkeeping columns among the variable columns, where a
    Long would normally sit in the fixed block, with no collation and no
    fixed bit -- ColumnSpec(..., variable=True) now says so. Its catalog
    row carries Flags 0x800A0000. And the table that has the column
    carries 0x40000, which no other table does; without it DAO opens the
    child recordset and finds no fields in it.

    A Complex column keeps only a Long in the row -- an id shared by
    every complex column in that row, handed out from a counter at 0x1C of
    the table definition and never reused. The values live one per row in
    f_<GUID>_<Column>, joined on that Long, and MSysComplexColumns
    names the pairing. FileData is a container of its own: a flag and an
    inflated size, then either a zlib stream or the bytes as they are, and
    inside that a header carrying the file's extension.

    Access decides whether to compress by file type, measured across 45
    extensions: it leaves docx, gif, jpeg, jpg, png, pptx,
    xlsx and zip alone and compresses everything else, including 7z
    and mp4. Those eight are written byte-identically; a compressed one
    was not at the time -- see the fix above: the deflate is classic zlib's
    at level 5 and memLevel 7, which the zlib-ng behind Python's zlib
    cannot reproduce.

    Two corrections to the table definition came out of this: the field at
    0x18 is a constant (1 in ACE, -1 in Jet 4) and not a counter, and the
    complex-id counter is the u32 at 0x1C. A complex column is flagged
    AutoNumber like any other, so the row writer had been handing it a
    value from the ordinary AutoNumber counter, which gave two columns in
    one row two different ids.

  • Access VBA is writable. AccessDatabase gained modules(),
    module(name), create_module(name, code, kind="module"|"class"),
    set_module_source(name, code), rename_module(old, new) and
    delete_module(name). Standard and class modules both, with whatever
    source you give them.

    The route is not a p-code writer. _VBA_PROJECT is [MS-OVBA]'s
    PerformanceCache, and its Version field names the build of VBA that
    compiled it; writing a version the host does not recognise makes VBA
    discard the cache and compile the project from the module streams, the
    same thing Access's /decompile does. A module's stream is therefore
    the compressed source alone with MODULEOFFSET at zero, and none of the
    compiled tables have to be generated -- one of which could not have
    been, since the 32-slot table ahead of the module table is runtime
    state and adding the same module to the same database twice gives two
    different tables. The cost is a recompile on the next open: the code
    has to compile, and the cache stops matching what Access wrote until
    Access rewrites it.

    Three of the rules are invisible from the file and were caught only by
    asking Access: a module's storage folder is named from the rows its
    container already holds and Access will not look under any other name,
    MSysObjects ids step by four rather than one, and a delete has to
    free the folder or the next create picks a name Access rejects. A live
    gate (RUN_LIVE_ACCESS_VBA=1) runs the result in Access and compares
    the value the code returns, class instantiation included.

  • More of the statement. TOP n PERCENT, ORDER BY <position>, and
    a comparison against ALL, ANY or SOME of a subquery. A column
    name two sources share is now qualified in the output the way the
    engine qualifies it, and a crosstab pivoting on a comparison names its
    columns -1 and 0.

  • The rest of the Jet expression functions. Replace, Space, String,
    StrComp, StrReverse, Asc, Chr, Sgn, Sqr, Exp, Log, Fix, Val, Str, Hex,
    Oct, the CBool/CByte/CCur/CSng/CDate family, DateAdd, DateDiff,
    DatePart, DateSerial, TimeSerial, Weekday, WeekdayName, MonthName,
    DateValue, TimeValue, Time, IsNull, IsNumeric, IsDate, Switch, Choose,
    Format and Partition, with the First, Last, StDev, StDevP, Var and
    VarP aggregates. A gate runs 131 expressions through DAO and through
    the executor over the same four rows and compares them cell by cell.

  • Truth values as Jet writes them. A computed comparison, logical
    operator or yes-or-no function now answers -1 or 0, and so does an
    aggregate over a Boolean column, which is what the engine answers. A
    Boolean column selected on its own still reads as a Boolean.

  • Linked tables. db.links() and db.link(name) read the tables a
    database only points at; db.link_table(name, database, source, connect=...) writes one and db.drop_link(name) forgets it, byte for
    byte as DAO's TableDefs.Append and Delete do, for a link to another
    Access file and to a folder of text files. Following a link is left to
    the caller: the path comes out of the database, so opening it is not
    something the library does on its own.

  • A column's own rules. Required, DefaultValue, ValidationRule and
    ValidationText are properties on the column, not bits in its header,
    and the writers now put them there the way the engine does: one blob
    write per column, the engine's DAO type and flags, and the catalog
    stamp that goes with it. CREATE TABLE ... NOT NULL and
    DEFAULT <expr> set them, and ColumnSpec carries them.

  • The engine's rules applied to every row. A column an INSERT does
    not name takes its DefaultValue, evaluated as Jet evaluates it; a null
    in a Required column and a value against a ValidationRule, the
    column's or the table's, are refused with the engine's own message, on
    insert and on update. A live gate has the engine reject the same four
    statements.

  • Which LVAL page a long value lands on, measured: a value of 256
    bytes or fewer goes on the first page the free-space map lists, a
    larger one on the last, and when that page cannot take it, on a new
    page rather than an earlier one.

  • A gate for a database built without the engine at all: every
    column type, keyed and unique indexes, a foreign key, long values,
    four saved queries and table and column properties, all written by
    pyOpenVBA, then read back field for field by DAO and compacted by it,
    which fails on any structure the engine cannot follow.

  • Subqueries in saved queries. A subquery in a WHERE, one used as a
    value, and a bracketed SELECT standing where a table does all save as
    the engine saves them; a derived table's text goes in its row's
    expression with only the alias naming it.

  • Pass-through queries. db.create_query(name, sql, connect=...)
    saves one, byte for byte as DAO does, dead row and all; the SQL is
    kept exactly as given because the server parses it, not Jet.
    query.connect reads the connect string back.

  • Usage maps past their row. A map whose bitmap can no longer grow
    inside its page becomes the engine's reference form: a row of chunk
    pointers, each naming a page holding one 32 736-page bitmap. The
    global free map converts the same way and marks each new chunk's
    unreached pages free. A 130 MB database of long values is now
    byte-identical to the engine's, both maps converted.

  • with db.transaction(): groups writes so they all land or none
    do; an exception puts the pages and the session's state back exactly
    as they were. The engine writes the same bytes either way, and a live
    gate checks that against DAO running the same statements inside
    BeginTrans/CommitTrans.

  • Crosstabs run, not just save. db.execute("TRANSFORM ... PIVOT ...") returns the pivoted rows: one column per pivot value, <> for
    a Null heading, an IN list fixing the columns and their order, an
    aggregate allowed among the row headings, and the rows sorted by those
    headings as the engine sorts them. Two of these answer exactly as DAO
    does (live gate).

  • Jet's Mod, \ and ^ operators, in VBA's order of
    precedence, with both sides rounded half to even and the division
    truncating toward zero.

  • Subqueries, unions and saved queries in db.execute. IN,
    NOT IN, EXISTS, NOT EXISTS and a scalar subquery work in any
    expression, correlated when they name the outer query; a bracketed
    SELECT or a saved query's name can stand where a table does in FROM;
    and UNION / UNION ALL fold left to right with a trailing ORDER BY
    over the result. Eight of these shapes answer exactly as DAO does on
    the same database (live gate).

  • DDL through db.execute. CREATE TABLE (every Jet type word, named
    primary keys, inline and table constraints, foreign keys), CREATE
    [UNIQUE] INDEX with ASC/DESC columns and WITH IGNORE NULL, DROP TABLE,
    DROP INDEX, and ALTER TABLE ADD / ALTER / DROP COLUMN and ADD / DROP
    CONSTRAINT. Fourteen statements leave the same bytes DAO's Execute
    leaves for the same SQL (live gate). What the Jet parser refuses --
    CHAR, DECIMAL, NUMERIC, WITH COMPRESSION -- is refused here
    with the reason.

  • A table with a BigInt column carries the engine's version
    properties
    (FCMinReadVer, FCMinWriteVer, FCMinDesignVer),
    written one at a time as the engine writes them.

  • Crosstab saved queries. db.create_query(name, "TRANSFORM ... PIVOT ...") writes the rows DAO writes, byte for byte, with an IN list, a
    TOP, a join or a parameter; db.query(name).sql gives the statement
    back. HAVING is refused there, as the engine refuses it.

  • db.drop_index(table, name), byte-identical to the engine's DROP
    INDEX: the index's pages released with their bytes untouched, its
    usage-map row deleted, its records taken out of the definition with
    the indexes after it moved up, and the catalog row stamped. The
    primary key and an index a relationship rests on are refused, as the
    engine refuses them.

  • SQL executor. AccessDatabase.execute(sql, parameters) runs Jet
    SQL against the engine in pure Python: SELECT with a column list or
    *, INNER / LEFT / RIGHT JOINs, WHERE, GROUP BY with Count, Sum, Avg,
    Min and Max, HAVING, ORDER BY, DISTINCT and TOP; the comparison,
    logical, arithmetic and & operators, LIKE with the engine's
    wildcards, IN, BETWEEN, IS NULL, [parameters] and the common string,
    numeric and date functions; INSERT ... VALUES, INSERT ... SELECT,
    UPDATE and DELETE through the row writers, with values coerced to the
    column type as the engine coerces them. Three-valued logic follows the
    engine. Eleven SELECT shapes answer exactly as DAO does on the same
    database, name for name and value for value, and an UPDATE plus a
    DELETE write the same bytes DAO's Execute writes (live gate).

  • Index row counters. An index built over existing rows records how
    many rows it holds next to its distinct-key count. Every row that
    leaves the index takes one off, whether deleted or written by an
    UPDATE that names one of the index's columns, and the distinct count
    is capped at what is left; a null key in an ignore-nulls index costs
    nothing, the count stops at zero, and an unfiltered DELETE zeroes
    both. Inserts leave the row counter alone, as the engine does.

Fixed

  • Code behind a written form sees its controls, and their events fire.
    Access lists a design's sections and controls in a TypeInfo stream
    beside it, and VBA takes that list as the form class's members. A form
    written here carried the empty template's list, so Me.<control> failed
    to compile and a button's click never bound. The stream is now carried
    forward on every change the way Access carries it: a new member is
    appended with the next ordinal, a removed one drops out and the rest
    keep their ordinals, a renamed one moves to the end with its ordinal.
    Each member's type id was read off a form and a report Access built
    with one of every control and every section
    (tests/live_access_test/designs_every.accdb): a report's controls
    share one class index, a label attached to a control or a button inside
    an option group has a class of its own, an ActiveX control's entry
    carries 36 more bytes, and a form's header, footer, page and group
    sections are read as sections. Three copies of that form edited in
    Access sit in the fixture, and the same edits made here give the same
    streams.

  • RowSourceType takes effect. The text is only what the property
    sheet shows; Access acts on a one-byte companion record, written now
    with the text (a value list, a field list, or none for a table or query).

  • A written control brings its type's control-defaults object. Access
    keeps one nameless object per control type ahead of a design's
    sections, the type's theme-derived defaults, and reads a control's
    themed properties against it: a button written without it ignored its
    UseTheme, colours and gradient and came out as a default themed
    button. The first control of a type now writes the object Access
    writes (captured per type for forms and for reports), the run of
    top-level objects is marked as the group it is, and a button's six
    hover and pressed slots are in its schema.

  • A colour or font set on a control takes effect. With the defaults
    object in place, Access reads the theme index ahead of the colour, so
    set_property("ForeColor", ...) now writes the -1 index Access writes
    beside it (and Gradient 0 with a button's fill, ThemeFontIndex -1
    and the pitch-and-family byte with a font name), measured one property
    at a time on nine control types.

  • set_database_properties() sets the database's own options --
    StartUpForm to open a form with the file, AppTitle and the rest --
    writing the MSysDb property blob byte for byte as DAO's
    Properties.Append does.

  • A second text box gets its tab index. The text box Access made for
    the slot table was the first on its form and so carried no TabIndex;
    every later focusable control does, and the writer now gives one to a
    text box as it already did to every other type (measured: three text
    boxes written without it came back from Access tabbed 1, 2, 0).

  • A large long value lands on the highest-numbered listed page. The
    engine takes the highest page the column's free-space map lists when
    it has room, and otherwise a fresh page; it does not go back to the
    page the last value went to. The two coincide until a delete lists an
    older page again, which is what Access's own module save does, and the
    library kept a per-column cursor for the wrong rule. Measured with DAO
    and held by the long-value placement gate; the page store's
    lval_cursor is gone.

  • A long value of 256 bytes or fewer now takes the first listed page of
    its column rather than the page the last value went to, which is where
    the engine puts it (measured on a compaction's 200 memos).

  • A relationship on a column that already has an index shares that index,
    as the engine's does; a table may now be related to itself; the
    relationship rows go before the index root.

  • Rewriting a definition no longer resets its complex-id counter.

  • A new object's owner is taken from MSysDb rather than the commonest
    owner among the tables, which in a fresh .mdb was the engine's own.

  • Access's own MSysNameMap and MSysAccessXML, whose OLE column has no
    free-space map, can be written to and copied.

  • Compressed attachments are byte for byte what Access writes. The
    engine's deflate turned out to be classic zlib's at level 5, memLevel 7
    and a 32 KB window -- one of eight engine-written streams admits exactly
    that parameter set, and classic zlib reproduces all eight -- where the
    earlier note that it "was not zlib's" came from comparing against the
    zlib-ng that Python bundles. Since a Python's zlib may not be classic
    zlib and exposes no memLevel, pyopenvba.access._deflate carries zlib's
    own algorithm; a live gate attaches five files through DAO and finds
    each stored container identical to ours.

  • A number keeps the engine's type through every operator. db.execute
    now answers an int, a float or a Decimal exactly where the engine
    answers a Long, a Double or a Currency/Decimal: 5.5 and its arithmetic
    are Decimals, 5.5 / 3 runs to 28 places, Sum(Long) and Abs are
    Doubles, Currency * Double is a Double while Currency + Double
    stays Currency, Date + 1 is a Date (it was a float), a Large Number
    takes everything into itself, and IIf widens its branches. Measured
    through DAO's reported type of 135 expressions and the full
    operator-by-type matrix; a live gate holds 185 of them and
    docs/access_engine.md has the rules, including the two that look at
    the shape of an operand rather than its value.

  • What a query stores in a column now matches the engine, measured
    statement by statement against DAO: over-long Text is cut to size
    rather than refused (a Memo is not), a Byte takes the low byte of its
    number, a number in a Date column is its serial, a value list with no
    column list covers the AutoNumber too, an explicit AutoNumber moves the
    counter and a Null there is an error, and no query updates an
    AutoNumber.

  • An action query is all or nothing. An INSERT, UPDATE or DELETE that
    fails part way leaves the rows as they were, through a page journal in
    the store rather than a copy of the file. The AutoNumber counter and the
    header's row count keep what the attempted rows took, which is what the
    engine leaves too.

  • Joins, GROUP BY and DISTINCT answer in the engine's order. An inner
    join scans the smaller side and probes the other newest-first, groups
    and distinct rows come out sorted by their keys; an UPDATE over a join
    keeps the last join row's write, and which row that is now follows.

  • One- and two-character Text values are stored uncompressed, as the
    engine stores them; compression is applied only where it shortens the
    value. Rows holding such values were one byte off the engine's.

Removed

  • Jet 3 (Access 97) databases are refused again. Reading them worked
    and was gated against the engine, but the scope is now what the Access
    application opens today, and Access has not opened an Access 97 file
    since 2013. A 2 KiB page format is a second set of offsets through the
    reader for a format nobody authors; git log has the implementation if
    it is ever wanted back.

    The plumbing that carried it went with it: the Layout record, the
    page size and text encoding read from the file rather than fixed, and
    the layout argument threaded through the page, row and definition
    readers. One page format means one set of constants, in the modules
    that own them, so there is no second source for an offset and no
    parameter that can only take one value.