diff --git a/README.md b/README.md
index edcbd23..08d50d7 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,7 @@ Supported highlights:
- string helpers (`SUBSTR`, `CONCAT`, `TRIM`, `REPLACE`, `LOWER`, `UPPER`),
- math (`ABS`, `CEIL`, `FLOOR`, `ROUND`, `LEAST`, `GREATEST`),
- JSON (`JSON_VALUE`),
+ - `CASE`/`WHEN`,
- and date helpers (`CURRENT_DATE`, `CURRENT_TIMESTAMP`).
- `WHERE` with comparison operators, `BETWEEN`, `IN`, `LIKE`, `IS (NOT) NULL`
- `ORDER BY`, `LIMIT`, `OFFSET`, `DISTINCT`
diff --git a/cmd/sql-to-logsql/web/ui/src/components/docs/Docs.tsx b/cmd/sql-to-logsql/web/ui/src/components/docs/Docs.tsx
index f931a6c..227495b 100644
--- a/cmd/sql-to-logsql/web/ui/src/components/docs/Docs.tsx
+++ b/cmd/sql-to-logsql/web/ui/src/components/docs/Docs.tsx
@@ -82,7 +82,7 @@ export function Docs() {
SUBSTR, CONCAT, LOWER, UPPER, TRIM, LTRIM, RTRIM, REPLACE
- LIKE, NOT LIKE, =, !=, <, >, <=, >=, BETWEEN
+ CASE/WHEN, LIKE, NOT LIKE, =, !=, <, >, <=, >=, BETWEEN
+,-, *, /, %, ^
ABS, GREATEST, LEAST, ROUND, FLOOR, CEIL, POW, LN, EXP
SUM, COUNT, MAX, MIN, AVG
diff --git a/cmd/sql-to-logsql/web/ui/src/components/sql-editor/examples.ts b/cmd/sql-to-logsql/web/ui/src/components/sql-editor/examples.ts
index 75d65ac..d9e1e41 100644
--- a/cmd/sql-to-logsql/web/ui/src/components/sql-editor/examples.ts
+++ b/cmd/sql-to-logsql/web/ui/src/components/sql-editor/examples.ts
@@ -87,6 +87,27 @@ SELECT UPPER(container), total
FROM container_stats
WHERE container IS NOT NULL
ORDER BY total DESC`,
+ },
+ {
+ id: "case",
+ title: "Case expression",
+ sql: `SELECT
+ x.severity,
+ COUNT(x.severity)
+FROM (
+ SELECT
+ CASE log.level
+ WHEN 'dpanic' THEN 'critical'
+ WHEN 'panic' THEN 'critical'
+ WHEN 'error' THEN 'high'
+ WHEN 'warn' THEN 'medium'
+ WHEN 'info' THEN 'low'
+ ELSE 'unknown'
+ END as severity
+ FROM logs
+ WHERE log.level IS NOT NULL
+) x
+GROUP BY x.severity`,
},
{
id: "subqueries",
diff --git a/lib/logsql/select.go b/lib/logsql/select.go
index 8fda47e..7e1677a 100644
--- a/lib/logsql/select.go
+++ b/lib/logsql/select.go
@@ -2193,6 +2193,117 @@ func (v *selectTranslatorVisitor) translateMathProjection(expr ast.Expr, alias s
return pipe, aliasName, nil
}
+func (v *selectTranslatorVisitor) translateCaseExpr(expr *ast.CaseExpr, alias string) ([]string, string, error) {
+ if expr == nil || len(expr.When) == 0 {
+ return nil, "", &TranslationError{
+ Code: http.StatusBadRequest,
+ Message: "translator: CASE expression requires WHEN clauses",
+ }
+ }
+
+ aliasSource := "case"
+ if rendered, err := render.Render(expr); err == nil {
+ aliasSource = rendered
+ }
+ aliasName, err := makeProjectionAlias(strings.TrimSpace(alias), "case", aliasSource)
+ if err != nil {
+ return nil, "", err
+ }
+ aliasField := formatFieldName(aliasName)
+
+ tmp := v.cloneForConditionalEvaluation()
+ conditions := make([]string, len(expr.When))
+ for i, clause := range expr.When {
+ condExpr := clause.Condition
+ if expr.Operand != nil {
+ condExpr = &ast.BinaryExpr{
+ Left: expr.Operand,
+ Operator: "=",
+ Right: clause.Condition,
+ }
+ }
+ cond, err := tmp.translateExpr(condExpr)
+ if err != nil {
+ return nil, "", err
+ }
+ conditions[i] = cond
+ }
+ prePipes := tmp.collectFilterPrefilters()
+ cleanup := tmp.collectFilterCleanup()
+
+ results := make([]string, len(expr.When))
+ for i, clause := range expr.When {
+ val, err := v.caseResultValue(clause.Result)
+ if err != nil {
+ return nil, "", err
+ }
+ results[i] = val
+ }
+
+ elseVal := quoteString("")
+ if expr.Else != nil {
+ val, err := v.caseResultValue(expr.Else)
+ if err != nil {
+ return nil, "", err
+ }
+ elseVal = val
+ }
+
+ pipes := make([]string, 0, len(prePipes)+len(expr.When)+2)
+ pipes = append(pipes, prePipes...)
+ pipes = append(pipes, fmt.Sprintf("format %s as %s", elseVal, aliasField))
+ for i := len(expr.When) - 1; i >= 0; i-- {
+ pipes = append(pipes, fmt.Sprintf("format if (%s) %s as %s", conditions[i], results[i], aliasField))
+ }
+ pipes = append(pipes, cleanup...)
+
+ return pipes, aliasName, nil
+}
+
+func (v *selectTranslatorVisitor) caseResultValue(expr ast.Expr) (string, error) {
+ switch e := expr.(type) {
+ case *ast.StringLiteral:
+ return quoteString(e.Value), nil
+ case *ast.NumericLiteral:
+ return e.Value, nil
+ case *ast.BooleanLiteral:
+ if e.Value {
+ return "true", nil
+ }
+ return "false", nil
+ case *ast.Identifier:
+ field, err := v.rawFieldName(e)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("\"<%s>\"", escapeFormatPattern(field)), nil
+ case *ast.FuncCall:
+ if v.aggResults != nil && isAggregateFunction(e) {
+ key, err := v.aggregateKeyFromFunc(e)
+ if err != nil {
+ return "", err
+ }
+ name, ok := v.aggResults[key]
+ if !ok {
+ return "", &TranslationError{
+ Code: http.StatusBadRequest,
+ Message: "translator: unknown aggregate referenced in CASE result",
+ }
+ }
+ return fmt.Sprintf("\"<%s>\"", escapeFormatPattern(formatFieldName(name))), nil
+ }
+ return "", &TranslationError{
+ Code: http.StatusBadRequest,
+ Message: fmt.Sprintf("translator: unsupported function %T in CASE result", e),
+ }
+ default:
+ return "", &TranslationError{
+ Code: http.StatusBadRequest,
+ Message: fmt.Sprintf("translator: unsupported CASE result expression %T", e),
+ }
+ }
+}
+
func (v *selectTranslatorVisitor) mathExprToString(expr ast.Expr) (string, error) {
switch e := expr.(type) {
case *ast.NumericLiteral:
@@ -2550,6 +2661,13 @@ func (v *selectTranslatorVisitor) buildProjectionPipes(columns []ast.SelectItem,
}
computedPipes = append(computedPipes, mathPipe)
fields = append(fields, formatFieldName(aliasName))
+ case *ast.CaseExpr:
+ pipes, aliasName, err := v.translateCaseExpr(expr, col.Alias)
+ if err != nil {
+ return nil, nil, err
+ }
+ computedPipes = append(computedPipes, pipes...)
+ fields = append(fields, formatFieldName(aliasName))
case *ast.StarExpr:
return nil, nil, &TranslationError{
Code: http.StatusBadRequest,
@@ -3023,6 +3141,20 @@ func (v *selectTranslatorVisitor) translateIsNullExpr(expr *ast.IsNullExpr) (str
return field + ":\"\"", nil
}
+func (v *selectTranslatorVisitor) cloneForConditionalEvaluation() *selectTranslatorVisitor {
+ clone := *v
+ clone.result = ""
+ clone.err = nil
+ clone.pendingLeftFilter = nil
+ clone.filterComputations = nil
+ clone.filterOrder = nil
+ clone.filterDelete = nil
+ clone.filterDeleteSet = nil
+ clone.aggTempDeletes = nil
+ clone.aggPreserve = nil
+ return &clone
+}
+
func (v *selectTranslatorVisitor) ensureFilterFunctionAlias(fn *ast.FuncCall) (string, error) {
if fn == nil || len(fn.Name.Parts) == 0 {
return "", &TranslationError{
diff --git a/lib/logsql/select_test.go b/lib/logsql/select_test.go
index 4aae2d0..8a79257 100644
--- a/lib/logsql/select_test.go
+++ b/lib/logsql/select_test.go
@@ -326,6 +326,22 @@ func TestToLogsQLSuccess(t *testing.T) {
sql: "SELECT REPLACE(message, 'foo', 'bar') AS updated FROM logs",
expected: "* | format \"\" as updated | replace ('foo', 'bar') at updated | fields updated",
},
+ {
+ name: "case expression",
+ sql: `SELECT CASE
+ WHEN score >= 90 THEN 'critical'
+ WHEN score >= 75 THEN 'high'
+ WHEN score >= 50 THEN 'medium'
+ WHEN score >= 25 THEN 'low'
+ ELSE 'info'
+END AS severity FROM logs`,
+ expected: "* | format \"info\" as severity | format if (score:>=25) \"low\" as severity | format if (score:>=50) \"medium\" as severity | format if (score:>=75) \"high\" as severity | format if (score:>=90) \"critical\" as severity | fields severity",
+ },
+ {
+ name: "case expression with operand",
+ sql: "SELECT CASE level WHEN 'error' THEN 'critical' WHEN 'warn' THEN 'warning' ELSE 'info' END AS sev FROM logs",
+ expected: "* | format \"info\" as sev | format if (level:warn) \"warning\" as sev | format if (level:error) \"critical\" as sev | fields sev",
+ },
{
name: "json value simple path with alias",
sql: "SELECT JSON_VALUE(payload, '$.ip') AS ip FROM logs",
diff --git a/lib/sql/parser/parser.go b/lib/sql/parser/parser.go
index d93423c..4dd1358 100644
--- a/lib/sql/parser/parser.go
+++ b/lib/sql/parser/parser.go
@@ -738,6 +738,8 @@ func (p *Parser) parseExpression(precedence int) ast.Expr {
left = p.parseQualifiedName()
case token.REPLACE:
left = &ast.Identifier{Parts: []string{p.curToken.Literal}}
+ case token.CASE:
+ left = p.parseCaseExpression()
case token.NUMBER:
left = &ast.NumericLiteral{Value: p.curToken.Literal}
case token.STRING:
@@ -790,13 +792,55 @@ func (p *Parser) parseExpression(precedence int) ast.Expr {
func terminatesExpression(t token.Type) bool {
switch t {
case token.SEMICOLON, token.COMMA, token.RPAREN, token.GROUP, token.ORDER, token.LIMIT, token.OFFSET,
- token.HAVING, token.UNION, token.INTERSECT, token.EXCEPT:
+ token.HAVING, token.UNION, token.INTERSECT, token.EXCEPT, token.THEN, token.ELSE, token.END:
return true
default:
return false
}
}
+func (p *Parser) parseCaseExpression() ast.Expr {
+ caseExpr := &ast.CaseExpr{}
+
+ if !p.peekTokenIs(token.WHEN) {
+ p.nextToken()
+ caseExpr.Operand = p.parseExpression(lowest)
+ if !p.expectPeek(token.WHEN) {
+ return caseExpr
+ }
+ } else {
+ p.nextToken()
+ }
+
+ for p.curTokenIs(token.WHEN) {
+ p.nextToken()
+ cond := p.parseExpression(lowest)
+ if !p.expectPeek(token.THEN) {
+ return caseExpr
+ }
+ p.nextToken()
+ result := p.parseExpression(lowest)
+ caseExpr.When = append(caseExpr.When, ast.WhenClause{
+ Condition: cond,
+ Result: result,
+ })
+ if p.peekTokenIs(token.WHEN) {
+ p.nextToken()
+ } else {
+ break
+ }
+ }
+
+ if p.peekTokenIs(token.ELSE) {
+ p.nextToken()
+ p.nextToken()
+ caseExpr.Else = p.parseExpression(lowest)
+ }
+
+ p.expectPeek(token.END)
+ return caseExpr
+}
+
func (p *Parser) parseExistsExpression(negate bool) ast.Expr {
if !p.expectPeek(token.LPAREN) {
return nil
diff --git a/lib/sql/render/render.go b/lib/sql/render/render.go
index 8415307..520803b 100644
--- a/lib/sql/render/render.go
+++ b/lib/sql/render/render.go
@@ -418,6 +418,25 @@ func (r *renderer) renderExpr(expr ast.Expr) {
r.write("EXISTS (")
r.renderSelect(e.Subquery)
r.write(")")
+ case *ast.CaseExpr:
+ r.write("CASE ")
+ if e.Operand != nil {
+ r.renderExpr(e.Operand)
+ r.write(" ")
+ }
+ for _, clause := range e.When {
+ r.write("WHEN ")
+ r.renderExpr(clause.Condition)
+ r.write(" THEN ")
+ r.renderExpr(clause.Result)
+ r.write(" ")
+ }
+ if e.Else != nil {
+ r.write("ELSE ")
+ r.renderExpr(e.Else)
+ r.write(" ")
+ }
+ r.write("END")
case *ast.SubqueryExpr:
r.write("(")
r.renderSelect(e.Select)
diff --git a/lib/sql/token/token.go b/lib/sql/token/token.go
index 32f5452..40682ae 100644
--- a/lib/sql/token/token.go
+++ b/lib/sql/token/token.go
@@ -61,6 +61,7 @@ const (
INSERT Type = "INSERT"
UPDATE Type = "UPDATE"
DELETE Type = "DELETE"
+ CASE Type = "CASE"
INTO Type = "INTO"
VALUES Type = "VALUES"
SET Type = "SET"
@@ -73,6 +74,10 @@ const (
LIMIT Type = "LIMIT"
OFFSET Type = "OFFSET"
AS Type = "AS"
+ WHEN Type = "WHEN"
+ THEN Type = "THEN"
+ ELSE Type = "ELSE"
+ END Type = "END"
IF Type = "IF"
DISTINCT Type = "DISTINCT"
WITH Type = "WITH"
@@ -124,6 +129,7 @@ var keywords = map[string]Type{
"INSERT": INSERT,
"UPDATE": UPDATE,
"DELETE": DELETE,
+ "CASE": CASE,
"INTO": INTO,
"VALUES": VALUES,
"SET": SET,
@@ -136,6 +142,10 @@ var keywords = map[string]Type{
"LIMIT": LIMIT,
"OFFSET": OFFSET,
"AS": AS,
+ "WHEN": WHEN,
+ "THEN": THEN,
+ "ELSE": ELSE,
+ "END": END,
"IF": IF,
"DISTINCT": DISTINCT,
"WITH": WITH,