Repository: TobikoData/sqlmesh (python)

Issue:
Add support for named arguments in SQLMesh macros
There is no clean way to skip optional arguments in macros today. For example, if we want to specify an `except` value for the `@STAR` [macro](https://sqlmesh.readthedocs.io/en/latest/concepts/macros/sqlmesh_macros/#star), we also need to specify an alias, otherwise the SQL arguments won't map to the correct python arguments.

One way to address this is to add support for named arguments. We could use `PropertyEQ` (ie. `:=`) to achieve this, so the `@STAR` issue would be resolved by simply doing:

```sql
SELECT @STAR(foo, except := [a, b, c])
```

Pull request description:
feat: vararg and kwarg macro defs closes #2484

Patch:
diff --git a/docs/concepts/macros/sqlmesh_macros.md b/docs/concepts/macros/sqlmesh_macros.md
index f8d51da2..25841a23 100644
--- a/docs/concepts/macros/sqlmesh_macros.md
+++ b/docs/concepts/macros/sqlmesh_macros.md
@@ -1210,6 +1210,27 @@ SELECT
 FROM table
 ```
 
+#### Vararg and kwarg arguments
+Varargs (variable arguments) and kwargs (keyword arguments) are possible to use in Python macros definitions. Kwargs can be used in SQL with the property assignment operator `:=`. 
+
+```python linenums="1"
+from sqlmesh import macro
+
+@macro()
+def kwarg_macro(evaluator, a, b=None, c=None):
+  if b:
+    return b
+  if c:
+    return c
+  return a
+```
+
+```sql linenums="1"
+SELECT
+  @kwarg_macro('hello', c := 'world')
+FROM table
+```
+
 #### Returning more than one value
 
 Macro functions are a convenient way to tidy model code by creating multiple outputs from one function call. Python macro functions do this by returning a list of strings or SQLGlot expressions.
@@ -1412,10 +1433,10 @@ SQLMesh automatically parses strings returned by Python macro functions into [SQ
 For example, consider a macro function that uses the `BETWEEN` operator in the predicate of a `WHERE` clause. A function returning the predicate as a string might look like this, where the function arguments are substituted into a Python f-string:
 
 ```python linenums="1"
-from sqlmesh import macro
+from sqlmesh import macro, SQL
 
 @macro()
-def between_where(evaluator, column_name, low_val, high_val):
+def between_where(evaluator, column_name: SQL, low_val: SQL, high_val: SQL):
     return f"{column_name} BETWEEN {low_val} AND {high_val}"
 ```
 
diff --git a/sqlmesh/core/macros.py b/sqlmesh/core/macros.py
index 9adb9897..0d0ee8b5 100644
--- a/sqlmesh/core/macros.py
+++ b/sqlmesh/core/macros.py
@@ -190,7 +190,7 @@ class MacroEvaluator:
         if annotations:
             spec = inspect.getfullargspec(func)
             callargs = inspect.getcallargs(func, self, *args, **kwargs)
-            new_args = []
+            new_args: t.List[t.Any] = []
 
             for arg, value in callargs.items():
                 typ = annotations.get(arg)
@@ -198,11 +198,12 @@ class MacroEvaluator:
                 if value is self:
                     continue
                 if arg == spec.varargs:
-                    for v in value:
-                        new_args.append(self._coerce(v, typ))
+                    new_args.extend(self._coerce(v, typ) for v in value)
                 elif arg == spec.varkw:
                     for k, v in value.items():
                         kwargs[k] = self._coerce(v, typ)
+                elif arg in kwargs:
+                    kwargs[arg] = self._coerce(value, typ)
                 else:
                     new_args.append(self._coerce(value, typ))
 
@@ -326,7 +327,21 @@ class MacroEvaluator:
             )
         else:
             func = t.cast(exp.Anonymous, node.this)
-            result = self.send(func.name, *func.expressions)
+
+            args = []
+            kwargs = {}
+            for e in func.expressions:
+                if isinstance(e, exp.PropertyEQ):
+                    kwargs[e.this.name] = e.expression
+                else:
+                    if kwargs:
+                        raise MacroEvalError(
+                            f"Positional argument cannot follow keyword argument.\n  {func.sql(dialect=self.dialect)} at '{self._path}'"
+                        )
+
+                    args.append(e)
+
+            result = self.send(func.name, *args, **kwargs)
 
         if result is None:
             return None


Test patch:
diff --git a/tests/core/test_macros.py b/tests/core/test_macros.py
index c381a7cb..08d1106b 100644
--- a/tests/core/test_macros.py
+++ b/tests/core/test_macros.py
@@ -4,7 +4,7 @@ import pytest
 from sqlglot import MappingSchema, exp, parse_one
 
 from sqlmesh.core.dialect import StagedFilePath
-from sqlmesh.core.macros import SQL, MacroEvaluator, macro
+from sqlmesh.core.macros import SQL, MacroEvalError, MacroEvaluator, macro
 from sqlmesh.utils.errors import SQLMeshError
 from sqlmesh.utils.metaprogramming import Executable
 
@@ -40,6 +40,18 @@ def macro_evaluator() -> MacroEvaluator:
             return (expr,) * times
         return expr * times
 
+    @macro()
+    def join_str(evaluator: MacroEvaluator, arg: str, *args: str, **kwargs: str):
+        return exp.Literal.string(
+            "-".join(
+                [
+                    arg,
+                    *args,
+                    *(f"{k}:{v}" for k, v in kwargs.items()),
+                ]
+            )
+        )
+
     @macro()
     def split(evaluator: MacroEvaluator, string: str, sep: str = ","):
         return string.split(sep)
@@ -418,6 +430,16 @@ def test_ast_correctness(macro_evaluator):
                 "x": {"a": 1},
             },
         ),
+        (
+            """select @repeated(x, multi := True)""",
+            "SELECT x, x",
+            {},
+        ),
+        (
+            """select @JOIN_STR(a1, b1, c2, d := d1, e := e2)""",
+            "SELECT 'a1-b1-c2-d:d1-e:e2'",
+            {},
+        ),
     ],
 )
 def test_macro_functions(macro_evaluator: MacroEvaluator, assert_exp_eq, sql, expected, args):
@@ -506,3 +528,8 @@ def test_macro_coercion(macro_evaluator: MacroEvaluator, assert_exp_eq):
             t.Union[exp.Column, exp.Identifier],
             strict=True,
         )
+
+
+def test_positional_follows_kwargs(macro_evaluator: MacroEvaluator):
+    with pytest.raises(MacroEvalError, match="Positional argument cannot follow"):
+        macro_evaluator.evaluate(parse_one("@repeated(x, multi := True, 3)"))  # type: ignore