Skip to content

Commit 513e1b2

Browse files
authored
added cloudwatch style contains operator (#5219)
1 parent ada2e34 commit 513e1b2

File tree

11 files changed

+173
-0
lines changed

11 files changed

+173
-0
lines changed

core/src/main/java/org/opensearch/sql/expression/operator/predicate/BinaryPredicateOperators.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public static void register(BuiltinFunctionRepository repository) {
5353
repository.register(greater());
5454
repository.register(gte());
5555
repository.register(like());
56+
repository.register(ilike());
5657
repository.register(notLike());
5758
repository.register(regexp());
5859
}
@@ -391,6 +392,12 @@ private static DefaultFunctionResolver like() {
391392
impl(nullMissingHandling(OperatorUtils::matches3), BOOLEAN, STRING, STRING, BOOLEAN));
392393
}
393394

395+
private static DefaultFunctionResolver ilike() {
396+
return define(
397+
BuiltinFunctionName.ILIKE.getName(),
398+
impl(nullMissingHandling(OperatorUtils::matches2), BOOLEAN, STRING, STRING));
399+
}
400+
394401
private static DefaultFunctionResolver regexp() {
395402
return define(
396403
BuiltinFunctionName.REGEXP.getName(),

docs/user/ppl/functions/condition.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,71 @@ fetched rows / total rows = 1/1
758758
+-----+
759759
```
760760

761+
## CONTAINS
762+
763+
### Description
764+
765+
Usage: `field contains 'substring'` returns TRUE if the field value contains the given substring (case-insensitive), FALSE otherwise.
766+
767+
The `contains` operator is a CloudWatch-style comparison operator that performs case-insensitive substring matching. It is sugar for an `ilike` comparison with `%substring%` wildcards.
768+
769+
Syntax: `<field> contains '<string_literal>'`
770+
771+
- The left-hand side must be a field reference.
772+
- The right-hand side must be a string literal. Using a field reference on the right-hand side will raise a semantic error.
773+
- Matching is case-insensitive.
774+
775+
**Argument type:** `STRING`
776+
**Return type:** `BOOLEAN`
777+
778+
### Example
779+
780+
Basic substring filter:
781+
782+
```ppl
783+
source=accounts
784+
| where firstname contains 'mbe'
785+
| fields firstname, age
786+
```
787+
788+
Expected output:
789+
790+
```text
791+
fetched rows / total rows = 1/1
792+
+-----------+-----+
793+
| firstname | age |
794+
|-----------+-----|
795+
| Amber | 32 |
796+
+-----------+-----+
797+
```
798+
799+
Case-insensitive matching (all of the following are equivalent):
800+
801+
```ppl ignore
802+
source=accounts | where firstname contains 'mbe'
803+
source=accounts | where firstname CONTAINS 'MBE'
804+
source=accounts | where firstname Contains 'Mbe'
805+
```
806+
807+
Combining with other conditions:
808+
809+
```ppl
810+
source=accounts
811+
| where employer contains 'ami' AND age > 30
812+
| fields firstname, employer, age
813+
```
814+
815+
Expected output:
816+
817+
```text
818+
fetched rows / total rows = 1/1
819+
+-----------+----------+-----+
820+
| firstname | employer | age |
821+
|-----------+----------+-----|
822+
| Amber | Pyrami | 32 |
823+
+-----------+----------+-----+
824+
```
825+
761826
## REGEXP_MATCH
762827

763828
### Description

docs/user/ppl/functions/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ PPL supports a wide range of built-in functions for data processing and analysis
5757
- [EARLIEST](condition.md/#earliest)
5858
- [LATEST](condition.md/#latest)
5959
- [REGEXP_MATCH](condition.md/#regexp_match)
60+
- [CONTAINS](condition.md/#contains)
6061

6162
- [Type Conversion Functions](conversion.md)
6263
- [CAST](conversion.md/#cast)

integ-test/src/test/java/org/opensearch/sql/ppl/WhereCommandIT.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,34 @@ public void testLikeOperatorCaseInsensitive() throws IOException {
144144
verifyDataRows(result3, rows("Amber"));
145145
}
146146

147+
@Test
148+
public void testContainsOperator() throws IOException {
149+
JSONObject result =
150+
executeQuery(
151+
String.format(
152+
"source=%s | where firstname contains 'mbe' | fields firstname",
153+
TEST_INDEX_ACCOUNT));
154+
verifyDataRows(result, rows("Amber"), rows("Chambers"));
155+
156+
result =
157+
executeQuery(
158+
String.format(
159+
"source=%s | where firstname contains 'zzz' | fields firstname",
160+
TEST_INDEX_ACCOUNT));
161+
assertEquals(0, result.getInt("total"));
162+
}
163+
164+
@Test
165+
public void testContainsOperatorCaseInsensitive() throws IOException {
166+
// contains uses ilike semantics - case insensitive
167+
JSONObject result =
168+
executeQuery(
169+
String.format(
170+
"source=%s | where firstname contains 'MBE' | fields firstname",
171+
TEST_INDEX_ACCOUNT));
172+
verifyDataRows(result, rows("Amber"), rows("Chambers"));
173+
}
174+
147175
@Test
148176
public void testIsNullFunction() throws IOException {
149177
JSONObject result =

language-grammar/src/main/antlr4/OpenSearchPPLLexer.g4

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ REDUCE: 'REDUCE';
413413

414414
// BOOL FUNCTIONS
415415
LIKE: 'LIKE';
416+
CONTAINS: 'CONTAINS';
416417
ISNULL: 'ISNULL';
417418
ISNOTNULL: 'ISNOTNULL';
418419
BETWEEN: 'BETWEEN';

language-grammar/src/main/antlr4/OpenSearchPPLParser.g4

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,7 @@ geoIpProperty
945945
| GREATER
946946
| NOT_GREATER
947947
| REGEXP
948+
| CONTAINS
948949
;
949950

950951
singleFieldRelevanceFunctionName

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/script/filter/FilterQueryBuilder.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public ScriptQueryUnSupportedException(String message) {
6868
.put(BuiltinFunctionName.LTE.getName(), new RangeQuery(Comparison.LTE))
6969
.put(BuiltinFunctionName.GTE.getName(), new RangeQuery(Comparison.GTE))
7070
.put(BuiltinFunctionName.LIKE.getName(), new LikeQuery())
71+
.put(BuiltinFunctionName.ILIKE.getName(), new LikeQuery())
7172
.put(BuiltinFunctionName.MATCH.getName(), new MatchQuery())
7273
.put(BuiltinFunctionName.MATCH_PHRASE.getName(), new MatchPhraseQuery())
7374
.put(BuiltinFunctionName.MATCHPHRASE.getName(), new MatchPhraseQuery())

ppl/src/main/antlr/OpenSearchPPLLexer.g4

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ CAST: 'CAST';
464464
// BOOL FUNCTIONS
465465
LIKE: 'LIKE';
466466
ILIKE: 'ILIKE';
467+
CONTAINS: 'CONTAINS';
467468
ISNULL: 'ISNULL';
468469
ISNOTNULL: 'ISNOTNULL';
469470
CIDRMATCH: 'CIDRMATCH';

ppl/src/main/antlr/OpenSearchPPLParser.g4

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,7 @@ positionFunctionName
14481448
| REGEXP
14491449
| LIKE
14501450
| ILIKE
1451+
| CONTAINS
14511452
;
14521453

14531454
singleFieldRelevanceFunctionName
@@ -1613,6 +1614,7 @@ searchableKeyWord
16131614
| ELSE
16141615
| ARROW
16151616
| BETWEEN
1617+
| CONTAINS
16161618
| EXISTS
16171619
| SOURCE
16181620
| INDEX

ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,18 @@ public UnresolvedExpression visitCompareExpr(CompareExprContext ctx) {
213213
String operator = ctx.comparisonOperator().getText();
214214
if ("==".equals(operator)) {
215215
operator = EQUAL.getName().getFunctionName();
216+
} else if ("contains".equalsIgnoreCase(operator)) {
217+
UnresolvedExpression left = visit(ctx.left);
218+
UnresolvedExpression right = visit(ctx.right);
219+
if (!(right instanceof Literal) || ((Literal) right).getType() != DataType.STRING) {
220+
throw new SemanticCheckException(
221+
"The right-hand side of 'contains' must be a string literal");
222+
}
223+
String raw = ((Literal) right).getValue().toString();
224+
String escaped = raw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
225+
String wrapped = "%" + escaped + "%";
226+
return new Compare(
227+
ILIKE.getName().getFunctionName(), left, new Literal(wrapped, DataType.STRING));
216228
} else if (LIKE.getName().getFunctionName().equalsIgnoreCase(operator)
217229
&& UnresolvedPlanHelper.isCalciteEnabled(astBuilder.getSettings())) {
218230
operator =

0 commit comments

Comments
 (0)