Skip to content

feat: replace tuple_every with tuple_reduce#5878

Merged
max-sixty merged 2 commits into
PRQL:mainfrom
bioteam:kg/tuple-reduce
May 12, 2026
Merged

feat: replace tuple_every with tuple_reduce#5878
max-sixty merged 2 commits into
PRQL:mainfrom
bioteam:kg/tuple-reduce

Conversation

@kgutwin

@kgutwin kgutwin commented May 11, 2026

Copy link
Copy Markdown
Collaborator

As I was looking at the tuple_* functions in the std lib, I noticed that tuple_every is a fairly straightforward function which essentially applies an "AND" reduce operation across its single tuple-value parameter. This PR replaces that single-purpose function with tuple_reduce, capable of performing a reduce across a tuple using any operator or two-parameter function.

It behaves very similar to the Python functools.reduce() function, in that it takes a function, tuple, and an optional initial named parameter. When the initial parameter is provided, the first step of the reduction uses the initial value as the first parameter to the reduction operation. If the provided tuple is empty, the initial value will be returned. When the initial parameter is omitted, if the tuple is empty, an error is raised. If the tuple has one entry, its value will be returned; otherwise, the reduce operation will proceed as normal.

Example usage:

from foo
select {bar, baz, quux}
select { 
  mysum = tuple_reduce initial:0 add {1, 2, 3},
  all_true = tuple_reduce std.and foo.*,
}

produces:

SELECT
  0 + 1 + 2 + 3 AS mysum,
  bar
  AND baz
  AND quux AS all_true
FROM
  foo

If we want to preserve tuple_every for backwards compatibility, it could be defined in the std lib as:

let tuple_every = func list -> <bool> tuple_reduce initial:true std.and list

However, I wasn't sure that this was necessary, since apparently only the other std lib functions intersect and remove use tuple_every, and those were easily ported over to use tuple_reduce.

@prql-bot prql-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A few observations on the tuple_reduce resolver implementation:

  • Sentinel-string collision. Using "__missing" as the default value for initial and matching against its quoted form means a user who passes tuple_reduce initial:"__missing" some_fn list silently hits the "no initial provided" path instead of seeing their string treated as the initial value. The risk is low in practice, but the design is a bit fragile — happy to defer to maintainer preference, but it would be worth at least a code comment explaining the convention. Other places in std.prql use the noresolve.* ident namespace for non-literal defaults (e.g. noresolve.side:inner); that pattern would be unambiguous here too.

  • Backward compatibility. tuple_every is removed entirely. The PR description correctly identifies that no other in-tree code uses it, and there are no docs references either, but it was a public std-lib name. The PR description's own suggestion — keeping a one-line shim let tuple_every = func list -> <bool> tuple_reduce initial:true std.and list — is cheap insurance for any external user. Worth a maintainer call.

  • Test coverage. The new tests exercise tuple_reduce with add, which is great for initial: semantics, but the original tuple_every use case (boolean reduce via std.and) is only covered transitively via the set_ops_remove snapshot. A small explicit test (e.g. tuple_reduce std.and {true, false, true}) would lock that path in.

Inline suggestion for the literal comparison below.

Comment thread prqlc/prqlc/src/semantic/resolver/transforms.rs Outdated
@kgutwin

kgutwin commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

To comment on @prql-bot's observations:

  • I am aware of the sentinel-string collision issue; I rejected the option to go with noresolve.* because I would like to enable the behavior where an ident is provided as the initial value. For example, the PRQL code
let myinit = 4

from foo
select {xyz = tuple_reduce initial:myinit add {1, 2, 3}}

will work as you would expect. I think that future support within PRQL for Symbol-type values (that aren't strings or anything else) makes sense, but is out of scope for this PR.

  • I'm fine to consider backwards compatibility, but since PRQL has not yet reached 1.0, my understanding is that backwards compatibility is never guaranteed, and in fact preserving a function such as tuple_every which has narrow and slightly awkward semantics seems unnecessary. I'm fine to add that to the PR from maintainer feedback though.
  • I think the current test coverage is sufficient, especially given the existing tests that cover the set operations.

@prql-bot

Copy link
Copy Markdown
Collaborator

Thanks for the responses.

On point 1: the ident-as-initial use case is a good reason to keep a string sentinel — agree that noresolve.* would block that. The 4320d855 tweak (matching Literal::String("__missing") directly instead of round-tripping through the quoted to_string() form) addresses the comparison fragility cleanly; the remaining theoretical collision against a user-supplied literal "__missing" is acceptable given the rarity.

Points 2 and 3 are maintainer judgment calls — happy to defer.

@max-sixty

Copy link
Copy Markdown
Member

agree @kgutwin !

@max-sixty max-sixty merged commit c2aad62 into PRQL:main May 12, 2026
37 checks passed
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.

3 participants