diff --git a/src/Exceptions/GrammarException.php b/src/Exceptions/GrammarException.php index 408c4ef..3e46b69 100644 --- a/src/Exceptions/GrammarException.php +++ b/src/Exceptions/GrammarException.php @@ -6,7 +6,7 @@ class GrammarException extends Exception { - public static function wrongJoin(JoinClause $joinClause) + public static function wrongJoin(JoinClause $joinClause): self { $whatMissing = []; @@ -22,8 +22,8 @@ public static function wrongJoin(JoinClause $joinClause) $whatMissing[] = 'table or subquery'; } - if (is_null($joinClause->getUsing())) { - $whatMissing[] = 'using'; + if (is_null($joinClause->getUsing()) && is_null($joinClause->getOnClauses())) { + $whatMissing[] = 'using or on clauses'; } $whatMissing = implode(', ', $whatMissing); @@ -31,17 +31,22 @@ public static function wrongJoin(JoinClause $joinClause) return new static("Missed required segments for 'JOIN' section. Missed: {$whatMissing}"); } - public static function wrongFrom($from) + public static function ambiguousJoinKeys(): self + { + return new static('You cannot use using and on clauses as join keys for the same join.'); + } + + public static function wrongFrom(): self { return new static("Missed table or subquery for 'FROM' section."); } - public static function missedTableForInsert() + public static function missedTableForInsert(): self { return new static('Missed table for insert statement.'); } - public static function missedWhereForDelete() + public static function missedWhereForDelete(): self { return new static('Missed where section for delete statement.'); } diff --git a/src/Query/BaseBuilder.php b/src/Query/BaseBuilder.php index 1476ff3..f063a88 100644 --- a/src/Query/BaseBuilder.php +++ b/src/Query/BaseBuilder.php @@ -37,11 +37,11 @@ abstract class BaseBuilder protected $sample; /** - * Join clause. + * Join clauses. * - * @var JoinClause + * @var JoinClause[]|null */ - protected $join; + protected $joins; /** * Array join clause. @@ -490,13 +490,13 @@ public function join( bool $global = false, ?string $alias = null ) { - $this->join = new JoinClause($this); + $join = new JoinClause($this); /* * If builder instance given, then we assume that sub-query should be used as table in join */ if ($table instanceof BaseBuilder) { - $this->join->query($table); + $join->query($table); $this->files = array_merge($this->files, $table->getFiles()); } @@ -506,7 +506,7 @@ public function join( * set up JoinClause object in callback */ if ($table instanceof Closure) { - $table($this->join); + $table($join); } /* @@ -514,34 +514,36 @@ public function join( * then we assume that table name was given. */ if (!$table instanceof Closure && !$table instanceof BaseBuilder) { - $this->join->table($table); + $join->table($table); } /* * If using was given, then merge it with using given before, in closure */ if (!is_null($using)) { - $this->join->addUsing($using); + $join->addUsing($using); } - if (!is_null($strict) && is_null($this->join->getStrict())) { - $this->join->strict($strict); + if (!is_null($strict) && is_null($join->getStrict())) { + $join->strict($strict); } - if (!is_null($type) && is_null($this->join->getType())) { - $this->join->type($type); + if (!is_null($type) && is_null($join->getType())) { + $join->type($type); } - if (!is_null($alias) && is_null($this->join->getAlias())) { - $this->join->as($alias); + if (!is_null($alias) && is_null($join->getAlias())) { + $join->as($alias); } - $this->join->distributed($global); + $join->distributed($global); - if (!is_null($this->join->getSubQuery())) { - $this->join->query($this->join->getSubQuery()); + if (!is_null($join->getSubQuery())) { + $join->query($join->getSubQuery()); } + $this->joins[] = $join; + return $this; } @@ -1933,11 +1935,11 @@ public function getArrayJoin(): ?ArrayJoinClause /** * Get JoinClause. * - * @return JoinClause + * @return JoinClause[]|null */ - public function getJoin(): ?JoinClause + public function getJoins(): ?array { - return $this->join; + return $this->joins; } /** diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php index 38f5461..c922bdc 100644 --- a/src/Query/Grammar.php +++ b/src/Query/Grammar.php @@ -45,7 +45,7 @@ class Grammar 'from', 'sample', 'arrayJoin', - 'join', + 'joins', 'prewheres', 'wheres', 'groups', diff --git a/src/Query/JoinClause.php b/src/Query/JoinClause.php index 11488d2..95c040a 100644 --- a/src/Query/JoinClause.php +++ b/src/Query/JoinClause.php @@ -2,8 +2,10 @@ namespace Tinderbox\ClickhouseBuilder\Query; +use Closure; use Tinderbox\ClickhouseBuilder\Query\Enums\JoinStrict; use Tinderbox\ClickhouseBuilder\Query\Enums\JoinType; +use Tinderbox\ClickhouseBuilder\Query\Enums\Operator; class JoinClause { @@ -61,10 +63,17 @@ class JoinClause /** * Join alias. * - * @var \Tinderbox\ClickhouseBuilder\Query\Identifier + * @var Identifier */ private $alias; + /** + * On clauses for joining rows between tables. + * + * @var TwoElementsLogicExpression[]|null + */ + private $onClauses; + /** * JoinClause constructor. * @@ -116,15 +125,26 @@ public function using(...$columns): self } /** - * Alias for using method. + * Set "on" clause for join. * - * @param array ...$columns + * @param string $first + * @param string $operator + * @param string $second + * @param string $concatOperator * * @return JoinClause */ - public function on(...$columns): self + public function on(string $first, string $operator, string $second, string $concatOperator = Operator::AND): self { - return $this->using($columns); + $expression = (new TwoElementsLogicExpression($this->query)) + ->firstElement(new Identifier($first)) + ->operator($operator) + ->secondElement(new Identifier($second)) + ->concatOperator($concatOperator); + + $this->onClauses[] = $expression; + + return $this; } /** @@ -174,7 +194,7 @@ public function type(string $type): self * * @return JoinClause */ - public function all() + public function all(): self { return $this->strict(JoinStrict::ALL); } @@ -184,7 +204,7 @@ public function all() * * @return JoinClause */ - public function any() + public function any(): self { return $this->strict(JoinStrict::ANY); } @@ -194,7 +214,7 @@ public function any() * * @return JoinClause */ - public function inner() + public function inner(): self { return $this->type(JoinType::INNER); } @@ -204,7 +224,7 @@ public function inner() * * @return JoinClause */ - public function left() + public function left(): self { return $this->type(JoinType::LEFT); } @@ -226,7 +246,7 @@ public function distributed(bool $global = false): self /** * Set sub-query as table to select from. * - * @param \Closure|BaseBuilder|null $query + * @param Closure|BaseBuilder|null $query * * @return JoinClause|BaseBuilder */ @@ -236,7 +256,7 @@ public function query($query = null) return $this->subQuery(); } - if ($query instanceof \Closure) { + if ($query instanceof Closure) { $query = tap($this->query->newQuery(), $query); } @@ -270,7 +290,7 @@ public function subQuery(string $alias = null): BaseBuilder * * @return $this */ - public function as(string $alias) + public function as(string $alias): self { $this->alias = new Identifier($alias); @@ -287,6 +307,16 @@ public function getUsing(): ?array return $this->using; } + /** + * Get on clauses. + * + * @return array|null + */ + public function getOnClauses(): ?array + { + return $this->onClauses; + } + /** * Get flag to use or not to use GLOBAL option. * @@ -330,7 +360,7 @@ public function getSubQuery(): ?BaseBuilder /** * Get table to select from. * - * @return Expression|null|Identifier + * @return Expression|null|string */ public function getTable() { diff --git a/src/Query/Traits/FromComponentCompiler.php b/src/Query/Traits/FromComponentCompiler.php index c81d3b2..f5d1914 100644 --- a/src/Query/Traits/FromComponentCompiler.php +++ b/src/Query/Traits/FromComponentCompiler.php @@ -48,7 +48,7 @@ public function compileFromComponent(BaseBuilder $builder, From $from): string private function verifyFrom(From $from) { if (is_null($from->getTable())) { - throw GrammarException::wrongFrom($from); + throw GrammarException::wrongFrom(); } } } diff --git a/src/Query/Traits/JoinComponentCompiler.php b/src/Query/Traits/JoinComponentCompiler.php index ccf7077..2cfe5ba 100644 --- a/src/Query/Traits/JoinComponentCompiler.php +++ b/src/Query/Traits/JoinComponentCompiler.php @@ -11,39 +11,48 @@ trait JoinComponentCompiler /** * Compiles join to string to pass this string in query. * - * @param Builder $query - * @param JoinClause $join + * @param Builder $query + * @param JoinClause[] $joins + * + * @throws GrammarException * * @return string */ - protected function compileJoinComponent(Builder $query, JoinClause $join): string + protected function compileJoinsComponent(Builder $query, array $joins): string { - $this->verifyJoin($join); - $result = []; - if ($join->isDistributed()) { - $result[] = 'GLOBAL'; - } + foreach ($joins as $join) { + $this->verifyJoin($join); - if (!is_null($join->getStrict())) { - $result[] = $join->getStrict(); - } + if ($join->isDistributed()) { + $result[] = 'GLOBAL'; + } - if (!is_null($join->getType())) { - $result[] = $join->getType(); - } + if (!is_null($join->getStrict())) { + $result[] = $join->getStrict(); + } + + if (!is_null($join->getType())) { + $result[] = $join->getType(); + } - $result[] = 'JOIN'; - $result[] = $this->wrap($join->getTable()); - if ($join->getAlias()) { - $result[] = 'AS'; - $result[] = $this->wrap($join->getAlias()); + $result[] = 'JOIN'; + $result[] = $this->wrap($join->getTable()); + if ($join->getAlias()) { + $result[] = 'AS'; + $result[] = $this->wrap($join->getAlias()); + } + if (!is_null($join->getUsing())) { + $result[] = 'USING'; + $result[] = implode(', ', array_map(function ($column) { + return $this->wrap($column); + }, $join->getUsing())); + } else { + $result[] = 'ON'; + $result[] = $this->compileTwoElementLogicExpressions($join->getOnClauses()); + } } - $result[] = 'USING'; - $result[] = implode(', ', array_map(function ($column) { - return $this->wrap($column); - }, $join->getUsing())); return implode(' ', $result); } @@ -55,13 +64,15 @@ protected function compileJoinComponent(Builder $query, JoinClause $join): strin * * @throws GrammarException */ - private function verifyJoin(JoinClause $joinClause) + private function verifyJoin(JoinClause $joinClause): void { if ( is_null($joinClause->getTable()) || - is_null($joinClause->getUsing()) + (is_null($joinClause->getUsing()) && is_null($joinClause->getOnClauses())) ) { throw GrammarException::wrongJoin($joinClause); + } elseif (!is_null($joinClause->getUsing()) && !is_null($joinClause->getOnClauses())) { + throw GrammarException::ambiguousJoinKeys(); } } } diff --git a/tests/BuilderTest.php b/tests/BuilderTest.php index e25321b..217e2dd 100644 --- a/tests/BuilderTest.php +++ b/tests/BuilderTest.php @@ -314,52 +314,66 @@ public function test_from_sub_query() public function test_join_simple() { $builder = $this->getBuilder(); - $builder->from('table')->join('table2', 'any', 'left', ['column']); $this->assertEquals('SELECT * FROM `table` ANY LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join('table2', 'any', 'inner', ['column', 'column2']); $this->assertEquals('SELECT * FROM `table` ANY INNER JOIN `table2` USING `column`, `column2`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join('table2', 'all', 'left', ['column']); $this->assertEquals('SELECT * FROM `table` ALL LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join('table2', 'any', 'left', ['column']); $this->assertEquals('SELECT * FROM `table` ANY LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join('table2', 'any', 'left', ['column'], 'global'); $this->assertEquals('SELECT * FROM `table` GLOBAL ANY LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join('table2', 'any', 'left', ['column'], 'global', 'table3'); $this->assertEquals('SELECT * FROM `table` GLOBAL ANY LEFT JOIN `table2` AS `table3` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join('table2 as table3', 'any', 'left', ['column'], 'global', 'table4'); $this->assertEquals('SELECT * FROM `table` GLOBAL ANY LEFT JOIN `table2` AS `table3` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join('table2 as table3 as table', 'any', 'left', ['column'], 'global', 'table4'); $this->assertEquals('SELECT * FROM `table` GLOBAL ANY LEFT JOIN `table2` AS `table3` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->leftJoin('table2', 'all', ['column']); $this->assertEquals('SELECT * FROM `table` ALL LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->leftJoin('table2', 'any', ['column']); $this->assertEquals('SELECT * FROM `table` ANY LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->innerJoin('table2', 'all', ['column']); $this->assertEquals('SELECT * FROM `table` ALL INNER JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->innerJoin('table2', 'any', ['column']); $this->assertEquals('SELECT * FROM `table` ANY INNER JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->anyLeftJoin('table2', ['column']); $this->assertEquals('SELECT * FROM `table` ANY LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->anyInnerJoin('table2', ['column']); $this->assertEquals('SELECT * FROM `table` ANY INNER JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->allLeftJoin('table2', ['column']); $this->assertEquals('SELECT * FROM `table` ALL LEFT JOIN `table2` USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->allInnerJoin('table2', ['column']); $this->assertEquals('SELECT * FROM `table` ALL INNER JOIN `table2` USING `column`', $builder->toSql()); } @@ -367,27 +381,30 @@ public function test_join_simple() public function test_join_with_closure() { $builder = $this->getBuilder(); - $builder->from('table')->anyLeftJoin(function ($join) { $this->assertInstanceOf(JoinClause::class, $join); }); + $builder = $this->getBuilder(); $builder->from('table')->allLeftJoin(function ($join) { $join->table('table2')->using(['column'])->addUsing('column2'); }); $this->assertEquals('SELECT * FROM `table` ALL LEFT JOIN `table2` USING `column`, `column2`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->anyInnerJoin(function ($join) { $join->table('table2')->using('column'); }, ['column2']); $this->assertEquals('SELECT * FROM `table` ANY INNER JOIN `table2` USING `column`, `column2`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->allInnerJoin(function ($join) { $join->query()->select('column')->from('table'); $join->addUsing('column', 'column2'); }); $this->assertEquals('SELECT * FROM `table` ALL INNER JOIN (SELECT `column` FROM `table`) USING `column`, `column2`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->allInnerJoin(function ($join) { $join->query()->select('column')->from(function ($from) { $from->query()->from('table2'); @@ -398,6 +415,7 @@ public function test_join_with_closure() $builder = $this->getBuilder()->anyLeftJoin($this->getBuilder()->from('table'), ['column']); $this->assertEquals('SELECT * ANY LEFT JOIN (SELECT * FROM `table`) USING `column`', $builder->toSql()); + $builder = $this->getBuilder(); $builder->from('table')->join(function ($join) { $join->query()->select('column1', 'column2')->from('table2'); }, 'any', 'left', ['column1', 'column2']); @@ -1145,4 +1163,25 @@ public function testToAsyncSqlsAndQueries() $this->assertContains('SELECT * FROM `system`.`tables` WHERE `database` = \'default\' AND `name` = \'builder_test2\'', $queries); $this->assertContains('SELECT * FROM `system`.`tables` WHERE `database` = \'default\' AND `name` = \'builder_test3\'', $queries); } + + public function testJoinWithOnClause() + { + $builder = $this->getBuilder(); + $builder->from('table1')->anyLeftJoin(function (JoinClause $join) { + $join->table('table2')->on('column_from_table_1', '=', 'column_from_table_2'); + }); + $this->assertEquals('SELECT * FROM `table1` ANY LEFT JOIN `table2` ON `column_from_table_1` = `column_from_table_2`', $builder->toSql()); + } + + public function testMultipleJoins() + { + $builder = $this->getBuilder(); + $builder->from('table1')->anyLeftJoin(function (JoinClause $join) { + $join->table('table2')->on('column_from_table_2', '=', 'column_from_table_1'); + }); + $builder->from('table1')->allLeftJoin(function (JoinClause $join) { + $join->table('table3')->on('column_from_table_3', '=', 'column_from_table_1'); + }); + $this->assertEquals('SELECT * FROM `table1` ANY LEFT JOIN `table2` ON `column_from_table_2` = `column_from_table_1` ALL LEFT JOIN `table3` ON `column_from_table_3` = `column_from_table_1`', $builder->toSql()); + } } diff --git a/tests/ExceptionsTest.php b/tests/ExceptionsTest.php index 9e2c3cb..fa8f7e0 100644 --- a/tests/ExceptionsTest.php +++ b/tests/ExceptionsTest.php @@ -10,7 +10,6 @@ use Tinderbox\ClickhouseBuilder\Exceptions\GrammarException; use Tinderbox\ClickhouseBuilder\Exceptions\NotSupportedException; use Tinderbox\ClickhouseBuilder\Query\Builder; -use Tinderbox\ClickhouseBuilder\Query\From; use Tinderbox\ClickhouseBuilder\Query\JoinClause; class ExceptionsTest extends TestCase @@ -33,15 +32,16 @@ public function testGrammarException() $e = GrammarException::missedTableForInsert(); $this->assertInstanceOf(GrammarException::class, $e); - $from = new From($this->getBuilder()); - - $e = GrammarException::wrongFrom($from); + $e = GrammarException::wrongFrom(); $this->assertInstanceOf(GrammarException::class, $e); $join = new JoinClause($this->getBuilder()); $e = GrammarException::wrongJoin($join); $this->assertInstanceOf(GrammarException::class, $e); + + $e = GrammarException::ambiguousJoinKeys(); + $this->assertInstanceOf(GrammarException::class, $e); } public function testNotSupportedException() diff --git a/tests/GrammarTest.php b/tests/GrammarTest.php index eb49ad2..8dcbeab 100644 --- a/tests/GrammarTest.php +++ b/tests/GrammarTest.php @@ -300,6 +300,12 @@ public function testCompileSelect() }, ['column'])); $this->assertEquals('SELECT * ANY LEFT JOIN (SELECT * FROM `table`) AS `test` USING `column`', $select); + $select = $grammar->compileSelect($this->getBuilder()->from($this->getBuilder()->table('test_1')->select('column', 'column_1'), 'test_1_alias') + ->anyLeftJoin(function (JoinClause $join) { + $join->table($this->getBuilder()->table('test_2')->select('column', 'column_2'))->as('test_2_alias')->on('test_1_alias.column', '=', 'test_2_alias.column'); + })); + $this->assertEquals('SELECT * FROM (SELECT `column`, `column_1` FROM `test_1`) AS `test_1_alias` ANY LEFT JOIN (SELECT `column`, `column_2` FROM `test_2`) AS `test_2_alias` ON `test_1_alias`.`column` = `test_2_alias`.`column`', $select); + /* * With complex two elements logic expressions */ @@ -348,15 +354,30 @@ public function testCompileSelectWithWrongJoin() $grammar->compileSelect($builder); } + public function testCompileSelectWithAmbiguousJoinKeys() + { + $grammar = new Grammar(); + + $builder = $this->getBuilder(); + + $builder->join(function (JoinClause $join) { + $join->table('table')->using(['aaa']) + ->on('aaa', '=', 'bbb'); + }); + + $this->expectException(GrammarException::class); + + $grammar->compileSelect($builder); + } + public function testCompileSelectFromNullTable() { $grammar = new Grammar(); $builder = $this->getBuilder(); $builder->from(null); - $from = $builder->getFrom(); - $e = GrammarException::wrongFrom($from); + $e = GrammarException::wrongFrom(); $this->expectException(GrammarException::class); $this->expectExceptionMessage($e->getMessage()); diff --git a/tests/JoinClauseTest.php b/tests/JoinClauseTest.php index 9223d64..f11fdae 100644 --- a/tests/JoinClauseTest.php +++ b/tests/JoinClauseTest.php @@ -9,8 +9,10 @@ use Tinderbox\ClickhouseBuilder\Query\Builder; use Tinderbox\ClickhouseBuilder\Query\Enums\JoinStrict; use Tinderbox\ClickhouseBuilder\Query\Enums\JoinType; +use Tinderbox\ClickhouseBuilder\Query\Enums\Operator; use Tinderbox\ClickhouseBuilder\Query\Identifier; use Tinderbox\ClickhouseBuilder\Query\JoinClause; +use Tinderbox\ClickhouseBuilder\Query\TwoElementsLogicExpression; class JoinClauseTest extends TestCase { @@ -27,17 +29,20 @@ public function testSettersGetters() $join->table('table'); $join->using(['column', 'another_column']); $join->addUsing('third_column'); + $join->addUsing(new Identifier('other_column')); $this->assertEquals('table', $join->getTable()); - $this->assertEquals(['column', 'another_column', 'third_column'], array_map(function ($using) { + $this->assertEquals(['column', 'another_column', 'third_column', 'other_column'], array_map(function ($using) { return (string) $using; }, $join->getUsing())); $join = new JoinClause($this->getBuilder()); - $join->on([new Identifier('column'), 'another_column']); - $this->assertEquals(['column', 'another_column'], array_map(function ($using) { - return (string) $using; - }, $join->getUsing())); + $join->on('first_column', '=', 'second_column'); + $join->on('first_column', '=', 'third_column'); + $this->assertEquals([ + (new TwoElementsLogicExpression($this->getBuilder()))->firstElement(new Identifier('first_column'))->operator('=')->secondElement(new Identifier('second_column'))->concatOperator(Operator::AND), + (new TwoElementsLogicExpression($this->getBuilder()))->firstElement(new Identifier('first_column'))->operator('=')->secondElement(new Identifier('third_column'))->concatOperator(Operator::AND), + ], $join->getOnClauses()); $join->strict(JoinStrict::ALL);