diff --git a/src/Clickhouse/Clause/ApplyClause.php b/src/Clickhouse/Clause/ApplyClause.php new file mode 100644 index 0000000..961c7c5 --- /dev/null +++ b/src/Clickhouse/Clause/ApplyClause.php @@ -0,0 +1,38 @@ +applies[] = (new ValueListExpression())->append($value); + $this->built = false; + return $this; + } + + protected function cloneApply(mixed $copy): void + { + foreach ($this->applies as $apply) { + $copy->apply(clone $apply); + } + } + + public function cleanApply(): void + { + $this->applies = []; + } + + protected function buildApply(): void + { + foreach ($this->applies as $apply) { + $this->sql .= " APPLY($apply)"; + } + } +} diff --git a/src/Clickhouse/Clause/ArrayJoinClause.php b/src/Clickhouse/Clause/ArrayJoinClause.php new file mode 100644 index 0000000..dd276b4 --- /dev/null +++ b/src/Clickhouse/Clause/ArrayJoinClause.php @@ -0,0 +1,48 @@ +arrayJoin ??= $this->createArrayJoinExpression(); + $this->arrayJoin->append($column, $alias); + $this->built = false; + return $this; + } + + private function createArrayJoinExpression(): FromExpression + { + return new FromExpression(); + } + + public function leftArrayJoin(): static + { + return $this; + } + + public function cloneArrayJoin(mixed $copy): void + { + $copy->arrayJoin = $this->arrayJoin ? clone $this->arrayJoin : null; + } + + public function cleanArrayJoin(): void + { + $this->arrayJoin = null; + } + + public function buildArrayJoin(): void + { + if ($this->arrayJoin) { + $this->sql .= " ARRAY JOIN $this->arrayJoin"; + $this->addParams($this->arrayJoin->getParams()); + } + } +} diff --git a/src/Clickhouse/Clause/ExceptClause.php b/src/Clickhouse/Clause/ExceptClause.php new file mode 100644 index 0000000..0555b00 --- /dev/null +++ b/src/Clickhouse/Clause/ExceptClause.php @@ -0,0 +1,43 @@ +except ??= $this->createSelectExpression(); + $this->except->append($except); + $this->built = false; + return $this; + } + + protected function createExceptSelectExpression(): SelectExpression + { + return new SelectExpression(); + } + + protected function cloneExcept(mixed $copy): void + { + $copy->except = $this->except ? clone $this->except : null; + } + + public function cleanExcept(): void + { + $this->except = null; + } + + protected function buildExcept(): void + { + if ($this->except !== null) { + $this->sql .= " EXCEPT ($this->except)"; + $this->addParams($this->except->getParams()); + } + } +} diff --git a/src/Clickhouse/Clause/ExceptQueryClause.php b/src/Clickhouse/Clause/ExceptQueryClause.php new file mode 100644 index 0000000..d20cf0a --- /dev/null +++ b/src/Clickhouse/Clause/ExceptQueryClause.php @@ -0,0 +1,51 @@ +exceptQuery ??= $this->createSelectExpression(); + $this->exceptQuery->append($except); + $this->exceptQueryDistinct = $distinct; + $this->built = false; + return $this; + } + + protected function createExceptQuerySelectExpression(): SelectExpression + { + return new SelectExpression(); + } + + protected function cloneExceptQuery(mixed $copy): void + { + $copy->exceptQuery = $this->exceptQuery ? clone $this->exceptQuery : null; + $copy->exceptQueryDistinct = $this->exceptQueryDistinct; + } + + public function cleanExceptQuery(): void + { + $this->exceptQuery = null; + $this->exceptQueryDistinct = false; + } + + protected function buildExceptQuery(): void + { + if ($this->exceptQuery !== null) { + $this->sql .= ' EXCEPT'; + if ($this->exceptQueryDistinct) { + $this->sql .= ' DISTINCT'; + } + $this->sql .= " $this->exceptQuery"; + $this->addParams($this->exceptQuery->getParams()); + } + } +} diff --git a/src/Clickhouse/Clause/FormatClause.php b/src/Clickhouse/Clause/FormatClause.php new file mode 100644 index 0000000..55d8a7a --- /dev/null +++ b/src/Clickhouse/Clause/FormatClause.php @@ -0,0 +1,34 @@ +format = $format; + $this->built = false; + return $this; + } + + public function cleanFormat(): void + { + $this->format = null; + } + + protected function cloneFormat(mixed $copy): void + { + $copy->format = $this->format; + } + + protected function buildFormat(): void + { + if ($this->format !== null) { + $this->sql .= " FORMAT $this->format"; + } + } +} diff --git a/src/Clickhouse/Clause/IntersectClause.php b/src/Clickhouse/Clause/IntersectClause.php new file mode 100644 index 0000000..902e0bb --- /dev/null +++ b/src/Clickhouse/Clause/IntersectClause.php @@ -0,0 +1,44 @@ +intersect = $statement; + $this->intersectDistinct = $distinct; + $this->built = false; + return $this; + } + + protected function cloneIntersect(mixed $copy): void + { + $copy->intersect = $this->intersect ? clone $this->intersect : null; + $copy->intersectDistinct = $this->intersectDistinct; + } + + public function cleanIntersect(): void + { + $this->intersect = null; + } + + protected function buildIntersect(): void + { + if ($this->intersect !== null) { + $this->sql .= ' INTERSECT'; + if ($this->intersectDistinct) { + $this->sql .= ' DISTINCT'; + } + $this->sql .= " $this->intersect"; + $this->addParams($this->intersect->getParams()); + } + } +} diff --git a/src/Clickhouse/Clause/IntoOutfileClause.php b/src/Clickhouse/Clause/IntoOutfileClause.php new file mode 100644 index 0000000..310e284 --- /dev/null +++ b/src/Clickhouse/Clause/IntoOutfileClause.php @@ -0,0 +1,91 @@ +existed = false; + $this->fileName = $file; + $this->stdout = $withStdout; + $this->compression = $compression; + $this->compressionLevel = $compressionLevel; + $this->built = false; + + return $this; + } + + public function intoExistedOutfile( + string $file, + bool $withStdout = false, + bool $truncate = false, + ?string $compression = null, + ?int $compressionLevel = null + ): static { + $this->existed = true; + $this->fileName = $file; + $this->stdout = $withStdout; + $this->truncate = $truncate; + $this->compression = $compression; + $this->compressionLevel = $compressionLevel; + $this->built = false; + + return $this; + } + + protected function cloneIntoOutfile(mixed $copy): void + { + $copy->existed = $this->existed; + $copy->fileName = $this->fileName; + $copy->stdout = $this->stdout; + $copy->truncate = $this->truncate; + $copy->compression = $this->compression; + $copy->compressionLevel = $this->compressionLevel; + } + + public function cleanIntoOutfile(): void + { + $this->fileName = null; + $this->stdout = false; + $this->truncate = false; + $this->compression = null; + $this->compressionLevel = null; + } + + protected function buildIntoOutfile(): void + { + if ($this->fileName !== null) { + $this->sql .= " INTO OUTFILE '$this->fileName'"; + if ($this->stdout) { + $this->sql .= " AND STDOUT"; + } + if ($this->existed) { + if ($this->truncate) { + $this->sql .= " TRUNCATE"; + } else { + $this->sql .= " APPEND"; + } + } + if ($this->compression !== null) { + $this->sql .= " COMPRESSION $this->compression"; + if ($this->compressionLevel !== null) { + $this->sql .= " LEVEL $this->compressionLevel"; + } + } + } + } +} diff --git a/src/Clickhouse/Clause/JoinClause.php b/src/Clickhouse/Clause/JoinClause.php new file mode 100644 index 0000000..a553478 --- /dev/null +++ b/src/Clickhouse/Clause/JoinClause.php @@ -0,0 +1,70 @@ +typeJoin('FULL JOIN', $table, $aliasOrCondition, $condition); + } + + public function fullOuterJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('FULL OUTER JOIN', $table, $aliasOrCondition, $condition); + } + + public function leftSemiJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('LEFT SEMI JOIN', $table, $aliasOrCondition, $condition); + } + + public function rightSemiJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('RIGHT SEMI JOIN', $table, $aliasOrCondition, $condition); + } + + public function leftAntiJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('LEFT ANTI JOIN', $table, $aliasOrCondition, $condition); + } + + public function leftRightJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('LEFT RIGHT JOIN', $table, $aliasOrCondition, $condition); + } + + public function leftAnyJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('LEFT ANY JOIN', $table, $aliasOrCondition, $condition); + } + + public function rightAnyJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('RIGHT ANY JOIN', $table, $aliasOrCondition, $condition); + } + + public function innerAnyJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('INNER ANY JOIN', $table, $aliasOrCondition, $condition); + } + + public function asofJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('ASOF JOIN', $table, $aliasOrCondition, $condition); + } + + public function leftAsofJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('LEFT ASOF JOIN', $table, $aliasOrCondition, $condition); + } + + public function pastJoin(mixed $table, mixed $aliasOrCondition = null, mixed $condition = null): static + { + return $this->typeJoin('PAST JOIN', $table, $aliasOrCondition, $condition); + } +} diff --git a/src/Clickhouse/Clause/PreWereClause.php b/src/Clickhouse/Clause/PreWereClause.php new file mode 100644 index 0000000..934e1da --- /dev/null +++ b/src/Clickhouse/Clause/PreWereClause.php @@ -0,0 +1,57 @@ +preWhere($column, $operator, $value); + } + + public function orPreWhere(mixed $column, mixed $operator = null, mixed $value = null): static + { + return $this->preWhere($column, $operator, $value, 'OR'); + } + + public function preWhere( + mixed $column, + mixed $operator = null, + mixed $value = null, + string $connector = 'AND' + ): static { + $this->preWhere ??= $this->createPreWhereExpression(); + $this->preWhere->where($column, $operator, $value, $connector); + $this->built = false; + return $this; + } + + protected function createPreWhereExpression(): WhereExpression + { + return new WhereExpression(); + } + + protected function clonePreWhere(mixed $copy): void + { + $copy->preWhere = $this->preWhere ? clone $this->preWhere : null; + } + + public function cleanPreWhere(): void + { + $this->preWhere = null; + } + + protected function buildPreWhere(): void + { + if ($this->preWhere) { + $this->sql .= " PREWHERE $this->preWhere"; + $this->addParams($this->preWhere->getParams()); + } + } +} diff --git a/src/Clickhouse/Clause/QualifyClause.php b/src/Clickhouse/Clause/QualifyClause.php new file mode 100644 index 0000000..ddde1ec --- /dev/null +++ b/src/Clickhouse/Clause/QualifyClause.php @@ -0,0 +1,38 @@ +qualify ??= new ValueListExpression(); + $this->qualify->append($value); + $this->built = false; + return $this; + } + + protected function cloneQualify(mixed $copy): void + { + $copy->qualify = $this->qualify ? clone $this->qualify : null; + } + + public function cleanQualify(): void + { + $this->qualify = null; + } + + protected function buildQualify(): void + { + if ($this->qualify) { + $this->sql .= " QUALIFY $this->qualify"; + $this->addParams($this->qualify->getParams()); + } + } +} diff --git a/src/Clickhouse/Clause/ReplaceClause.php b/src/Clickhouse/Clause/ReplaceClause.php new file mode 100644 index 0000000..da358ad --- /dev/null +++ b/src/Clickhouse/Clause/ReplaceClause.php @@ -0,0 +1,37 @@ +replace ??= new SelectExpression(); + $this->replace->append($column, $alias); + $this->built = false; + return $this; + } + + protected function cloneReplace(mixed $copy): void + { + $copy->replace = $this->replace ? clone $this->replace : null; + } + + public function cleanReplace(): void + { + $this->replace = null; + } + + protected function buildReplace(): void + { + if ($this->replace !== null) { + $this->sql .= " REPLACE($this->replace)"; + } + } +} diff --git a/src/Clickhouse/Clause/SampleClause.php b/src/Clickhouse/Clause/SampleClause.php new file mode 100644 index 0000000..25fbb94 --- /dev/null +++ b/src/Clickhouse/Clause/SampleClause.php @@ -0,0 +1,41 @@ +sample = $sample; + $this->sampleOffset = $offset; + $this->built = false; + return $this; + } + + protected function cloneSample(mixed $copy): void + { + $copy->sample = $this->sample; + $copy->sampleOffset = $this->sampleOffset; + } + + public function cleanSample(): void + { + $this->sample = null; + $this->sampleOffset = null; + } + + protected function buildSample(): void + { + if ($this->sample !== null) { + $this->sql .= " SAMPLE $this->sample"; + if ($this->sampleOffset !== null) { + $this->sql .= " OFFSET $this->sampleOffset"; + } + } + } +} diff --git a/src/Clickhouse/Clause/SettingsClause.php b/src/Clickhouse/Clause/SettingsClause.php new file mode 100644 index 0000000..90ade94 --- /dev/null +++ b/src/Clickhouse/Clause/SettingsClause.php @@ -0,0 +1,38 @@ +settings ??= new ValueListExpression(); + $this->settings->append($values); + $this->built = false; + return $this; + } + + protected function cloneSettings(mixed $copy): void + { + $copy->settings = $this->settings ? clone $this->settings : null; + } + + public function cleanSettings(): void + { + $this->settings = null; + } + + protected function buildSettings(): void + { + if ($this->settings !== null) { + $this->sql .= " SETTINGS $this->settings"; + $this->addParams($this->settings->getParams()); + } + } +} diff --git a/src/Clickhouse/Clause/WindowClause.php b/src/Clickhouse/Clause/WindowClause.php new file mode 100644 index 0000000..de39bc3 --- /dev/null +++ b/src/Clickhouse/Clause/WindowClause.php @@ -0,0 +1,38 @@ +windowAlias = $alias; + $this->windowFunction = $function; + $this->built = false; + return $this; + } + + protected function cloneWindow(mixed $copy): void + { + $copy->windowAlias = $this->windowAlias; + $copy->windowFunction = $this->windowFunction; + } + + protected function cleanWindow(): void + { + $this->windowAlias = null; + $this->windowFunction = null; + } + + protected function buildWindow(): void + { + if ($this->windowAlias !== null && $this->windowFunction !== null) { + $this->sql .= " WINDOW $this->windowAlias AS ($this->windowFunction)"; + } + } +} diff --git a/src/Clickhouse/InsertStatement.php b/src/Clickhouse/InsertStatement.php new file mode 100644 index 0000000..afcae67 --- /dev/null +++ b/src/Clickhouse/InsertStatement.php @@ -0,0 +1,66 @@ +db); + $this->cloneWith($copy); + $this->cloneInsert($copy); + $this->cloneColumns($copy); + $this->cloneValueList($copy); + $this->cloneQuery($copy); + $this->cloneConflict($copy); + $this->cloneReturning($copy); + return $copy; + } + + public function clean(): static + { + $this->cleanWith(); + $this->cleanInsert(); + $this->cleanColumns(); + $this->cleanValueList(); + $this->cleanQuery(); + $this->cleanConflict(); + $this->cleanReturning(); + return $this; + } + + public function build(): static + { + if ($this->built) { + return $this; + } + $this->sql = ''; + $this->params = []; + $this->buildWith(); + $this->buildInsert(); + $this->buildColumns(); + $this->buildValues(); + $this->buildQuery(); + $this->buildOnConflictDoUpdate(); + $this->buildReturning(); + $this->built = true; + return $this; + } +} diff --git a/src/Clickhouse/SelectStatement.php b/src/Clickhouse/SelectStatement.php new file mode 100644 index 0000000..f7f4abb --- /dev/null +++ b/src/Clickhouse/SelectStatement.php @@ -0,0 +1,143 @@ +db); + + $this->cloneApply($copy); + $this->cloneArrayJoin($copy); + $this->cloneExcept($copy); + $this->cloneExceptQuery($copy); + $this->cloneFormat($copy); + $this->cloneFrom($copy); + $this->cloneGroupBy($copy); + $this->cloneHaving($copy); + $this->cloneIntersect($copy); + $this->cloneIntoOutfile($copy); + $this->cloneJoin($copy); + $this->cloneLimit($copy); + $this->cloneLock($copy); + $this->cloneOffset($copy); + $this->cloneOrderBy($copy); + $this->clonePreWhere($copy); + $this->cloneQualify($copy); + $this->cloneReplace($copy); + $this->cloneSample($copy); + $this->cloneSelect($copy); + $this->cloneSettings($copy); + $this->cloneWhere($copy); + $this->cloneWindow($copy); + $this->cloneWith($copy); + + return $copy; + } + + public function clean(): static + { + $this->cleanApply(); + $this->cleanArrayJoin(); + $this->cleanExcept(); + $this->cleanExceptQuery(); + $this->cleanFormat(); + $this->cleanFrom(); + $this->cleanGroupBy(); + $this->cleanHaving(); + $this->cleanIntersect(); + $this->cleanIntoOutfile(); + $this->cleanJoin(); + $this->cleanLimit(); + $this->cleanLock(); + $this->cleanOffset(); + $this->cleanOrderBy(); + $this->cleanPreWhere(); + $this->cleanQualify(); + $this->cleanReplace(); + $this->cleanSample(); + $this->cleanSelect(); + $this->cleanSettings(); + $this->cleanWhere(); + $this->cleanWindow(); + $this->cleanWith(); + + return $this; + } + + public function build(): static + { + if ($this->built) { + return $this; + } + $this->sql = ''; + $this->params = []; + if ($this->union) { + $this->buildUnion(); + $this->buildOrderBy(); + $this->buildLimit(); + $this->buildOffset(); + } else { + $this->buildWith(); + $this->buildSelect(); + $this->buildReplace(); + $this->buildApply(); + $this->buildExcept(); + $this->buildFrom(); + $this->buildJoin(); + $this->buildPreWhere(); + $this->buildWhere(); + $this->buildSample(); + $this->buildExceptQuery(); + $this->buildGroupBy(); + $this->buildHaving(); + $this->buildWindow(); + $this->buildQualify(); + $this->buildOrderBy(); + $this->buildLimit(); + $this->buildOffset(); + $this->buildIntersect(); + $this->buildArrayJoin(); + $this->buildIntoOutfile(); + $this->buildFormat(); + $this->buildLock(); + $this->buildSettings(); + } + $this->built = true; + return $this; + } +} diff --git a/tests/Clickhouse/InsertStatementTest.php b/tests/Clickhouse/InsertStatementTest.php new file mode 100644 index 0000000..4b5aa3f --- /dev/null +++ b/tests/Clickhouse/InsertStatementTest.php @@ -0,0 +1,82 @@ +with('now() AS current_time') + ->with('1000 AS price_threshold') + ->with( + 'high_value_orders', + (new SelectStatement()) + ->select('groupArray(order_id)') + ->from('orders') + ->where('amount > price_threshold') + ) + ->into('premium_users') + ->columns(['id', 'name', 'total_spent', 'country', 'created_at', 'order_ids']) + ->select( + (new SelectStatement()) + ->select([ + 'u.id', + 'u.name', + 'SUM(o.amount) AS total_spent', + 'u.country', + 'current_time AS created_at', + 'high_value_orders AS order_ids', + ]) + ->from('users AS u') + ->join('orders AS o ON u.id = o.user_id') + ->preWhere('u.is_active = 1') + ->exceptQuery( + (new SelectStatement()) + ->select('id') + ->from('users') + ->where('registration_date > current_time - INTERVAL 30 DAY') + ) + ->sample(0.1, 0.01) + ->groupBy('u.id, u.name, u.country') + ->having('total_spent > price_threshold') + ->window('win', 'PARTITION BY u.country ORDER BY SUM(o.amount) DESC') + ->qualify('RANK() OVER win <= 5') + ->orderBy('total_spent', 'DESC') + ->limit(50) + ->offset(10) + ); + + self::assertSame( + "WITH now() AS current_time, 1000 AS price_threshold, (SELECT groupArray(order_id) FROM orders WHERE amount > price_threshold) AS high_value_orders " . + "INSERT INTO premium_users " . + "(id, name, total_spent, country, created_at, order_ids) " . + "SELECT u.id, u.name, SUM(o.amount) AS total_spent, u.country, current_time AS created_at, high_value_orders AS order_ids " . + "FROM users AS u " . + "JOIN orders AS o ON u.id = o.user_id " . + "PREWHERE u.is_active = 1 " . + "SAMPLE 0.1 OFFSET 0.01 " . + "EXCEPT (SELECT id FROM users WHERE registration_date > current_time - INTERVAL 30 DAY) " . + "GROUP BY u.id, u.name, u.country " . + "HAVING total_spent > price_threshold " . + "WINDOW win AS (PARTITION BY u.country ORDER BY SUM(o.amount) DESC) " . + "QUALIFY RANK() OVER win <= 5 " . + "ORDER BY total_spent DESC " . + "LIMIT 50 OFFSET 10", + $st->toSql() + ); + self::assertEmpty($st->getParams()); + } +} diff --git a/tests/Clickhouse/SelectStatementTest.php b/tests/Clickhouse/SelectStatementTest.php new file mode 100644 index 0000000..b5b199a --- /dev/null +++ b/tests/Clickhouse/SelectStatementTest.php @@ -0,0 +1,600 @@ +toString()); + self::assertEmpty($st->getParams()); + } + + //region APPLY, EXCEPT, REPLACE + + /** + * @test + */ + public function selectWithApply(): void + { + $st = new SelectStatement(); + $st->apply('sum') + ->from('table'); + + self::assertSame('SELECT * APPLY(sum) FROM table', $st->toString()); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithMultiApply(): void + { + $st = new SelectStatement(); + $st->apply('sum'); + $st->apply('toString'); + $st->apply('length'); + + self::assertSame('SELECT * APPLY(sum) APPLY(toString) APPLY(length)', $st->toString()); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectColumnsWithMultiApply(): void + { + $st = new SelectStatement(); + $st->select("COLUMNS('[jk]')") + ->apply('toString') + ->apply('length') + ->apply('max') + ->from('columns_transformers'); + + self::assertSame( + "SELECT COLUMNS('[jk]') APPLY(toString) APPLY(length) APPLY(max) FROM columns_transformers", + $st->toString() + ); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithExpect(): void + { + $st = new SelectStatement(); + $st->except('c1') + ->from('table'); + + self::assertSame('SELECT * EXCEPT (c1) FROM table', $st->toString()); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithMultiExpect(): void + { + $st = new SelectStatement(); + $st->except(['c1', 'c2', 'c3']); + + self::assertSame('SELECT * EXCEPT (c1, c2, c3)', $st->toString()); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithSubQueryExcept(): void + { + $st = new SelectStatement(); + $st->select('crypto_name') + ->from('holdings') + ->exceptQuery( + (new SelectStatement())->select('crypto_name') + ->from('crypto_prices') + ->where('price', '<', 10) + ); + + self::assertSame( + 'SELECT crypto_name FROM holdings EXCEPT (SELECT crypto_name FROM crypto_prices WHERE price < :p1)', + $st->toString() + ); + self::assertSame(['p1' => 10], $st->getParams()); + } + + /** + * @test + */ + public function selectWithSubQueryExceptDistinct(): void + { + $st = new SelectStatement(); + $st->select('crypto_name') + ->from('holdings') + ->exceptQuery( + (new SelectStatement())->select('crypto_name') + ->from('crypto_prices') + ->where('price', '<', 10), + true + ); + + self::assertSame( + 'SELECT crypto_name FROM holdings EXCEPT DISTINCT (SELECT crypto_name FROM crypto_prices WHERE price < :p1)', + $st->toString() + ); + self::assertSame(['p1' => 10], $st->getParams()); + } + + /** + * @test + */ + public function selectWithReplace(): void + { + $st = new SelectStatement(); + $st->select('*') + ->from('columns_transformers') + ->replace('i + 1', 'i'); + + self::assertSame('SELECT * REPLACE(i + 1 i) FROM columns_transformers', $st->toString()); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithReplaceExpectApply(): void + { + $st = new SelectStatement(); + $st->select('*') + ->replace('i + 1', 'i') + ->apply('sum') + ->except('j') + ->from('columns_transformers'); + + self::assertSame('SELECT * REPLACE(i + 1 i) APPLY(sum) EXCEPT (j) FROM columns_transformers', $st->toString()); + self::assertEmpty($st->getParams()); + } + + //endregion + + //region SETTINGS + + /** + * @test + */ + public function selectWithSettings(): void + { + $st = new SelectStatement(); + $st->select('*') + ->from('some_table') + ->settings([ + 'optimize_read_in_order=1', + 'cast_keep_nullable=1', + ]); + + self::assertSame('SELECT * FROM some_table SETTINGS (:p1, :p2)', $st->toString()); + self::assertSame([ + 'p1' => 'optimize_read_in_order=1', + 'p2' => 'cast_keep_nullable=1', + ], $st->getParams()); + } + + //endregion + + //region INTERSECT + + /** + * @test + */ + public function selectWithIntersect(): void + { + $st = new SelectStatement(); + $st->select(['column1', 'column2']) + ->from('table1') + ->where('1=1') + ->intersect( + (new SelectStatement()) + ->select(['column1', 'column2']) + ->from('table2') + ->where('2=2') + ); + + self::assertSame( + 'SELECT column1, column2 FROM table1 WHERE 1=1 INTERSECT SELECT column1, column2 FROM table2 WHERE 2=2', + $st->toString() + ); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithIntersectDistinct(): void + { + $st = new SelectStatement(); + $st->select(['column1', 'column2']) + ->from('table1') + ->where('1=1') + ->intersect( + (new SelectStatement()) + ->select(['column1', 'column2']) + ->from('table2') + ->where('2=2'), + true + ); + + self::assertSame( + 'SELECT column1, column2 FROM table1 WHERE 1=1 INTERSECT DISTINCT SELECT column1, column2 FROM table2 WHERE 2=2', + $st->toString() + ); + self::assertEmpty($st->getParams()); + } + + //endregion + + // region FORMAT, INTO OUTFILE + + /** + * @test + */ + public function selectWithIntoOutfile(): void + { + $st = new SelectStatement(); + $st->select('*') + ->from('my_table') + ->intoOutfile('/var/lib/clickhouse/user_files/output.csv') + ->format('CSV'); + + self::assertSame( + "SELECT * FROM my_table INTO OUTFILE '/var/lib/clickhouse/user_files/output.csv' FORMAT CSV", + $st->toString() + ); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithFormat(): void + { + $st = new SelectStatement(); + $st->select('*') + ->from('my_table') + ->format('JSON'); + + self::assertSame("SELECT * FROM my_table FORMAT JSON", $st->toString()); + self::assertEmpty($st->getParams()); + } + + //endregion + + //region PREWHERE, WHERE + + /** + * @test + */ + public function selectWithPreWhere(): void + { + $st = new SelectStatement(); + $st->select([ + 'id', + 'name', + 'created_at', + ]) + ->from('my_table') + ->preWhere('created_at', '>=', '2024-01-01') + ->where('name', 'like', 'AB%'); + self::assertSame( + "SELECT id, name, created_at FROM my_table PREWHERE created_at >= :p1 WHERE name like :p2", + $st->toString() + ); + self::assertSame([ + 'p1' => '2024-01-01', + 'p2' => 'AB%', + ], $st->getParams()); + } + + /** + * @test + */ + public function selectWithMultiPreWhere(): void + { + $st = new SelectStatement(); + $st->select([ + 'id', + 'name', + 'created_at', + ]) + ->from('my_table') + ->preWhere('created_at', '>=', '2024-01-01') + ->preWhere('created_at', '<=', '2025-01-01') + ->where('name', 'like', 'AB%'); + self::assertSame( + "SELECT id, name, created_at FROM my_table PREWHERE created_at >= :p1 AND created_at <= :p2 WHERE name like :p3", + $st->toString() + ); + self::assertSame([ + 'p1' => '2024-01-01', + 'p2' => '2025-01-01', + 'p3' => 'AB%', + ], $st->getParams()); + } + + //endregion + + //region SAMPLE + + /** + * @test + */ + public function selectWithSample(): void + { + $st = new SelectStatement(); + $st->select('*') + ->from('my_table') + ->sample(0.05); + + self::assertSame("SELECT * FROM my_table SAMPLE 0.05", $st->toString()); + self::assertEmpty($st->getParams()); + + $st = new SelectStatement(); + $st->select('*') + ->from('my_table') + ->sample(10000); + + self::assertSame("SELECT * FROM my_table SAMPLE 10000", $st->toString()); + self::assertEmpty($st->getParams()); + } + + /** + * @test + */ + public function selectWithSampleAndOffset(): void + { + $st = new SelectStatement(); + $st->select('*') + ->from('my_table') + ->sample(100, 100); + + self::assertSame("SELECT * FROM my_table SAMPLE 100 OFFSET 100", $st->toString()); + self::assertEmpty($st->getParams()); + + $st = new SelectStatement(); + $st->select('*') + ->from('my_table') + ->sample(1 / 10, 1 / 100); + + self::assertSame("SELECT * FROM my_table SAMPLE 0.1 OFFSET 0.01", $st->toString()); + self::assertEmpty($st->getParams()); + } + + //endregion + + //region QUALIFY + + /** + * @test + */ + public function selectWithQualify(): void + { + $st = new SelectStatement(); + $st->select('user_id') + ->select('order_id') + ->select('order_date') + ->select('ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC)', 'AS rn') + ->from('orders') + ->qualify('rn = 1'); + + self::assertSame( + "SELECT user_id, order_id, order_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) AS rn FROM orders QUALIFY rn = 1", + $st->toString() + ); + self::assertEmpty($st->getParams()); + + $st = new SelectStatement(); + $st->select([ + 'employee_id', + 'department', + 'salary', + 'rnk' => 'DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC)', + ]) + ->from('employees') + ->qualify('rnk <= 5'); + + self::assertSame( + "SELECT employee_id, department, salary, DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) rnk FROM employees QUALIFY rnk <= 5", + $st->toString() + ); + self::assertEmpty($st->getParams()); + } + //endregion + + /** + * @test + */ + public function selectWithAllClauses(): void + { + $st = new SelectStatement(); + $st->with('current_time', 'now()') + ->with('1000 AS price_threshold') + ->with('filtered_orders', (new SelectStatement()) + ->select('groupArray(order_id)') + ->from('orders') + ->where('orders.user_id = u.id')) + ->select([ + 'u.id', + 'u.name', + 'total_spent' => 'SUM(o.amount)', + 'order_count' => 'COUNT(*)', + 'rank_in_country' => 'RANK() OVER (PARTITION BY u.country ORDER BY SUM(o.amount) DESC)', + 'global_percent_rank' => 'PERCENT_RANK() OVER (ORDER BY SUM(o.amount) DESC)', + 'order_id', + ]) + ->from('users', 'u') + ->join('orders', 'o', 'u.id = o.user_id') + ->preWhere('u.is_active = 1') + ->where('u.status', 'NOT IN', ['banned', 'deleted']) + ->sample(0.1, 0.01) + ->exceptQuery( + (new SelectStatement())->select('id') + ->from('users') + ->where('registration_date > current_time - INTERVAL 30 DAY') + ) + ->groupBy(['u.id', 'u.name', 'u.country']) + ->having('total_spent > price_threshold') + ->window('win', 'PARTITION BY u.country ORDER BY SUM(o.amount) DESC') + ->qualify('rank_in_country <= 5') + ->orderBy('total_spent', 'DESC') + ->limit(100) + ->offset(10) + ->arrayJoin('filtered_orders', 'order_id') + ->intoOutfile('/var/lib/clickhouse/user_files/output.json') + ->format('JSONEachRow'); + + self::assertSame($st->toString(), $st->toSql()); + self::assertSame( + "WITH now() AS current_time, 1000 AS price_threshold, (SELECT groupArray(order_id) FROM orders WHERE orders.user_id = u.id) AS filtered_orders " . + "SELECT u.id, u.name, SUM(o.amount) total_spent, COUNT(*) order_count, RANK() OVER (PARTITION BY u.country ORDER BY SUM(o.amount) DESC) rank_in_country, PERCENT_RANK() OVER (ORDER BY SUM(o.amount) DESC) global_percent_rank, order_id " . + "FROM users u " . + "JOIN orders o ON u.id = o.user_id " . + "PREWHERE u.is_active = 1 " . + "WHERE u.status NOT IN (:p1, :p2) " . + "SAMPLE 0.1 OFFSET 0.01 " . + "EXCEPT (SELECT id FROM users WHERE registration_date > current_time - INTERVAL 30 DAY) " . + "GROUP BY u.id, u.name, u.country " . + "HAVING total_spent > price_threshold " . + "WINDOW win AS (PARTITION BY u.country ORDER BY SUM(o.amount) DESC) " . + "QUALIFY rank_in_country <= 5 " . + "ORDER BY total_spent DESC " . + "LIMIT 100 OFFSET 10 " . + "ARRAY JOIN filtered_orders order_id " . + "INTO OUTFILE '/var/lib/clickhouse/user_files/output.json' " . + "FORMAT JSONEachRow", + $st->toString() + ); + self::assertSame([ + 'p1' => 'banned', + 'p2' => 'deleted', + ], $st->getParams()); + } + + /** + * @test + */ + public function copySelectStatement(): void + { + $executor = $this->getMockBuilder(StatementExecutor::class)->getMock(); + $st = new SelectStatement($executor); + $st->with('current_time', 'now()') + ->with('1000 AS price_threshold') + ->with('filtered_orders', (new SelectStatement()) + ->select('groupArray(order_id)') + ->from('orders') + ->where('orders.user_id = u.id')) + ->select([ + 'u.id', + 'u.name', + 'total_spent' => 'SUM(o.amount)', + 'order_count' => 'COUNT(*)', + 'rank_in_country' => 'RANK() OVER (PARTITION BY u.country ORDER BY SUM(o.amount) DESC)', + 'global_percent_rank' => 'PERCENT_RANK() OVER (ORDER BY SUM(o.amount) DESC)', + 'order_id', + ]) + ->from('users', 'u') + ->join('orders', 'o', 'u.id = o.user_id') + ->preWhere('u.is_active = 1') + ->where('u.status', 'NOT IN', ['banned', 'deleted']) + ->sample(0.1, 0.01) + ->exceptQuery( + (new SelectStatement())->select('id') + ->from('users') + ->where('registration_date > current_time - INTERVAL 30 DAY') + ) + ->groupBy(['u.id', 'u.name', 'u.country']) + ->having('total_spent > price_threshold') + ->window('win', 'PARTITION BY u.country ORDER BY SUM(o.amount) DESC') + ->qualify('rank_in_country <= 5') + ->orderBy('total_spent', 'DESC') + ->limit(100) + ->offset(10) + ->arrayJoin('filtered_orders', 'order_id') + ->intoOutfile('/var/lib/clickhouse/user_files/output.json') + ->format('JSONEachRow'); + + $copy = $st->copy(); + self::assertSame($copy->getStatementExecutor(), $executor); + self::assertSame($st->toSql(), $copy->toSql()); + self::assertSame($st->getParams(), $copy->getParams()); + } + + /** + * @test + */ + public function cleanSelectStatement(): void + { + $executor = $this->getMockBuilder(StatementExecutor::class)->getMock(); + $st = new SelectStatement($executor); + $st->with('current_time', 'now()') + ->with('1000 AS price_threshold') + ->with('filtered_orders', (new SelectStatement()) + ->select('groupArray(order_id)') + ->from('orders') + ->where('orders.user_id = u.id')) + ->select([ + 'u.id', + 'u.name', + 'total_spent' => 'SUM(o.amount)', + 'order_count' => 'COUNT(*)', + 'rank_in_country' => 'RANK() OVER (PARTITION BY u.country ORDER BY SUM(o.amount) DESC)', + 'global_percent_rank' => 'PERCENT_RANK() OVER (ORDER BY SUM(o.amount) DESC)', + 'order_id', + ]) + ->from('users', 'u') + ->join('orders', 'o', 'u.id = o.user_id') + ->preWhere('u.is_active = 1') + ->where('u.status', 'NOT IN', ['banned', 'deleted']) + ->sample(0.1, 0.01) + ->exceptQuery( + (new SelectStatement())->select('id') + ->from('users') + ->where('registration_date > current_time - INTERVAL 30 DAY') + ) + ->groupBy(['u.id', 'u.name', 'u.country']) + ->having('total_spent > price_threshold') + ->window('win', 'PARTITION BY u.country ORDER BY SUM(o.amount) DESC') + ->qualify('rank_in_country <= 5') + ->orderBy('total_spent', 'DESC') + ->limit(100) + ->offset(10) + ->arrayJoin('filtered_orders', 'order_id') + ->intoOutfile('/var/lib/clickhouse/user_files/output.json') + ->format('JSONEachRow'); + + $st->clean(); + + self::assertSame($executor, $st->getStatementExecutor()); + self::assertSame("SELECT *", $st->toSql()); + self::assertEmpty($st->getParams()); + } +}