Skip to content

1979 Records with type annotations - #2566

Merged
ndw merged 11 commits into
qt4cg:masterfrom
michaelhkay:1979-Records
May 19, 2026
Merged

1979 Records with type annotations#2566
ndw merged 11 commits into
qt4cg:masterfrom
michaelhkay:1979-Records

Conversation

@michaelhkay

@michaelhkay michaelhkay commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

First cut for review: the PR probably needs further passes before it is ready for acceptance.

We introduce the idea of records, being maps that have a type annotation which is a specific record type. A map acquires the type annotation when it is coerced to the record type. A map that has a record type annotation (a "record") throws a type error if map:get() (or a lookup expression) requests a key that is not one of the declared field names for that type. The type error can be raised statically.

map:put() applied to a record is constrained to produce a record with the same type annotation. (I've no objection to the "with" operator for this, but that can be done separately.)

Optional fields in record types are dropped; in a record, there is no distinction between an absent field and a field whose value is the empty sequence. There is no longer any need for this distinction, and eliminating it simplifies things a lot. (But we will need to decide whether to treat the fields as present or absent when serialising to JSON).

Fix #1979
Fix #2537

@michaelhkay michaelhkay added the Enhancement A change or improvement to an existing feature label Mar 31, 2026

@ChristianGruen ChristianGruen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I like all of the proposed changes. It gives an elegant answer to the question that I recently got:

How do record(data as xs:int?), record(data? as xs:int) and record(data? as xs:int?) differ?

…it feels like the logical next step, and it even simplifies the syntax.

We should add the error case to the rules for dynamic function calls: As [](1) raises an error, let $r as record() := {} return $r('x') should do so, too.

Comment thread specifications/xpath-functions-40/src/function-catalog.xml Outdated
Comment thread specifications/xpath-functions-40/src/function-catalog.xml Outdated
@michaelhkay michaelhkay mentioned this pull request Apr 1, 2026
@ChristianGruen

Copy link
Copy Markdown
Contributor

The PR helped me reflect on further choices. Maybe we can treat them more independently:

First, I (now) think we should keep map:get and map:put unchanged. It can be a perfectly reasonable and deliberate choice to add a new entry to a record if it is regarded as a map. It is good that alternatives exist, such as map:put({ $map }, ...), but they are probably not very intuitive. Most importantly, though, the map prefix does not imply at all that records are to be treated differently under the hood. Finally, the behavior of other map functions will remain consistent. For example, we won’t need to care about consistency among other map functions, for example whether map:remove should raise an error.

Instead, I would suggest raising errors only for the lookup operator and dynamic function calls. In my experience, these two are used a lot, and interchangeably, for retrieving known entries, whereas map:get is mostly used for lookups of dynamic keys.

Next, I believe that a custom operator such as with is an appropriate solution to raise errors, even though there may be no record-specific function that backs the behavior (…yet, one never knows). Additionally, I would propose limiting this operator to records and raising an error for maps without record annotation. We already have so many other ways to create and update maps, and it might be helpful to let records evolve independently.

@michaelhkay

Copy link
Copy Markdown
Contributor Author

First, I (now) think we should keep map:get and map:put unchanged

There's certainly a substitutability argument that would favour that.

@ChristianGruen ChristianGruen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looking forward to the discussion.

Comment thread specifications/xpath-functions-40/src/function-catalog.xml Outdated
Comment thread specifications/xpath-functions-40/src/function-catalog.xml Outdated
Comment thread specifications/xpath-functions-40/src/function-catalog.xml Outdated
Comment thread specifications/xpath-functions-40/src/function-catalog.xml Outdated

</div2>

<div2 id="applying-functions-to-records">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe the full section can be dropped. It seems to refer to the previous version of the proposal.

Comment thread specifications/xquery-40/src/expressions.xml
Comment thread specifications/xquery-40/src/expressions.xml
Comment thread specifications/xpath-datamodel-40/src/xpath-datamodel.xml Outdated
Comment thread specifications/xquery-40/src/expressions.xml
Comment thread specifications/xquery-40/src/expressions.xml Outdated
@ChristianGruen

Copy link
Copy Markdown
Contributor

With the new rule, I would suggest dropping the following rule:

Entries in J whose key does not match the name of any field in R are discarded.

Otherwise, arbitrary maps can be coerced to arbitrary records (as the default value types are item()*), and code gets much more error-prone. An example:

let $r as record(lat, long) := { 'latitude': 0, 'longitude': 1 }

Similar as with other coercion rules, I think that existing data should not be dropped.

@michaelhkay

Copy link
Copy Markdown
Contributor Author

I have revised the PR in the light of points raised here, and in the discussion on 21 April 2026. The main changes are:

  • Various obvious errors are corrected (for example WithExpr is now referenced in the grammar)
  • The with operator now requires a record as its LH operand
  • Dynamic function calls on records raise an error if the key is unknown
  • Coercion to a record type no longer silently drops unwanted fields.

For the time being I have dropped the xsl:record instruction entirely. I will raise an issue to bring it back in a different form.

I have not (yet) made the changes to introduce "named typing" on record types. That is, the "instance of" test is still based on content comparison rather than on the type annotation. I have come to the conclusion that to use named typing for "instance of" tests, we also need to use some kind of named type relationship to test subsumption, which probably means we need to introduce a type hierarchy for named record types that includes derivation by restriction and/or extension. That's probably best handled in a separate proposal.

<item><p><function>map:size</function> returns the number of fields
in the record type definition.</p>
<p>To count the number of fields that
have a non-empty value, use <code>map:size(map:filter(fn($k, $v){exists($v)})</code>.</p></item>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
have a non-empty value, use <code>map:size(map:filter(fn($k, $v){exists($v)})</code>.</p></item>
have a non-empty value, use <code>$map => map:filter(fn($k, $v) { exists($v) }) => map:size()</code>.</p></item>

@GuntherRademacher

Copy link
Copy Markdown
Contributor

I tried generating parsers from the grammar as extended by this PR and ran into a couple of issues:

  • the with keyword may need to be added to the exceptions in the unreserved-name constraint. Without that, I see LALR(2) shift/reduce conflicts for input such as with {

  • even with that change, with appears to introduce additional LALR(2) conflicts when this grammar is combined with the XQuery Update Facility grammar. In particular, I see:

    LALR(2) conflict #1 (shift-reduce):
        WithExpr ::= InstanceofExpr ( 'with' InstanceofExpr )* .
        WithExpr ::= InstanceofExpr ( . 'with' InstanceofExpr )*
    

    This seems to arise from the coexistence of WithExpr with the following Update Facility grammar fragment:

    ReplaceExpr
             ::= 'replace' ( 'value' 'of' )? 'node' TargetExpr 'with' ExprSingle
    TargetExpr
             ::= ExprSingle
    

Separately, the XPath spec currently has a dead link to #parse-note-unreserved-name.

@michaelhkay

michaelhkay commented Apr 22, 2026

Copy link
Copy Markdown
Contributor Author

Interesting problem.

(We're going to hit the same issue with the Gizmo utility -- https://www.saxonica.com/documentation12/index.html#!gizmo/replace -- but that's our problem!)

I guess we could drop the precedence of "with" so it comes just after ",", and then make TargetExpr in XQUF map to whatever we call an "expression without with".

Or to put it another way:

Expr ::= WithExpr ++ "with"
WithExpr ::= ExprSingle ++ ","
XQUF_TargetExpr := ExprSingle

@ChristianGruen

Copy link
Copy Markdown
Contributor

An alternative could be a classical function fn:with

declare function with(
  $record  as record(*),
  $update  as map(*)
) as record(*) {
  fold-left(
    map:entries($update),
    $record,
    fn($r, $e) { record:replace-value($r, map:keys($e), map:items($e)) }
  )
};

let $coord as record(x, y, z) := { 'x': 1, 'y': 2: 'z': 3 }

(: before: $coord with { 'x': 4 } with { 'y': 5, 'z': 6 } :)
return $coord => with({ 'x': 4 }) => with({ 'y': 5, 'z': 6 })

…accompanied by fn:get for retrieving record entries (and both replacing dm:record-replace-value and dm:record-get-value).

@GuntherRademacher

Copy link
Copy Markdown
Contributor

My preference would be to avoid reusing the withkeyword here. From a grammar perspective it seems to introduce avoidable conflicts, especially in combination with XQUF.

If we want to keep a keyword for readability, something more specific like override or patch might work better, as it avoids overloading an existing keyword and more clearly reflects the “apply partial updates” semantics.

Happy to test again if there’s a concrete proposal along those lines.

@michaelhkay

Copy link
Copy Markdown
Contributor Author

An alternative could be a classical function fn:with…

But it seems odd to have such a function that's not in the "map" namespace.

@michaelhkay

Copy link
Copy Markdown
Contributor Author

Perhaps an operator symbol, say +:= ?

@ChristianGruen

Copy link
Copy Markdown
Contributor

Perhaps an operator symbol, say +:= ?

A good idea; it looks intuitive to me:

let $coord as record(x, y, z) := { 'x': 1, 'y': 2: 'z': 3 }
return $coord +:= { 'x': 4 } +:= { 'y': 5, 'z': 6 }

One might be surprised that no new entries can be “added” this way. But it’s better than all other operators I get in mind (<-, <:=, |:=, 🛠️…).

@ghislainfourny

ghislainfourny commented Apr 23, 2026

Copy link
Copy Markdown

Just a thought: JSON database systems/document stores have different conventions regarding null (which corresponds to the empty sequence in XQuery 3.1/4) vs. absent being the same or being distinguishable.

If we conflate the two in XQuery 4.0, then XQuery 4.0 might no longer be powerful enough to let the user control outputting nulls or absent values, or distinguishing them upon reading, when used together with systems that make a difference, restricting its usability to only systems that consider the two to be the same.

Does this only impact type matching, or also the semantics of record items themselves?

@michaelhkay

Copy link
Copy Markdown
Contributor Author

different conventions regarding null

We addressed that with the "null" option on parse-json.

@ghislainfourny

Copy link
Copy Markdown

different conventions regarding null

We addressed that with the "null" option on parse-json.

Ah yes, I see it. Very good indeed. Thank you.

@michaelhkay
michaelhkay force-pushed the 1979-Records branch 2 times, most recently from cf35c27 to b49338e Compare May 12, 2026 18:24

@Arithmeticus Arithmeticus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Assorted comments.

</change>
<change issue="1979" PR="2566" date="2026-03-31">
When applied to a record, the lookup operator <code>?</code> now raises a type
error if the requested key is not present in the record.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
error if the requested key is not present in the record.
error if the requested key is not defined in the record type annotation.

Or something like this.

</g:zeroOrMore>
</g:production>

<g:production name="WithExpr">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see WithExpr defined in the EBNF grammar, but I don't see where it is invoked by any other definition in the same grammar. How does this definition get deployed?

<olist>
<item><p>The entries in the supplied map must correspond one-for-one with the fields defined
in the required record type.</p></item>
<item><p>The key of the entry must match a defined field in the record type.</p></item>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are items 1 and 2 trying to say the same thing? The wording for item 2 and item 3 is awkward, and I think it would be better expressed as an iteration: "For every entry in the supplied map, it must be the case that X, Y, and Z"


<p><termdef id="dt-record-type" term="record type">A <term>record type</term>
is a sequence of field declarations, where each field declaration defines a name (an arbitrary
<xtermref spec="DM40" ref="dt-string"/>) and a type (an arbitrary <termref def="dt-sequence-type"/>).</termdef></p>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems that, in its current form, this prose does not support default values for a field, as we have in XSLT. Is that forthcoming?

In 5.4.2 of the XSLT specs, we say...

For example, the declaration:

<xsl:record-type name="cx:complex">
    <xsl:field name="r" as="xs:double"/>
    <xsl:field name="i" as="xs:double" required="no" default="0"/>
</xsl:record-type>
produces the equivalent item type declaration:

<xsl:item-type name="cx:complex"
       as="record(r as xs:double, i? as xs:double)"/>

...but the value of @as does not have any syntax that captures the defined default value.

@ndw

ndw commented May 19, 2026

Copy link
Copy Markdown
Contributor

At meeting 165, the CG agreed to merge this PR.

@ndw
ndw merged commit 6442b97 into qt4cg:master May 19, 2026
3 checks passed
@michaelhkay michaelhkay added the Tests Needed Tests need to be written or merged label Jun 9, 2026
ChristianGruen added a commit to qt4cg/qt4tests that referenced this pull request Jul 1, 2026
ChristianGruen added a commit to qt4cg/qt4tests that referenced this pull request Jul 2, 2026
ChristianGruen added a commit to qt4cg/qt4tests that referenced this pull request Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement A change or improvement to an existing feature Tests Needed Tests need to be written or merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update records: with { ... } Records: Type Safety

6 participants