diff --git a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java index 2bc65b658f3d9..f337824cd7429 100644 --- a/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java +++ b/sandbox/libs/analytics-framework/src/main/java/org/opensearch/analytics/spi/ScalarFunction.java @@ -50,6 +50,7 @@ public enum ScalarFunction { FUZZY(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION), WILDCARD(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION), REGEXP(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION), + REGEXP_CONTAINS(Category.FULL_TEXT, SqlKind.OTHER_FUNCTION), // ── String ─────────────────────────────────────────────────────── UPPER(Category.STRING, SqlKind.OTHER_FUNCTION), diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java index 9e34579447db2..9989781a5d854 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionAnalyticsBackendPlugin.java @@ -70,6 +70,7 @@ public class DataFusionAnalyticsBackendPlugin implements AnalyticsSearchBackendP ScalarFunction.IS_NOT_NULL, ScalarFunction.IN, ScalarFunction.LIKE, + ScalarFunction.REGEXP_CONTAINS, ScalarFunction.SARG_PREDICATE, ScalarFunction.PLUS, ScalarFunction.MINUS, @@ -105,6 +106,7 @@ public class DataFusionAnalyticsBackendPlugin implements AnalyticsSearchBackendP ScalarFunction.LESS_THAN_OR_EQUAL, ScalarFunction.IN, ScalarFunction.LIKE, + ScalarFunction.REGEXP_CONTAINS, ScalarFunction.PLUS, ScalarFunction.MINUS, ScalarFunction.TIMES, diff --git a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java index e7d67bb5879cf..1382c0be89936 100644 --- a/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java +++ b/sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java @@ -85,6 +85,10 @@ public class DataFusionFragmentConvertor implements FragmentConvertor { *
Mirrors {@code CalciteRegexCommandIT} from the {@code opensearch-project/sql} repository so + * that the analytics-engine path can be verified inside core without cross-plugin dependencies on + * the SQL plugin. Each test sends a PPL query through {@code POST /_analytics/ppl} (exposed by the + * {@code test-ppl-frontend} plugin), which runs the same {@code UnifiedQueryPlanner} → + * {@code CalciteRelNodeVisitor} → Substrait → DataFusion pipeline. + * + *
Both surfaces lower to Calcite {@code SqlLibraryOperators.REGEXP_CONTAINS}: + *
Provisions the {@code calcs} dataset (parquet-backed) once per class via + * {@link DatasetProvisioner}; {@link AnalyticsRestTestCase#preserveIndicesUponCompletion()} + * keeps it across test methods. + */ +public class RegexCommandIT extends AnalyticsRestTestCase { + + private static final Dataset DATASET = new Dataset("calcs", "calcs"); + + private static boolean dataProvisioned = false; + + /** + * Lazily provision the calcs dataset on first invocation. Mirrors the + * {@code FillNullCommandIT} pattern — {@code client()} is unavailable at static init. + */ + private void ensureDataProvisioned() throws IOException { + if (dataProvisioned == false) { + DatasetProvisioner.provision(client(), DATASET); + dataProvisioned = true; + } + } + + // ── command form: positive match ──────────────────────────────────────────── + + public void testRegexExactMatchOnKeyword() throws IOException { + // str0 has 2 rows with "FURNITURE", 6 with "OFFICE SUPPLIES", 9 with "TECHNOLOGY". + assertRowCount("source=" + DATASET.indexName + " | regex str0='FURNITURE' | fields str0", 2); + } + + public void testRegexContainsSubstring() throws IOException { + // REGEXP_CONTAINS — pattern matches anywhere in the field, not anchored. + assertRowCount("source=" + DATASET.indexName + " | regex str0='OFFICE' | fields str0", 6); + } + + public void testRegexAnchoredStart() throws IOException { + // ^TECH anchors to start: only TECHNOLOGY (×9), not strings containing TECH elsewhere. + assertRowCount("source=" + DATASET.indexName + " | regex str0='^TECH' | fields str0", 9); + } + + public void testRegexAnchoredEnd() throws IOException { + // OGY$ anchors to end: TECHNOLOGY (×9). + assertRowCount("source=" + DATASET.indexName + " | regex str0='OGY$' | fields str0", 9); + } + + public void testRegexWildcardPattern() throws IOException { + // BINDER appears in BINDER ACCESSORIES + BINDER CLIPS (2 rows). + assertRowCount("source=" + DATASET.indexName + " | regex str1='BINDER' | fields str1", 2); + } + + public void testRegexCharacterClass() throws IOException { + // [BC]INDING matches BINDING (BINDING MACHINES, BINDING SUPPLIES) but not BUSINESS. + assertRowCount("source=" + DATASET.indexName + " | regex str1='BINDING' | fields str1", 2); + } + + // ── command form: negated match ───────────────────────────────────────────── + + public void testRegexNegated() throws IOException { + // 17 total rows, 2 are FURNITURE → 15 pass when negated. + assertRowCount("source=" + DATASET.indexName + " | regex str0!='FURNITURE' | fields str0", 15); + } + + public void testRegexNegatedAnchored() throws IOException { + // Negate ^OFFICE: 17 - 6 = 11 rows. + assertRowCount("source=" + DATASET.indexName + " | regex str0!='^OFFICE' | fields str0", 11); + } + + // ── command form: full row content check ──────────────────────────────────── + + public void testRegexExpectedRowsForFurniture() throws IOException { + // Verify the actual matched values, not just count, for the FURNITURE selection. + assertRows( + "source=" + DATASET.indexName + " | regex str0='FURNITURE' | fields str0, str1 | sort str1", + row("FURNITURE", "CLAMP ON LAMPS"), + row("FURNITURE", "CLOCKS") + ); + } + + // ── function form: regexp_match in eval projection (BOOLEAN result) ──────── + + public void testRegexpMatchInEvalAllTrue() throws IOException { + // regexp_match returns BOOLEAN. Pattern that matches every str0 value. + assertRowCount( + "source=" + DATASET.indexName + + " | eval m = regexp_match(str0, '.*') | where m=true | fields str0", + 17 + ); + } + + public void testRegexpMatchInEvalSelective() throws IOException { + // regexp_match selects rows whose str0 contains 'TECH' — TECHNOLOGY ×9. + assertRowCount( + "source=" + DATASET.indexName + + " | eval m = regexp_match(str0, 'TECH') | where m=true | fields str0", + 9 + ); + } + + public void testRegexpMatchProducesBooleanColumn() throws IOException { + // Project the boolean result alongside the source field — verifies REGEXP_CONTAINS + // round-trips through Substrait → DataFusion as a project-side BOOLEAN expression. + assertRows( + "source=" + DATASET.indexName + + " | regex str0='FURNITURE' | eval m = regexp_match(str1, 'CLAMP') | fields str1, m | sort str1", + row("CLAMP ON LAMPS", true), + row("CLOCKS", false) + ); + } + + // ── error path: regex on non-string field ────────────────────────────────── + + public void testRegexOnNumericFieldErrors() { + // CalciteRelNodeVisitor.visitRegex enforces SqlTypeFamily.CHARACTER on the field — + // a numeric field must fail the preflight type check, not reach DataFusion. + assertErrorContains( + "source=" + DATASET.indexName + " | regex num0='1.*'", + "Regex command requires field of string type" + ); + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + private static List