Repository: AfterShip/clickhouse-sql-parser
Pull request #61 (2024-03-29 11:03:47)

Add support of parsing ADD PROJECTION statement
Resolve the part of #59 

coveralls: ## Pull Request Test Coverage Report for [Build 8480245041](https://coveralls.io/builds/66604546)


### Details

* **144** of **231**   **(62.34%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.1%**) to **57.346%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [parser/ast_visitor.go](https://coveralls.io/builds/66604546/source?filename=parser%2Fast_visitor.go#L241) | 12 | 20 | 60.0%
| [parser/parser_alter.go](https://coveralls.io/builds/66604546/source?filename=parser%2Fparser_alter.go#L85) | 61 | 94 | 64.89%
| [parser/ast.go](https://coveralls.io/builds/66604546/source?filename=parser%2Fast.go#L501) | 71 | 117 | 60.68%
<!-- | **Total:** | **144** | **231** | **62.34%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/66604546/badge)](https://coveralls.io/builds/66604546) |
| :-- | --: |
| Change from base [Build 8153056278](https://coveralls.io/builds/66083504): |  0.1% |
| Covered Lines: | 5695 |
| Relevant Lines: | 9931 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/parser/ast.go b/parser/ast.go
index 47b1261..05e2c32 100644
--- a/parser/ast.go
+++ b/parser/ast.go
@@ -493,6 +493,178 @@ func (a *AlterTableAddIndex) Accept(visitor ASTVisitor) error {
 	return visitor.VisitAlterTableAddIndex(a)
 }
 
+type ProjectionOrderBy struct {
+	OrderByPos Pos
+	Columns    *ColumnExprList
+}
+
+func (p *ProjectionOrderBy) Pos() Pos {
+	return p.OrderByPos
+}
+
+func (p *ProjectionOrderBy) End() Pos {
+	return p.Columns.End()
+}
+
+func (p *ProjectionOrderBy) String(level int) string {
+	var builder strings.Builder
+	builder.WriteString("ORDER BY ")
+	builder.WriteString(p.Columns.String(level))
+	return builder.String()
+}
+
+func (p *ProjectionOrderBy) Accept(visitor ASTVisitor) error {
+	visitor.enter(p)
+	defer visitor.leave(p)
+	return visitor.VisitProjectionOrderBy(p)
+}
+
+type ProjectionSelect struct {
+	LeftParenPos  Pos
+	RightParenPos Pos
+	With          *WithExpr
+	SelectColumns *ColumnExprList
+	GroupBy       *GroupByExpr
+	OrderBy       *ProjectionOrderBy
+}
+
+func (p *ProjectionSelect) Pos() Pos {
+	return p.LeftParenPos
+
+}
+
+func (p *ProjectionSelect) End() Pos {
+	return p.RightParenPos
+}
+
+func (p *ProjectionSelect) String(level int) string {
+	var builder strings.Builder
+	builder.WriteString("(")
+	if p.With != nil {
+		builder.WriteString(p.With.String(level))
+		builder.WriteByte(' ')
+	}
+	builder.WriteString("SELECT ")
+	builder.WriteString(p.SelectColumns.String(level))
+	if p.GroupBy != nil {
+		builder.WriteString(" ")
+		builder.WriteString(p.GroupBy.String(level))
+	}
+	if p.OrderBy != nil {
+		builder.WriteString(" ")
+		builder.WriteString(p.OrderBy.String(level))
+	}
+	builder.WriteString(")")
+	return builder.String()
+}
+
+func (p *ProjectionSelect) Accept(visitor ASTVisitor) error {
+	visitor.enter(p)
+	defer visitor.leave(p)
+	if p.With != nil {
+		if err := p.With.Accept(visitor); err != nil {
+			return err
+		}
+	}
+	if err := p.SelectColumns.Accept(visitor); err != nil {
+		return err
+	}
+	if p.GroupBy != nil {
+		if err := p.GroupBy.Accept(visitor); err != nil {
+			return err
+		}
+	}
+	if p.OrderBy != nil {
+		if err := p.OrderBy.Accept(visitor); err != nil {
+			return err
+		}
+	}
+	return visitor.VisitProjectionSelect(p)
+}
+
+type TableProjection struct {
+	ProjectionPos Pos
+	Identifier    *NestedIdentifier
+	Select        *ProjectionSelect
+}
+
+func (t *TableProjection) Pos() Pos {
+	return t.ProjectionPos
+}
+
+func (t *TableProjection) End() Pos {
+	return t.Select.End()
+}
+
+func (t *TableProjection) String(level int) string {
+	var builder strings.Builder
+	builder.WriteString(t.Identifier.String(level))
+	builder.WriteString(" ")
+	builder.WriteString(t.Select.String(level))
+	return builder.String()
+}
+
+func (t *TableProjection) Accept(visitor ASTVisitor) error {
+	visitor.enter(t)
+	defer visitor.leave(t)
+	if err := t.Identifier.Accept(visitor); err != nil {
+		return err
+	}
+	if err := t.Select.Accept(visitor); err != nil {
+		return err
+	}
+	return visitor.VisitTableProjection(t)
+}
+
+type AlterTableAddProjection struct {
+	AddPos       Pos
+	StatementEnd Pos
+
+	IfNotExists     bool
+	TableProjection *TableProjection
+	After           *NestedIdentifier
+}
+
+func (a *AlterTableAddProjection) Pos() Pos {
+	return a.AddPos
+}
+
+func (a *AlterTableAddProjection) End() Pos {
+	return a.StatementEnd
+}
+
+func (a *AlterTableAddProjection) AlterType() string {
+	return "ADD_PROJECTION"
+}
+
+func (a *AlterTableAddProjection) String(level int) string {
+	var builder strings.Builder
+	builder.WriteString("ADD PROJECTION ")
+	if a.IfNotExists {
+		builder.WriteString("IF NOT EXISTS ")
+	}
+	builder.WriteString(a.TableProjection.String(level))
+	if a.After != nil {
+		builder.WriteString(" AFTER ")
+		builder.WriteString(a.After.String(level))
+	}
+	return builder.String()
+}
+
+func (a *AlterTableAddProjection) Accept(visitor ASTVisitor) error {
+	visitor.enter(a)
+	defer visitor.leave(a)
+	if err := a.TableProjection.Accept(visitor); err != nil {
+		return err
+	}
+	if a.After != nil {
+		if err := a.After.Accept(visitor); err != nil {
+			return err
+		}
+	}
+	return visitor.VisitAlterTableAddProjection(a)
+}
+
 type AlterTableDropColumn struct {
 	DropPos    Pos
 	ColumnName *NestedIdentifier
diff --git a/parser/ast_visitor.go b/parser/ast_visitor.go
index 5b1f96b..28af596 100644
--- a/parser/ast_visitor.go
+++ b/parser/ast_visitor.go
@@ -11,6 +11,10 @@ type ASTVisitor interface {
 	VisitAlterTableFreezePartition(expr *AlterTableFreezePartition) error
 	VisitAlterTableAddColumn(expr *AlterTableAddColumn) error
 	VisitAlterTableAddIndex(expr *AlterTableAddIndex) error
+	VisitAlterTableAddProjection(expr *AlterTableAddProjection) error
+	VisitTableProjection(expr *TableProjection) error
+	VisitProjectionOrderBy(expr *ProjectionOrderBy) error
+	VisitProjectionSelect(expr *ProjectionSelect) error
 	VisitAlterTableDropColumn(expr *AlterTableDropColumn) error
 	VisitAlterTableDropIndex(expr *AlterTableDropIndex) error
 	VisitAlterTableRemoveTTL(expr *AlterTableRemoveTTL) error
@@ -232,6 +236,34 @@ func (v *DefaultASTVisitor) VisitAlterTableAddIndex(expr *AlterTableAddIndex) er
 	return nil
 }
 
+func (v *DefaultASTVisitor) VisitAlterTableAddProjection(expr *AlterTableAddProjection) error {
+	if v.Visit != nil {
+		return v.Visit(expr)
+	}
+	return nil
+}
+
+func (v *DefaultASTVisitor) VisitProjectionOrderBy(expr *ProjectionOrderBy) error {
+	if v.Visit != nil {
+		return v.Visit(expr)
+	}
+	return nil
+}
+
+func (v *DefaultASTVisitor) VisitProjectionSelect(expr *ProjectionSelect) error {
+	if v.Visit != nil {
+		return v.Visit(expr)
+	}
+	return nil
+}
+
+func (v *DefaultASTVisitor) VisitTableProjection(expr *TableProjection) error {
+	if v.Visit != nil {
+		return v.Visit(expr)
+	}
+	return nil
+}
+
 func (v *DefaultASTVisitor) VisitAlterTableDropColumn(expr *AlterTableDropColumn) error {
 	if v.Visit != nil {
 		return v.Visit(expr)
diff --git a/parser/parser_alter.go b/parser/parser_alter.go
index 36a215f..91e542b 100644
--- a/parser/parser_alter.go
+++ b/parser/parser_alter.go
@@ -79,8 +79,10 @@ func (p *Parser) parseAlterTableAdd(pos Pos) (AlterTableExpr, error) {
 		return p.parseAlterTableAddColumn(pos)
 	case p.matchKeyword(KeywordIndex):
 		return p.parseAlterTableAddIndex(pos)
+	case p.matchKeyword(KeywordProjection):
+		return p.parseAlterTableAddProjection(pos)
 	default:
-		return nil, errors.New("expected token: COLUMN|INDEX")
+		return nil, errors.New("expected token: COLUMN|INDEX|PROJECTION")
 	}
 }
 
@@ -148,6 +150,106 @@ func (p *Parser) parseAlterTableAddIndex(pos Pos) (*AlterTableAddIndex, error) {
 	}, nil
 }
 
+func (p *Parser) parseProjectionOrderBy(pos Pos) (*ProjectionOrderBy, error) {
+	if err := p.consumeKeyword(KeywordOrder); err != nil {
+		return nil, err
+	}
+	if err := p.consumeKeyword(KeywordBy); err != nil {
+		return nil, err
+	}
+	columns, err := p.parseColumnExprList(p.Pos())
+	if err != nil {
+		return nil, err
+	}
+	return &ProjectionOrderBy{
+		OrderByPos: pos,
+		Columns:    columns,
+	}, nil
+}
+
+func (p *Parser) parseProjectionSelect(pos Pos) (*ProjectionSelect, error) {
+	if _, err := p.consumeTokenKind("("); err != nil {
+		return nil, err
+	}
+	withExpr, err := p.tryParseWithExpr(p.Pos())
+	if err != nil {
+		return nil, err
+	}
+	if err := p.consumeKeyword(KeywordSelect); err != nil {
+		return nil, err
+	}
+	columns, err := p.parseColumnExprList(p.Pos())
+	if err != nil {
+		return nil, err
+	}
+	groupBy, err := p.tryParseGroupByExpr(p.Pos())
+	if err != nil {
+		return nil, err
+	}
+	orderBy, err := p.parseProjectionOrderBy(p.Pos())
+	if err != nil {
+		return nil, err
+	}
+	rightParen, err := p.consumeTokenKind(")")
+	if err != nil {
+		return nil, err
+	}
+	return &ProjectionSelect{
+		LeftParenPos:  pos,
+		RightParenPos: rightParen.Pos,
+		With:          withExpr,
+		SelectColumns: columns,
+		GroupBy:       groupBy,
+		OrderBy:       orderBy,
+	}, nil
+}
+
+func (p *Parser) parseTableProjection(pos Pos) (*TableProjection, error) {
+	identifier, err := p.ParseNestedIdentifier(pos)
+	if err != nil {
+		return nil, err
+	}
+	selectExpr, err := p.parseProjectionSelect(p.Pos())
+	if err != nil {
+		return nil, err
+	}
+	return &TableProjection{
+		ProjectionPos: pos,
+		Identifier:    identifier,
+		Select:        selectExpr,
+	}, nil
+}
+
+func (p *Parser) parseAlterTableAddProjection(pos Pos) (*AlterTableAddProjection, error) {
+	if err := p.consumeKeyword(KeywordProjection); err != nil {
+		return nil, err
+	}
+
+	ifNotExists, err := p.tryParseIfNotExists()
+	if err != nil {
+		return nil, err
+	}
+	tableProjection, err := p.parseTableProjection(p.Pos())
+	if err != nil {
+		return nil, err
+	}
+	statementEnd := tableProjection.End()
+	after, err := p.tryParseAfterClause()
+	if err != nil {
+		return nil, err
+	}
+	if after != nil {
+		statementEnd = after.End()
+	}
+	return &AlterTableAddProjection{
+		AddPos:          pos,
+		StatementEnd:    statementEnd,
+		IfNotExists:     ifNotExists,
+		TableProjection: tableProjection,
+		After:           after,
+	}, nil
+}
+
 func (p *Parser) parseTableIndex(pos Pos) (*TableIndex, error) {
 	name, err := p.ParseNestedIdentifier(p.Pos())
 	if err != nil {


Test patch:
diff --git a/parser/testdata/ddl/alter_table_add_projection.sql b/parser/testdata/ddl/alter_table_add_projection.sql
new file mode 100644
index 0000000..58f0ff0
--- /dev/null
+++ b/parser/testdata/ddl/alter_table_add_projection.sql
@@ -0,0 +1,3 @@
+ALTER TABLE visits_order
+ADD PROJECTION  IF NOT EXISTS user_name_projection
+(SELECT * GROUP BY user_name ORDER BY user_name) AFTER a.user_id;
diff --git a/parser/testdata/ddl/format/alter_table_add_projection.sql b/parser/testdata/ddl/format/alter_table_add_projection.sql
new file mode 100644
index 0000000..01db416
--- /dev/null
+++ b/parser/testdata/ddl/format/alter_table_add_projection.sql
@@ -0,0 +1,9 @@
+-- Origin SQL:
+ALTER TABLE visits_order
+ADD PROJECTION  IF NOT EXISTS user_name_projection
+(SELECT * GROUP BY user_name ORDER BY user_name) AFTER a.user_id;
+
+
+-- Format SQL:
+ALTER TABLE visits_order
+ADD PROJECTION IF NOT EXISTS user_name_projection (SELECT * GROUP BY user_name ORDER BY user_name) AFTER a.user_id;
diff --git a/parser/testdata/ddl/output/alter_table_add_projection.sql.golden.json b/parser/testdata/ddl/output/alter_table_add_projection.sql.golden.json
new file mode 100644
index 0000000..b7f4c47
--- /dev/null
+++ b/parser/testdata/ddl/output/alter_table_add_projection.sql.golden.json
@@ -0,0 +1,103 @@
+[
+  {
+    "AlterPos": 0,
+    "StatementEnd": 140,
+    "TableIdentifier": {
+      "Database": null,
+      "Table": {
+        "Name": "visits_order",
+        "QuoteType": 1,
+        "NamePos": 12,
+        "NameEnd": 24
+      }
+    },
+    "OnCluster": null,
+    "AlterExprs": [
+      {
+        "AddPos": 25,
+        "StatementEnd": 140,
+        "IfNotExists": true,
+        "TableProjection": {
+          "ProjectionPos": 55,
+          "Identifier": {
+            "Ident": {
+              "Name": "user_name_projection",
+              "QuoteType": 1,
+              "NamePos": 55,
+              "NameEnd": 75
+            },
+            "DotIdent": null
+          },
+          "Select": {
+            "LeftParenPos": 76,
+            "RightParenPos": 123,
+            "With": null,
+            "SelectColumns": {
+              "ListPos": 84,
+              "ListEnd": 84,
+              "HasDistinct": false,
+              "Items": [
+                {
+                  "Name": "*",
+                  "QuoteType": 0,
+                  "NamePos": 84,
+                  "NameEnd": 84
+                }
+              ]
+            },
+            "GroupBy": {
+              "GroupByPos": 86,
+              "AggregateType": "",
+              "Expr": {
+                "ListPos": 95,
+                "ListEnd": 104,
+                "HasDistinct": false,
+                "Items": [
+                  {
+                    "Name": "user_name",
+                    "QuoteType": 1,
+                    "NamePos": 95,
+                    "NameEnd": 104
+                  }
+                ]
+              },
+              "WithCube": false,
+              "WithRollup": false,
+              "WithTotals": false
+            },
+            "OrderBy": {
+              "OrderByPos": 105,
+              "Columns": {
+                "ListPos": 114,
+                "ListEnd": 123,
+                "HasDistinct": false,
+                "Items": [
+                  {
+                    "Name": "user_name",
+                    "QuoteType": 1,
+                    "NamePos": 114,
+                    "NameEnd": 123
+                  }
+                ]
+              }
+            }
+          }
+        },
+        "After": {
+          "Ident": {
+            "Name": "a",
+            "QuoteType": 1,
+            "NamePos": 131,
+            "NameEnd": 132
+          },
+          "DotIdent": {
+            "Name": "user_id",
+            "QuoteType": 1,
+            "NamePos": 133,
+            "NameEnd": 140
+          }
+        }
+      }
+    ]
+  }
+]
\ No newline at end of file