Repository: David-Wobrock/sqlvalidator

Issue:
`SELECT * REPLACE` isn't currently handled
Hi David,

https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#select_replace

sqlvalidator 0.0.18:

```diff
- SELECT * REPLACE('bar' AS foo)
+ SELECT * REPLACE
```

A quick unit test causes
```
self = <Alias: <Column: '*'> as=False 'REPLACE'>

    def __str__(self):
>       return str(self.value)
E       AttributeError: 'Alias' object has no attribute 'value'

../../sqlvalidator/grammar/sql.py:239: AttributeError
```
when trying to print the expression

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock) Report
> Merging [#40](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock) (95aefde) into [main](https://codecov.io/gh/David-Wobrock/sqlvalidator/commit/9705a9b4e43b2242d23eaa5922eca7900400d197?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock) (9705a9b) will **increase** coverage by `0.04%`.
> The diff coverage is `97.22%`.

```diff
@@            Coverage Diff             @@
##             main      #40      +/-   ##
==========================================
+ Coverage   92.92%   92.97%   +0.04%     
==========================================
  Files          15       15              
  Lines        3097     3131      +34     
==========================================
+ Hits         2878     2911      +33     
- Misses        219      220       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `92.97% <97.22%> (+0.04%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock) | Coverage Δ | |
|---|---|---|
| [sqlvalidator/grammar/lexer.py](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock#diff-c3FsdmFsaWRhdG9yL2dyYW1tYXIvbGV4ZXIucHk=) | `94.33% <90.00%> (-0.08%)` | :arrow_down: |
| [sqlvalidator/grammar/sql.py](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock#diff-c3FsdmFsaWRhdG9yL2dyYW1tYXIvc3FsLnB5) | `94.24% <100.00%> (+0.06%)` | :arrow_up: |
| [tests/integration/test\_formatting.py](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock#diff-dGVzdHMvaW50ZWdyYXRpb24vdGVzdF9mb3JtYXR0aW5nLnB5) | `100.00% <100.00%> (ø)` | |
| [tests/integration/test\_validation.py](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock#diff-dGVzdHMvaW50ZWdyYXRpb24vdGVzdF92YWxpZGF0aW9uLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock). Last update [9705a9b...95aefde](https://codecov.io/gh/David-Wobrock/sqlvalidator/pull/40?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=David+Wobrock).


Patch:
diff --git a/sqlvalidator/grammar/lexer.py b/sqlvalidator/grammar/lexer.py
index d48cf96..039d70c 100644
--- a/sqlvalidator/grammar/lexer.py
+++ b/sqlvalidator/grammar/lexer.py
@@ -35,6 +35,7 @@ from sqlvalidator.grammar.sql import (
     OrderByClause,
     OrderByItem,
     Parenthesis,
+    ReplaceClause,
     SelectStatement,
     String,
     Table,
@@ -895,6 +896,19 @@ class ExpressionParser:
             expression = ExceptClause(expression, arguments)
             next_token = next(tokens, None)
 
+        if lower(next_token) == "replace":
+            opening_parenthesis = next(tokens, None)
+            if opening_parenthesis != "(":
+                raise ParsingError("expected '('")
+            argument_tokens = get_tokens_until_closing_parenthesis(tokens)
+            arguments = ExpressionListParser.parse(iter(argument_tokens))
+            for arg in arguments:
+                assert (
+                    isinstance(arg, Alias) and arg.with_as is True
+                ), "SELECT * REPLACE arguments must be alias with AS"
+            expression = ReplaceClause(expression, arguments)
+            next_token = next(tokens, None)
+
         if (
             next_token is not None
             and next_token != ")"
diff --git a/sqlvalidator/grammar/sql.py b/sqlvalidator/grammar/sql.py
index 71ced3c..d2c3701 100644
--- a/sqlvalidator/grammar/sql.py
+++ b/sqlvalidator/grammar/sql.py
@@ -1529,7 +1529,9 @@ class Negation(Expression):
         return bool
 
 
-class ExceptClause(Expression):
+class SelectAllClause(Expression):
+    KEYWORD = ""
+
     def __init__(self, expression, args):
         super().__init__(expression)
         self.args = args
@@ -1540,7 +1542,7 @@ class ExceptClause(Expression):
         )
 
     def __str__(self):
-        except_str = "{} EXCEPT (".format(transform(self.value))
+        except_str = "{} {} (".format(transform(self.value), self.KEYWORD)
         if len(self.args) > 1:
             except_str += "\n {}\n".format(",\n ".join(map(transform, self.args)))
         elif except_str:
@@ -1548,6 +1550,20 @@ class ExceptClause(Expression):
         return except_str + ")"
 
 
+class ExceptClause(SelectAllClause):
+    KEYWORD = "EXCEPT"
+
+
+class ReplaceClause(SelectAllClause):
+    KEYWORD = "REPLACE"
+
+    def validate(self, known_fields: Set[_FieldInfo]) -> list:
+        errors = super().validate(known_fields)
+        for arg in self.args:
+            errors += arg.alias.validate(known_fields)
+        return errors
+
+
 class Case(Expression):
     def __init__(self, expression, when_then, else_expression):
         super().__init__(expression)


Test patch:
diff --git a/tests/integration/test_formatting.py b/tests/integration/test_formatting.py
index 9fe6087..169b28c 100644
--- a/tests/integration/test_formatting.py
+++ b/tests/integration/test_formatting.py
@@ -2152,3 +2152,33 @@ SELECT col = "Test\'"
 FROM t
 """
     assert format_sql(sql) == expected.strip()
+
+
+def test_select_star_replace():
+    sql = 'select * replace ("x" as col) from t'
+    expected = """
+SELECT * REPLACE ("x" AS col)
+FROM t
+"""
+    assert format_sql(sql) == expected.strip()
+
+
+def test_select_star_multiple_replace():
+    sql = 'select * replace ("x" as col, "y" as f) from t'
+    expected = """
+SELECT * REPLACE (
+ "x" AS col,
+ "y" AS f
+)
+FROM t
+"""
+    assert format_sql(sql) == expected.strip()
+
+
+def test_select_star_replace_nospace():
+    sql = 'select * replace("x" as col) from t'
+    expected = """
+SELECT * REPLACE ("x" AS col)
+FROM t
+"""
+    assert format_sql(sql) == expected.strip()
diff --git a/tests/integration/test_validation.py b/tests/integration/test_validation.py
index ad755bc..272da00 100644
--- a/tests/integration/test_validation.py
+++ b/tests/integration/test_validation.py
@@ -545,3 +545,19 @@ def test_between_condition_is_valid():
       f BETWEEN 200 AND 299
     """
     assert_valid_sql(sql)
+
+
+def test_select_star_replace():
+    sql = """
+SELECT * REPLACE ("x" AS col)
+FROM t
+"""
+    assert_valid_sql(sql)
+
+
+def test_select_star_unknown_column():
+    sql = """
+SELECT * REPLACE ("x" AS col)
+FROM (select field from t)
+"""
+    assert_invalid_sql(sql)