Skip to content

Propagate occurrences from a repeating choice to its members#93

Open
veewee wants to merge 2 commits into
goetas-webservices:masterfrom
veewee:fix/repeating-group-ref-choice-occurrences
Open

Propagate occurrences from a repeating choice to its members#93
veewee wants to merge 2 commits into
goetas-webservices:masterfrom
veewee:fix/repeating-group-ref-choice-occurrences

Conversation

@veewee

@veewee veewee commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem

A <group> referenced with maxOccurs greater than 1 (or unbounded) around an <xs:choice> did not pass that repetition down to the choice members. Each member kept maxOccurs="1", so a consumer could not tell that the elements may repeat.

<xs:group name="Operation">
  <xs:choice>
    <xs:element name="delete" type="xs:string"/>
    <xs:element name="write" type="xs:string"/>
  </xs:choice>
</xs:group>

<xs:complexType name="Message">
  <xs:sequence>
    <xs:group ref="tns:Operation" minOccurs="0" maxOccurs="unbounded"/>
  </xs:sequence>
</xs:complexType>

Before this change, delete and write were read as maxOccurs="1". After it, both are minOccurs="0", maxOccurs="-1" (optional, unbounded).

Why the old behavior happened

GroupRef::getElements() already applies the reference occurrences to its direct children (#91). When the group's direct child is a <choice>, that only updates the Choice node's own min/max; the choice members are never touched. SchemaReader does handle an inline <choice maxOccurs="..."> at parse time (#88), but a named group is parsed once and shared across every reference to it, so the reference maxOccurs is not known at parse time and cannot be written onto the shared definition. The reference occurrences only become known when the reference is expanded, which is GroupRef::getElements().

The fix

Choice::getElements() now applies the choice occurrences to its members when the choice is repeatable, the same way GroupRef already does for group references.

Why maxOccurs becomes -1 instead of a product

This keeps the decision from #88. A choice selects one branch per occurrence, and a member can carry its own maxOccurs, so a single integer can no longer describe the member count across repetitions. A member with maxOccurs="3" under a choice repeated five times has valid counts that do not reduce to one number. Instead of computing a product that is hard to trust, a repeating choice marks its members as unbounded (-1). That is the safe upper bound: it never rejects a document the schema allows.

Why minOccurs becomes 0, except for a single-member choice

With more than one branch, any single member can be absent because another branch satisfies the occurrence. This holds even when the choice itself is required: <choice minOccurs="2"> over a and b can be satisfied with two b values, so a may appear zero times. The choice minOccurs does not transfer to a member, and neither does the member's own minOccurs, which only applies when that branch is selected. So a member of a multi-member choice gets minOccurs="0".

A single-member choice is the exception. The one member is the only branch, so it is always selected, and it keeps the choice's own minOccurs rather than dropping to 0.

$min = count($this->elements) > 1 ? 0 : $this->getMin();

Scope

Non-repeating choices are unchanged. The change composes with #91: a group reference sets the cloned choice's max, and the choice then applies that to its members when it is expanded.

Tests

ChoiceTest covers a repeating multi-member choice (members become 0 / -1) and a single-member choice (member keeps the choice's min). ElementsTest covers the group-reference-around-a-choice case.

veewee and others added 2 commits June 11, 2026 14:46
A group referenced with maxOccurs > 1 (or unbounded) around an
xs:choice did not pass that repetition to the choice members: each
member kept maxOccurs="1". GroupRef::getElements() multiplies the
reference occurrences onto its direct children (goetas-webservices#91), but when that
child is a choice only the Choice node's own min/max is updated, never
its members. SchemaReader handles an inline repeating choice at parse
time (goetas-webservices#88), but a named group is parsed once and shared across refs, so
the ref's maxOccurs is unknown then.

Choice::getElements() now applies the choice occurrences to its members
when the choice is repeatable:

- maxOccurs becomes -1 (unbounded) rather than a product, following goetas-webservices#88:
  a choice picks one branch per occurrence and members carry their own
  occurrences, so an exact count is not expressible in one integer.
- minOccurs becomes 0 for a multi-member choice (any branch can be
  absent, even when the choice is required), and keeps the choice's own
  minOccurs for a single-member choice (that member is always selected).

Non-repeating choices are unchanged.
@veewee

veewee commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Hi @goetas ,

Let me know if there's anything I can do to move this PR forward.

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