1979 Records with type annotations - #2566
Conversation
ChristianGruen
left a comment
There was a problem hiding this comment.
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)andrecord(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.
|
The PR helped me reflect on further choices. Maybe we can treat them more independently: First, I (now) think we should keep 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 Next, I believe that a custom operator such as |
There's certainly a substitutability argument that would favour that. |
50c40ec to
3d949c7
Compare
3d949c7 to
7ba92b8
Compare
ChristianGruen
left a comment
There was a problem hiding this comment.
Looking forward to the discussion.
|
|
||
| </div2> | ||
|
|
||
| <div2 id="applying-functions-to-records"> |
There was a problem hiding this comment.
Maybe the full section can be dropped. It seems to refer to the previous version of the proposal.
|
With the new rule, I would suggest dropping the following rule:
Otherwise, arbitrary maps can be coerced to arbitrary records (as the default value types are 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. |
|
I have revised the PR in the light of points raised here, and in the discussion on 21 April 2026. The main changes are:
For the time being I have dropped the 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> |
There was a problem hiding this comment.
| 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> |
|
I tried generating parsers from the grammar as extended by this PR and ran into a couple of issues:
Separately, the XPath spec currently has a dead link to |
|
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: |
|
An alternative could be a classical function 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 |
|
My preference would be to avoid reusing the If we want to keep a keyword for readability, something more specific like Happy to test again if there’s a concrete proposal along those lines. |
But it seems odd to have such a function that's not in the "map" namespace. |
|
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 ( |
|
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? |
We addressed that with the "null" option on parse-json. |
Ah yes, I see it. Very good indeed. Thank you. |
c88f43e to
bdd65e1
Compare
cf35c27 to
b49338e
Compare
| </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. |
There was a problem hiding this comment.
| 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"> |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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.
|
At meeting 165, the CG agreed to merge this PR. |
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