From 3180eceb80be60d6dadd5397f225436ba3708351 Mon Sep 17 00:00:00 2001 From: Makar Date: Mon, 2 Jun 2025 15:36:28 +0300 Subject: [PATCH 01/12] Fix support for ClickHouse alternative `WITH` syntax that uses the `WITH (query) AS alias` form in SQL queries. --- .../Dialects/ClickhouseDialectTests.cs | 24 +++++++++++++++++- src/SqlParser/Dialects/ClickHouseDialect.cs | 25 ++++++++++++++++--- src/SqlParser/Dialects/Dialect.cs | 5 ++++ src/SqlParser/Parser.cs | 5 ++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index 1f9dcfd..5d29d4e 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -9,7 +9,7 @@ public ClickhouseDialectTests() { DefaultDialects = [new ClickHouseDialect()]; } - + [Fact] public void Parse_Map_Access_Expr() { @@ -960,4 +960,26 @@ public void Parse_Alter_Table_Clear_And_Materialize_Projection() Assert.Throws(() => ParseSqlStatements($"ALTER TABLE t0 {{keyword}} PROJECTION my_name IN")); } } + + [Fact] + public void Parse_ClickHouse_Alternative_With_Syntax() + { + var dialect = new ClickHouseDialect(); + + var standardSql = "WITH test AS (SELECT 1 AS col) SELECT * FROM test"; + + var standardSqlStatement = ParseSqlStatements(standardSql, [dialect]); + + Assert.NotNull(standardSqlStatement); + Assert.Single(standardSqlStatement); + Assert.IsType(standardSqlStatement[0]); + + var clickhouseSql = "WITH (SELECT 1 AS col) AS test SELECT * FROM test"; + + var clickhouseStatement = ParseSqlStatements(clickhouseSql, [dialect]); + + Assert.NotNull(clickhouseStatement); + Assert.Single(clickhouseStatement); + Assert.IsType(clickhouseStatement[0]); + } } \ No newline at end of file diff --git a/src/SqlParser/Dialects/ClickHouseDialect.cs b/src/SqlParser/Dialects/ClickHouseDialect.cs index 65890a7..e7d8d8e 100644 --- a/src/SqlParser/Dialects/ClickHouseDialect.cs +++ b/src/SqlParser/Dialects/ClickHouseDialect.cs @@ -1,4 +1,7 @@ -namespace SqlParser.Dialects; +using SqlParser.Ast; +using SqlParser.Tokens; + +namespace SqlParser.Dialects; /// /// ClickHouse SQL dialect @@ -9,11 +12,27 @@ public class ClickHouseDialect : Dialect { public override bool IsIdentifierStart(char character) => character.IsLetter() || character == Symbols.Underscore; - public override bool IsIdentifierPart(char character) => IsIdentifierStart(character) || character.IsDigit(); - + public override bool IsIdentifierPart(char character) => IsIdentifierStart(character) || character.IsDigit(); + public override bool SupportsStringLiteralBackslashEscape => true; public override bool SupportsSelectWildcardExcept => true; public override bool DescribeRequiresTableKeyword => true; public override bool RequireIntervalQualifier => true; public override bool SupportsLimitComma => true; + + public override CommonTableExpression ParseCommonTableExpression(Parser parser) + { + if (parser.PeekToken() is LeftParen) + { + parser.ConsumeToken(); + var query = parser.ParseQuery(false); + parser.ConsumeToken(); + parser.ExpectKeyword(Keyword.AS); + var identifier = parser.ParseIdentifier(); + var tableAlias = new TableAlias(identifier); + return new CommonTableExpression(tableAlias, query); + } + + return parser.ParseCommonTableExpressionInternal(); + } } \ No newline at end of file diff --git a/src/SqlParser/Dialects/Dialect.cs b/src/SqlParser/Dialects/Dialect.cs index 8aaffd2..78b8896 100644 --- a/src/SqlParser/Dialects/Dialect.cs +++ b/src/SqlParser/Dialects/Dialect.cs @@ -251,4 +251,9 @@ public virtual bool IsCustomOperatorPart(char character) /// Returns the precedence when the precedence is otherwise unknown /// public virtual short PrecedenceUnknown => 0; + + public virtual CommonTableExpression ParseCommonTableExpression(Parser parser) + { + return parser.ParseCommonTableExpressionInternal(); + } } \ No newline at end of file diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index b7aecf0..4f2a20e 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -4454,6 +4454,11 @@ KillType ParseMutation() } public CommonTableExpression ParseCommonTableExpression() + { + return _dialect.ParseCommonTableExpression(this); + } + + public CommonTableExpression ParseCommonTableExpressionInternal() { var name = ParseIdentifier(); From 8011a580ddf63fcfbd73509aae1208ffec510d5f Mon Sep 17 00:00:00 2001 From: Makar Date: Thu, 12 Jun 2025 11:24:48 +0300 Subject: [PATCH 02/12] Better tests, support for WITH AS in ClickHouseDialect --- .../Dialects/ClickhouseDialectTests.cs | 25 +++---- src/SqlParser/Dialects/ClickHouseDialect.cs | 69 +++++++++++++++++-- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index c62afcb..b5fdea3 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -968,22 +968,19 @@ public void Parse_Alter_Table_Clear_And_Materialize_Projection() [Fact] public void Parse_ClickHouse_Alternative_With_Syntax() { - var dialect = new ClickHouseDialect(); - var standardSql = "WITH test AS (SELECT 1 AS col) SELECT * FROM test"; - - var standardSqlStatement = ParseSqlStatements(standardSql, [dialect]); - - Assert.NotNull(standardSqlStatement); - Assert.Single(standardSqlStatement); - Assert.IsType(standardSqlStatement[0]); + VerifiedStatement(standardSql, DefaultDialects!); var clickhouseSql = "WITH (SELECT 1 AS col) AS test SELECT * FROM test"; - - var clickhouseStatement = ParseSqlStatements(clickhouseSql, [dialect]); - - Assert.NotNull(clickhouseStatement); - Assert.Single(clickhouseStatement); - Assert.IsType(clickhouseStatement[0]); + var expectedCanonical = "WITH test AS (SELECT 1 AS col) SELECT * FROM test"; + OneStatementParsesTo(clickhouseSql, expectedCanonical, DefaultDialects!); + } + + + [Fact] + public void Parse_With_Expression() + { + var sql = "WITH (neighbor(player_id, -1)) AS sql_identifier"; + VerifiedStatement(sql, DefaultDialects!); } } \ No newline at end of file diff --git a/src/SqlParser/Dialects/ClickHouseDialect.cs b/src/SqlParser/Dialects/ClickHouseDialect.cs index e7d8d8e..0410863 100644 --- a/src/SqlParser/Dialects/ClickHouseDialect.cs +++ b/src/SqlParser/Dialects/ClickHouseDialect.cs @@ -22,17 +22,74 @@ public class ClickHouseDialect : Dialect public override CommonTableExpression ParseCommonTableExpression(Parser parser) { + if (parser.PeekToken() is not LeftParen) return parser.ParseCommonTableExpressionInternal(); + parser.ConsumeToken(); + var query = parser.ParseQuery(false); + parser.ConsumeToken(); + parser.ExpectKeyword(Keyword.AS); + var identifier = parser.ParseIdentifier(); + var tableAlias = new TableAlias(identifier); + return new CommonTableExpression(tableAlias, query); + } + + + public override Statement? ParseStatement(Parser parser) + { + if (parser.PeekToken() is not Word { Keyword: Keyword.WITH }) return null; + parser.NextToken(); + if (parser.PeekToken() is LeftParen) { - parser.ConsumeToken(); - var query = parser.ParseQuery(false); - parser.ConsumeToken(); + parser.NextToken(); + + var isQuery = false; + var peekToken = parser.PeekToken(); + if (peekToken is Word { Keyword: Keyword.SELECT } || + peekToken is Word { Keyword: Keyword.WITH } || + peekToken is Word { Keyword: Keyword.VALUES } || + peekToken is LeftParen) + { + isQuery = true; + } + + if (isQuery) + { + parser.PrevToken(); + parser.PrevToken(); + return null; + } + + var expr = parser.ParseExpr(); + parser.ExpectRightParen(); parser.ExpectKeyword(Keyword.AS); var identifier = parser.ParseIdentifier(); - var tableAlias = new TableAlias(identifier); - return new CommonTableExpression(tableAlias, query); + + return new WithStatement(expr, identifier); + } + else + { + parser.PrevToken(); + } + + return null; + } + + private record WithStatement : Statement + { + private Expression Expression { get; } + private Ident Identifier { get; } + + public WithStatement(Expression expression, Ident identifier) + { + Expression = expression; + Identifier = identifier; } - return parser.ParseCommonTableExpressionInternal(); + public override void ToSql(SqlTextWriter writer) + { + writer.Write("WITH ("); + Expression.ToSql(writer); + writer.Write($") AS {Identifier}"); + } } } \ No newline at end of file From fe23cd9205597e4ab29f6a23d0da72e7d06f98f1 Mon Sep 17 00:00:00 2001 From: Makar Date: Wed, 9 Jul 2025 14:25:37 +0300 Subject: [PATCH 03/12] Expressions inside with --- .../Dialects/ClickhouseDialectTests.cs | 186 +++++++++++++++++- src/SqlParser/Ast/CommonTableExpression.cs | 37 +++- src/SqlParser/Ast/SetExpression.cs | 15 +- src/SqlParser/Dialects/ClickHouseDialect.cs | 73 ------- src/SqlParser/Dialects/Dialect.cs | 5 - src/SqlParser/Parser.cs | 5 - 6 files changed, 227 insertions(+), 94 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index b5fdea3..bdb2cc7 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -971,16 +971,200 @@ public void Parse_ClickHouse_Alternative_With_Syntax() var standardSql = "WITH test AS (SELECT 1 AS col) SELECT * FROM test"; VerifiedStatement(standardSql, DefaultDialects!); + var standardSql2 = "WITH city_table AS (SELECT NAME, (POPULATION + 1000000) AS POP FROM CITY) SELECT POP FROM city_table"; + VerifiedStatement(standardSql2, DefaultDialects!); + var clickhouseSql = "WITH (SELECT 1 AS col) AS test SELECT * FROM test"; var expectedCanonical = "WITH test AS (SELECT 1 AS col) SELECT * FROM test"; OneStatementParsesTo(clickhouseSql, expectedCanonical, DefaultDialects!); } + + [Fact] + public void Parse_With_Expression_Main_Common_Dialect_As_Test() + { + var sql = "WITH current_time AS now() SELECT * FROM current_time"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_Expression_Main_Test() + { + var sql = "WITH now() AS current_time SELECT * FROM current_time"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Function() + { + var sql = "SELECT NOW()"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Select_Without_Function() + { + var sql = "WITH SELECT_TEST AS (SELECT 1) SELECT * FROM SELECT_TEST"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Clickhouse_As_Select_Without_Function() + { + var sql = "WITH (SELECT 1) AS SELECT_TEST SELECT * FROM SELECT_TEST"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] public void Parse_With_Expression() { - var sql = "WITH (neighbor(player_id, -1)) AS sql_identifier"; + var sql = "With (select uniq(player_id) FROM (select player_id from mw2.registration where date >= '2021-02-17' and date <= '2021-02-26' and player_install_source IN ('', 'None') group by player_id)) as All_players Select All_players"; + var expectedCanonical = "WITH All_players AS (SELECT uniq(player_id) FROM (SELECT player_id FROM mw2.registration WHERE date >= '2021-02-17' AND date <= '2021-02-26' AND player_install_source IN ('', 'None') GROUP BY player_id)) SELECT All_players"; + OneStatementParsesTo(sql, expectedCanonical, DefaultDialects!); + } + + + [Fact] + public void Parse_With_Missing_Select() + { + var sql = "WITH (date - install_date) AS visit_day"; + Assert.ThrowsAny(() => + { + VerifiedStatement(sql, DefaultDialects!); + }); + } + + [Fact] + public void Parse_Inner_With() + { + // Test 1: WITH inside CTE definition + var sql = "WITH city_table AS(WITH (POPULATION - 10000) AS new_pop SELECT NAME, new_pop AS POP FROM (SELECT NAME, POPULATION FROM CITY) AS base_city) SELECT POP FROM city_table"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Subqueries() + { + // Test 2: WITH inside FROM subquery + var sql = "SELECT * FROM (WITH (1) AS x SELECT x) AS subquery"; VerifiedStatement(sql, DefaultDialects!); } + + [Fact] + public void Parse_With_In_Set_Operations() + { + // WITH in UNION subquery + var sql1 = "SELECT 1 UNION ALL (WITH (2) AS val SELECT val)"; + VerifiedStatement(sql1, DefaultDialects!); + + // WITH in both sides of UNION + var sql2 = "(WITH (1) AS a SELECT a) UNION ALL (WITH (2) AS b SELECT b)"; + VerifiedStatement(sql2, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Predicate_Subqueries() + { + var sql1 = "SELECT * FROM table1 WHERE EXISTS (WITH (1) AS x SELECT x)"; + VerifiedStatement(sql1, DefaultDialects!); + + var sql2 = "SELECT * FROM table1 WHERE col IN (WITH (SELECT col FROM table2) AS vals SELECT vals)"; + VerifiedStatement(sql2, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Scalar_Subqueries() + { + var sql = "SELECT (WITH (1) AS val SELECT val) AS result FROM table1"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Window_Expressions() + { + var sql = "SELECT ROW_NUMBER() OVER (ORDER BY (WITH (col) AS sorted_col SELECT sorted_col)) FROM table1"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Case_Expressions() + { + var sql = "SELECT CASE WHEN (WITH (1) AS val SELECT val) = 1 THEN 'one' ELSE 'other' END"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Join_Subqueries() + { + var sql = "SELECT * FROM table1 t1 JOIN (WITH (SELECT id FROM table2) AS ids SELECT * FROM ids) t2 ON t1.id = t2.id"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_Table_References() + { + var sql = "WITH table1.column1 AS alias_col SELECT alias_col FROM table1"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Union() + { + var sql = "WITH (1) AS x SELECT x UNION ALL WITH (2) AS y SELECT y"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Multiple_With_Expressions() + { + var sql = "WITH (SELECT 1) AS a, (SELECT 2) AS b SELECT a, b"; + var expectedCanonical = "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT a, b"; + OneStatementParsesTo(sql, expectedCanonical, DefaultDialects!); + } + + [Fact] + public void Parse_With_Case_Expressions() + { + var sql = "WITH (CASE WHEN col > 10 THEN 'high' ELSE 'low' END) AS category SELECT category FROM table1"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_Array_And_Tuple_Expressions() + { + var sql = "WITH ([1, 2, 3]) AS arr SELECT arr"; + VerifiedStatement(sql, DefaultDialects!); + + var sql2 = "WITH ((1, 'a')) AS tup SELECT tup"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_Expression_With_Parenthesis() + { + var sql = "WITH now() AS current_time SELECT current_time"; + VerifiedStatement(sql, DefaultDialects!); + + var sql2 = "WITH arrayJoin([1,2,3]) AS arr_val SELECT arr_val"; + VerifiedStatement(sql2, DefaultDialects!); + + var missingParenthesis = "WITH neighbor(player_id, -1) AS sql_identifier SELECT * FROM sql_identifier"; + VerifiedStatement(missingParenthesis, DefaultDialects!); + } + + + [Fact] + public void Parse_With_Expression_Without_Parenthesis() + { + var sql = "WITH (now()) AS current_time SELECT current_time"; + VerifiedStatement(sql, DefaultDialects!); + + var sql2 = "WITH (arrayJoin([1,2,3])) AS arr_val SELECT arr_val"; + VerifiedStatement(sql2, DefaultDialects!); + + var parenthesisExample = "WITH (neighbor(player_id, -1)) AS sql_identifier SELECT * FROM sql_identifier"; + VerifiedStatement(parenthesisExample, DefaultDialects!); + } } \ No newline at end of file diff --git a/src/SqlParser/Ast/CommonTableExpression.cs b/src/SqlParser/Ast/CommonTableExpression.cs index e9803c8..ab937fb 100644 --- a/src/SqlParser/Ast/CommonTableExpression.cs +++ b/src/SqlParser/Ast/CommonTableExpression.cs @@ -9,24 +9,43 @@ /// CTE Alias /// CTE Select /// Optional From identifier -public record CommonTableExpression(TableAlias Alias, Query Query, Ident? From = null, CteAsMaterialized? Materialized = null) : IWriteSql, IElement +public record CommonTableExpression(TableAlias Alias, Query Query, Ident? From = null, CteAsMaterialized? Materialized = null, bool IsExpression = false) : IWriteSql, IElement { public Ident? From { get; internal set; } = From; public void ToSql(SqlTextWriter writer) { - if (Materialized == null) + if (IsExpression) { - writer.WriteSql($"{Alias} AS ({Query})"); + if (Materialized == null) + { + writer.WriteSql($"{Alias} AS {Query}"); + } + else + { + writer.WriteSql($"{Alias} AS {Materialized} {Query}"); + } + + if (From != null) + { + writer.WriteSql($" FROM {From}"); + } } else { - writer.WriteSql($"{Alias} AS {Materialized} ({Query})"); - } - - if (From != null) - { - writer.WriteSql($" FROM {From}"); + if (Materialized == null) + { + writer.WriteSql($"{Alias} AS ({Query})"); + } + else + { + writer.WriteSql($"{Alias} AS {Materialized} ({Query})"); + } + + if (From != null) + { + writer.WriteSql($" FROM {From}"); + } } } } diff --git a/src/SqlParser/Ast/SetExpression.cs b/src/SqlParser/Ast/SetExpression.cs index d775fdb..8244c02 100644 --- a/src/SqlParser/Ast/SetExpression.cs +++ b/src/SqlParser/Ast/SetExpression.cs @@ -7,7 +7,7 @@ public abstract record SetExpression : IWriteSql, IElement { /// - /// Insert query bdy + /// Insert query body /// /// Statement public record Insert(Statement Statement) : SetExpression @@ -17,6 +17,19 @@ public override void ToSql(SqlTextWriter writer) Statement.ToSql(writer); } } + + /// + /// Expression-only body + /// + /// The expression + public record ExpressionOnly(Expression Expression) : SetExpression + { + public override void ToSql(SqlTextWriter writer) + { + Expression.ToSql(writer); + } + } + /// /// Select expression body /// diff --git a/src/SqlParser/Dialects/ClickHouseDialect.cs b/src/SqlParser/Dialects/ClickHouseDialect.cs index 0410863..3e7015e 100644 --- a/src/SqlParser/Dialects/ClickHouseDialect.cs +++ b/src/SqlParser/Dialects/ClickHouseDialect.cs @@ -19,77 +19,4 @@ public class ClickHouseDialect : Dialect public override bool DescribeRequiresTableKeyword => true; public override bool RequireIntervalQualifier => true; public override bool SupportsLimitComma => true; - - public override CommonTableExpression ParseCommonTableExpression(Parser parser) - { - if (parser.PeekToken() is not LeftParen) return parser.ParseCommonTableExpressionInternal(); - parser.ConsumeToken(); - var query = parser.ParseQuery(false); - parser.ConsumeToken(); - parser.ExpectKeyword(Keyword.AS); - var identifier = parser.ParseIdentifier(); - var tableAlias = new TableAlias(identifier); - return new CommonTableExpression(tableAlias, query); - } - - - public override Statement? ParseStatement(Parser parser) - { - if (parser.PeekToken() is not Word { Keyword: Keyword.WITH }) return null; - parser.NextToken(); - - if (parser.PeekToken() is LeftParen) - { - parser.NextToken(); - - var isQuery = false; - var peekToken = parser.PeekToken(); - if (peekToken is Word { Keyword: Keyword.SELECT } || - peekToken is Word { Keyword: Keyword.WITH } || - peekToken is Word { Keyword: Keyword.VALUES } || - peekToken is LeftParen) - { - isQuery = true; - } - - if (isQuery) - { - parser.PrevToken(); - parser.PrevToken(); - return null; - } - - var expr = parser.ParseExpr(); - parser.ExpectRightParen(); - parser.ExpectKeyword(Keyword.AS); - var identifier = parser.ParseIdentifier(); - - return new WithStatement(expr, identifier); - } - else - { - parser.PrevToken(); - } - - return null; - } - - private record WithStatement : Statement - { - private Expression Expression { get; } - private Ident Identifier { get; } - - public WithStatement(Expression expression, Ident identifier) - { - Expression = expression; - Identifier = identifier; - } - - public override void ToSql(SqlTextWriter writer) - { - writer.Write("WITH ("); - Expression.ToSql(writer); - writer.Write($") AS {Identifier}"); - } - } } \ No newline at end of file diff --git a/src/SqlParser/Dialects/Dialect.cs b/src/SqlParser/Dialects/Dialect.cs index 359ed45..aae3514 100644 --- a/src/SqlParser/Dialects/Dialect.cs +++ b/src/SqlParser/Dialects/Dialect.cs @@ -262,9 +262,4 @@ public virtual bool IsTableFactorAlias(bool @explicit, Keyword keyword) => /// Returns the precedence when the precedence is otherwise unknown /// public virtual short PrecedenceUnknown => 0; - - public virtual CommonTableExpression ParseCommonTableExpression(Parser parser) - { - return parser.ParseCommonTableExpressionInternal(); - } } \ No newline at end of file diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index 7004621..6db88b8 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -4454,11 +4454,6 @@ KillType ParseMutation() } public CommonTableExpression ParseCommonTableExpression() - { - return _dialect.ParseCommonTableExpression(this); - } - - public CommonTableExpression ParseCommonTableExpressionInternal() { var name = ParseIdentifier(); From f9e99471d0887d3e0abb7588446e98a9d2a0dc02 Mon Sep 17 00:00:00 2001 From: Makar Date: Thu, 10 Jul 2025 11:07:08 +0300 Subject: [PATCH 04/12] Parser --- src/SqlParser/Parser.cs | 53 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index 6db88b8..ba899f7 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -4475,10 +4475,23 @@ public CommonTableExpression ParseCommonTableExpression() return null; }); - - var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + + if (IsExpression()) + { + var expression = ParseExpr(); + + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query,null, Materialized: isMaterialized, true); + } + else + { + var query = ExpectParens(() => ParseQuery()); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } } else { @@ -4515,6 +4528,38 @@ public CommonTableExpression ParseCommonTableExpression() return cte; } + + private bool IsExpression() + { + var token = PeekToken(); + + if (token is Word word) + { + var keyword = word.Keyword; + if (keyword == Keyword.SELECT || + keyword == Keyword.WITH || + keyword == Keyword.VALUES || + keyword == Keyword.TABLE || + keyword == Keyword.INSERT || + keyword == Keyword.UPDATE || + keyword == Keyword.DELETE) + { + return false; + } + + var peekedToken = PeekNthToken(1); + if (peekedToken is LeftParen) + { + return true; + } + + return true; + } + + return false; + } + + /// /// Parse a `FOR JSON` clause /// From 9bb67bd329fe4b8469d25ba7372cea18aee2f22d Mon Sep 17 00:00:00 2001 From: Makar Date: Fri, 11 Jul 2025 16:55:14 +0300 Subject: [PATCH 05/12] Optional clickhouse AS reverse --- .../Dialects/ClickhouseDialectTests.cs | 17 ++-- src/SqlParser/Ast/CommonTableExpression.cs | 20 +++- src/SqlParser/Parser.cs | 92 ++++++++++++++++++- 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index bdb2cc7..fddea76 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -993,14 +993,6 @@ public void Parse_With_Expression_Main_Test() VerifiedStatement(sql, DefaultDialects!); } - - [Fact] - public void Parse_Function() - { - var sql = "SELECT NOW()"; - VerifiedStatement(sql, DefaultDialects!); - } - [Fact] public void Parse_Select_Without_Function() { @@ -1015,7 +1007,12 @@ public void Parse_Clickhouse_As_Select_Without_Function() VerifiedStatement(sql, DefaultDialects!); } - + [Fact] + public void Parse_Function() + { + var sql = "SELECT NOW()"; + VerifiedStatement(sql, DefaultDialects!); + } [Fact] public void Parse_With_Expression() @@ -1147,7 +1144,7 @@ public void Parse_With_Expression_With_Parenthesis() var sql = "WITH now() AS current_time SELECT current_time"; VerifiedStatement(sql, DefaultDialects!); - var sql2 = "WITH arrayJoin([1,2,3]) AS arr_val SELECT arr_val"; + var sql2 = "WITH arrayJoin([1, 2, 3]) AS arr_val SELECT arr_val"; VerifiedStatement(sql2, DefaultDialects!); var missingParenthesis = "WITH neighbor(player_id, -1) AS sql_identifier SELECT * FROM sql_identifier"; diff --git a/src/SqlParser/Ast/CommonTableExpression.cs b/src/SqlParser/Ast/CommonTableExpression.cs index ab937fb..10f63ab 100644 --- a/src/SqlParser/Ast/CommonTableExpression.cs +++ b/src/SqlParser/Ast/CommonTableExpression.cs @@ -9,13 +9,29 @@ /// CTE Alias /// CTE Select /// Optional From identifier -public record CommonTableExpression(TableAlias Alias, Query Query, Ident? From = null, CteAsMaterialized? Materialized = null, bool IsExpression = false) : IWriteSql, IElement +public record CommonTableExpression(TableAlias Alias, Query Query, Ident? From = null, CteAsMaterialized? Materialized = null, bool IsExpression = false, bool IsReversed = false) : IWriteSql, IElement { public Ident? From { get; internal set; } = From; public void ToSql(SqlTextWriter writer) { - if (IsExpression) + if (IsReversed && IsExpression) + { + if (Materialized == null) + { + writer.WriteSql($"{Query} AS {Alias}"); + } + else + { + writer.WriteSql($"{Query} AS {Materialized} {Alias}"); + } + + if (From != null) + { + writer.WriteSql($" FROM {From}"); + } + } + else if (IsExpression) { if (Materialized == null) { diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index ba899f7..a48c43a 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -4455,7 +4455,86 @@ KillType ParseMutation() public CommonTableExpression ParseCommonTableExpression() { - var name = ParseIdentifier(); + var wasExpression = IsExpression(); + if (_dialect is ClickHouseDialect && wasExpression) + { + var expression = ParseExpr(); + + CommonTableExpression? cte; + + if (ParseKeyword(Keyword.AS)) + { + var name = ParseIdentifier(); + var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + { + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } + + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } + + return null; + }); + + if (wasExpression) + { + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query, null, Materialized: isMaterialized, true, true); + } + else + { + var query = ExpectParens(() => ParseQuery()); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } + } + else + { + var name = ParseIdentifier(); + var columns = ParseTableAliasColumnDefs(); + if (columns != null && !columns.Any()) + { + columns = null; + } + + ExpectKeyword(Keyword.AS); + CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + { + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } + + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } + + return null; + }); + var query = ExpectParens(() => ParseQuery()); + + var alias = new TableAlias(name, columns); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } + + if (ParseKeyword(Keyword.FROM)) + { + cte.From = ParseIdentifier(); + } + + return cte; + } + else + { + var name = ParseIdentifier(); CommonTableExpression? cte; @@ -4527,6 +4606,7 @@ public CommonTableExpression ParseCommonTableExpression() } return cte; + } } private bool IsExpression() @@ -4548,15 +4628,17 @@ private bool IsExpression() } var peekedToken = PeekNthToken(1); - if (peekedToken is LeftParen) + if (peekedToken is not LeftParen) { - return true; + return false; } return true; } - - return false; + else + { + return false; + } } From 9278f9c51b6f9c45ddaeff9de44d03d233ed165d Mon Sep 17 00:00:00 2001 From: Makar Date: Tue, 15 Jul 2025 15:11:44 +0300 Subject: [PATCH 06/12] IsScalar check, clickhouse tests --- .../Dialects/ClickhouseDialectTests.cs | 59 +++--- src/SqlParser/Parser.cs | 173 ++++++++++-------- 2 files changed, 127 insertions(+), 105 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index fddea76..4b88bec 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -975,21 +975,25 @@ public void Parse_ClickHouse_Alternative_With_Syntax() VerifiedStatement(standardSql2, DefaultDialects!); var clickhouseSql = "WITH (SELECT 1 AS col) AS test SELECT * FROM test"; - var expectedCanonical = "WITH test AS (SELECT 1 AS col) SELECT * FROM test"; - OneStatementParsesTo(clickhouseSql, expectedCanonical, DefaultDialects!); + VerifiedStatement(clickhouseSql, DefaultDialects!); } [Fact] public void Parse_With_Expression_Main_Common_Dialect_As_Test() { - var sql = "WITH current_time AS now() SELECT * FROM current_time"; - VerifiedStatement(sql, DefaultDialects!); + IEnumerable dialects = new List + { + new MySqlDialect(), + new ClickHouseDialect() + }; + var sql = "WITH current_time AS NOW() SELECT * FROM current_time"; + VerifiedStatement(sql, dialects); } [Fact] public void Parse_With_Expression_Main_Test() { - var sql = "WITH now() AS current_time SELECT * FROM current_time"; + var sql = "WITH NOW() AS current_time SELECT current_time"; VerifiedStatement(sql, DefaultDialects!); } @@ -1003,7 +1007,7 @@ public void Parse_Select_Without_Function() [Fact] public void Parse_Clickhouse_As_Select_Without_Function() { - var sql = "WITH (SELECT 1) AS SELECT_TEST SELECT * FROM SELECT_TEST"; + var sql = "WITH SELECT_TEST AS (SELECT 1 AS value) SELECT value FROM SELECT_TEST"; VerifiedStatement(sql, DefaultDialects!); } @@ -1017,9 +1021,8 @@ public void Parse_Function() [Fact] public void Parse_With_Expression() { - var sql = "With (select uniq(player_id) FROM (select player_id from mw2.registration where date >= '2021-02-17' and date <= '2021-02-26' and player_install_source IN ('', 'None') group by player_id)) as All_players Select All_players"; - var expectedCanonical = "WITH All_players AS (SELECT uniq(player_id) FROM (SELECT player_id FROM mw2.registration WHERE date >= '2021-02-17' AND date <= '2021-02-26' AND player_install_source IN ('', 'None') GROUP BY player_id)) SELECT All_players"; - OneStatementParsesTo(sql, expectedCanonical, DefaultDialects!); + var sql = "WITH (SELECT uniq(player_id) FROM (SELECT player_id FROM mw2.registration WHERE date >= '2021-02-17' AND date <= '2021-02-26' AND player_install_source IN ('', 'None') GROUP BY player_id)) AS All_players SELECT All_players"; + VerifiedStatement(sql, DefaultDialects!); } @@ -1036,16 +1039,29 @@ public void Parse_With_Missing_Select() [Fact] public void Parse_Inner_With() { - // Test 1: WITH inside CTE definition - var sql = "WITH city_table AS(WITH (POPULATION - 10000) AS new_pop SELECT NAME, new_pop AS POP FROM (SELECT NAME, POPULATION FROM CITY) AS base_city) SELECT POP FROM city_table"; + var sql = "WITH outer_cte AS (WITH inner_value AS (SELECT 1 AS val) SELECT val FROM inner_value) SELECT * FROM outer_cte"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + //TODO: + public void Parse_Inner_With_() + { + var sql = "WITH city_table AS (WITH new_pop AS (POPULATION - 10000) SELECT NAME, new_pop AS POP FROM (SELECT NAME, POPULATION FROM CITY) AS base_city) SELECT POP FROM city_table"; VerifiedStatement(sql, DefaultDialects!); } [Fact] public void Parse_With_In_Subqueries() { - // Test 2: WITH inside FROM subquery - var sql = "SELECT * FROM (WITH (1) AS x SELECT x) AS subquery"; + var sql = "SELECT * FROM (WITH x AS (SELECT 1) SELECT x) AS subquery"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Subqueries_Common() + { + var sql = "SELECT * FROM (WITH (SELECT 1) AS x SELECT x) AS subquery"; VerifiedStatement(sql, DefaultDialects!); } @@ -1117,8 +1133,7 @@ public void Parse_With_In_Union() public void Parse_Multiple_With_Expressions() { var sql = "WITH (SELECT 1) AS a, (SELECT 2) AS b SELECT a, b"; - var expectedCanonical = "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT a, b"; - OneStatementParsesTo(sql, expectedCanonical, DefaultDialects!); + VerifiedStatement(sql, DefaultDialects!); } [Fact] @@ -1150,18 +1165,4 @@ public void Parse_With_Expression_With_Parenthesis() var missingParenthesis = "WITH neighbor(player_id, -1) AS sql_identifier SELECT * FROM sql_identifier"; VerifiedStatement(missingParenthesis, DefaultDialects!); } - - - [Fact] - public void Parse_With_Expression_Without_Parenthesis() - { - var sql = "WITH (now()) AS current_time SELECT current_time"; - VerifiedStatement(sql, DefaultDialects!); - - var sql2 = "WITH (arrayJoin([1,2,3])) AS arr_val SELECT arr_val"; - VerifiedStatement(sql2, DefaultDialects!); - - var parenthesisExample = "WITH (neighbor(player_id, -1)) AS sql_identifier SELECT * FROM sql_identifier"; - VerifiedStatement(parenthesisExample, DefaultDialects!); - } } \ No newline at end of file diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index a48c43a..5144ee7 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -4456,9 +4456,20 @@ KillType ParseMutation() public CommonTableExpression ParseCommonTableExpression() { var wasExpression = IsExpression(); - if (_dialect is ClickHouseDialect && wasExpression) + var isScalarSubquery = IsScalarSubquery(); + if (_dialect is ClickHouseDialect && (wasExpression ^ isScalarSubquery)) { - var expression = ParseExpr(); + return ParseCommonTableExpression_ClickhouseQuery(); + } + else + { + return ParseCommonTableExpression_CommonQuery(); + } + } + + public CommonTableExpression ParseCommonTableExpression_ClickhouseQuery() + { + var expression = ParseExpr(); CommonTableExpression? cte; @@ -4480,20 +4491,10 @@ public CommonTableExpression ParseCommonTableExpression() return null; }); - if (wasExpression) - { - var expressionBody = new SetExpression.ExpressionOnly(expression); - var query = new Query(expressionBody); - - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query, null, Materialized: isMaterialized, true, true); - } - else - { - var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query, null, Materialized: isMaterialized, true, true); } else { @@ -4531,84 +4532,83 @@ public CommonTableExpression ParseCommonTableExpression() } return cte; - } - else - { - var name = ParseIdentifier(); + } - CommonTableExpression? cte; + public CommonTableExpression ParseCommonTableExpression_CommonQuery() + { + var name = ParseIdentifier(); + CommonTableExpression? cte; - if (ParseKeyword(Keyword.AS)) - { - var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + if (ParseKeyword(Keyword.AS)) { - if (ParseKeyword(Keyword.MATERIALIZED)) + var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - return CteAsMaterialized.Materialized; - } + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) - { - return CteAsMaterialized.NotMaterialized; - } + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } - return null; - }); - - if (IsExpression()) - { - var expression = ParseExpr(); + return null; + }); - var expressionBody = new SetExpression.ExpressionOnly(expression); - var query = new Query(expressionBody); - - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query,null, Materialized: isMaterialized, true); + if (IsExpression()) + { + var expression = ParseExpr(); + + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query,null, Materialized: isMaterialized, true); + } + else + { + var query = ExpectParens(() => ParseQuery()); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } } else { - var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } - } - else - { - var columns = ParseTableAliasColumnDefs(); - if (columns != null && !columns.Any()) - { - columns = null; - } - - ExpectKeyword(Keyword.AS); - CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => - { - if (ParseKeyword(Keyword.MATERIALIZED)) + var columns = ParseTableAliasColumnDefs(); + if (columns != null && !columns.Any()) { - return CteAsMaterialized.Materialized; + columns = null; } - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + + ExpectKeyword(Keyword.AS); + CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - return CteAsMaterialized.NotMaterialized; - } + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } - return null; - }); - var query = ExpectParens(() => ParseQuery()); + return null; + }); + var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name, columns); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } + var alias = new TableAlias(name, columns); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } - if (ParseKeyword(Keyword.FROM)) - { - cte.From = ParseIdentifier(); - } + if (ParseKeyword(Keyword.FROM)) + { + cte.From = ParseIdentifier(); + } - return cte; - } + return cte; } - + private bool IsExpression() { var token = PeekToken(); @@ -4640,6 +4640,27 @@ private bool IsExpression() return false; } } + + private bool IsScalarSubquery() + { + var token = PeekToken(); + + if (token is LeftParen) + { + var nextToken = PeekNthToken(1); + + if (nextToken is Word word) + { + var keyword = word.Keyword; + if (keyword == Keyword.SELECT || keyword == Keyword.WITH) + { + return true; + } + } + } + + return false; + } /// From 4f0ec719226ee8a2275e8666d691ddc13e58a49a Mon Sep 17 00:00:00 2001 From: Makar Date: Fri, 18 Jul 2025 13:36:35 +0300 Subject: [PATCH 07/12] Tests, minus with --- .../Dialects/ClickhouseDialectTests.cs | 116 ++++++-- src/SqlParser/Parser.cs | 247 ++++++++++-------- 2 files changed, 228 insertions(+), 135 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index 4b88bec..92a7e07 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -997,6 +997,13 @@ public void Parse_With_Expression_Main_Test() VerifiedStatement(sql, DefaultDialects!); } + [Fact] + public void Parse_With_Expression_Main_Test_With_Arguments() + { + var sql = "WITH DATEADD('hour', 1, NOW()) AS current_time SELECT current_time"; + VerifiedStatement(sql, DefaultDialects!); + } + [Fact] public void Parse_Select_Without_Function() { @@ -1044,10 +1051,9 @@ public void Parse_Inner_With() } [Fact] - //TODO: public void Parse_Inner_With_() { - var sql = "WITH city_table AS (WITH new_pop AS (POPULATION - 10000) SELECT NAME, new_pop AS POP FROM (SELECT NAME, POPULATION FROM CITY) AS base_city) SELECT POP FROM city_table"; + var sql = "WITH city_table AS (WITH new_pop AS (SELECT POPULATION - 10000) SELECT NAME, new_pop AS POP FROM (SELECT NAME, POPULATION FROM CITY) AS base_city) SELECT POP FROM city_table"; VerifiedStatement(sql, DefaultDialects!); } @@ -1069,18 +1075,18 @@ public void Parse_With_In_Subqueries_Common() public void Parse_With_In_Set_Operations() { // WITH in UNION subquery - var sql1 = "SELECT 1 UNION ALL (WITH (2) AS val SELECT val)"; + var sql1 = "SELECT 1 UNION ALL (WITH (SELECT 2) AS val SELECT val)"; VerifiedStatement(sql1, DefaultDialects!); // WITH in both sides of UNION - var sql2 = "(WITH (1) AS a SELECT a) UNION ALL (WITH (2) AS b SELECT b)"; + var sql2 = "(WITH (SELECT 1) AS a SELECT a) UNION ALL (WITH (SELECT 2) AS b SELECT b)"; VerifiedStatement(sql2, DefaultDialects!); } [Fact] public void Parse_With_In_Predicate_Subqueries() { - var sql1 = "SELECT * FROM table1 WHERE EXISTS (WITH (1) AS x SELECT x)"; + var sql1 = "SELECT * FROM table1 WHERE EXISTS (WITH (SELECT 1) AS x SELECT x)"; VerifiedStatement(sql1, DefaultDialects!); var sql2 = "SELECT * FROM table1 WHERE col IN (WITH (SELECT col FROM table2) AS vals SELECT vals)"; @@ -1090,52 +1096,71 @@ public void Parse_With_In_Predicate_Subqueries() [Fact] public void Parse_With_In_Scalar_Subqueries() { - var sql = "SELECT (WITH (1) AS val SELECT val) AS result FROM table1"; + var sql = "SELECT (WITH val AS (SELECT 1) SELECT val) AS result FROM table1"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_With_In_Window_Expressions() + public void Parse_With_In_Scalar_Subqueries_Common() { - var sql = "SELECT ROW_NUMBER() OVER (ORDER BY (WITH (col) AS sorted_col SELECT sorted_col)) FROM table1"; + var sql = "SELECT (WITH (SELECT 1) AS val SELECT val) AS result FROM table1"; VerifiedStatement(sql, DefaultDialects!); } + + [Fact] public void Parse_With_In_Case_Expressions() { - var sql = "SELECT CASE WHEN (WITH (1) AS val SELECT val) = 1 THEN 'one' ELSE 'other' END"; + var sql = "SELECT CASE WHEN (WITH (SELECT 1) AS val SELECT val) = 1 THEN 'one' ELSE 'other' END"; VerifiedStatement(sql, DefaultDialects!); } [Fact] public void Parse_With_In_Join_Subqueries() { - var sql = "SELECT * FROM table1 t1 JOIN (WITH (SELECT id FROM table2) AS ids SELECT * FROM ids) t2 ON t1.id = t2.id"; + var sql = "SELECT * FROM table1 AS t1 JOIN (WITH (SELECT id FROM table2) AS ids SELECT * FROM ids) AS t2 ON t1.id = t2.id"; VerifiedStatement(sql, DefaultDialects!); } + + [Fact] - public void Parse_With_Table_References() + public void Parse_With_In_Union() { - var sql = "WITH table1.column1 AS alias_col SELECT alias_col FROM table1"; + var sql = "SELECT 1 AS x UNION ALL SELECT 2 AS y"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_With_In_Union() + public void Parse_Multiple_With_Expressions_Common() { - var sql = "WITH (1) AS x SELECT x UNION ALL WITH (2) AS y SELECT y"; + var sql = "WITH (SELECT 1) AS a, (SELECT 2) AS b SELECT a, b"; VerifiedStatement(sql, DefaultDialects!); } [Fact] public void Parse_Multiple_With_Expressions() { - var sql = "WITH (SELECT 1) AS a, (SELECT 2) AS b SELECT a, b"; + var sql = "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT a, b"; VerifiedStatement(sql, DefaultDialects!); } + + + [Fact] + public void Parse_With_Expression_With_Parenthesis() + { + var sql = "WITH now() AS current_time SELECT current_time"; + VerifiedStatement(sql, DefaultDialects!); + + var sql2 = "WITH arrayJoin([1, 2, 3]) AS arr_val SELECT arr_val"; + VerifiedStatement(sql2, DefaultDialects!); + + var missingParenthesis = "WITH neighbor(player_id, -1) AS sql_identifier SELECT * FROM sql_identifier"; + VerifiedStatement(missingParenthesis, DefaultDialects!); + } + [Fact] public void Parse_With_Case_Expressions() { @@ -1154,15 +1179,60 @@ public void Parse_With_Array_And_Tuple_Expressions() } [Fact] - public void Parse_With_Expression_With_Parenthesis() + public void Private_Test_Case_Expected_Join_Table() { - var sql = "WITH now() AS current_time SELECT current_time"; + var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date,player_id, retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN\n retention AS rr, [0, 1, 3, 7, 14, 28] AS day)GROUP BY t ORDER BY t ASC FORMAT JSON"; VerifiedStatement(sql, DefaultDialects!); - - var sql2 = "WITH arrayJoin([1, 2, 3]) AS arr_val SELECT arr_val"; - VerifiedStatement(sql2, DefaultDialects!); - - var missingParenthesis = "WITH neighbor(player_id, -1) AS sql_identifier SELECT * FROM sql_identifier"; - VerifiedStatement(missingParenthesis, DefaultDialects!); + } + + [Fact] + public void Private_Test_Case_Expected_Right_FoundLeft() + { + var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(cohort_day), visit_users / total_users)) FROM (WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT install_date, cohort_day, uniqExactIf(player_id, visit_day = cohort_day) AS visit_users, uniqExactIf(player_id, visit_day = 0) AS total_users FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day WHERE date BETWEEN toDate(1741163034) AND toDate(1748935434) + toIntervalDay(28) AND install_date BETWEEN toDate(1741163034) AND toDate(1748935434) GROUP BY install_date, cohort_day ORDER BY install_date, cohort_day) GROUP BY t ORDER BY t ASC FORMAT JSON"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void With_Substraction() + { + var sql = "WITH visit_day AS date - install_date SELECT visit_day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void With_Substraction_Common() + { + var sql = "WITH date - install_date AS visit_day SELECT visit_day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void With_Substraction2_Common() + { + var sql = "WITH toDate('2024-06-01') AS date, toDate('2024-05-25') AS install_date, date - install_date AS visit_day SELECT date, install_date, visit_day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_Table_References() + { + var sql = "WITH table1.column1 AS alias_col SELECT alias_col FROM table1"; + VerifiedStatement(sql, DefaultDialects!); + } + + + //TODO: Both can be parsed though ParseCommonTableExpression_ClickhouseQuery, but needs better detection method for col or sorted_col in order to not break other tests + [Fact] + public void Parse_With_In_Window_Expressions_Common() + { + var sql = "SELECT ROW_NUMBER() OVER (ORDER BY (WITH col AS sorted_col SELECT sorted_col)) FROM table1"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_With_In_Window_Expressions_Clickhouse() + { + var sql = "SELECT ROW_NUMBER() OVER (ORDER BY (WITH sorted_col AS col SELECT sorted_col)) FROM table1"; + VerifiedStatement(sql, DefaultDialects!); } } \ No newline at end of file diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index 5144ee7..27cef65 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -4455,9 +4455,11 @@ KillType ParseMutation() public CommonTableExpression ParseCommonTableExpression() { - var wasExpression = IsExpression(); + var isExpression = IsExpression(); var isScalarSubquery = IsScalarSubquery(); - if (_dialect is ClickHouseDialect && (wasExpression ^ isScalarSubquery)) + var isAlias = IsAlias(); + + if (_dialect is ClickHouseDialect && (isExpression || isScalarSubquery)) { return ParseCommonTableExpression_ClickhouseQuery(); } @@ -4467,146 +4469,146 @@ public CommonTableExpression ParseCommonTableExpression() } } - public CommonTableExpression ParseCommonTableExpression_ClickhouseQuery() + private CommonTableExpression ParseCommonTableExpression_ClickhouseQuery() { var expression = ParseExpr(); - CommonTableExpression? cte; + CommonTableExpression? cte; - if (ParseKeyword(Keyword.AS)) + if (ParseKeyword(Keyword.AS)) + { + var name = ParseIdentifier(); + var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - var name = ParseIdentifier(); - var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + if (ParseKeyword(Keyword.MATERIALIZED)) { - if (ParseKeyword(Keyword.MATERIALIZED)) - { - return CteAsMaterialized.Materialized; - } + return CteAsMaterialized.Materialized; + } - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) - { - return CteAsMaterialized.NotMaterialized; - } + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } - return null; - }); + return null; + }); - var expressionBody = new SetExpression.ExpressionOnly(expression); - var query = new Query(expressionBody); - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query, null, Materialized: isMaterialized, true, true); + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query, null, Materialized: isMaterialized, true, true); + } + else + { + var name = ParseIdentifier(); + var columns = ParseTableAliasColumnDefs(); + if (columns != null && !columns.Any()) + { + columns = null; } - else + + ExpectKeyword(Keyword.AS); + CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - var name = ParseIdentifier(); - var columns = ParseTableAliasColumnDefs(); - if (columns != null && !columns.Any()) + if (ParseKeyword(Keyword.MATERIALIZED)) { - columns = null; + return CteAsMaterialized.Materialized; } - - ExpectKeyword(Keyword.AS); - CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) { - if (ParseKeyword(Keyword.MATERIALIZED)) - { - return CteAsMaterialized.Materialized; - } - - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) - { - return CteAsMaterialized.NotMaterialized; - } - - return null; - }); - var query = ExpectParens(() => ParseQuery()); - - var alias = new TableAlias(name, columns); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } + return CteAsMaterialized.NotMaterialized; + } + + return null; + }); + var query = ExpectParens(() => ParseQuery()); + + var alias = new TableAlias(name, columns); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } - if (ParseKeyword(Keyword.FROM)) - { - cte.From = ParseIdentifier(); - } + if (ParseKeyword(Keyword.FROM)) + { + cte.From = ParseIdentifier(); + } - return cte; + return cte; } - public CommonTableExpression ParseCommonTableExpression_CommonQuery() + private CommonTableExpression ParseCommonTableExpression_CommonQuery() { var name = ParseIdentifier(); - CommonTableExpression? cte; + CommonTableExpression? cte; - if (ParseKeyword(Keyword.AS)) + if (ParseKeyword(Keyword.AS)) + { + var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + if (ParseKeyword(Keyword.MATERIALIZED)) { - if (ParseKeyword(Keyword.MATERIALIZED)) - { - return CteAsMaterialized.Materialized; - } - - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) - { - return CteAsMaterialized.NotMaterialized; - } - - return null; - }); - - if (IsExpression()) - { - var expression = ParseExpr(); - - var expressionBody = new SetExpression.ExpressionOnly(expression); - var query = new Query(expressionBody); - - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query,null, Materialized: isMaterialized, true); + return CteAsMaterialized.Materialized; } - else + + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) { - var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + return CteAsMaterialized.NotMaterialized; } + + return null; + }); + + if (IsExpression()) + { + var expression = ParseExpr(); + + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query,null, Materialized: isMaterialized, true); } else { - var columns = ParseTableAliasColumnDefs(); - if (columns != null && !columns.Any()) + var query = ExpectParens(() => ParseQuery()); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } + } + else + { + var columns = ParseTableAliasColumnDefs(); + if (columns != null && !columns.Any()) + { + columns = null; + } + + ExpectKeyword(Keyword.AS); + CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + { + if (ParseKeyword(Keyword.MATERIALIZED)) { - columns = null; + return CteAsMaterialized.Materialized; } - - ExpectKeyword(Keyword.AS); - CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) { - if (ParseKeyword(Keyword.MATERIALIZED)) - { - return CteAsMaterialized.Materialized; - } - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) - { - return CteAsMaterialized.NotMaterialized; - } + return CteAsMaterialized.NotMaterialized; + } - return null; - }); - var query = ExpectParens(() => ParseQuery()); + return null; + }); + var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name, columns); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } + var alias = new TableAlias(name, columns); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } - if (ParseKeyword(Keyword.FROM)) - { - cte.From = ParseIdentifier(); - } + if (ParseKeyword(Keyword.FROM)) + { + cte.From = ParseIdentifier(); + } - return cte; + return cte; } private bool IsExpression() @@ -4628,12 +4630,8 @@ private bool IsExpression() } var peekedToken = PeekNthToken(1); - if (peekedToken is not LeftParen) - { - return false; - } - - return true; + + return peekedToken is LeftParen or Minus; } else { @@ -4663,6 +4661,31 @@ private bool IsScalarSubquery() } + private bool IsAlias() + { + var token = PeekToken(); + + if (token is Word word) + { + var keyword = word.Keyword; + + // Only allow non-keyword identifiers to be aliases + if (keyword != Keyword.undefined) + { + return false; + } + + var nextToken = PeekNthToken(1); + if (nextToken is Word nextWord && nextWord.Keyword == Keyword.AS) + { + return true; + } + } + + return false; + } + + /// /// Parse a `FOR JSON` clause /// From e552b3dc6498a850f3cfeeea8cee7ef4430b0318 Mon Sep 17 00:00:00 2001 From: Makar Date: Wed, 23 Jul 2025 10:28:52 +0300 Subject: [PATCH 08/12] clickhouse array join --- src/SqlParser/Ast/Join.cs | 17 +++++++++++- src/SqlParser/Ast/TableFactor.cs | 18 ++++++++++++- src/SqlParser/Parser.cs | 46 ++++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/SqlParser/Ast/Join.cs b/src/SqlParser/Ast/Join.cs index 70dec1a..6e584af 100644 --- a/src/SqlParser/Ast/Join.cs +++ b/src/SqlParser/Ast/Join.cs @@ -20,6 +20,12 @@ public void ToSql(SqlTextWriter writer) case JoinOperator.CrossJoin: writer.WriteSql($" CROSS JOIN {Relation}"); return; + case JoinOperator.InnerArrayJoin: + writer.WriteSql($" ARRAY JOIN {Relation}"); + return; + case JoinOperator.LeftArrayJoin: + writer.WriteSql($" LEFT ARRAY JOIN {Relation}"); + return; case JoinOperator.AsOf a: writer.WriteSql($" ASOF JOIN {Relation} MATCH_CONDITION ({a.MatchCondition})"); @@ -144,6 +150,15 @@ public record CrossApply : JoinOperator; /// public record OuterApply : JoinOperator; + /// + /// Inner array join + /// + public record InnerArrayJoin : JoinOperator; + /// + /// Left array join + /// + public record LeftArrayJoin : JoinOperator; + public record AsOf(Expression MatchCondition, JoinConstraint Constraint) : JoinOperator; } @@ -170,4 +185,4 @@ public record Natural : JoinConstraint; /// No join constraint /// public record None : JoinConstraint; -} \ No newline at end of file +} diff --git a/src/SqlParser/Ast/TableFactor.cs b/src/SqlParser/Ast/TableFactor.cs index d8713a1..da78cfc 100644 --- a/src/SqlParser/Ast/TableFactor.cs +++ b/src/SqlParser/Ast/TableFactor.cs @@ -312,6 +312,22 @@ public override void ToSql(SqlTextWriter writer) } } /// + /// An expression that can be used as a table factor. + /// Used for `ARRAY JOIN` in ClickHouse. + /// + /// Expression + public record ExpressionTable(Expression Expression) : TableFactor + { + public override void ToSql(SqlTextWriter writer) + { + Expression.ToSql(writer); + if (Alias != null) + { + writer.WriteSql($" AS {Alias}"); + } + } + } + /// /// Match_Recognize operation on a table /// /// Table factor @@ -384,4 +400,4 @@ public Table AsTable() } public abstract void ToSql(SqlTextWriter writer); -} \ No newline at end of file +} diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index 27cef65..acaf07f 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -5167,6 +5167,43 @@ public TableWithJoins ParseTableAndJoins() while (true) { + if (_dialect is ClickHouseDialect) + { + var left = ParseKeyword(Keyword.LEFT); + var inner = ParseKeyword(Keyword.INNER); + + if (ParseKeyword(Keyword.ARRAY)) + { + ExpectKeyword(Keyword.JOIN); + var arrayExpr = ParseExpr(); + var arrayAlias = MaybeParseTableAlias(); + var arrayRel = new TableFactor.ExpressionTable(arrayExpr) { Alias = arrayAlias }; + + if (inner && left) + { + throw new ParserException("Cannot have both LEFT and INNER for ARRAY JOIN", + PeekToken().Location); + } + + var op = left ? (JoinOperator)new JoinOperator.LeftArrayJoin() : new JoinOperator.InnerArrayJoin(); + + var item = new Join(arrayRel, op); + joins ??= []; + joins.Add(item); + continue; + } + + if (inner) + { + PrevToken(); + } + + if (left) + { + PrevToken(); + } + } + var global = ParseKeyword(Keyword.GLOBAL); Join join; @@ -5538,7 +5575,12 @@ or TableFactor.MatchRecognize var args = ParseInit(ConsumeToken(), ParseTableFunctionArgs); var ordinality = ParseKeywordSequence(Keyword.WITH, Keyword.ORDINALITY); - var optionalAlias = MaybeParseTableAlias(); + + TableAlias? optionalAlias = null; + if (!(_dialect is ClickHouseDialect && PeekToken() is Word { Keyword: Keyword.ARRAY })) + { + optionalAlias = MaybeParseTableAlias(); + } Sequence? withHints = null; if (ParseKeyword(Keyword.WITH)) @@ -6746,4 +6788,4 @@ public static string Found(Token token) } } -public record ParsedAction(Keyword Keyword, Sequence? Idents = null); \ No newline at end of file +public record ParsedAction(Keyword Keyword, Sequence? Idents = null); From 2c3cfbef030542480d591ab3dbd4f3732db548fc Mon Sep 17 00:00:00 2001 From: Makar Date: Thu, 24 Jul 2025 12:04:33 +0300 Subject: [PATCH 09/12] Parse double array join clickhouse --- .../Dialects/ClickhouseDialectTests.cs | 102 ++++++++++++++++-- src/SqlParser/Ast/TableWithJoins.cs | 20 +++- src/SqlParser/Parser.cs | 17 +-- 3 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index 92a7e07..cb79361 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -270,11 +270,11 @@ public void Parse_Create_Table_With_Nullable() var columns = new Sequence { - new ("k", new DataType.UInt8()), - new (new Ident("a", Symbols.Backtick), new DataType.Nullable(new DataType.StringType())), - new (new Ident("b", Symbols.Backtick), new DataType.Nullable(new DataType.Datetime64(9, "UTC"))), - new ("c", new DataType.Nullable(new DataType.Datetime64(9))), - new ("d", new DataType.Date32(), Options:[new ColumnOptionDef(new ColumnOption.Null())]), + new("k", new DataType.UInt8()), + new(new Ident("a", Symbols.Backtick), new DataType.Nullable(new DataType.StringType())), + new(new Ident("b", Symbols.Backtick), new DataType.Nullable(new DataType.Datetime64(9, "UTC"))), + new("c", new DataType.Nullable(new DataType.Datetime64(9))), + new("d", new DataType.Date32(), Options: [new ColumnOptionDef(new ColumnOption.Null())]), }; Assert.Equal("table", create.Name); @@ -1181,21 +1181,95 @@ public void Parse_With_Array_And_Tuple_Expressions() [Fact] public void Private_Test_Case_Expected_Join_Table() { - var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date,player_id, retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN\n retention AS rr, [0, 1, 3, 7, 14, 28] AS day)GROUP BY t ORDER BY t ASC FORMAT JSON"; + var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date,player_id, retention (visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; VerifiedStatement(sql, DefaultDialects!); - } + } + + [Fact] + public void Reduction_Test() + { + var changedOneLinesSql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date, player_id, retention (visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; + var sql = @" + SELECT + toUInt32(toDateTime(install_date)) * 1000 AS t, + groupArray(('Day ' || toString(day), rr / users)) + FROM ( + SELECT install_date, total AS users, rr, day FROM ( + + SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM ( + + WITH date - install_date AS visit_day + + SELECT install_date, + player_id, + retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r + FROM ( + + SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date + FROM mw2.pause + WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day + AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) + + UNION ALL + + SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date + FROM mw2.registration + WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day + AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) + ) + + GROUP BY install_date, player_id + ) + GROUP BY install_date + ) ARRAY JOIN + retention AS rr ARRAY JOIN + [0, 1, 3, 7, 14, 28] AS day + ) + GROUP BY t + ORDER BY t ASC FORMAT JSON + "; + VerifiedStatement(changedOneLinesSql, DefaultDialects!); + } + + [Fact] + public void Parse_Double_Array_Join() + { + var sql = "SELECT user_id, rr, day FROM my_table ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } [Fact] public void Private_Test_Case_Expected_Right_FoundLeft() { - var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(cohort_day), visit_users / total_users)) FROM (WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT install_date, cohort_day, uniqExactIf(player_id, visit_day = cohort_day) AS visit_users, uniqExactIf(player_id, visit_day = 0) AS total_users FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day WHERE date BETWEEN toDate(1741163034) AND toDate(1748935434) + toIntervalDay(28) AND install_date BETWEEN toDate(1741163034) AND toDate(1748935434) GROUP BY install_date, cohort_day ORDER BY install_date, cohort_day) GROUP BY t ORDER BY t ASC FORMAT JSON"; + var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(cohort_day), visit_users / total_users)) FROM (WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT install_date, cohort_day, uniqExactIf(player_id, visit_day = cohort_day) AS visit_users, uniqExactIf(player_id, visit_day = 0) AS total_users FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day WHERE date BETWEEN toDate(1741163034) AND toDate(1748935434) + toIntervalDay(28) AND install_date BETWEEN toDate(1741163034) AND toDate(1748935434) GROUP BY install_date, cohort_day ORDER BY install_date, cohort_day) GROUP BY t ORDER BY t ASC FORMAT JSON"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void With_Substraction() + public void Function_In_Function() { - var sql = "WITH visit_day AS date - install_date SELECT visit_day"; + var sql = "WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date SELECT * FROM install_date"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Function_In_Function_In_With_In_From() + { + var sql = "SELECT install_date FROM (WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT install_date FROM mw2.pause)"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void With_Several() + { + var sql = "WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT * FROM install_date"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Reducing_Test() + { + var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(cohort_day), visit_users / total_users)) FROM (WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT install_date, cohort_day, uniqExactIf(player_id, visit_day = cohort_day) AS visit_users, uniqExactIf(player_id, visit_day = 0) AS total_users FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day WHERE date BETWEEN toDate(1741163034) AND toDate(1748935434) + toIntervalDay(28) AND install_date BETWEEN toDate(1741163034) AND toDate(1748935434) GROUP BY install_date, cohort_day ORDER BY install_date, cohort_day) GROUP BY t ORDER BY t ASC FORMAT JSON"; VerifiedStatement(sql, DefaultDialects!); } @@ -1206,6 +1280,14 @@ public void With_Substraction_Common() VerifiedStatement(sql, DefaultDialects!); } + [Fact] + public void With_ArrayJoin_Explicit_List() + { + var sql = "SELECT cohort_day FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] public void With_Substraction2_Common() { diff --git a/src/SqlParser/Ast/TableWithJoins.cs b/src/SqlParser/Ast/TableWithJoins.cs index 28419d8..699c17b 100644 --- a/src/SqlParser/Ast/TableWithJoins.cs +++ b/src/SqlParser/Ast/TableWithJoins.cs @@ -16,7 +16,23 @@ public void ToSql(SqlTextWriter writer) if (Joins.SafeAny()) { - writer.WriteList(Joins); + Join? previousJoin = null; + + foreach (var join in Joins) + { + var isArrayJoin = join.JoinOperator is JoinOperator.InnerArrayJoin or JoinOperator.LeftArrayJoin; + var previousIsArrayJoin = previousJoin?.JoinOperator is JoinOperator.InnerArrayJoin or JoinOperator.LeftArrayJoin; + + if (isArrayJoin && previousIsArrayJoin) + { + writer.WriteSql($", {join.Relation}"); + } + else + { + writer.WriteSql($"{join}"); + } + previousJoin = join; + } } } -} \ No newline at end of file +} diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index acaf07f..b008c59 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -5175,9 +5175,6 @@ public TableWithJoins ParseTableAndJoins() if (ParseKeyword(Keyword.ARRAY)) { ExpectKeyword(Keyword.JOIN); - var arrayExpr = ParseExpr(); - var arrayAlias = MaybeParseTableAlias(); - var arrayRel = new TableFactor.ExpressionTable(arrayExpr) { Alias = arrayAlias }; if (inner && left) { @@ -5187,9 +5184,17 @@ public TableWithJoins ParseTableAndJoins() var op = left ? (JoinOperator)new JoinOperator.LeftArrayJoin() : new JoinOperator.InnerArrayJoin(); - var item = new Join(arrayRel, op); - joins ??= []; - joins.Add(item); + do + { + var arrayExpr = ParseExpr(); + var arrayAlias = MaybeParseTableAlias(); + var arrayRel = new TableFactor.ExpressionTable(arrayExpr) { Alias = arrayAlias }; + + var item = new Join(arrayRel, op); + joins ??= []; + joins.Add(item); + } while (ConsumeToken()); + continue; } From f54086b7657fff89cdbff9470eadf93aea3ea529 Mon Sep 17 00:00:00 2001 From: Makar Date: Fri, 25 Jul 2025 14:27:19 +0300 Subject: [PATCH 10/12] ARRAY JOIN clickhouse --- .../Dialects/ClickhouseDialectTests.cs | 89 +++++++++++++++++-- src/SqlParser/Ast/Join.cs | 18 ++-- src/SqlParser/Ast/TableWithJoins.cs | 18 +--- src/SqlParser/Keywords.cs | 3 +- src/SqlParser/Parser.cs | 33 ++++--- 5 files changed, 110 insertions(+), 51 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index cb79361..aa00640 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -1178,17 +1178,95 @@ public void Parse_With_Array_And_Tuple_Expressions() VerifiedStatement(sql, DefaultDialects!); } + [Fact] + public void Parse_Double_Array_Join_Inside_Subquery() + { + var sql = "SELECT rr, day FROM (SELECT retention FROM my_table) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_With_Subquery_Minimal() + { + // This should isolate the issue - minimal subquery with ARRAY JOIN + var sql = "SELECT install_date, rr, day FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_With_Nested_Subquery_Minimal() + { + // Add one more level of nesting to see if that's the issue + var sql = "SELECT install_date, rr, day FROM (SELECT install_date, retention FROM (SELECT install_date, retention FROM my_table) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_With_Group_By_Minimal() + { + // Test if GROUP BY before ARRAY JOIN causes issues + var sql = "SELECT install_date, rr, day FROM (SELECT install_date, retention FROM my_table GROUP BY install_date, player_id) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_With_Aggregation_Minimal() + { + // Test if aggregation functions cause the issue + var sql = "SELECT install_date, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_With_Complex_Select_Minimal() + { + // Test with more complex SELECT items before the subquery + var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, rr, day FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_With_Outer_Group_By_Minimal() + { + // Test if outer GROUP BY after ARRAY JOIN causes issues + var sql = "SELECT install_date, rr FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day GROUP BY install_date"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_With_Complex_Function_Minimal() + { + // Test with the groupArray function from your original query + var sql = "SELECT groupArray(('Day ' || toString(day), rr)) FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Array_Join_Triple_Nested_Minimal() + { + // Test with triple nesting like in your original query + var sql = "SELECT rr, day FROM (SELECT retention FROM (SELECT retention FROM (SELECT retention FROM my_table) GROUP BY col1) GROUP BY col2) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; + VerifiedStatement(sql, DefaultDialects!); + } + [Fact] public void Private_Test_Case_Expected_Join_Table() { - var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date,player_id, retention (visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; + var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date, player_id, retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; + VerifiedStatement(sql, DefaultDialects!); + } + + [Fact] + public void Parse_Double_Array_Join() + { + var sql = "SELECT user_id, rr, day FROM my_table ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] public void Reduction_Test() { - var changedOneLinesSql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date, player_id, retention (visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; + var changedOneLinesSql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date, player_id, retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; var sql = @" SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, @@ -1231,13 +1309,6 @@ ORDER BY t ASC FORMAT JSON VerifiedStatement(changedOneLinesSql, DefaultDialects!); } - [Fact] - public void Parse_Double_Array_Join() - { - var sql = "SELECT user_id, rr, day FROM my_table ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; - VerifiedStatement(sql, DefaultDialects!); - } - [Fact] public void Private_Test_Case_Expected_Right_FoundLeft() { diff --git a/src/SqlParser/Ast/Join.cs b/src/SqlParser/Ast/Join.cs index 6e584af..84f1630 100644 --- a/src/SqlParser/Ast/Join.cs +++ b/src/SqlParser/Ast/Join.cs @@ -20,11 +20,9 @@ public void ToSql(SqlTextWriter writer) case JoinOperator.CrossJoin: writer.WriteSql($" CROSS JOIN {Relation}"); return; - case JoinOperator.InnerArrayJoin: - writer.WriteSql($" ARRAY JOIN {Relation}"); - return; - case JoinOperator.LeftArrayJoin: - writer.WriteSql($" LEFT ARRAY JOIN {Relation}"); + case JoinOperator.ArrayJoin arrayJoin: + writer.Write(arrayJoin.Left ? " LEFT ARRAY JOIN " : " ARRAY JOIN "); + writer.WriteDelimited(arrayJoin.Relations, ", "); return; case JoinOperator.AsOf a: @@ -151,13 +149,11 @@ public record CrossApply : JoinOperator; public record OuterApply : JoinOperator; /// - /// Inner array join - /// - public record InnerArrayJoin : JoinOperator; - /// - /// Left array join + /// ARRAY JOIN operator /// - public record LeftArrayJoin : JoinOperator; + /// True if LEFT ARRAY JOIN + /// Expressions to join + public record ArrayJoin(bool Left, Sequence Relations) : JoinOperator; public record AsOf(Expression MatchCondition, JoinConstraint Constraint) : JoinOperator; } diff --git a/src/SqlParser/Ast/TableWithJoins.cs b/src/SqlParser/Ast/TableWithJoins.cs index 699c17b..df2d375 100644 --- a/src/SqlParser/Ast/TableWithJoins.cs +++ b/src/SqlParser/Ast/TableWithJoins.cs @@ -16,23 +16,7 @@ public void ToSql(SqlTextWriter writer) if (Joins.SafeAny()) { - Join? previousJoin = null; - - foreach (var join in Joins) - { - var isArrayJoin = join.JoinOperator is JoinOperator.InnerArrayJoin or JoinOperator.LeftArrayJoin; - var previousIsArrayJoin = previousJoin?.JoinOperator is JoinOperator.InnerArrayJoin or JoinOperator.LeftArrayJoin; - - if (isArrayJoin && previousIsArrayJoin) - { - writer.WriteSql($", {join.Relation}"); - } - else - { - writer.WriteSql($"{join}"); - } - previousJoin = join; - } + writer.WriteList(Joins); } } } diff --git a/src/SqlParser/Keywords.cs b/src/SqlParser/Keywords.cs index 8f46e9d..a20a79f 100644 --- a/src/SqlParser/Keywords.cs +++ b/src/SqlParser/Keywords.cs @@ -122,6 +122,7 @@ static Keywords() Keyword.AS, // TODO remove? // Reserved for snowflake MATCH_RECOGNIZE Keyword.MATCH_RECOGNIZE, + Keyword.ARRAY, ]; } @@ -892,4 +893,4 @@ public enum Keyword ZONE, undefined -} \ No newline at end of file +} diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index b008c59..ae7f8b0 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -5176,25 +5176,32 @@ public TableWithJoins ParseTableAndJoins() { ExpectKeyword(Keyword.JOIN); + var relations = new Sequence(); + do + { + var arrayExpr = ParseExpr(); + var aliasIdent = ParseOptionalAlias(System.Array.Empty()); + var arrayRel = new TableFactor.ExpressionTable(arrayExpr) + { + Alias = aliasIdent != null ? new TableAlias(aliasIdent) : null + }; + relations.Add(arrayRel); + + } while (ConsumeToken()); + + if (inner && left) { throw new ParserException("Cannot have both LEFT and INNER for ARRAY JOIN", PeekToken().Location); } + + var op = new JoinOperator.ArrayJoin(left, relations); - var op = left ? (JoinOperator)new JoinOperator.LeftArrayJoin() : new JoinOperator.InnerArrayJoin(); - - do - { - var arrayExpr = ParseExpr(); - var arrayAlias = MaybeParseTableAlias(); - var arrayRel = new TableFactor.ExpressionTable(arrayExpr) { Alias = arrayAlias }; + var item = new Join(JoinOperator: op); + joins ??= []; + joins.Add(item); - var item = new Join(arrayRel, op); - joins ??= []; - joins.Add(item); - } while (ConsumeToken()); - continue; } @@ -5208,7 +5215,7 @@ public TableWithJoins ParseTableAndJoins() PrevToken(); } } - + var global = ParseKeyword(Keyword.GLOBAL); Join join; From bda14a34d68d95730d44ff8ee22cc93f91549b1b Mon Sep 17 00:00:00 2001 From: Makar Date: Mon, 28 Jul 2025 11:25:08 +0300 Subject: [PATCH 11/12] Code cleanup --- .../Dialects/ClickhouseDialectTests.cs | 139 +--------- src/SqlParser/Ast/Join.cs | 6 - src/SqlParser/Parser.cs | 251 ++++++++---------- 3 files changed, 120 insertions(+), 276 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index aa00640..0659bff 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -1074,11 +1074,9 @@ public void Parse_With_In_Subqueries_Common() [Fact] public void Parse_With_In_Set_Operations() { - // WITH in UNION subquery var sql1 = "SELECT 1 UNION ALL (WITH (SELECT 2) AS val SELECT val)"; VerifiedStatement(sql1, DefaultDialects!); - - // WITH in both sides of UNION + var sql2 = "(WITH (SELECT 1) AS a SELECT a) UNION ALL (WITH (SELECT 2) AS b SELECT b)"; VerifiedStatement(sql2, DefaultDialects!); } @@ -1162,160 +1160,75 @@ public void Parse_With_Expression_With_Parenthesis() } [Fact] - public void Parse_With_Case_Expressions() - { - var sql = "WITH (CASE WHEN col > 10 THEN 'high' ELSE 'low' END) AS category SELECT category FROM table1"; - VerifiedStatement(sql, DefaultDialects!); - } - - [Fact] - public void Parse_With_Array_And_Tuple_Expressions() - { - var sql = "WITH ([1, 2, 3]) AS arr SELECT arr"; - VerifiedStatement(sql, DefaultDialects!); - - var sql2 = "WITH ((1, 'a')) AS tup SELECT tup"; - VerifiedStatement(sql, DefaultDialects!); - } - - [Fact] - public void Parse_Double_Array_Join_Inside_Subquery() + public void Parse_Double_Array_Join_Inside() { var sql = "SELECT rr, day FROM (SELECT retention FROM my_table) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_With_Subquery_Minimal() + public void Parse_Array_Join_With_Subquery() { - // This should isolate the issue - minimal subquery with ARRAY JOIN var sql = "SELECT install_date, rr, day FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_With_Nested_Subquery_Minimal() + public void Parse_Array_Join_With_Nested_Subquery() { - // Add one more level of nesting to see if that's the issue var sql = "SELECT install_date, rr, day FROM (SELECT install_date, retention FROM (SELECT install_date, retention FROM my_table) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_With_Group_By_Minimal() + public void Parse_Array_Join_With_Group_By() { - // Test if GROUP BY before ARRAY JOIN causes issues var sql = "SELECT install_date, rr, day FROM (SELECT install_date, retention FROM my_table GROUP BY install_date, player_id) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_With_Aggregation_Minimal() + public void Parse_Array_Join_With_Aggregation() { - // Test if aggregation functions cause the issue var sql = "SELECT install_date, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_With_Complex_Select_Minimal() + public void Parse_Array_Join_With_Complex_Select() { - // Test with more complex SELECT items before the subquery var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, rr, day FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_With_Outer_Group_By_Minimal() + public void Parse_Array_Join_With_Outer_Group_By() { - // Test if outer GROUP BY after ARRAY JOIN causes issues var sql = "SELECT install_date, rr FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day GROUP BY install_date"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_With_Complex_Function_Minimal() + public void Parse_Array_Join_With_Complex_Function() { - // Test with the groupArray function from your original query var sql = "SELECT groupArray(('Day ' || toString(day), rr)) FROM (SELECT install_date, retention FROM my_table GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void Parse_Array_Join_Triple_Nested_Minimal() + public void Parse_Array_Join_Triple_Nested() { - // Test with triple nesting like in your original query var sql = "SELECT rr, day FROM (SELECT retention FROM (SELECT retention FROM (SELECT retention FROM my_table) GROUP BY col1) GROUP BY col2) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } - [Fact] - public void Private_Test_Case_Expected_Join_Table() - { - var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date, player_id, retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; - VerifiedStatement(sql, DefaultDialects!); - } - [Fact] public void Parse_Double_Array_Join() { var sql = "SELECT user_id, rr, day FROM my_table ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day"; VerifiedStatement(sql, DefaultDialects!); } - - [Fact] - public void Reduction_Test() - { - var changedOneLinesSql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(day), rr / users)) FROM (SELECT install_date, total AS users, rr, day FROM (SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM (WITH date - install_date AS visit_day SELECT install_date, player_id, retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r FROM (SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.pause WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) UNION ALL SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date FROM mw2.registration WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429)) GROUP BY install_date, player_id) GROUP BY install_date) ARRAY JOIN retention AS rr, [0, 1, 3, 7, 14, 28] AS day) GROUP BY t ORDER BY t ASC FORMAT JSON"; - var sql = @" - SELECT - toUInt32(toDateTime(install_date)) * 1000 AS t, - groupArray(('Day ' || toString(day), rr / users)) - FROM ( - SELECT install_date, total AS users, rr, day FROM ( - - SELECT install_date, sum(r[1]) AS total, sumForEach(r) AS retention FROM ( - - WITH date - install_date AS visit_day - - SELECT install_date, - player_id, - retention(visit_day = 0, visit_day = 1, visit_day = 3, visit_day = 7, visit_day = 14, visit_day = 28) AS r - FROM ( - - SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date - FROM mw2.pause - WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day - AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) - - UNION ALL - - SELECT player_id, date, toDate(toDateTimeOrZero(player_install_date)) AS install_date - FROM mw2.registration - WHERE date BETWEEN toDate(1741163029) AND toDate(1748935429) + INTERVAL 28 day - AND install_date BETWEEN toDate(1741163029) AND toDate(1748935429) - ) - - GROUP BY install_date, player_id - ) - GROUP BY install_date - ) ARRAY JOIN - retention AS rr ARRAY JOIN - [0, 1, 3, 7, 14, 28] AS day - ) - GROUP BY t - ORDER BY t ASC FORMAT JSON - "; - VerifiedStatement(changedOneLinesSql, DefaultDialects!); - } - [Fact] - public void Private_Test_Case_Expected_Right_FoundLeft() - { - var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(cohort_day), visit_users / total_users)) FROM (WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT install_date, cohort_day, uniqExactIf(player_id, visit_day = cohort_day) AS visit_users, uniqExactIf(player_id, visit_day = 0) AS total_users FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day WHERE date BETWEEN toDate(1741163034) AND toDate(1748935434) + toIntervalDay(28) AND install_date BETWEEN toDate(1741163034) AND toDate(1748935434) GROUP BY install_date, cohort_day ORDER BY install_date, cohort_day) GROUP BY t ORDER BY t ASC FORMAT JSON"; - VerifiedStatement(sql, DefaultDialects!); - } - [Fact] public void Function_In_Function() { @@ -1345,19 +1258,18 @@ public void Reducing_Test() } [Fact] - public void With_Substraction_Common() + public void With_ArrayJoin_Explicit_List() { - var sql = "WITH date - install_date AS visit_day SELECT visit_day"; + var sql = "SELECT cohort_day FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day"; VerifiedStatement(sql, DefaultDialects!); } [Fact] - public void With_ArrayJoin_Explicit_List() + public void With_Substraction_Common() { - var sql = "SELECT cohort_day FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day"; + var sql = "WITH date - install_date AS visit_day SELECT visit_day"; VerifiedStatement(sql, DefaultDialects!); } - [Fact] public void With_Substraction2_Common() @@ -1365,27 +1277,4 @@ public void With_Substraction2_Common() var sql = "WITH toDate('2024-06-01') AS date, toDate('2024-05-25') AS install_date, date - install_date AS visit_day SELECT date, install_date, visit_day"; VerifiedStatement(sql, DefaultDialects!); } - - [Fact] - public void Parse_With_Table_References() - { - var sql = "WITH table1.column1 AS alias_col SELECT alias_col FROM table1"; - VerifiedStatement(sql, DefaultDialects!); - } - - - //TODO: Both can be parsed though ParseCommonTableExpression_ClickhouseQuery, but needs better detection method for col or sorted_col in order to not break other tests - [Fact] - public void Parse_With_In_Window_Expressions_Common() - { - var sql = "SELECT ROW_NUMBER() OVER (ORDER BY (WITH col AS sorted_col SELECT sorted_col)) FROM table1"; - VerifiedStatement(sql, DefaultDialects!); - } - - [Fact] - public void Parse_With_In_Window_Expressions_Clickhouse() - { - var sql = "SELECT ROW_NUMBER() OVER (ORDER BY (WITH sorted_col AS col SELECT sorted_col)) FROM table1"; - VerifiedStatement(sql, DefaultDialects!); - } } \ No newline at end of file diff --git a/src/SqlParser/Ast/Join.cs b/src/SqlParser/Ast/Join.cs index 84f1630..913d65a 100644 --- a/src/SqlParser/Ast/Join.cs +++ b/src/SqlParser/Ast/Join.cs @@ -147,12 +147,6 @@ public record CrossApply : JoinOperator; /// Outer apply join /// public record OuterApply : JoinOperator; - - /// - /// ARRAY JOIN operator - /// - /// True if LEFT ARRAY JOIN - /// Expressions to join public record ArrayJoin(bool Left, Sequence Relations) : JoinOperator; public record AsOf(Expression MatchCondition, JoinConstraint Constraint) : JoinOperator; diff --git a/src/SqlParser/Parser.cs b/src/SqlParser/Parser.cs index ae7f8b0..545026b 100644 --- a/src/SqlParser/Parser.cs +++ b/src/SqlParser/Parser.cs @@ -4457,158 +4457,145 @@ public CommonTableExpression ParseCommonTableExpression() { var isExpression = IsExpression(); var isScalarSubquery = IsScalarSubquery(); - var isAlias = IsAlias(); if (_dialect is ClickHouseDialect && (isExpression || isScalarSubquery)) { - return ParseCommonTableExpression_ClickhouseQuery(); - } - else - { - return ParseCommonTableExpression_CommonQuery(); - } - } + var expression = ParseExpr(); + CommonTableExpression? cte; + if (ParseKeyword(Keyword.AS)) + { + var name = ParseIdentifier(); + var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + { + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } - private CommonTableExpression ParseCommonTableExpression_ClickhouseQuery() - { - var expression = ParseExpr(); + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } - CommonTableExpression? cte; + return null; + }); - if (ParseKeyword(Keyword.AS)) - { - var name = ParseIdentifier(); - var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query, null, Materialized: isMaterialized, true, true); + } + else { - if (ParseKeyword(Keyword.MATERIALIZED)) + var name = ParseIdentifier(); + var columns = ParseTableAliasColumnDefs(); + if (columns != null && !columns.Any()) { - return CteAsMaterialized.Materialized; + columns = null; } - - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + + ExpectKeyword(Keyword.AS); + CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - return CteAsMaterialized.NotMaterialized; - } + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } + + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } + + return null; + }); + var query = ExpectParens(() => ParseQuery()); + + var alias = new TableAlias(name, columns); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } - return null; - }); + if (ParseKeyword(Keyword.FROM)) + { + cte.From = ParseIdentifier(); + } - var expressionBody = new SetExpression.ExpressionOnly(expression); - var query = new Query(expressionBody); - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query, null, Materialized: isMaterialized, true, true); + return cte; } else - { + { var name = ParseIdentifier(); - var columns = ParseTableAliasColumnDefs(); - if (columns != null && !columns.Any()) - { - columns = null; - } - - ExpectKeyword(Keyword.AS); - CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => + CommonTableExpression? cte; + + if (ParseKeyword(Keyword.AS)) { - if (ParseKeyword(Keyword.MATERIALIZED)) - { - return CteAsMaterialized.Materialized; - } - - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - return CteAsMaterialized.NotMaterialized; - } - - return null; - }); - var query = ExpectParens(() => ParseQuery()); - - var alias = new TableAlias(name, columns); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } - - if (ParseKeyword(Keyword.FROM)) - { - cte.From = ParseIdentifier(); - } - - return cte; - } + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } - private CommonTableExpression ParseCommonTableExpression_CommonQuery() - { - var name = ParseIdentifier(); - CommonTableExpression? cte; + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } - if (ParseKeyword(Keyword.AS)) - { - var isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => - { - if (ParseKeyword(Keyword.MATERIALIZED)) + return null; + }); + + if (IsExpression()) { - return CteAsMaterialized.Materialized; + var expression = ParseExpr(); + + var expressionBody = new SetExpression.ExpressionOnly(expression); + var query = new Query(expressionBody); + + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query,null, Materialized: isMaterialized, true); } - - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + else { - return CteAsMaterialized.NotMaterialized; + var query = ExpectParens(() => ParseQuery()); + var alias = new TableAlias(name); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); } - - return null; - }); - - if (IsExpression()) - { - var expression = ParseExpr(); - - var expressionBody = new SetExpression.ExpressionOnly(expression); - var query = new Query(expressionBody); - - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query,null, Materialized: isMaterialized, true); } else { - var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } - } - else - { - var columns = ParseTableAliasColumnDefs(); - if (columns != null && !columns.Any()) - { - columns = null; - } - - ExpectKeyword(Keyword.AS); - CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => - { - if (ParseKeyword(Keyword.MATERIALIZED)) + var columns = ParseTableAliasColumnDefs(); + if (columns != null && !columns.Any()) { - return CteAsMaterialized.Materialized; + columns = null; } - if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + + ExpectKeyword(Keyword.AS); + CteAsMaterialized? isMaterialized = ParseInit(_dialect is PostgreSqlDialect, () => { - return CteAsMaterialized.NotMaterialized; - } + if (ParseKeyword(Keyword.MATERIALIZED)) + { + return CteAsMaterialized.Materialized; + } + if (ParseKeywordSequence(Keyword.NOT, Keyword.MATERIALIZED)) + { + return CteAsMaterialized.NotMaterialized; + } - return null; - }); - var query = ExpectParens(() => ParseQuery()); + return null; + }); + var query = ExpectParens(() => ParseQuery()); - var alias = new TableAlias(name, columns); - cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); - } + var alias = new TableAlias(name, columns); + cte = new CommonTableExpression(alias, query.Query, Materialized: isMaterialized); + } - if (ParseKeyword(Keyword.FROM)) - { - cte.From = ParseIdentifier(); - } + if (ParseKeyword(Keyword.FROM)) + { + cte.From = ParseIdentifier(); + } - return cte; + return cte; + } } private bool IsExpression() @@ -4659,32 +4646,6 @@ private bool IsScalarSubquery() return false; } - - - private bool IsAlias() - { - var token = PeekToken(); - - if (token is Word word) - { - var keyword = word.Keyword; - - // Only allow non-keyword identifiers to be aliases - if (keyword != Keyword.undefined) - { - return false; - } - - var nextToken = PeekNthToken(1); - if (nextToken is Word nextWord && nextWord.Keyword == Keyword.AS) - { - return true; - } - } - - return false; - } - /// /// Parse a `FOR JSON` clause From f037c550ace71a2aa0c4f2635e2c46b75a5eb320 Mon Sep 17 00:00:00 2001 From: Makar Date: Mon, 28 Jul 2025 11:27:08 +0300 Subject: [PATCH 12/12] Code cleanup --- src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs index 0659bff..bd74b05 100644 --- a/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs +++ b/src/SqlParser.Tests/Dialects/ClickhouseDialectTests.cs @@ -1250,13 +1250,6 @@ public void With_Several() VerifiedStatement(sql, DefaultDialects!); } - [Fact] - public void Reducing_Test() - { - var sql = "SELECT toUInt32(toDateTime(install_date)) * 1000 AS t, groupArray(('Day ' || toString(cohort_day), visit_users / total_users)) FROM (WITH toDate(toDateTimeOrZero(player_install_date)) AS install_date, date - install_date AS visit_day SELECT install_date, cohort_day, uniqExactIf(player_id, visit_day = cohort_day) AS visit_users, uniqExactIf(player_id, visit_day = 0) AS total_users FROM mw2.pause ARRAY JOIN [0, 1, 3, 7, 14, 28] AS cohort_day WHERE date BETWEEN toDate(1741163034) AND toDate(1748935434) + toIntervalDay(28) AND install_date BETWEEN toDate(1741163034) AND toDate(1748935434) GROUP BY install_date, cohort_day ORDER BY install_date, cohort_day) GROUP BY t ORDER BY t ASC FORMAT JSON"; - VerifiedStatement(sql, DefaultDialects!); - } - [Fact] public void With_ArrayJoin_Explicit_List() {