Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Supported highlights:
- `SELECT` with :
- aliases,
- arithmetic expressions,
- string helpers (`SUBSTR`, `CONCAT`, `TRIM`, `REPLACE`, `LOWER`, `UPPER`),
- string helpers (`SUBSTR`, `CONCAT`, `TRIM`, `REPLACE`, `LOWER`, `UPPER`, `REGEXP_REPLACE`),
- math (`ABS`, `CEIL`, `FLOOR`, `ROUND`, `LEAST`, `GREATEST`),
- JSON (`JSON_VALUE`),
- `CASE`/`WHEN`,
Expand Down
2 changes: 1 addition & 1 deletion cmd/sql-to-logsql/web/ui/src/components/docs/Docs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function Docs() {
<AccordionContent className="flex flex-col gap-4 text-balance">
<p>
<ul className={"list-disc pl-4 pt-2"}>
<li><code>SUBSTR, CONCAT, LOWER, UPPER, TRIM, LTRIM, RTRIM, REPLACE</code></li>
<li><code>SUBSTR, CONCAT, LOWER, UPPER, TRIM, LTRIM, RTRIM, REPLACE, REGEXP_REPLACE</code></li>
<li><code>CASE/WHEN, LIKE, NOT LIKE, =, !=, &lt;, &gt;, &lt;=, &gt;=, BETWEEN</code></li>
<li><code>+,-, *, /, %, ^</code></li>
<li><code>ABS, GREATEST, LEAST, ROUND, FLOOR, CEIL, POW, LN, EXP</code></li>
Expand Down
92 changes: 92 additions & 0 deletions lib/logsql/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,9 @@ func (v *selectTranslatorVisitor) translateStringFunction(fn *ast.FuncCall, alia
case "REPLACE":
pipes, aliasName, err := v.translateReplaceFunction(fn, alias)
return pipes, aliasName, true, err
case "REGEXP_REPLACE":
pipes, aliasName, err := v.translateRegexpReplaceFunction(fn, alias)
return pipes, aliasName, true, err
case "JSON_VALUE":
pipes, aliasName, err := v.translateJSONValueFunction(fn, alias)
return pipes, aliasName, true, err
Expand Down Expand Up @@ -1829,6 +1832,37 @@ func parseSubstringIntArg(expr ast.Expr, name string) (int, error) {
return val, nil
}

func parsePositiveIntLiteral(expr ast.Expr, context string) (int, error) {
lit, ok := expr.(*ast.NumericLiteral)
if !ok {
return 0, &TranslationError{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("translator: %s must be integer literal", context),
}
}
clean := strings.ReplaceAll(strings.TrimSpace(lit.Value), "_", "")
if clean == "" || strings.ContainsAny(clean, ".eE") {
return 0, &TranslationError{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("translator: %s must be integer literal", context),
}
}
val, err := strconv.Atoi(clean)
if err != nil {
return 0, &TranslationError{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("translator: %s must be integer literal", context),
}
}
if val < 1 {
return 0, &TranslationError{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("translator: %s must be positive integer", context),
}
}
return val, nil
}

func (v *selectTranslatorVisitor) translateConcatFunction(fn *ast.FuncCall, alias string) ([]string, string, error) {
if len(fn.Args) == 0 {
return nil, "", &TranslationError{
Expand Down Expand Up @@ -1928,6 +1962,64 @@ func (v *selectTranslatorVisitor) translateReplaceFunction(fn *ast.FuncCall, ali
return []string{copyPipe, replacePipe}, aliasName, nil
}

func (v *selectTranslatorVisitor) translateRegexpReplaceFunction(fn *ast.FuncCall, alias string) ([]string, string, error) {
if len(fn.Args) < 3 || len(fn.Args) > 4 {
return nil, "", &TranslationError{
Code: http.StatusBadRequest,
Message: "translator: regexp_replace expects three or four arguments",
}
}
ident, ok := fn.Args[0].(*ast.Identifier)
if !ok {
return nil, "", &TranslationError{
Code: http.StatusBadRequest,
Message: "translator: regexp_replace only supports identifiers as first argument",
}
}
rawField, err := v.rawFieldName(ident)
if err != nil {
return nil, "", err
}
patternLit, err := literalFromExpr(fn.Args[1])
if err != nil {
return nil, "", err
}
if patternLit.kind != literalString {
return nil, "", &TranslationError{
Code: http.StatusBadRequest,
Message: "translator: regexp_replace pattern must be string literal",
}
}
replaceLit, err := literalFromExpr(fn.Args[2])
if err != nil {
return nil, "", err
}
if replaceLit.kind != literalString {
return nil, "", &TranslationError{
Code: http.StatusBadRequest,
Message: "translator: regexp_replace replacement must be string literal",
}
}

var limitClause string
if len(fn.Args) == 4 {
limit, err := parsePositiveIntLiteral(fn.Args[3], "regexp_replace limit")
if err != nil {
return nil, "", err
}
limitClause = fmt.Sprintf(" limit %d", limit)
}

aliasName, err := makeProjectionAlias(strings.TrimSpace(alias), "regexp_replace", rawField)
if err != nil {
return nil, "", err
}
pattern := fmt.Sprintf("<%s>", rawField)
copyPipe := fmt.Sprintf("format \"%s\" as %s", escapeFormatPattern(pattern), aliasName)
replacePipe := fmt.Sprintf("replace_regexp ('%s', '%s') at %s%s", escapeSingleQuotes(patternLit.value), escapeSingleQuotes(replaceLit.value), aliasName, limitClause)
return []string{copyPipe, replacePipe}, aliasName, nil
}

func (v *selectTranslatorVisitor) translateJSONValueFunction(fn *ast.FuncCall, alias string) ([]string, string, error) {
if len(fn.Args) != 2 {
return nil, "", &TranslationError{
Expand Down
64 changes: 64 additions & 0 deletions lib/logsql/select_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,16 @@ func TestToLogsQLSuccess(t *testing.T) {
sql: "SELECT REPLACE(message, 'foo', 'bar') AS updated FROM logs",
expected: "* | format \"<message>\" as updated | replace ('foo', 'bar') at updated | fields updated",
},
{
name: "regexp replace function",
sql: "SELECT REGEXP_REPLACE(message, 'host-(.+?)-foo', '$1') AS cleaned FROM logs",
expected: "* | format \"<message>\" as cleaned | replace_regexp ('host-(.+?)-foo', '$1') at cleaned | fields cleaned",
},
{
name: "regexp replace with limit and default alias",
sql: "SELECT REGEXP_REPLACE(message, 'password: [^ ]+', '', 1) FROM logs",
expected: "* | format \"<message>\" as regexp_replace_message | replace_regexp ('password: [^ ]+', '') at regexp_replace_message limit 1 | fields regexp_replace_message",
},
{
name: "case expression",
sql: `SELECT CASE
Expand Down Expand Up @@ -723,6 +733,60 @@ func TestJSONValueTranslationErrors(t *testing.T) {
}
}

func TestRegexpReplaceTranslationErrors(t *testing.T) {
t.Parallel()

tests := []struct {
name string
sql string
message string
}{
{
name: "missing arguments",
sql: "SELECT REGEXP_REPLACE(message, 'foo') FROM logs",
message: "translator: regexp_replace expects three or four arguments",
},
{
name: "non identifier first argument",
sql: "SELECT REGEXP_REPLACE('message', 'foo', 'bar') FROM logs",
message: "translator: regexp_replace only supports identifiers as first argument",
},
{
name: "pattern not string literal",
sql: "SELECT REGEXP_REPLACE(message, 1, 'bar') FROM logs",
message: "translator: regexp_replace pattern must be string literal",
},
{
name: "replacement not string literal",
sql: "SELECT REGEXP_REPLACE(message, 'foo', 1) FROM logs",
message: "translator: regexp_replace replacement must be string literal",
},
{
name: "limit not positive",
sql: "SELECT REGEXP_REPLACE(message, 'foo', 'bar', 0) FROM logs",
message: "translator: regexp_replace limit must be positive integer",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := translate(t, tt.sql)
if err == nil {
t.Fatalf("expected error for %q", tt.sql)
}
var te *logsql.TranslationError
if !errors.As(err, &te) {
t.Fatalf("expected TranslationError, got %T", err)
}
if te.Message != tt.message {
t.Fatalf("unexpected error message: want %q, got %q", tt.message, te.Message)
}
})
}
}

func TestCreateViewStoresFile(t *testing.T) {
dir := t.TempDir()
tables := map[string]string{"logs": "*"}
Expand Down