Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,17 @@ void populate() {
registerOperator(AND, SqlStdOperatorTable.AND);
registerOperator(OR, SqlStdOperatorTable.OR);
registerOperator(NOT, SqlStdOperatorTable.NOT);
registerOperator(ADD, SqlStdOperatorTable.PLUS);

// Register ADD (+ symbol) for numeric addition
register(
Comment thread
ahkcs marked this conversation as resolved.
ADD,
(RexBuilder builder, RexNode... args) -> builder.makeCall(SqlStdOperatorTable.PLUS, args),
new PPLTypeChecker.PPLFamilyTypeChecker(SqlTypeFamily.NUMERIC, SqlTypeFamily.NUMERIC));

// Register ADD (+ symbol) for string concatenation
registerOperator(ADD, SqlStdOperatorTable.CONCAT);

// Register ADDFUNCTION for numeric addition only
registerOperator(ADDFUNCTION, SqlStdOperatorTable.PLUS);
registerOperator(SUBTRACT, SqlStdOperatorTable.MINUS);
registerOperator(SUBTRACTFUNCTION, SqlStdOperatorTable.MINUS);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.calcite.remote;

import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK;
import static org.opensearch.sql.util.MatcherUtils.rows;
import static org.opensearch.sql.util.MatcherUtils.schema;
import static org.opensearch.sql.util.MatcherUtils.verifyDataRows;
import static org.opensearch.sql.util.MatcherUtils.verifySchema;

import java.io.IOException;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import org.opensearch.client.Request;
import org.opensearch.sql.ppl.PPLIntegTestCase;

public class CalciteEvalCommandIT extends PPLIntegTestCase {

@Override
public void init() throws Exception {
super.init();
enableCalcite();

loadIndex(Index.BANK);

// Create test data for string concatenation
Request request1 = new Request("PUT", "/test_eval/_doc/1?refresh=true");
request1.setJsonEntity("{\"name\": \"Alice\", \"age\": 25, \"title\": \"Engineer\"}");
client().performRequest(request1);

Request request2 = new Request("PUT", "/test_eval/_doc/2?refresh=true");
request2.setJsonEntity("{\"name\": \"Bob\", \"age\": 30, \"title\": \"Manager\"}");
client().performRequest(request2);

Request request3 = new Request("PUT", "/test_eval/_doc/3?refresh=true");
request3.setJsonEntity("{\"name\": \"Charlie\", \"age\": null, \"title\": \"Analyst\"}");
client().performRequest(request3);
}

@Test
public void testEvalStringConcatenation() throws IOException {
JSONObject result = executeQuery("source=test_eval | eval greeting = 'Hello ' + name");
verifySchema(
result,
schema("name", "string"),
schema("title", "string"),
schema("age", "bigint"),
schema("greeting", "string"));
verifyDataRows(
result,
rows("Alice", "Engineer", 25, "Hello Alice"),
rows("Bob", "Manager", 30, "Hello Bob"),
rows("Charlie", "Analyst", null, "Hello Charlie"));
}

@Test
public void testEvalStringConcatenationWithNullField() throws IOException {
JSONObject result =
executeQuery(
"source=test_eval | eval age_desc = 'Age: ' + CAST(age AS STRING) | fields name, age,"
+ " age_desc");
verifySchema(
result, schema("name", "string"), schema("age", "bigint"), schema("age_desc", "string"));
verifyDataRows(
result,
rows("Alice", 25, "Age: 25"),
rows("Bob", 30, "Age: 30"),
rows("Charlie", null, null));
}

@Test
public void testEvalStringConcatenationWithLiterals() throws IOException {
JSONObject result =
executeQuery(
"source=test_eval | eval full_info = 'Name: ' + name + ', Title: ' + title | fields"
+ " name, title, full_info");
verifySchema(
result, schema("name", "string"), schema("title", "string"), schema("full_info", "string"));
verifyDataRows(
result,
rows("Alice", "Engineer", "Name: Alice, Title: Engineer"),
rows("Bob", "Manager", "Name: Bob, Title: Manager"),
rows("Charlie", "Analyst", "Name: Charlie, Title: Analyst"));
}

@Test
public void testEvalStringConcatenationWithExistingData() throws IOException {
JSONObject result =
executeQuery(
String.format(
"source=%s | eval full_name = firstname + ' ' + lastname | head 3 | fields"
+ " firstname, lastname, full_name",
TEST_INDEX_BANK));
verifySchema(
result,
schema("firstname", "string"),
schema("lastname", "string"),
schema("full_name", "string"));
verifyDataRows(
result,
rows("Amber JOHnny", "Duke Willmington", "Amber JOHnny Duke Willmington"),
rows("Hattie", "Bond", "Hattie Bond"),
rows("Nanette", "Bates", "Nanette Bates"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -354,4 +354,102 @@ public void testDependedLateralEval() {
+ "GROUP BY `DEPTNO`";
verifyPPLToSparkSQL(root, expectedSparkSql);
}

@Test
public void testEvalStringConcatenationWithPlus() {
Comment thread
ahkcs marked this conversation as resolved.
Outdated
String ppl =
"source=EMP | eval full_name = ENAME + ' ' + JOB | fields EMPNO, ENAME, JOB, full_name |"
+ " head 3";
RelNode root = getRelNode(ppl);
String expectedLogical =
"LogicalSort(fetch=[3])\n"
+ " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], full_name=[||(||($1, ' '),"
+ " $2)])\n"
+ " LogicalTableScan(table=[[scott, EMP]])\n";
verifyLogical(root, expectedLogical);

String expectedSparkSql =
"SELECT `EMPNO`, `ENAME`, `JOB`, `ENAME` || ' ' || `JOB` `full_name`\n"
+ "FROM `scott`.`EMP`\n"
+ "LIMIT 3";
verifyPPLToSparkSQL(root, expectedSparkSql);
}

@Test
public void testEvalStringConcatenationWithNullableField() {

@yuancu yuancu Aug 19, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be removed since concatenating string with null operands is actually verified in IT

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

// Test with COMM field which naturally contains nulls in the EMP table
// When COMM is null, concatenation should result in null per SQL standard
String ppl =
"source=EMP | eval "
+ "comm_desc = 'Commission: ' + CAST(COMM AS STRING) "
+ "| fields EMPNO, ENAME, COMM, comm_desc | head 5";
RelNode root = getRelNode(ppl);

String expectedLogical =
"LogicalSort(fetch=[5])\n"
+ " LogicalProject(EMPNO=[$0], ENAME=[$1], COMM=[$6], "
+ "comm_desc=[||('Commission: ':VARCHAR, NUMBER_TO_STRING($6))])\n"
+ " LogicalTableScan(table=[[scott, EMP]])\n";
verifyLogical(root, expectedLogical);

String expectedSparkSql =
"SELECT `EMPNO`, `ENAME`, `COMM`, "
+ "'Commission: ' || `NUMBER_TO_STRING`(`COMM`) `comm_desc`\n"
+ "FROM `scott`.`EMP`\n"
+ "LIMIT 5";
verifyPPLToSparkSQL(root, expectedSparkSql);
}

@Test
public void testEvalStringConcatenationChained() {
// Test chained concatenation
String ppl =
"source=EMP | eval "
+ "description = ENAME + ' - ' + JOB + ' (Dept: ' + CAST(DEPTNO AS STRING) + ')' "
+ "| where EMPNO IN (7369, 7499) | fields EMPNO, description";
RelNode root = getRelNode(ppl);

String expectedLogical =
"LogicalProject(EMPNO=[$0], description=[$8])\n"
+ " LogicalFilter(condition=[SEARCH($0, Sarg[7369, 7499])])\n"
+ " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4],"
+ " SAL=[$5], COMM=[$6], DEPTNO=[$7], description=[||(||(||(||(||($1, ' - ':VARCHAR),"
+ " $2), ' (Dept: ':VARCHAR), SAFE_CAST($7)), ')')])\n"
+ " LogicalTableScan(table=[[scott, EMP]])\n";
verifyLogical(root, expectedLogical);

String expectedSparkSql =
"SELECT `EMPNO`, `description`\n"
+ "FROM (SELECT `EMPNO`, `ENAME`, `JOB`, `MGR`, `HIREDATE`, `SAL`, `COMM`, `DEPTNO`,"
+ " `ENAME` || ' - ' || `JOB` || ' (Dept: ' || SAFE_CAST(`DEPTNO` AS STRING) || ')'"
+ " `description`\n"
+ "FROM `scott`.`EMP`) `t`\n"
+ "WHERE `EMPNO` IN (7369, 7499)";
verifyPPLToSparkSQL(root, expectedSparkSql);
}

@Test
public void testEvalStringConcatenationMultipleExpressions() {
// Test multiple concatenation expressions in a single eval command
String ppl =
"source=EMP | eval "
+ "name_job = ENAME + ' - ' + JOB, "
+ "name_dept = ENAME + ' (Dept ' + CAST(DEPTNO AS STRING) + ')' "
+ "| fields EMPNO, name_job, name_dept | head 2";
RelNode root = getRelNode(ppl);

String expectedLogical =
"LogicalSort(fetch=[2])\n"
+ " LogicalProject(EMPNO=[$0], name_job=[||(||($1, ' - ':VARCHAR), $2)], "
+ "name_dept=[||(||(||($1, ' (Dept ':VARCHAR), SAFE_CAST($7)), ')')])\n"
+ " LogicalTableScan(table=[[scott, EMP]])\n";
verifyLogical(root, expectedLogical);

String expectedSparkSql =
"SELECT `EMPNO`, `ENAME` || ' - ' || `JOB` `name_job`, "
+ "`ENAME` || ' (Dept ' || SAFE_CAST(`DEPTNO` AS STRING) || ')' `name_dept`\n"
+ "FROM `scott`.`EMP`\n"
+ "LIMIT 2";
verifyPPLToSparkSQL(root, expectedSparkSql);
}
}
Loading