JoinCondition.php 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl>
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\DB\QueryBuilder\Partitioned;
  8. use OC\DB\QueryBuilder\CompositeExpression;
  9. use OC\DB\QueryBuilder\QueryFunction;
  10. use OCP\DB\QueryBuilder\IQueryFunction;
  11. /**
  12. * Utility class for working with join conditions
  13. */
  14. class JoinCondition {
  15. public function __construct(
  16. public string|IQueryFunction $fromColumn,
  17. public ?string $fromAlias,
  18. public string|IQueryFunction $toColumn,
  19. public ?string $toAlias,
  20. public array $fromConditions,
  21. public array $toConditions,
  22. ) {
  23. if (is_string($this->fromColumn) && str_starts_with($this->fromColumn, '(')) {
  24. $this->fromColumn = new QueryFunction($this->fromColumn);
  25. }
  26. if (is_string($this->toColumn) && str_starts_with($this->toColumn, '(')) {
  27. $this->toColumn = new QueryFunction($this->toColumn);
  28. }
  29. }
  30. /**
  31. * @param JoinCondition[] $conditions
  32. * @return JoinCondition
  33. */
  34. public static function merge(array $conditions): JoinCondition {
  35. $fromColumn = '';
  36. $toColumn = '';
  37. $fromAlias = null;
  38. $toAlias = null;
  39. $fromConditions = [];
  40. $toConditions = [];
  41. foreach ($conditions as $condition) {
  42. if (($condition->fromColumn && $fromColumn) || ($condition->toColumn && $toColumn)) {
  43. throw new InvalidPartitionedQueryException("Can't join from {$condition->fromColumn} to {$condition->toColumn} as it already join froms {$fromColumn} to {$toColumn}");
  44. }
  45. if ($condition->fromColumn) {
  46. $fromColumn = $condition->fromColumn;
  47. }
  48. if ($condition->toColumn) {
  49. $toColumn = $condition->toColumn;
  50. }
  51. if ($condition->fromAlias) {
  52. $fromAlias = $condition->fromAlias;
  53. }
  54. if ($condition->toAlias) {
  55. $toAlias = $condition->toAlias;
  56. }
  57. $fromConditions = array_merge($fromConditions, $condition->fromConditions);
  58. $toConditions = array_merge($toConditions, $condition->toConditions);
  59. }
  60. return new JoinCondition($fromColumn, $fromAlias, $toColumn, $toAlias, $fromConditions, $toConditions);
  61. }
  62. /**
  63. * @param null|string|CompositeExpression $condition
  64. * @param string $join
  65. * @param string $alias
  66. * @param string $fromAlias
  67. * @return JoinCondition
  68. * @throws InvalidPartitionedQueryException
  69. */
  70. public static function parse($condition, string $join, string $alias, string $fromAlias): JoinCondition {
  71. if ($condition === null) {
  72. throw new InvalidPartitionedQueryException("Can't join on $join without a condition");
  73. }
  74. $result = self::parseSubCondition($condition, $join, $alias, $fromAlias);
  75. if (!$result->fromColumn || !$result->toColumn) {
  76. throw new InvalidPartitionedQueryException("No join condition found from $fromAlias to $alias");
  77. }
  78. return $result;
  79. }
  80. private static function parseSubCondition($condition, string $join, string $alias, string $fromAlias): JoinCondition {
  81. if ($condition instanceof CompositeExpression) {
  82. if ($condition->getType() === CompositeExpression::TYPE_OR) {
  83. throw new InvalidPartitionedQueryException("Cannot join on $join with an OR expression");
  84. }
  85. return self::merge(array_map(function ($subCondition) use ($join, $alias, $fromAlias) {
  86. return self::parseSubCondition($subCondition, $join, $alias, $fromAlias);
  87. }, $condition->getParts()));
  88. }
  89. $condition = (string)$condition;
  90. $isSubCondition = self::isExtraCondition($condition);
  91. if ($isSubCondition) {
  92. if (self::mentionsAlias($condition, $fromAlias)) {
  93. return new JoinCondition('', null, '', null, [$condition], []);
  94. } else {
  95. return new JoinCondition('', null, '', null, [], [$condition]);
  96. }
  97. }
  98. $condition = str_replace('`', '', $condition);
  99. // expect a condition in the form of 'alias1.column1 = alias2.column2'
  100. if (!str_contains($condition, ' = ')) {
  101. throw new InvalidPartitionedQueryException("Can only join on $join with an `eq` condition");
  102. }
  103. $parts = explode(' = ', $condition, 2);
  104. $parts = array_map(function (string $part) {
  105. return self::clearConditionPart($part);
  106. }, $parts);
  107. if (!self::isSingleCondition($parts[0]) || !self::isSingleCondition($parts[1])) {
  108. throw new InvalidPartitionedQueryException("Can only join on $join with a single condition");
  109. }
  110. if (self::mentionsAlias($parts[0], $fromAlias)) {
  111. return new JoinCondition($parts[0], self::getAliasForPart($parts[0]), $parts[1], self::getAliasForPart($parts[1]), [], []);
  112. } elseif (self::mentionsAlias($parts[1], $fromAlias)) {
  113. return new JoinCondition($parts[1], self::getAliasForPart($parts[1]), $parts[0], self::getAliasForPart($parts[0]), [], []);
  114. } else {
  115. throw new InvalidPartitionedQueryException("join condition for $join needs to explicitly refer to the table by alias");
  116. }
  117. }
  118. private static function isSingleCondition(string $condition): bool {
  119. return !(str_contains($condition, ' OR ') || str_contains($condition, ' AND '));
  120. }
  121. private static function getAliasForPart(string $part): ?string {
  122. if (str_contains($part, ' ')) {
  123. return uniqid('join_alias_');
  124. } else {
  125. return null;
  126. }
  127. }
  128. private static function clearConditionPart(string $part): string {
  129. if (str_starts_with($part, 'CAST(')) {
  130. // pgsql/mysql cast
  131. $part = substr($part, strlen('CAST('));
  132. [$part] = explode(' AS ', $part);
  133. } elseif (str_starts_with($part, 'to_number(to_char(')) {
  134. // oracle cast to int
  135. $part = substr($part, strlen('to_number(to_char('), -2);
  136. } elseif (str_starts_with($part, 'to_number(to_char(')) {
  137. // oracle cast to string
  138. $part = substr($part, strlen('to_char('), -1);
  139. }
  140. return $part;
  141. }
  142. /**
  143. * Check that a condition is an extra limit on the from/to part, and not the join condition
  144. *
  145. * This is done by checking that only one of the halves of the condition references a column
  146. */
  147. private static function isExtraCondition(string $condition): bool {
  148. $parts = explode(' ', $condition, 2);
  149. return str_contains($parts[0], '`') xor str_contains($parts[1], '`');
  150. }
  151. private static function mentionsAlias(string $condition, string $alias): bool {
  152. return str_contains($condition, "$alias.");
  153. }
  154. }