Skip to content

Commit 71aa9ba

Browse files
authored
Support pushdown physical sort operator to speedup SortMergeJoin (opensearch-project#3864)
* Support pushdown physical sort operator to speedup SortMergeJoin Signed-off-by: Lantao Jin <ltjin@amazon.com> * Enable sort pushdown when the type is TEXT without fields and fielddata=true Signed-off-by: Lantao Jin <ltjin@amazon.com> * Fix UT Signed-off-by: Lantao Jin <ltjin@amazon.com> * Add OpenSearchTextType.toKeywordSubField() Signed-off-by: Lantao Jin <ltjin@amazon.com> * Fix IT Signed-off-by: Lantao Jin <ltjin@amazon.com> * Fix IT Signed-off-by: Lantao Jin <ltjin@amazon.com> * Only push down sort when order is ASC for fielddata text Signed-off-by: Lantao Jin <ltjin@amazon.com> * revert CalciteNoPushdownIT Signed-off-by: Lantao Jin <ltjin@amazon.com> * revert the fielddata logic Signed-off-by: Lantao Jin <ltjin@amazon.com> * Fix IT Signed-off-by: Lantao Jin <ltjin@amazon.com> * 'gender' in test data should contain keyword subfield Signed-off-by: Lantao Jin <ltjin@amazon.com> * Fix IT Signed-off-by: Lantao Jin <ltjin@amazon.com> * revert the typo change in test Signed-off-by: Lantao Jin <ltjin@amazon.com> * fix no pushdown IT Signed-off-by: Lantao Jin <ltjin@amazon.com> --------- Signed-off-by: Lantao Jin <ltjin@amazon.com>
1 parent 56436d4 commit 71aa9ba

23 files changed

Lines changed: 348 additions & 253 deletions

File tree

benchmarks/src/jmh/java/org/opensearch/sql/expression/operator/predicate/MergeArrayAndObjectMapBenchmark.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import java.util.Map;
1313
import org.openjdk.jmh.annotations.Benchmark;
1414
import org.opensearch.sql.opensearch.data.type.OpenSearchDataType;
15-
import org.opensearch.sql.opensearch.request.system.OpenSearchDescribeIndexRequest;
15+
import org.opensearch.sql.opensearch.util.MergeRules.MergeRuleHelper;
1616

1717
public class MergeArrayAndObjectMapBenchmark {
1818
private static final List<Map<String, OpenSearchDataType>> candidateMaps = prepareListOfMaps(120);
@@ -21,7 +21,7 @@ public class MergeArrayAndObjectMapBenchmark {
2121
public void testMerge() {
2222
Map<String, OpenSearchDataType> finalResult = new HashMap<>();
2323
for (Map<String, OpenSearchDataType> map : candidateMaps) {
24-
OpenSearchDescribeIndexRequest.mergeObjectAndArrayInsideMap(finalResult, map);
24+
MergeRuleHelper.merge(finalResult, map);
2525
}
2626
}
2727

integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,23 @@ public void supportSearchSargPushDown_multiRange() throws IOException {
4848
// Only for Calcite
4949
@Test
5050
public void supportSearchSargPushDown_timeRange() throws IOException {
51+
String query =
52+
"source=opensearch-sql_test_index_bank"
53+
+ "| where birthdate >= '2016-12-08 00:00:00.000000000' "
54+
+ "and birthdate < '2018-11-09 00:00:00.000000000'";
55+
var result = explainQueryToString(query);
5156
String expected = loadExpectedPlan("explain_sarg_filter_push_time_range.json");
52-
assertJsonEqualsIgnoreId(
53-
expected,
54-
explainQueryToString(
55-
"source=opensearch-sql_test_index_bank"
56-
+ "| where birthdate >= '2016-12-08 00:00:00.000000000' "
57-
+ "and birthdate < '2018-11-09 00:00:00.000000000' "));
57+
assertJsonEqualsIgnoreId(expected, result);
58+
}
59+
60+
// Only for Calcite
61+
@Test
62+
public void supportPushDownSortMergeJoin() throws IOException {
63+
String query =
64+
"source=opensearch-sql_test_index_bank| join left=l right=r on"
65+
+ " l.account_number=r.account_number opensearch-sql_test_index_bank";
66+
var result = explainQueryToString(query);
67+
String expected = loadExpectedPlan("explain_merge_join_sort_push.json");
68+
assertJsonEqualsIgnoreId(expected, result);
5869
}
5970
}

integ-test/src/test/java/org/opensearch/sql/legacy/AggregationExpressionIT.java

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public void hasGroupKeyAvgOnIntegerShouldPass() {
7171
Index.BANK.getName()));
7272

7373
verifySchema(response, schema("gender", null, "text"), schema("AVG(age)", "avg", "double"));
74-
verifyDataRows(response, rows("m", 34.25), rows("f", 33.666666666666664d));
74+
verifyDataRows(response, rows("M", 34.25), rows("F", 33.666666666666664d));
7575
}
7676

7777
@Test
@@ -86,7 +86,7 @@ public void hasGroupKeyMaxAddMinShouldPass() {
8686
response,
8787
schema("gender", null, "text"),
8888
schema("MAX(age) + MIN(age)", "addValue", "long"));
89-
verifyDataRows(response, rows("m", 60), rows("f", 60));
89+
verifyDataRows(response, rows("M", 60), rows("F", 60));
9090
}
9191

9292
@Test
@@ -98,7 +98,7 @@ public void hasGroupKeyMaxAddLiteralShouldPass() {
9898
Index.ACCOUNT.getName()));
9999

100100
verifySchema(response, schema("gender", null, "text"), schema("MAX(age) + 1", "add", "long"));
101-
verifyDataRows(response, rows("m", 41), rows("f", 41));
101+
verifyDataRows(response, rows("M", 41), rows("F", 41));
102102
}
103103

104104
@Test
@@ -126,7 +126,7 @@ public void hasGroupKeyLogMaxAddMinShouldPass() {
126126
response,
127127
schema("gender", null, "text"),
128128
schema("Log(MAX(age) + MIN(age))", "logValue", "double"));
129-
verifyDataRows(response, rows("m", 4.0943445622221d), rows("f", 4.0943445622221d));
129+
verifyDataRows(response, rows("M", 4.0943445622221d), rows("F", 4.0943445622221d));
130130
}
131131

132132
@Test
@@ -136,7 +136,7 @@ public void AddLiteralOnGroupKeyShouldPass() {
136136
String.format(
137137
"SELECT gender, age+10, max(balance) as `max` "
138138
+ "FROM %s "
139-
+ "WHERE gender = 'm' and age < 22 "
139+
+ "WHERE gender = 'M' and age < 22 "
140140
+ "GROUP BY gender, age "
141141
+ "ORDER BY age",
142142
Index.ACCOUNT.getName()));
@@ -146,7 +146,7 @@ public void AddLiteralOnGroupKeyShouldPass() {
146146
schema("gender", null, "text"),
147147
schema("age+10", null, "long"),
148148
schema("max(balance)", "max", "long"));
149-
verifyDataRows(response, rows("m", 30, 49568), rows("m", 31, 49433));
149+
verifyDataRows(response, rows("M", 30, 49568), rows("M", 31, 49433));
150150
}
151151

152152
@Test
@@ -156,7 +156,7 @@ public void logWithAddLiteralOnGroupKeyShouldPass() {
156156
String.format(
157157
"SELECT gender, Log(age+10) as logAge, max(balance) as max "
158158
+ "FROM %s "
159-
+ "WHERE gender = 'm' and age < 22 "
159+
+ "WHERE gender = 'M' and age < 22 "
160160
+ "GROUP BY gender, age "
161161
+ "ORDER BY age",
162162
Index.ACCOUNT.getName()));
@@ -167,7 +167,7 @@ public void logWithAddLiteralOnGroupKeyShouldPass() {
167167
schema("Log(age+10)", "logAge", "double"),
168168
schema("max(balance)", "max", "long"));
169169
verifyDataRows(
170-
response, rows("m", 3.4011973816621555d, 49568), rows("m", 3.4339872044851463d, 49433));
170+
response, rows("M", 3.4011973816621555d, 49568), rows("M", 3.4339872044851463d, 49433));
171171
}
172172

173173
@Test
@@ -177,7 +177,7 @@ public void logWithAddLiteralOnGroupKeyAndMaxSubtractLiteralShouldPass() {
177177
String.format(
178178
"SELECT gender, Log(age+10) as logAge, max(balance) - 100 as max "
179179
+ "FROM %s "
180-
+ "WHERE gender = 'm' and age < 22 "
180+
+ "WHERE gender = 'M' and age < 22 "
181181
+ "GROUP BY gender, age "
182182
+ "ORDER BY age",
183183
Index.ACCOUNT.getName()));
@@ -188,7 +188,7 @@ public void logWithAddLiteralOnGroupKeyAndMaxSubtractLiteralShouldPass() {
188188
schema("Log(age+10)", "logAge", "double"),
189189
schema("max(balance) - 100", "max", "long"));
190190
verifyDataRows(
191-
response, rows("m", 3.4011973816621555d, 49468), rows("m", 3.4339872044851463d, 49333));
191+
response, rows("M", 3.4011973816621555d, 49468), rows("M", 3.4339872044851463d, 49333));
192192
}
193193

194194
/** The date is in JDBC format. */

integ-test/src/test/java/org/opensearch/sql/legacy/PrettyFormatResponseIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ public void aggregationFunctionInHaving() throws IOException {
437437

438438
JSONArray dataRows = getDataRows(response);
439439
assertEquals(1, dataRows.length());
440-
assertEquals("m", dataRows.getJSONArray(0).getString(0));
440+
assertEquals("M", dataRows.getJSONArray(0).getString(0));
441441
}
442442

443443
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ public void testMultiSortPushDownExplain() throws IOException {
134134
explainQueryToString(
135135
"source=opensearch-sql_test_index_account "
136136
+ "| sort account_number, firstname, address, balance "
137-
+ "| sort - balance, - gender, address "
137+
+ "| sort - balance, - gender, account_number "
138138
+ "| fields account_number, firstname, address, balance, gender"));
139139
}
140140

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public void testRareWithGroup() throws IOException {
5959
rows("F", "OK", 7),
6060
rows("F", "KS", 7),
6161
rows("F", "CO", 7),
62-
rows("F", "NV", 8),
62+
isPushdownEnabled() ? rows("F", "AR", 8) : rows("F", "NV", 8),
6363
rows("M", "NE", 5),
6464
rows("M", "RI", 5),
6565
rows("M", "NV", 5),

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

Lines changed: 18 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import static org.opensearch.sql.util.MatcherUtils.rows;
1212
import static org.opensearch.sql.util.MatcherUtils.schema;
1313
import static org.opensearch.sql.util.MatcherUtils.verifyDataRows;
14-
import static org.opensearch.sql.util.MatcherUtils.verifyDataRowsInOrder;
1514
import static org.opensearch.sql.util.MatcherUtils.verifySchema;
1615
import static org.opensearch.sql.util.MatcherUtils.verifySchemaInOrder;
1716

@@ -358,27 +357,15 @@ public void testStatsBySpanAndMultipleFields() throws IOException {
358357
schema("span(age,10)", null, "int"),
359358
schema("gender", null, "string"),
360359
schema("state", null, "string"));
361-
if (isCalciteEnabled()) {
362-
verifyDataRows(
363-
response,
364-
rows(1, 20, "F", "VA"),
365-
rows(1, 30, "F", "IN"),
366-
rows(1, 30, "F", "PA"),
367-
rows(1, 30, "M", "IL"),
368-
rows(1, 30, "M", "MD"),
369-
rows(1, 30, "M", "TN"),
370-
rows(1, 30, "M", "WA"));
371-
} else {
372-
verifyDataRowsInOrder(
373-
response,
374-
rows(1, 20, "f", "VA"),
375-
rows(1, 30, "f", "IN"),
376-
rows(1, 30, "f", "PA"),
377-
rows(1, 30, "m", "IL"),
378-
rows(1, 30, "m", "MD"),
379-
rows(1, 30, "m", "TN"),
380-
rows(1, 30, "m", "WA"));
381-
}
360+
verifyDataRows(
361+
response,
362+
rows(1, 20, "F", "VA"),
363+
rows(1, 30, "F", "IN"),
364+
rows(1, 30, "F", "PA"),
365+
rows(1, 30, "M", "IL"),
366+
rows(1, 30, "M", "MD"),
367+
rows(1, 30, "M", "TN"),
368+
rows(1, 30, "M", "WA"));
382369
}
383370

384371
@Test
@@ -395,27 +382,15 @@ public void testStatsByMultipleFieldsAndSpan() throws IOException {
395382
schema("span(age,10)", null, "int"),
396383
schema("gender", null, "string"),
397384
schema("state", null, "string"));
398-
if (isCalciteEnabled()) {
399-
verifyDataRows(
400-
response,
401-
rows(1, 20, "F", "VA"),
402-
rows(1, 30, "F", "IN"),
403-
rows(1, 30, "F", "PA"),
404-
rows(1, 30, "M", "IL"),
405-
rows(1, 30, "M", "MD"),
406-
rows(1, 30, "M", "TN"),
407-
rows(1, 30, "M", "WA"));
408-
} else {
409-
verifyDataRowsInOrder(
410-
response,
411-
rows(1, 20, "f", "VA"),
412-
rows(1, 30, "f", "IN"),
413-
rows(1, 30, "f", "PA"),
414-
rows(1, 30, "m", "IL"),
415-
rows(1, 30, "m", "MD"),
416-
rows(1, 30, "m", "TN"),
417-
rows(1, 30, "m", "WA"));
418-
}
385+
verifyDataRows(
386+
response,
387+
rows(1, 20, "F", "VA"),
388+
rows(1, 30, "F", "IN"),
389+
rows(1, 30, "F", "PA"),
390+
rows(1, 30, "M", "IL"),
391+
rows(1, 30, "M", "MD"),
392+
rows(1, 30, "M", "TN"),
393+
rows(1, 30, "M", "WA"));
419394
}
420395

421396
@Test
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"calcite": {
3+
"logical": "LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], r.account_number=[$13], r.firstname=[$14], r.address=[$15], r.birthdate=[$16], r.gender=[$17], r.city=[$18], r.lastname=[$19], r.balance=[$20], r.employer=[$21], r.state=[$22], r.age=[$23], r.email=[$24], r.male=[$25])\n LogicalJoin(condition=[=($0, $13)], joinType=[inner])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n",
4+
"physical": "EnumerableMergeJoin(condition=[=($0, $13)], joinType=[inner])\n CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]], PushDownContext=[[PROJECT->[account_number, firstname, address, birthdate, gender, city, lastname, balance, employer, state, age, email, male], SORT->[{\n \"account_number\" : {\n \"order\" : \"asc\",\n \"missing\" : \"_last\"\n }\n}]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"account_number\",\"firstname\",\"address\",\"birthdate\",\"gender\",\"city\",\"lastname\",\"balance\",\"employer\",\"state\",\"age\",\"email\",\"male\"],\"excludes\":[]},\"sort\":[{\"account_number\":{\"order\":\"asc\",\"missing\":\"_last\"}}]}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]], PushDownContext=[[PROJECT->[account_number, firstname, address, birthdate, gender, city, lastname, balance, employer, state, age, email, male], SORT->[{\n \"account_number\" : {\n \"order\" : \"asc\",\n \"missing\" : \"_last\"\n }\n}]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"account_number\",\"firstname\",\"address\",\"birthdate\",\"gender\",\"city\",\"lastname\",\"balance\",\"employer\",\"state\",\"age\",\"email\",\"male\"],\"excludes\":[]},\"sort\":[{\"account_number\":{\"order\":\"asc\",\"missing\":\"_last\"}}]}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
5+
}
6+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"calcite": {
3-
"logical": "LogicalSort(sort0=[$3], sort1=[$4], sort2=[$2], dir0=[DESC-nulls-last], dir1=[DESC-nulls-last], dir2=[ASC-nulls-first])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], balance=[$3], gender=[$4])\n LogicalSort(sort0=[$3], sort1=[$4], sort2=[$2], dir0=[DESC-nulls-last], dir1=[DESC-nulls-last], dir2=[ASC-nulls-first])\n LogicalSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account]])\n",
4-
"physical": "CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account]], PushDownContext=[[PROJECT->[account_number, firstname, address, balance, gender], SORT->[{\n \"balance\" : {\n \"order\" : \"desc\",\n \"missing\" : \"_last\"\n }\n}, {\n \"gender\" : {\n \"order\" : \"desc\",\n \"missing\" : \"_last\"\n }\n}, {\n \"address\" : {\n \"order\" : \"asc\",\n \"missing\" : \"_first\"\n }\n}]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"account_number\",\"firstname\",\"address\",\"balance\",\"gender\"],\"excludes\":[]},\"sort\":[{\"balance\":{\"order\":\"desc\",\"missing\":\"_last\"}},{\"gender\":{\"order\":\"desc\",\"missing\":\"_last\"}},{\"address\":{\"order\":\"asc\",\"missing\":\"_first\"}}]}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
3+
"logical": "LogicalSort(sort0=[$3], sort1=[$4], sort2=[$0], dir0=[DESC-nulls-last], dir1=[DESC-nulls-last], dir2=[ASC-nulls-first])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], balance=[$3], gender=[$4])\n LogicalSort(sort0=[$3], sort1=[$4], sort2=[$0], dir0=[DESC-nulls-last], dir1=[DESC-nulls-last], dir2=[ASC-nulls-first])\n LogicalSort(sort0=[$0], sort1=[$1], sort2=[$2], sort3=[$3], dir0=[ASC-nulls-first], dir1=[ASC-nulls-first], dir2=[ASC-nulls-first], dir3=[ASC-nulls-first])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account]])\n",
4+
"physical": "CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account]], PushDownContext=[[PROJECT->[account_number, firstname, address, balance, gender], SORT->[{\n \"balance\" : {\n \"order\" : \"desc\",\n \"missing\" : \"_last\"\n }\n}, {\n \"gender.keyword\" : {\n \"order\" : \"desc\",\n \"missing\" : \"_last\"\n }\n}, {\n \"account_number\" : {\n \"order\" : \"asc\",\n \"missing\" : \"_first\"\n }\n}]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"account_number\",\"firstname\",\"address\",\"balance\",\"gender\"],\"excludes\":[]},\"sort\":[{\"balance\":{\"order\":\"desc\",\"missing\":\"_last\"}},{\"gender.keyword\":{\"order\":\"desc\",\"missing\":\"_last\"}},{\"account_number\":{\"order\":\"asc\",\"missing\":\"_first\"}}]}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
55
}
66
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"calcite": {
3+
"logical": "LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12], r.account_number=[$13], r.firstname=[$14], r.address=[$15], r.birthdate=[$16], r.gender=[$17], r.city=[$18], r.lastname=[$19], r.balance=[$20], r.employer=[$21], r.state=[$22], r.age=[$23], r.email=[$24], r.male=[$25])\n LogicalJoin(condition=[=($0, $13)], joinType=[inner])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n LogicalProject(account_number=[$0], firstname=[$1], address=[$2], birthdate=[$3], gender=[$4], city=[$5], lastname=[$6], balance=[$7], employer=[$8], state=[$9], age=[$10], email=[$11], male=[$12])\n CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n",
4+
"physical": "EnumerableMergeJoin(condition=[=($0, $13)], joinType=[inner])\n EnumerableSort(sort0=[$0], dir0=[ASC])\n EnumerableCalc(expr#0..18=[{inputs}], proj#0..12=[{exprs}])\n CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n EnumerableSort(sort0=[$0], dir0=[ASC])\n EnumerableCalc(expr#0..18=[{inputs}], proj#0..12=[{exprs}])\n CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_bank]])\n"
5+
}
6+
}

0 commit comments

Comments
 (0)