Skip to content

fix: let leaf expressions reach the scan past a RepartitionExec - #27152

Merged
jayzhan211 merged 3 commits into
apache:mainfrom
cool9850311:fix-27131-leaf-projection-split
Nov 8, 2026
Merged

jayzhan211 merged 3 commits into
apache:mainfrom
cool9850311:fix-27131-leaf-projection-split

Conversation

@cool9850311

@cool9850311 cool9850311 commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A MoveTowardsLeafNodes expression loses leaf pushdown as soon as it shares a
projection with a KeepInPlace expression and a RepartitionExec is inserted.
The two symptom classes are:

-- errors: "file_row_index() is source dependent and cannot be evaluated directly"
SELECT file_row_index(), id + 1 FROM t;

-- no error, but reads the whole struct instead of pruning to one field
SELECT s['value'], id + 1 FROM t;

ProjectionExec::benefits_from_input_partitioning is true as soon as any
expression in the projection is KeepInPlace (the default placement). That one
flag does two opposing things:

  • EnsureRequirements inserts a RepartitionExec beneath the projection, and
  • RepartitionExec::try_swapping_with_projection then refuses to let that same
    projection descend past it.

So the leaf expression can never reach the source. The leaf call does not have to
be wrapped in anything — an unrelated computed column in the same projection is
enough, because the flag is a property of the whole projection, not of one
expression. get_field is the headline case: nested column pruning is its
documented purpose and it degrades silently.

The trigger needs a RepartitionExec, so the table has to exceed batch_size
(EnsureRequirements uses batch_size as a row-count threshold). That is why
this has not shown up in the existing small-fixture tests.

What changes are included in this PR?

ProjectionPushdown now splits such a projection instead of giving up:

  • the leaf expressions move into a lower projection that the source absorbs;
  • the remaining computation stays above the repartition, where it is still
    parallelised.

Both guards keep their intent rather than one being traded for the other: the
scan only gains the cheap leaf expression, and the expensive computation is still
run in parallel.

Deliberate limits:

  • The split is committed only when child.try_swapping_with_projection actually
    returns Some for the lower projection, i.e. when the source really does
    absorb it. Otherwise the plan is left untouched.
  • Only Partitioning::RoundRobinBatch is handled. Hash and range partitioning
    reference columns by index, so changing the schema below them would require
    remapping their expressions; that is left out of this PR.
  • Only the columns the outer expressions still reference outside the extracted
    leaves are passed through, so s['a'] + 1 does not end up reading the whole
    struct alongside the extracted field.

What is the testing strategy for this PR?

  • datafusion/sqllogictest/test_files/file_row_index.slt — value-based
    regression tests for the error class: bare marker plus a computed sibling,
    + 1, arrow_typeof, two different markers mixed, a duplicated marker, and
    non-regression for Filter / Sort / Aggregate.
  • datafusion/sqllogictest/test_files/projection_pushdown.slt — plan assertions
    for the get_field class. Value comparison can never catch that one: without
    pruning the results are still correct, only more data is read, so the test has
    to assert the pruned projection=[...] in the plan.
  • datafusion/physical-optimizer unit tests: 50 passed.

Two traps are called out in comments in both test files, because either one makes
the tests pass against unfixed code:

  1. the fixture must exceed batch_size, or no RepartitionExec is inserted and
    there is nothing to reproduce;
  2. a bare LIMIT n is pushed into the scan and hides the bug again.

Verified by reverting the source file with git checkout main -- <file>: the new
tests then fail with 6 errors and 16 plan-diff lines.

Are there any user-facing changes?

No API changes. Queries that previously failed with "is source dependent and
cannot be evaluated directly" now succeed, and get_field regains nested column
pruning when it shares a projection with other expressions.

One plan-shape note for #25456 (inlining projection columns), which touches
adjacent code: its test pins file_row_index() used twice as not inlined. When
the split here fires, the two identical leaf calls are de-duplicated into a
single lower-projection column. Values are unaffected; only the plan shape
differs. Happy to rebase in whichever order is convenient.

🤖 Generated with Claude Code

@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Nov 6, 2026
@cool9850311
cool9850311 force-pushed the fix-27131-leaf-projection-split branch from dd4a8ee to e501896 Compare November 6, 2026 00:58

@jayzhan211 jayzhan211 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.

@cool9850311 thanks for tracking this down so thoroughly! The split fixes the reported errors and pruning, and scanned bytes drop from 178.2 KB to 67.6 KB in my s['a'] + 1 check. I left two inline comments on shapes that work on main but fail with this PR.

Two queries from the issue's tables still error with this PR, because a different node sits under the projection:

SELECT file_row_index() + 1 FROM big OFFSET 5 LIMIT 3;                                    -- GlobalLimitExec
SELECT file_row_index() AS r, sum(column1) OVER (ORDER BY column1) AS w FROM big;         -- BoundedWindowAggExec

These need different handling, so a follow-up is fine. Could you change Closes #27131 to Part of #27131 so the issue stays open for them?

return Ok(Transformed::yes(Arc::new(remapped) as Arc<dyn PhysicalExpr>));
}
}
Ok(Transformed::no(node))

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.

Lambda parameters are indexed after the input columns, so they go stale when the split widens the input. This query works on main but now fails with Field of physical LambdaVariable with index 0 doesn't match batch field:

SET datafusion.sql_parser.dialect = databricks;
SELECT array_transform(arr, x -> x + 1) AS a, s['value'] AS v, s['label'] AS l FROM t ORDER BY v;

This fixes it, with child_schema.fields().len() passed in as a new old_input_len parameter and let new_input_len = first_leaf + leaves.len(); added next to first_leaf. Could you add the query above to the SLT, plus a nested lambda case?

Suggested change
Ok(Transformed::no(node))
// Lambda parameters are appended after the input columns, so they move
// when the split changes the input width.
if let Some(variable) = node.downcast_ref::<LambdaVariable>()
&& old_input_len != new_input_len
{
let rebased = LambdaVariable::new(
variable.index() - old_input_len + new_input_len,
Arc::clone(variable.field()),
);
return Ok(Transformed::yes(Arc::new(rebased) as Arc<dyn PhysicalExpr>));
}
Ok(Transformed::no(node))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Modified.

Comment on lines +562 to +565
Ok(Some(Arc::new(ProjectionExec::try_new(
new_exprs,
new_repartition,
)?)))

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.

try_new drops metadata that the planner set through try_new_with_schema_metadata, so this select now fails with PhysicalOptimizer rule 'ProjectionPushdown' failed. Schema mismatch, while it works on main:

ctx.table("t").await?.select(vec![
    get_field(col("s"), "value").alias_with_metadata("v", Some(metadata.into())),
    (col("id") + lit(1i64)).alias("w"),
])?;

Could you apply this and add that select as a test, with batch_size below the row count so the repartition appears, asserting the output field keeps its metadata?

Suggested change
Ok(Some(Arc::new(ProjectionExec::try_new(
new_exprs,
new_repartition,
)?)))
Ok(Some(Arc::new(
ProjectionExec::try_new_with_schema_metadata(
new_exprs,
new_repartition,
projection.schema().as_ref(),
)?,
)))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Modified.

@cool9850311
cool9850311 force-pushed the fix-27131-leaf-projection-split branch from e501896 to b69dd03 Compare November 7, 2026 01:06
@github-actions github-actions Bot added the core Core DataFusion crate label Nov 7, 2026

@jayzhan211 jayzhan211 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.

@cool9850311 thanks for the quick update! The lambda and metadata fixes both hold, including nested lambdas and a lambda whose input width doesn't change. One thing is left before this can merge: no test covers a source that refuses the split, see the inline comment on that check. The other comments are optional.


// Only commit the split if the child really absorbs the leaf projection.
// Otherwise this would just add a projection that helps nobody.
let Some(new_child) = child.try_swapping_with_projection(&lower_projection)? else {

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.

No test fails if this check is removed. SELECT s['value'] AS v, id + 1 AS w on a memory table then gets an extra ProjectionExec that the source never absorbs, and every test in this PR still passes. The PR description promises this limit, so could you pin it here with an EXPLAIN in projection_pushdown.slt?

SLT test, placed before the batch_size = 8192 restore (passes on this PR, fails with the check removed)
# A source that does not absorb the projection keeps the plan unchanged: the
# split must not add a projection nobody can use.
statement ok
CREATE TABLE leaf_split_mem AS
SELECT column1 AS id, column2 AS s FROM VALUES
    (1, {value: 100}), (2, {value: 200}), (3, {value: 150}),
    (4, {value: 300}), (5, {value: 250});

query TT
EXPLAIN SELECT s['value'] AS v, id + 1 AS w FROM leaf_split_mem;
----
logical_plan
01)Projection: get_field(leaf_split_mem.s, Utf8("value")) AS v, leaf_split_mem.id + Int64(1) AS w
02)--TableScan: leaf_split_mem projection=[id, s]
physical_plan
01)ProjectionExec: expr=[get_field(s@1, value) as v, id@0 + 1 as w]
02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
03)----DataSourceExec: partitions=1, partition_sizes=[3]

statement ok
DROP TABLE leaf_split_mem;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — your SLT block went in verbatim at the position you named, and the
expected plan matched exactly, including partition_sizes=[3].

I measured how far "no test fails" reached, since it decides what the green
suite was worth. With the check removed:

suite result
sqllogictest, 546 files RC=0
physical-optimizer unit, 50 RC=0
core_integration physical_optimizer, 668 RC=0

Nothing in 1264 tests noticed, including the four this PR had added — all of
mine were positive tests, none checked that the split doesn't happen.

Running the same check over the rest of the function found a second unpinned
promise, so de-duplication has an EXPLAIN now too. Values can't show it (two
calls to one marker agree either way) but the scan projection can:

projection=[column1, CAST(__datafusion_file_row_index@1 AS Int64;N) as __datafusion_leaf_0]

Without de-duplication that line gains a second CAST(...) as __datafusion_leaf_1,
which is what the new test fails on.

Comment on lines 73 to 81
let plan = plan
.transform_down(|plan| {
Ok(match try_split_leaf_projection(&plan)? {
Some(new_plan) => Transformed::yes(new_plan),
None => Transformed::no(plan),
})
})
.data()?;
plan.transform_down(remove_unnecessary_projections).data()

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.

Optional cleanup: the last walk only matters when a split fired. Skipping it otherwise means a plan with no split gets the same single cleanup walk as on main. All 545 SLT files pass with this change.

Suggested change
let plan = plan
.transform_down(|plan| {
Ok(match try_split_leaf_projection(&plan)? {
Some(new_plan) => Transformed::yes(new_plan),
None => Transformed::no(plan),
})
})
.data()?;
plan.transform_down(remove_unnecessary_projections).data()
let split = plan.transform_down(|plan| {
Ok(match try_split_leaf_projection(&plan)? {
Some(new_plan) => Transformed::yes(new_plan),
None => Transformed::no(plan),
})
})?;
if split.transformed {
split
.data
.transform_down(remove_unnecessary_projections)
.data()
} else {
Ok(split.data)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I went a step further than this and removed the walk entirely — please tell me
if you'd rather have your version. What pushed me that way is that it seems not
to do anything even when a split does fire.

Instrumented to diff the plan string before and after it:

suite splits fired walk changed a plan
sqllogictest, 545 files 19 0
unit 0 0
core_integration 0 0

Both directions of the mutation are green too — never running it, and running it
unconditionally — so no test observes it either way.

Why it looks like it can't fire

remove_unnecessary_projections has three paths for a projection
(projection.rs:1047), and as far as I can tell the outer projection takes none:

  1. is_projection_removable wants alias == column name and
    projection.schema() == input.schema(). The split renames (v over
    __datafusion_leaf_0) and keeps the original output schema, for the metadata
    reason you raised on the other thread. Instrumented: both false in 20/20 splits.
  2. overrides_metadata returns Transformed::no.
  3. RepartitionExec::try_swapping_with_projection declines either way. If every
    outer expression stayed a plain column, each original expression was exactly
    one leaf or exactly one passthrough, so N >= P + L and the narrowing guard
    refuses. If it does narrow, some expression consumed more than one leaf and so
    isn't a plain Column, and all_columns refuses.

Measured per split (N = outer expressions, P = passthrough, L = leaves):

N P L count refused by
1 0 1 2 narrowing guard
2 1 1 8 narrowing guard
2 1 2 1 all_columns
3 1 1 3 narrowing guard
3 1 2 4 narrowing guard
3 1 3 1 narrowing guard
4 2 2 1 narrowing guard

18 of 20 on the first guard, 2 on the second — the narrowing case is a nested
lambda whose single output expression consumes two leaves.

Nothing else in the tree moves either: the split preserves the projection's
schema, so ancestors see no change, and the lower projection is absorbed by the
source.

02)--RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
03)----DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/file_row_index/parquet_table/data.parquet]]}, projection=[column1, input_file_name() as __datafusion_leaf_0], file_type=parquet

# The same marker twice. Both calls are one expression, so the split gives them

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.

Optional: three shapes fail on main and work with this PR, but have no test yet: a TopK whose dynamic filter reaches the scan through the rewritten marker column, input_file_name() on a CSV source, and a marker inside a lambda body. The queries below all pass on this PR.

The three SLT tests and which file each goes in

In file_row_index.slt, inside the batch_size = 2 block:

query II
SELECT file_row_index() AS r, column1 + 1 FROM parquet_table ORDER BY r DESC LIMIT 2
----
4 51
3 41

In projection_pushdown.slt, inside the databricks dialect block:

query ?IT
SELECT array_transform(arr, x -> x + file_row_index()) AS a, s['value'] AS v, s['label'] AS l
FROM leaf_split ORDER BY v;
----
[1, 2, 3] 100 alpha
[8] 150 gamma
[5, 6] 200 beta
[14] 250 epsilon
[10, 11, 12] 300 delta

In input_file_name.slt, while csv_table exists:

statement ok
SET datafusion.execution.batch_size = 2;

query BI
SELECT length(input_file_name()) > 0, column1 + 1
FROM csv_table
ORDER BY 2
----
true 11
true 21
true 31
true 41
true 51
true 61

statement ok
SET datafusion.execution.batch_size = 8192;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All three are in. They match your expected values and all three error against
main with is source dependent and cannot be evaluated directly — TopK gives
4 51 / 3 41, the lambda-body marker [1, 2, 3] 100 alpha …, CSV true 11
… true 61.

One thing I couldn't reproduce: the dynamic filter reaching the scan in the TopK
query. With ORDER BY r DESC LIMIT 2, where r is the rewritten marker:

SortExec: TopK(fetch=2), expr=[r@0 DESC], preserve_partitioning=[true]
  ProjectionExec: expr=[__datafusion_leaf_0@1 as r, column1@0 + 1 as ...]
    RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
      DataSourceExec: ..., projection=[column1, CAST(...) as __datafusion_leaf_0], file_type=parquet

No predicate on the scan. Sorting on the computed sibling instead (ORDER BY w)
does give predicate=DynamicFilter and dynamic_rg_pruning=eligible — my guess
is __datafusion_leaf_0 is a synthetic row-index column with no statistics to
prune row groups with, but I haven't chased it.

The test still seems worth keeping for the path it does cover: the sort key is a
column the split produced, and the TopK runs above the repartition with
preserve_partitioning=[true]. So I kept it and rewrote the comment to say that
instead. I'd put your wording in without checking it first, which was my slip
rather than anything wrong with the test you suggested.

Comment on lines +275 to +276
# Raising `batch_size` above the row count removes the repartition. This must
# keep working, and it is also the control that proves trap 1 above is real.

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.

Optional: "trap 1" is only defined in the PR description, so this comment won't make sense to someone reading the file.

Suggested change
# Raising `batch_size` above the row count removes the repartition. This must
# keep working, and it is also the control that proves trap 1 above is real.
# Raising `batch_size` above the row count removes the repartition. This must
# keep working, and it is also the control showing that `batch_size` is what
# triggers the repartition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied verbatim — thanks, that one was mine to catch. "trap 1" was a number I
made up in the PR description and never defined in the file.

cool9850311 and others added 3 commits November 8, 2026 11:01
`ProjectionExec::benefits_from_input_partitioning` is true as soon as any
expression in the projection has `KeepInPlace` placement. That single flag does
two opposing things: `EnsureRequirements` inserts a `RepartitionExec` beneath the
projection, and `RepartitionExec::try_swapping_with_projection` then refuses to
let the same projection descend. A `MoveTowardsLeafNodes` expression sharing that
projection can therefore never reach the source.

`file_row_index()` and `input_file_name()` are then evaluated directly and fail
with "is source dependent and cannot be evaluated directly", while `get_field`
silently reads the whole struct instead of pruning to the field. The leaf call
does not have to be wrapped in anything: an unrelated computed column in the same
projection is enough, because the flag is a property of the whole projection.

`ProjectionPushdown` now splits such a projection. The leaf expressions move into
a lower projection that the source absorbs, and the computation stays above the
repartition where it is still parallelised, so the intent of both guards is kept
rather than one traded for the other. The split is committed only when the child
really absorbs the leaf projection, and only `RoundRobinBatch` repartitioning is
handled, because hash and range partitioning reference columns by index and would
need remapping.

Only the columns the outer expressions still reference outside the extracted
leaves are passed through, so `s['a'] + 1` no longer reads the whole struct
alongside the extracted field.

Regression tests cover both symptom classes. Two traps are documented in the test
files because either one makes the tests pass against broken code: the table must
exceed `batch_size` for a repartition to be inserted at all, and a bare `LIMIT n`
is pushed into the scan and hides the bug again.

Closes apache#27131

Co-Authored-By: Claude Opus 5 <[email protected]>
…ection

Two regressions in the projection split, both reported in review. Each query
below works on `main` and failed with the split.

Lambda parameters are indexed after the input columns, so widening the input
leaves them pointing at the wrong column:

    SELECT array_transform(arr, x -> x + 1), s['value'], s['label'] FROM t;

`remap_filter_expr` in this file already rebases parameters for the join filter
pushdown, so the node-level shift moves into `rebase_lambda_variable` and both
callers use it rather than carrying two copies.

The symptom depends on where the stale index lands. Out of range it is dropped
and the query happens to be correct; in range it fails `LambdaVariable`'s field
check; in range with a matching name and type it reads the wrong column and
returns wrong values with no error. The last case is covered, since the field
check is a backstop rather than the guarantee.

`ProjectionExec::try_new` derives the output schema from the expressions alone
and drops the field metadata the planner attached through
`try_new_with_schema_metadata`, which the rule's own `schema_check` then
rejects:

    PhysicalOptimizer rule 'ProjectionPushdown' failed. Schema mismatch.

The split now rebuilds the projection with that constructor, taking the
metadata from the projection it replaces.

Tests. The value queries live in `projection_pushdown.slt`; the fixture gains
`arr` and `tags`, appended after `s` so the existing `get_field(s@1, ...)`
assertions do not move. A nested lambda needs two leaf expressions: with one
the split is width-neutral, the parameter does not move, and the query passes
against unfixed code. The metadata test is a Rust test because no SQL syntax
attaches field metadata; it asserts on the physical plan's schema, not the
DataFrame's, and uses Parquet because only a source that absorbs the leaf
projection makes the split fire.

Part of apache#27131

Co-Authored-By: Claude Opus 5 <[email protected]>
…fixes

Review follow-up. Nothing here changes what the split computes.

The split is only committed when the child really absorbs the leaf projection,
but no test held that: removing the check left all 545 SLT files, 50
physical-optimizer unit tests and 669 `core_integration` ones green. A memory
table refuses the lower projection, because `all_alias_free_columns` rejects an
aliased `get_field`, and that is also what any node that does not override
`try_swapping_with_projection` does. An `EXPLAIN` on such a table now pins the
plan; with the check removed it fails on the extra `ProjectionExec`.

Collecting each distinct leaf once was likewise unpinned, and the values cannot
show it: two calls to the same marker agree with each other whether they share a
column or not. The duplicated-marker case gains an `EXPLAIN`, which fails with a
second `__datafusion_leaf_1` in the scan projection if the de-duplication goes.

Three shapes the split fixes had no test either. Each reaches a different path:
a TopK sorts on a column the split produced and runs above the repartition with
`preserve_partitioning=[true]`; a marker inside a lambda body has to be found
through `LambdaExpr`; and CSV is a second scan implementation, while every other
test here uses Parquet. All three error with `is source dependent and cannot be
evaluated directly` without the split.

The cleanup walk this PR had added after the split is removed rather than made
conditional, because it cannot do anything. `remove_unnecessary_projections`
has three paths for a projection: it is not removable, since the split renames
and keeps the original output schema; `overrides_metadata` changes nothing; and
`RepartitionExec` declines it either for not narrowing, when every expression
stayed a plain column, or for holding a computed one, when an expression
consumed more than one leaf and the projection did narrow. Nothing else in the
tree moved, as the split preserves the projection's schema. Instrumenting the
walk confirmed it changed no plan in any of the 19 splits the suite fires.

Handling only `RoundRobinBatch` was unpinned the same way, and no SQL reaches
that shape: the guard is hit 1326 times across the suite, every one of them
round robin. The rule is a plain plan-to-plan function though, so the plan can
be built by hand, which `split_declines_hash_repartition` does, after
`ensure_requirements.rs` for the hash repartition and `test_utils.rs` for the
scan. The scan has to be Parquet rather than `EmptyExec`: only a source that
absorbs the leaf projection lets the split commit once the check is gone.

Also drops a comment reference to a trap number that only exists in the pull
request description, and rewrites the one on `collect_leaf_exprs` that used
`s['a']['b']` as its example: the logical optimizer folds that into a single
call, so the branch it describes never sees it. It now says what stopping at a
leaf avoids instead.

Part of apache#27131

Co-Authored-By: Claude Opus 5 <[email protected]>
@cool9850311
cool9850311 force-pushed the fix-27131-leaf-projection-split branch from b69dd03 to 4ee8c64 Compare November 8, 2026 03:16

@jayzhan211 jayzhan211 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 @cool9850311 !

@jayzhan211
jayzhan211 added this pull request to the merge queue Nov 8, 2026
Merged via the queue into apache:main with commit 4c430b4 Nov 8, 2026
44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) v57.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants