Skip to content

Commit f60f5a4

Browse files
ES|QL|DS: fix partition_path literal binding (#158510)
Assisted by Cursor/Claude/Copilot
1 parent 4746880 commit f60f5a4

12 files changed

Lines changed: 564 additions & 123 deletions

File tree

docs/changelog/158510.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 158510
2+
summary: Fix partition_path literal binding and reject templates that cannot name distinct columns
3+
area: ES|QL
4+
type: bug
5+
issues: []

x-pack/plugin/esql-datasource-s3/src/test/java/org/elasticsearch/xpack/esql/datasource/s3/S3DataSourceValidatorTests.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,11 +641,38 @@ public void testValidateDatasetErrorBudgetConflictsWithFailFast() {
641641

642642
public void testValidateDatasetPartitionPath() {
643643
assertEquals(
644-
"year=*/month=*",
645-
validator.validateDataset(Map.of(), "s3://b/p", Map.of("partition_path", "year=*/month=*")).get("partition_path")
644+
"{year}/{month}",
645+
validator.validateDataset(Map.of(), "s3://b/p", Map.of("partition_path", "{year}/{month}")).get("partition_path")
646646
);
647647
}
648648

649+
public void testValidateDatasetRejectsPlaceholderlessPartitionPath() {
650+
ValidationException e = expectThrows(
651+
ValidationException.class,
652+
() -> validator.validateDataset(Map.of(), "s3://b/p", Map.of("partition_path", "year={year}"))
653+
);
654+
assertThat(e.getMessage(), containsString("partition_path"));
655+
assertThat(e.getMessage(), containsString("{name}"));
656+
}
657+
658+
public void testValidateDatasetRejectsGlobShapedPartitionPath() {
659+
ValidationException e = expectThrows(
660+
ValidationException.class,
661+
() -> validator.validateDataset(Map.of(), "s3://b/p", Map.of("partition_path", "{year}/{month}/*.csv"))
662+
);
663+
assertThat(e.getMessage(), containsString("partition_path"));
664+
assertThat(e.getMessage(), containsString("*.csv"));
665+
}
666+
667+
public void testValidateDatasetRejectsDuplicatePlaceholderPartitionPath() {
668+
ValidationException e = expectThrows(
669+
ValidationException.class,
670+
() -> validator.validateDataset(Map.of(), "s3://b/p", Map.of("partition_path", "{year}/junk/{year}"))
671+
);
672+
assertThat(e.getMessage(), containsString("partition_path"));
673+
assertThat(e.getMessage(), containsString("more than once"));
674+
}
675+
649676
public void testValidateDatasetHivePartitioning() {
650677
assertEquals(false, validator.validateDataset(Map.of(), "s3://b/p", Map.of("hive_partitioning", false)).get("hive_partitioning"));
651678
assertEquals(true, validator.validateDataset(Map.of(), "s3://b/p", Map.of("hive_partitioning", true)).get("hive_partitioning"));

x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/ExternalCsvHivePartitionedIT.java

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,29 +79,21 @@ public void testHivePartitioningValidatesAndParses() throws Exception {
7979
}
8080

8181
/**
82-
* Same fixture, with an explicit {@code partition_path}. This template names NO columns — the detector matches a
83-
* path segment in full, so {@code year={year}} is not a placeholder. With no {@code partition_detection} the
84-
* strategy is AUTO, which tries Hive first, so the dataset keeps the {@code year}/{@code month} columns it had
85-
* before the setting reached the read path.
86-
*
87-
* <p>It previously asserted only that the query did not fail with "unknown option [partition_path]". An
88-
* acceptance assertion cannot tell an applied template from an unapplied one, so it passed while the setting
89-
* was unread. It now asserts the resulting columns.
82+
* Same Hive-shaped fixture with a legal {@code partition_path}. Strategy is AUTO, so Hive runs first and
83+
* the query still sees {@code year}, {@code month}, and the physical {@code id} column.
9084
*/
9185
public void testPartitionPathValidatesAndParses() throws Exception {
9286
Path root = createTempDir().resolve("template_csv");
9387
writePartitionedCsvFiles(root);
9488

9589
@SuppressWarnings("checkstyle:EmptyJavadoc") // checkstyle thinks this is Javadoc
9690
String glob = StoragePath.fileUri(root) + "/**/*.csv";
97-
String dataset = registerDataset("template_csv", glob, Map.of("partition_path", "year={year}/month={month}/*.csv"));
91+
String dataset = registerDataset("template_csv", glob, Map.of("partition_path", "{year}/{month}"));
9892
String query = "FROM " + dataset + " | LIMIT 1";
9993

10094
try (var response = run(syncEsqlQueryRequest(query))) {
10195
List<String> columnNames = response.columns().stream().map(c -> c.name()).collect(Collectors.toList());
10296
assertThat("the data columns must still be read", columnNames, hasItem("id"));
103-
// The template names no columns, so AUTO falls through to Hive detection -- the same columns this
104-
// dataset produced before partition_path reached the read path.
10597
assertThat("the Hive-derived year must still appear", columnNames, hasItem("year"));
10698
assertThat("and the Hive-derived month", columnNames, hasItem("month"));
10799
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/datasources/PartitionConfig.java

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
package org.elasticsearch.xpack.esql.datasources;
99

1010
import org.elasticsearch.core.Nullable;
11+
import org.elasticsearch.xpack.esql.datasources.TemplatePartitionDetector.TemplateSegment;
12+
import org.elasticsearch.xpack.esql.datasources.spi.StoragePath;
1113

14+
import java.util.HashSet;
15+
import java.util.List;
1216
import java.util.Locale;
1317
import java.util.Map;
1418
import java.util.Set;
@@ -196,5 +200,80 @@ public static void validate(Map<String, Object> config) {
196200
+ "] or enable partition detection"
197201
);
198202
}
203+
204+
if (hasTemplate) {
205+
validatePathTemplate(template);
206+
}
207+
}
208+
209+
/**
210+
* A {@code partition_path} must name each column once as a whole-segment {@code {name}}, and every other
211+
* segment must be a concrete directory name. A glob-shaped literal cannot match a real directory. A
212+
* repeated placeholder cannot be one column value when the two slots differ.
213+
*/
214+
private static void validatePathTemplate(String template) {
215+
List<TemplateSegment> parsed = TemplatePartitionDetector.parseTemplate(template);
216+
Set<String> names = new HashSet<>();
217+
boolean hasPlaceholder = false;
218+
for (TemplateSegment segment : parsed) {
219+
if (segment instanceof TemplateSegment.Placeholder(String name)) {
220+
hasPlaceholder = true;
221+
if (names.add(name) == false) {
222+
throw new IllegalArgumentException(
223+
"["
224+
+ CONFIG_PARTITIONING_PATH
225+
+ "] ["
226+
+ template
227+
+ "] names ["
228+
+ name
229+
+ "] more than once; each partition column must appear once"
230+
);
231+
}
232+
}
233+
}
234+
if (hasPlaceholder == false) {
235+
throw new IllegalArgumentException(
236+
"["
237+
+ CONFIG_PARTITIONING_PATH
238+
+ "] ["
239+
+ template
240+
+ "] names no columns; a partition column must be a path segment that is exactly {name}, such as "
241+
+ "[{year}/{month}]. For a key=value directory layout set ["
242+
+ CONFIG_PARTITIONING_DETECTION
243+
+ "] to [hive] instead of a template"
244+
);
245+
}
246+
// year={year} also contains '{', so this scan runs after the empty-column throw: that
247+
// template must report that it names no columns, not that a segment is glob-shaped.
248+
for (TemplateSegment segment : parsed) {
249+
if (segment instanceof TemplateSegment.Literal(String value)) {
250+
if (TemplatePartitionDetector.containsEmbeddedPlaceholder(value)) {
251+
throw new IllegalArgumentException(
252+
"["
253+
+ CONFIG_PARTITIONING_PATH
254+
+ "] ["
255+
+ template
256+
+ "] has a key=value segment ["
257+
+ value
258+
+ "]; a partition column must be a path segment that is exactly {name}, such as "
259+
+ "[{year}/{month}]. For a key=value directory layout set ["
260+
+ CONFIG_PARTITIONING_DETECTION
261+
+ "] to [hive] instead of a template"
262+
);
263+
}
264+
if (StoragePath.containsGlobMetacharacter(value)) {
265+
throw new IllegalArgumentException(
266+
"["
267+
+ CONFIG_PARTITIONING_PATH
268+
+ "] ["
269+
+ template
270+
+ "] has a glob-shaped segment ["
271+
+ value
272+
+ "]; a non-placeholder segment is a required directory name, such as "
273+
+ "[junk] in [{year}/junk/{month}]"
274+
);
275+
}
276+
}
277+
}
199278
}
200279
}

0 commit comments

Comments
 (0)