Skip to content

Commit 43826cd

Browse files
srielauYicong-Huang
authored andcommitted
[SPARK-58224][SQL] ASOF JOIN analysis fixes found during FVT review
### What changes were proposed in this pull request? Product fixes uncovered while landing ASOF JOIN golden-file tests (SPARK-58173): 1. **Non-boolean `ON`** — report `JOIN_CONDITION_IS_NOT_BOOLEAN_TYPE` (`CheckAnalysis`) 2. **Invalid `MATCH_CONDITION` operator errors** — recover SQL operator text (`!=` vs `<>`, `IS [NOT] DISTINCT FROM`) via parse-tree inspection instead of Catalyst pretty names (`AstBuilder`) 3. **Foldable `MATCH_CONDITION` operands** — assign literals, `CURRENT_TIMESTAMP()`, and session variables to join sides by syntactic position (`AsOfJoin.normalizeMatchOperands`) 4. **Subquery resolution** — resolve subqueries in `AsOfJoin` `ON` / `MATCH_CONDITION` like regular `Join` (`Analyzer.ResolveSubquery`, `ValidateSubqueryExpression`) 5. **Struct/array `MATCH_CONDITION` analysis** — reject empty structs; require identical array element types so analysis matches order-expression construction (`ResolveAsOfJoin`) ### Why are the changes needed? Without these fixes, FVT and integration tests surfaced misleading errors (e.g. `TABLE_OR_VIEW_NOT_FOUND` for unresolved subqueries) or analysis/execution mismatches on array operands. ### Does this PR introduce _any_ user-facing change? Yes — clearer analysis errors and correct behavior for foldable operands and subqueries in ASOF JOIN. ### How was this patch tested? - `PlanParserSuite` — invalid `MATCH_CONDITION` operator text - `AsOfJoinSQLSuite` — foldable operands, subqueries, struct/array operands ```bash build/sbt -Dscalastyle.skip=true \ "sql/testOnly org.apache.spark.sql.catalyst.parser.PlanParserSuite -- -z asof" \ "sql/testOnly org.apache.spark.sql.AsOfJoinSQLSuite" ``` Stacked PR: [SPARK-58173](#57306) (FVT goldens) builds on this branch. ### Was this patch authored or co-authored using generative AI tooling? No. Closes #57380 from srielau/SPARK-58224. Authored-by: Serge Rielau <serge@rielau.com> Signed-off-by: Yicong-Huang <17627829+Yicong-Huang@users.noreply.github.com>
1 parent ab513d6 commit 43826cd

9 files changed

Lines changed: 523 additions & 83 deletions

File tree

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2633,6 +2633,8 @@ class Analyzer(
26332633
resolveSubQueries(r, r)
26342634
case j: Join if j.childrenResolved && j.duplicateResolved =>
26352635
resolveSubQueries(j, j)
2636+
case j: AsOfJoin if j.childrenResolved && j.duplicateResolved =>
2637+
resolveSubQueries(j, j)
26362638
case tvf: UnresolvedTableValuedFunction =>
26372639
resolveSubQueries(tvf, tvf)
26382640
case s: SupportsSubquery if s.childrenResolved =>

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -678,11 +678,11 @@ trait CheckAnalysis extends LookupCatalog with QueryErrorsBase with PlanToString
678678

679679
case j @ AsOfJoin(_, _, _, Some(condition), _, _, _, _, _, _, _, _, _, _)
680680
if condition.dataType != BooleanType =>
681-
throw SparkException.internalError(
682-
msg = s"join condition '${toSQLExpr(condition)}' " +
683-
s"of type ${toSQLType(condition.dataType)} is not a boolean.",
684-
context = j.origin.getQueryContext,
685-
summary = j.origin.context.summary)
681+
j.failAnalysis(
682+
errorClass = "JOIN_CONDITION_IS_NOT_BOOLEAN_TYPE",
683+
messageParameters = Map(
684+
"joinCondition" -> toSQLExpr(condition),
685+
"conditionType" -> toSQLType(condition.dataType)))
686686

687687
case j @ AsOfJoin(_, _, _, _, _, _, Some(toleranceAssertion), _, _, _, _, _, _, _) =>
688688
if (!toleranceAssertion.foldable) {

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveAsOfJoin.scala

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,17 @@ package org.apache.spark.sql.catalyst.analysis
2020
import org.apache.spark.sql.catalyst.SQLConfHelper
2121
import org.apache.spark.sql.catalyst.expressions.{
2222
Expression,
23-
RowOrdering,
2423
SubqueryExpression,
2524
WindowExpression
2625
}
2726
import org.apache.spark.sql.catalyst.expressions.AttributeSet
2827
import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
2928
import org.apache.spark.sql.catalyst.plans.logical.{AsOfJoin, LogicalPlan, Project}
29+
import org.apache.spark.sql.catalyst.plans.logical.AsOfJoin.MatchConditionTypes
3030
import org.apache.spark.sql.catalyst.rules.Rule
3131
import org.apache.spark.sql.catalyst.trees.TreePattern.{AS_OF_JOIN, GENERATOR}
3232
import org.apache.spark.sql.catalyst.util._
3333
import org.apache.spark.sql.errors.QueryErrorsBase
34-
import org.apache.spark.sql.types.{ArrayType, DataType, DatetimeType, StringType, StructType}
3534

3635
/**
3736
* Resolves SQL [[AsOfJoin]] operators: materializes `MATCH_CONDITION` into `asOfCondition` and
@@ -147,9 +146,9 @@ private[analysis] object AsOfJoinValidation extends QueryErrorsBase {
147146
messageParameters = Map("expr" -> toSQLExpr(invalidExpr)))
148147
}
149148
}
150-
if (!RowOrdering.isOrderable(leftExpr.dataType) ||
151-
!RowOrdering.isOrderable(rightExpr.dataType) ||
152-
!areMatchConditionTypesCompatible(leftExpr.dataType, rightExpr.dataType)) {
149+
if (!MatchConditionTypes.isValidOperandType(leftExpr.dataType) ||
150+
!MatchConditionTypes.isValidOperandType(rightExpr.dataType) ||
151+
!MatchConditionTypes.areOperandsCompatible(leftExpr.dataType, rightExpr.dataType)) {
153152
join.failAnalysis(
154153
errorClass = "ASOF_JOIN_MATCH_CONDITION_INVALID_TYPE",
155154
messageParameters = Map(
@@ -158,40 +157,6 @@ private[analysis] object AsOfJoinValidation extends QueryErrorsBase {
158157
}
159158
}
160159

161-
/**
162-
* Tuple/struct operands may use different field names on each side; compare field-wise by
163-
* position when [[TypeCoercion.findWiderTypeForTwo]] does not apply.
164-
*/
165-
private def areMatchConditionTypesCompatible(t1: DataType, t2: DataType): Boolean = {
166-
if (isIncompatibleMatchConditionPair(t1, t2)) {
167-
false
168-
} else {
169-
TypeCoercion.findWiderTypeForTwo(t1, t2).isDefined ||
170-
areStructurallyComparableTypes(t1, t2)
171-
}
172-
}
173-
174-
private def isIncompatibleMatchConditionPair(t1: DataType, t2: DataType): Boolean = {
175-
def isString(dt: DataType): Boolean = dt.isInstanceOf[StringType]
176-
def isTemporal(dt: DataType): Boolean = dt.isInstanceOf[DatetimeType]
177-
(isTemporal(t1) && isString(t2)) || (isString(t1) && isTemporal(t2))
178-
}
179-
180-
private def areStructurallyComparableTypes(t1: DataType, t2: DataType): Boolean = {
181-
(t1, t2) match {
182-
case (s1: StructType, s2: StructType) if s1.length == s2.length =>
183-
s1.zip(s2).forall { case (f1, f2) =>
184-
RowOrdering.isOrderable(f1.dataType) &&
185-
RowOrdering.isOrderable(f2.dataType) &&
186-
areMatchConditionTypesCompatible(f1.dataType, f2.dataType)
187-
}
188-
case (ArrayType(e1, _), ArrayType(e2, _)) =>
189-
RowOrdering.isOrderable(e1) && RowOrdering.isOrderable(e2) &&
190-
areMatchConditionTypesCompatible(e1, e2)
191-
case _ => false
192-
}
193-
}
194-
195160
private def findInvalidMatchConditionExpression(expr: Expression): Option[Expression] = {
196161
expr.collect {
197162
case e: SubqueryExpression => e

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ValidateSubqueryExpression.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ object ValidateSubqueryExpression
225225

226226
case inSubqueryOrExistsSubquery =>
227227
plan match {
228-
case _: Filter | _: SupportsSubquery | _: Join |
228+
case _: Filter | _: SupportsSubquery | _: Join | _: AsOfJoin |
229229
_: Project | _: Aggregate | _: Window => // Ok
230230
case _ =>
231231
expr.failAnalysis(

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2562,17 +2562,72 @@ class AstBuilder extends DataTypeAstBuilder
25622562
case LessThan(left, right) => (left, LessThanOp, right)
25632563
case _ =>
25642564
throw QueryParsingErrors.sqlAsOfJoinMatchConditionInvalidOperator(
2565-
asOfMatchConditionInvalidOperatorText(expr), ctx)
2565+
asOfMatchConditionInvalidOperatorText(expr, ctx), ctx)
25662566
}
25672567
}
25682568

2569-
private def asOfMatchConditionInvalidOperatorText(expr: Expression): String = expr match {
2570-
case EqualTo(_, _) => "="
2571-
case Not(EqualTo(_, _)) => "<>"
2572-
case EqualNullSafe(_, _) => "<=>"
2573-
case And(_, _) => "AND"
2574-
case Or(_, _) => "OR"
2575-
case _ => expr.prettyName
2569+
/**
2570+
* Map a rejected MATCH_CONDITION expression back to the SQL operator the user wrote.
2571+
*
2572+
* Catalyst has no `NotEqualTo` class: both `<>` and `!=` parse to `Not(EqualTo(...))`, and
2573+
* both `<=>` and `IS NOT DISTINCT FROM` parse to `EqualNullSafe(...)`. Walk the match
2574+
* expression parse tree (not `prettyName`) to recover the token the user wrote.
2575+
*/
2576+
private def asOfMatchConditionInvalidOperatorText(
2577+
expr: Expression,
2578+
ctx: ParserRuleContext): String = {
2579+
expr match {
2580+
case And(_, _) => "AND"
2581+
case Or(_, _) => "OR"
2582+
case _ =>
2583+
findFirstComparisonContext(ctx)
2584+
.map(comparisonOperatorText)
2585+
.orElse(findPredicatedContext(ctx).flatMap(distinctFromOperatorText))
2586+
.getOrElse(getOriginalText(ctx).trim)
2587+
}
2588+
}
2589+
2590+
private def findFirstComparisonContext(ctx: ParserRuleContext): Option[ComparisonContext] = {
2591+
ctx match {
2592+
case comparison: ComparisonContext => Some(comparison)
2593+
case _ =>
2594+
Option(ctx.children).iterator.flatMap(_.asScala).collectFirst {
2595+
case child: ParserRuleContext => findFirstComparisonContext(child)
2596+
}.flatten
2597+
}
2598+
}
2599+
2600+
private def findPredicatedContext(ctx: ParserRuleContext): Option[PredicatedContext] = {
2601+
ctx match {
2602+
case predicated: PredicatedContext => Some(predicated)
2603+
case _ =>
2604+
Option(ctx.children).iterator.flatMap(_.asScala).collectFirst {
2605+
case child: ParserRuleContext => findPredicatedContext(child)
2606+
}.flatten
2607+
}
2608+
}
2609+
2610+
private def comparisonOperatorText(ctx: ComparisonContext): String = {
2611+
val operator = ctx.comparisonOperator().getChild(0).asInstanceOf[TerminalNode]
2612+
operator.getSymbol.getType match {
2613+
case SqlBaseParser.EQ => "="
2614+
case SqlBaseParser.NSEQ => "<=>"
2615+
case SqlBaseParser.NEQ => "<>"
2616+
case SqlBaseParser.NEQJ => "!="
2617+
case SqlBaseParser.LT => "<"
2618+
case SqlBaseParser.LTE => "<="
2619+
case SqlBaseParser.GT => ">"
2620+
case SqlBaseParser.GTE => ">="
2621+
case _ => source(ctx.comparisonOperator())
2622+
}
2623+
}
2624+
2625+
private def distinctFromOperatorText(predicated: PredicatedContext): Option[String] = {
2626+
Option(predicated.predicate).flatMap { predicate =>
2627+
Option(predicate.kind).filter(_.getType == SqlBaseParser.DISTINCT).map { _ =>
2628+
if (predicate.errorCapturingNot != null) "IS NOT DISTINCT FROM" else "IS DISTINCT FROM"
2629+
}
2630+
}
25762631
}
25772632

25782633
/**

sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala

Lines changed: 120 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2639,19 +2639,103 @@ object AsOfJoin {
26392639
(asOfCondition, orderExpression, leftSortExprs, rightSortExprs)
26402640
}
26412641

2642+
/**
2643+
* Shared MATCH_CONDITION operand type rules used by analysis validation and by expression
2644+
* materialization so the two paths cannot drift.
2645+
*/
2646+
private[catalyst] object MatchConditionTypes {
2647+
2648+
def isValidOperandType(dataType: DataType): Boolean =
2649+
RowOrdering.isOrderable(dataType) && !containsEmptyStructType(dataType)
2650+
2651+
def areOperandsCompatible(leftType: DataType, rightType: DataType): Boolean = {
2652+
if (!isValidOperandType(leftType) || !isValidOperandType(rightType)) {
2653+
false
2654+
} else if (isStringTemporalMismatch(leftType, rightType)) {
2655+
false
2656+
} else {
2657+
(leftType, rightType) match {
2658+
case (ArrayType(_, _), ArrayType(_, _)) =>
2659+
usesArrayOrderExpression(leftType, rightType)
2660+
case _ =>
2661+
TypeCoercion.findWiderTypeForTwo(leftType, rightType).isDefined ||
2662+
arePositionalStructsCompatible(leftType, rightType)
2663+
}
2664+
}
2665+
}
2666+
2667+
def usesArrayOrderExpression(leftType: DataType, rightType: DataType): Boolean =
2668+
(leftType, rightType) match {
2669+
case (ArrayType(leftElem, _), ArrayType(rightElem, _)) =>
2670+
areArrayElementsCompatible(leftElem, rightElem)
2671+
case _ => false
2672+
}
2673+
2674+
private def areArrayElementsCompatible(leftElem: DataType, rightElem: DataType): Boolean = {
2675+
if (DataTypeUtils.sameType(leftElem, rightElem)) {
2676+
RowOrdering.isOrderable(leftElem)
2677+
} else {
2678+
arePositionalStructsCompatible(leftElem, rightElem)
2679+
}
2680+
}
2681+
2682+
/** Positional struct operands with the same field count (names may differ). */
2683+
def usesStructDecomposition(leftType: DataType, rightType: DataType): Boolean =
2684+
(leftType, rightType) match {
2685+
case (leftStruct: StructType, rightStruct: StructType) =>
2686+
leftStruct.length == rightStruct.length && leftStruct.nonEmpty
2687+
case _ => false
2688+
}
2689+
2690+
/** Whole struct columns with identical schemas sort as a single struct value. */
2691+
def usesIdenticalStructSort(leftType: DataType, rightType: DataType): Boolean =
2692+
(leftType, rightType) match {
2693+
case (leftStruct: StructType, rightStruct: StructType) =>
2694+
leftStruct.sameType(rightStruct) && leftStruct.nonEmpty
2695+
case _ => false
2696+
}
2697+
2698+
private def isStringTemporalMismatch(leftType: DataType, rightType: DataType): Boolean = {
2699+
def isString(dataType: DataType): Boolean = dataType.isInstanceOf[StringType]
2700+
def isTemporal(dataType: DataType): Boolean = dataType.isInstanceOf[DatetimeType]
2701+
(isTemporal(leftType) && isString(rightType)) || (isString(leftType) && isTemporal(rightType))
2702+
}
2703+
2704+
private def arePositionalStructsCompatible(
2705+
leftType: DataType,
2706+
rightType: DataType): Boolean = {
2707+
(leftType, rightType) match {
2708+
case (leftStruct: StructType, rightStruct: StructType)
2709+
if usesStructDecomposition(leftType, rightType) =>
2710+
leftStruct.zip(rightStruct).forall { case (leftField, rightField) =>
2711+
areOperandsCompatible(leftField.dataType, rightField.dataType)
2712+
}
2713+
case _ => false
2714+
}
2715+
}
2716+
2717+
private def containsEmptyStructType(dataType: DataType): Boolean = dataType match {
2718+
case struct: StructType =>
2719+
struct.isEmpty || struct.exists(field => containsEmptyStructType(field.dataType))
2720+
case ArrayType(elementType, _) => containsEmptyStructType(elementType)
2721+
case _ => false
2722+
}
2723+
}
2724+
26422725
/**
26432726
* Sort-merge ASOF join sorts each side by these expressions (after equi-keys) so the
26442727
* right-side buffer is ordered consistently with MATCH_CONDITION lexicographic comparison.
26452728
*
26462729
* SQL tuple literals `(t.a, t.b)` are flattened to scalar leaves. Whole struct columns
26472730
* (`t.k >= r.k`) sort by the struct value directly so nested struct shapes stay intact.
2731+
* Array operands sort element-wise; length mismatches follow Spark array ordering semantics.
26482732
*/
26492733
def matchSortExpressions(
26502734
leftOperand: Expression,
26512735
rightOperand: Expression): (Seq[Expression], Seq[Expression]) = {
26522736
(leftOperand.dataType, rightOperand.dataType) match {
26532737
case (leftStruct: StructType, rightStruct: StructType)
2654-
if leftStruct.sameType(rightStruct) && leftStruct.nonEmpty =>
2738+
if MatchConditionTypes.usesIdenticalStructSort(leftStruct, rightStruct) =>
26552739
if (isSqlTupleStructOperand(leftOperand) || isSqlTupleStructOperand(rightOperand)) {
26562740
val pairs = collectStructLeafPairs(leftOperand, rightOperand, leftStruct)
26572741
(pairs.map(_._1), pairs.map(_._2))
@@ -2675,8 +2759,8 @@ object AsOfJoin {
26752759
expr2: Expression): (Expression, Expression, MatchComparisonOperator) = {
26762760
val leftSet = left.outputSet
26772761
val rightSet = right.outputSet
2678-
val expr1Side = operandJoinSide(expr1, leftSet, rightSet)
2679-
val expr2Side = operandJoinSide(expr2, leftSet, rightSet)
2762+
val expr1Side = operandJoinSide(expr1, leftSet, rightSet, syntacticIsLeft = true)
2763+
val expr2Side = operandJoinSide(expr2, leftSet, rightSet, syntacticIsLeft = false)
26802764
(expr1Side, expr2Side) match {
26812765
case (Some(true), Some(false)) => (expr1, expr2, operator)
26822766
case (Some(false), Some(true)) => (expr2, expr1, operator.flip)
@@ -2688,10 +2772,13 @@ object AsOfJoin {
26882772
private def operandJoinSide(
26892773
expr: Expression,
26902774
leftSet: AttributeSet,
2691-
rightSet: AttributeSet): Option[Boolean] = {
2775+
rightSet: AttributeSet,
2776+
syntacticIsLeft: Boolean): Option[Boolean] = {
26922777
val refs = expr.references
26932778
if (refs.isEmpty) {
2694-
None
2779+
// Literals, CURRENT_TIMESTAMP(), session variables, etc. have no column refs;
2780+
// use MATCH_CONDITION syntactic position (expr1/expr2) for join-side assignment.
2781+
Some(syntacticIsLeft)
26952782
} else if (refs.subsetOf(leftSet)) {
26962783
Some(true)
26972784
} else if (refs.subsetOf(rightSet)) {
@@ -2725,13 +2812,17 @@ object AsOfJoin {
27252812
rightOperand: Expression,
27262813
operator: MatchComparisonOperator): Expression = {
27272814
(leftOperand.dataType, rightOperand.dataType) match {
2728-
case (ArrayType(leftElem, _), ArrayType(rightElem, _))
2729-
if DataTypeUtils.sameType(leftElem, rightElem) =>
2730-
buildArrayOrderExpression(leftOperand, rightOperand, leftElem, operator)
2731-
case (leftStruct: StructType, rightStruct: StructType)
2732-
if leftStruct.length == rightStruct.length && leftStruct.nonEmpty =>
2815+
case (ArrayType(elementType, _), _)
2816+
if MatchConditionTypes.usesArrayOrderExpression(
2817+
leftOperand.dataType, rightOperand.dataType) =>
2818+
// MATCH_CONDITION array comparison uses Spark lexicographic ordering (including length).
2819+
// The ordering distance below is element-wise via ZipWith, padding the shorter side with
2820+
// null when lengths differ (e.g. [0, null]), not a lexicographic length tie-break.
2821+
buildArrayOrderExpression(leftOperand, rightOperand, elementType, operator)
2822+
case (leftType, rightType)
2823+
if MatchConditionTypes.usesStructDecomposition(leftType, rightType) =>
27332824
buildFlattenedStructOrderExpression(
2734-
leftOperand, rightOperand, leftStruct, operator)
2825+
leftOperand, rightOperand, leftType.asInstanceOf[StructType], operator)
27352826
case _ =>
27362827
buildLeafOrderExpression(leftOperand, rightOperand, operator)
27372828
}
@@ -2844,12 +2935,11 @@ object AsOfJoin {
28442935
.zip(structFieldExprs(rightOperand, structType))
28452936
.flatMap {
28462937
case (left, right) =>
2847-
(left.dataType, right.dataType) match {
2848-
case (leftStruct: StructType, rightStruct: StructType)
2849-
if leftStruct.length == rightStruct.length && leftStruct.nonEmpty =>
2850-
collectStructLeafPairs(left, right, leftStruct)
2851-
case _ =>
2852-
Seq((left, right))
2938+
if (MatchConditionTypes.usesStructDecomposition(left.dataType, right.dataType)) {
2939+
collectStructLeafPairs(
2940+
left, right, left.dataType.asInstanceOf[StructType])
2941+
} else {
2942+
Seq((left, right))
28532943
}
28542944
}
28552945
}
@@ -2871,17 +2961,19 @@ object AsOfJoin {
28712961
private def decomposeStructOperands(
28722962
leftOperand: Expression,
28732963
rightOperand: Expression): Option[Seq[(Expression, Expression)]] = {
2874-
(leftOperand.dataType, rightOperand.dataType) match {
2875-
case (leftStruct: StructType, rightStruct: StructType)
2876-
if leftStruct.length == rightStruct.length && leftStruct.nonEmpty =>
2877-
val leftFields = structFieldExprs(leftOperand, leftStruct)
2878-
val rightFields = structFieldExprs(rightOperand, rightStruct)
2879-
if (leftFields.length == rightFields.length) {
2880-
Some(leftFields.zip(rightFields))
2881-
} else {
2882-
None
2883-
}
2884-
case _ => None
2964+
if (MatchConditionTypes.usesStructDecomposition(
2965+
leftOperand.dataType, rightOperand.dataType)) {
2966+
val leftStruct = leftOperand.dataType.asInstanceOf[StructType]
2967+
val rightStruct = rightOperand.dataType.asInstanceOf[StructType]
2968+
val leftFields = structFieldExprs(leftOperand, leftStruct)
2969+
val rightFields = structFieldExprs(rightOperand, rightStruct)
2970+
if (leftFields.length == rightFields.length) {
2971+
Some(leftFields.zip(rightFields))
2972+
} else {
2973+
None
2974+
}
2975+
} else {
2976+
None
28852977
}
28862978
}
28872979

0 commit comments

Comments
 (0)