From 3cac5c668a0885a2558b6c0bcb8eff41b62d92e7 Mon Sep 17 00:00:00 2001 From: hongyu guo Date: Tue, 31 Mar 2026 03:50:38 +0800 Subject: [PATCH 1/7] [CALCITE-6539] Improve error messages due to misspellings using (say) Levenshtein distance --- .../calcite/sql/validate/DelegatingScope.java | 116 +++++++--- .../sql/validate/IdentifierNamespace.java | 34 ++- .../calcite/sql/validate/SqlNameMatchers.java | 204 ++++++++++++++++++ .../apache/calcite/test/SqlValidatorTest.java | 15 ++ 4 files changed, 337 insertions(+), 32 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java index 5153d3160e50..bbdef67fc69c 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java @@ -42,7 +42,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -194,6 +193,68 @@ protected void addColumnNames( } } + private List findColumnSuggestions(String columnName) { + final List columnNames = new ArrayList<>(); + findAllColumnNames(columnNames); + return SqlNameMatchers.bestMatches(columnName, simpleNames(columnNames)); + } + + private @Nullable String findCaseInsensitiveTableSuggestion(List names) { + final SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); + final ResolvedImpl resolved = new ResolvedImpl(); + resolve(names, liberalMatcher, false, resolved); + if (resolved.count() == 1) { + final Step lastStep = Util.last(resolved.only().path.steps()); + return lastStep.name; + } + return null; + } + + private @Nullable String findTableSuggestion(SqlIdentifier prefix) { + final @Nullable String caseInsensitiveSuggestion = + findCaseInsensitiveTableSuggestion(prefix.names); + if (caseInsensitiveSuggestion != null) { + return caseInsensitiveSuggestion; + } + if (prefix.names.size() == 1) { + final List aliases = new ArrayList<>(); + findAliases(aliases); + return SqlNameMatchers.bestMatch(prefix.names.get(0), simpleNames(aliases)); + } + final SqlNameMatchers.NameSuggestion suggestion = + SqlNameMatchers.bestObjectName(validator.catalogReader, prefix.names); + return suggestion == null ? null : suggestion.suggestion; + } + + private static @Nullable String findFieldSuggestion(RelDataType rowType, + List names) { + RelDataType currentType = rowType; + for (String name : names) { + final RelDataTypeField field = currentType.getField(name, true, false); + if (field == null) { + return SqlNameMatchers.bestMatch(name, fieldNames(currentType)); + } + currentType = field.getType(); + } + return null; + } + + private static List fieldNames(RelDataType rowType) { + final List names = new ArrayList<>(); + for (RelDataTypeField field : rowType.getFieldList()) { + names.add(field.getName()); + } + return names; + } + + private static List simpleNames(Iterable monikers) { + final List names = new ArrayList<>(); + for (SqlMoniker moniker : monikers) { + names.add(Util.last(moniker.getFullyQualifiedNames())); + } + return names; + } + @Override public void findAllColumnNames(List result) { parent.findAllColumnNames(result); } @@ -270,24 +331,11 @@ protected void addColumnNames( switch (map.size()) { case 0: if (nameMatcher.isCaseSensitive()) { - final SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); - final Map map2 = - findQualifyingTableNames(columnName, identifier, liberalMatcher); - if (!map2.isEmpty()) { - final List list = new ArrayList<>(); - for (ScopeChild entry : map2.values()) { - final RelDataTypeField field = - liberalMatcher.field(entry.namespace.getRowType(), - columnName); - if (field == null) { - continue; - } - list.add(field.getName()); - } - Collections.sort(list); + final List suggestions = findColumnSuggestions(columnName); + if (!suggestions.isEmpty()) { throw validator.newValidationError(identifier, RESOURCE.columnNotFoundDidYouMean(columnName, - Util.sepList(list, "', '"))); + Util.sepList(suggestions, "', '"))); } } throw validator.newValidationError(identifier, @@ -342,14 +390,11 @@ protected void addColumnNames( } // Look for a table alias that is the wrong case. if (nameMatcher.isCaseSensitive()) { - final SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); - resolved.clear(); - resolve(prefix.names, liberalMatcher, false, resolved); - if (resolved.count() == 1) { - final Step lastStep = Util.last(resolved.only().path.steps()); + final @Nullable String suggestion = findTableSuggestion(prefix); + if (suggestion != null) { throw validator.newValidationError(prefix, RESOURCE.tableNameNotFoundDidYouMean(prefix.toString(), - lastStep.name)); + suggestion)); } } } @@ -361,6 +406,18 @@ protected void addColumnNames( switch (map.size()) { default: final SqlIdentifier prefix1 = identifier.skipLast(1); + if (nameMatcher.isCaseSensitive()) { + final @Nullable String suggestion = + fromNs instanceof SchemaNamespace + ? SqlNameMatchers.bestMatch(Util.last(prefix1.names), + fieldNames(fromNs.getRowType())) + : findTableSuggestion(prefix1); + if (suggestion != null) { + throw validator.newValidationError(prefix1, + RESOURCE.tableNameNotFoundDidYouMean(prefix1.toString(), + suggestion)); + } + } throw validator.newValidationError(prefix1, RESOURCE.tableNameNotFound(prefix1.toString())); case 1: { @@ -439,20 +496,17 @@ protected void addColumnNames( final Path path; switch (resolved.count()) { case 0: - // Maybe the last component was correct, just wrong case if (nameMatcher.isCaseSensitive()) { - SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); - resolved.clear(); - resolveInNamespace(fromNs, false, suffix.names, liberalMatcher, - Path.EMPTY, resolved); - if (resolved.count() > 0) { + final @Nullable String suggestion = + findFieldSuggestion(requireNonNull(fromRowType, "fromRowType"), + suffix.names); + if (suggestion != null) { int k = size - 1; final SqlIdentifier prefix = identifier.getComponent(0, i); final SqlIdentifier suffix3 = identifier.getComponent(i, k + 1); - final Step step = Util.last(resolved.resolves.get(0).path.steps()); throw validator.newValidationError(suffix3, RESOURCE.columnNotFoundInTableDidYouMean(suffix3.toString(), - prefix.toString(), step.name)); + prefix.toString(), suggestion)); } } // Find the shortest suffix that also fails. Suppose we cannot resolve diff --git a/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java b/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java index f23ddec087ec..c1874a588148 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java @@ -31,6 +31,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.Nullable; +import java.util.ArrayList; import java.util.List; import static org.apache.calcite.util.Static.RESOURCE; @@ -175,11 +176,42 @@ private SqlValidatorNamespace resolveImpl(SqlIdentifier id) { SqlIdentifier.getString(prefix), next)); } } else { + final String missingName = resolve.remainingNames.get(0); + final List hints = new ArrayList<>(); + SqlValidatorUtil.getSchemaObjectMonikers(validator.catalogReader, names, + hints); + final List candidateNames = new ArrayList<>(); + for (SqlMoniker hint : hints) { + final List hintNames = hint.getFullyQualifiedNames(); + candidateNames.add(hintNames.get(hintNames.size() - 1)); + } + final String suggestion = + SqlNameMatchers.bestMatch(missingName, candidateNames); + if (suggestion != null) { + throw validator.newValidationError(id, + RESOURCE.objectNotFoundWithinDidYouMean(missingName, + SqlIdentifier.getString(resolve.path.stepNames()), + suggestion)); + } throw validator.newValidationError(id, - RESOURCE.objectNotFoundWithin(resolve.remainingNames.get(0), + RESOURCE.objectNotFoundWithin(missingName, SqlIdentifier.getString(resolve.path.stepNames()))); } } + final SqlNameMatchers.NameSuggestion suggestion = + SqlNameMatchers.bestObjectName(validator.catalogReader, names); + if (suggestion != null) { + if (suggestion.prefixNames.isEmpty()) { + throw validator.newValidationError(id, + RESOURCE.objectNotFoundDidYouMean(suggestion.name, + suggestion.suggestion)); + } else { + throw validator.newValidationError(id, + RESOURCE.objectNotFoundWithinDidYouMean(suggestion.name, + SqlIdentifier.getString(suggestion.prefixNames), + suggestion.suggestion)); + } + } } throw validator.newValidationError(id, RESOURCE.objectNotFound(id.getComponent(0).toString())); diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java index c01b7187acdb..9b338066fa2b 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java @@ -21,12 +21,18 @@ import org.apache.calcite.sql.SqlIdentifier; import org.apache.calcite.util.Util; +import org.apache.commons.text.similarity.JaroWinklerSimilarity; +import org.apache.commons.text.similarity.LevenshteinDistance; + import com.google.common.collect.ImmutableList; import org.checkerframework.checker.nullness.qual.Nullable; +import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TreeSet; @@ -40,6 +46,10 @@ public class SqlNameMatchers { private static final BaseMatcher CASE_SENSITIVE = new BaseMatcher(true); private static final BaseMatcher CASE_INSENSITIVE = new BaseMatcher(false); + private static final LevenshteinDistance LEVENSHTEIN_DISTANCE = + LevenshteinDistance.getDefaultInstance(); + private static final JaroWinklerSimilarity JARO_WINKLER_SIMILARITY = + new JaroWinklerSimilarity(); private SqlNameMatchers() {} @@ -55,6 +65,200 @@ public static SqlNameMatcher liberal() { return new LiberalNameMatcher(); } + /** Suggestion for the first component of a multi-part object name that failed + * to resolve. */ + static class NameSuggestion { + final ImmutableList prefixNames; + final String name; + final String suggestion; + + NameSuggestion(List prefixNames, String name, String suggestion) { + this.prefixNames = ImmutableList.copyOf(prefixNames); + this.name = requireNonNull(name, "name"); + this.suggestion = requireNonNull(suggestion, "suggestion"); + } + } + + /** Returns the best near-match suggestions for a name. */ + public static List bestMatches(String name, Iterable candidateNames) { + final String normalizedName = normalize(name); + final double similarityThreshold = normalizedName.length() > 3 ? 0.834D : 0.77D; + final int distanceThreshold = (normalizedName.length() + 3) / 4; + final List matches = new ArrayList<>(); + int ordinal = 0; + for (String candidateName : candidateNames) { + if (name.equals(candidateName)) { + ordinal++; + continue; + } + final String normalizedCandidateName = normalize(candidateName); + final int distance = + LEVENSHTEIN_DISTANCE.apply(normalizedCandidateName, normalizedName); + final double similarity = + JARO_WINKLER_SIMILARITY.apply(normalizedCandidateName, normalizedName); + if (distance > distanceThreshold + && similarity < similarityThreshold) { + ordinal++; + continue; + } + matches.add(new MatchResult(candidateName, normalizedCandidateName, + similarity, distance, ordinal)); + ordinal++; + } + if (matches.isEmpty()) { + return ImmutableList.of(); + } + matches.sort(Comparator + .comparingDouble((MatchResult match) -> match.similarity) + .reversed() + .thenComparing(match -> match.candidateName) + .thenComparingInt(match -> match.ordinal)); + + int bestDistance = Integer.MAX_VALUE; + for (MatchResult match : matches) { + if (match.distance <= distanceThreshold) { + bestDistance = Math.min(bestDistance, match.distance); + } + } + if (bestDistance != Integer.MAX_VALUE) { + final List corrections = new ArrayList<>(); + for (MatchResult match : matches) { + if (match.distance == bestDistance) { + corrections.add(match.candidateName); + } + } + return ImmutableList.copyOf(corrections); + } + for (MatchResult match : matches) { + final int length = + Math.min(normalizedName.length(), match.normalizedCandidateName.length()); + if (match.distance < length) { + return ImmutableList.of(match.candidateName); + } + } + return ImmutableList.of(); + } + + /** Returns the best near-match suggestion for a name. */ + public static @Nullable String bestMatch(String name, Iterable candidateNames) { + final List matches = bestMatches(name, candidateNames); + return matches.isEmpty() ? null : matches.get(0); + } + + /** Returns the best near-match suggestion for the first unresolved component + * of a multi-part catalog object name. */ + static @Nullable NameSuggestion bestObjectName( + SqlValidatorCatalogReader catalogReader, List names) { + final Iterable> schemaPaths; + if (names.size() > 1 && catalogReader.getSchemaPaths().size() > 1) { + schemaPaths = Util.skip(catalogReader.getSchemaPaths()); + } else { + schemaPaths = catalogReader.getSchemaPaths(); + } + for (List schemaPath : schemaPaths) { + final @Nullable NameSuggestion suggestion = + bestObjectName(catalogReader, schemaPath, names); + if (suggestion != null) { + return suggestion; + } + } + final @Nullable NameSuggestion suggestion = + bestObjectName(catalogReader, ImmutableList.of(), names); + if (suggestion != null) { + return suggestion; + } + if (names.size() > 1) { + final Set candidateNames = + new LinkedHashSet<>(directObjectNames(catalogReader, ImmutableList.of())); + for (List schemaPath : catalogReader.getSchemaPaths()) { + if (!schemaPath.isEmpty()) { + candidateNames.add(Util.last(schemaPath)); + } + } + final @Nullable String firstSuggestion = + bestMatch(names.get(0), candidateNames); + if (firstSuggestion != null) { + return new NameSuggestion(ImmutableList.of(), names.get(0), firstSuggestion); + } + } + return null; + } + + private static @Nullable NameSuggestion bestObjectName( + SqlValidatorCatalogReader catalogReader, List schemaPath, + List names) { + List objectPath = ImmutableList.copyOf(schemaPath); + List prefixNames = ImmutableList.of(); + for (String name : names) { + final List candidateNames = directObjectNames(catalogReader, objectPath); + final @Nullable String exactMatch = exactMatch(name, candidateNames); + if (exactMatch != null) { + objectPath = + ImmutableList.builder().addAll(objectPath).add(exactMatch).build(); + prefixNames = + ImmutableList.builder().addAll(prefixNames).add(exactMatch).build(); + continue; + } + final @Nullable String suggestion = bestMatch(name, candidateNames); + return suggestion == null ? null + : new NameSuggestion(prefixNames, name, suggestion); + } + return null; + } + + static List directObjectNames( + SqlValidatorCatalogReader catalogReader, List prefixNames) { + final List names = new ArrayList<>(); + final int expectedDepth = prefixNames.size() + 1; + for (SqlMoniker moniker : catalogReader.getAllSchemaObjectNames(prefixNames)) { + switch (moniker.getType()) { + case CATALOG: + case SCHEMA: + case TABLE: + case VIEW: + final List objectNames = moniker.getFullyQualifiedNames(); + if (objectNames.size() == expectedDepth) { + names.add(Util.last(objectNames)); + } + break; + default: + break; + } + } + return names; + } + + private static @Nullable String exactMatch(String name, Iterable candidateNames) { + for (String candidateName : candidateNames) { + if (candidateName.equals(name)) { + return candidateName; + } + } + return null; + } + + private static String normalize(String name) { + return name.toLowerCase(Locale.ROOT); + } + + private static class MatchResult { + private final String candidateName; + private final String normalizedCandidateName; + private final double similarity; + private final int distance; + private final int ordinal; + + MatchResult(String candidateName, String normalizedCandidateName, + double similarity, int distance, int ordinal) { + this.candidateName = requireNonNull(candidateName, "candidateName"); + this.normalizedCandidateName = + requireNonNull(normalizedCandidateName, "normalizedCandidateName"); + this.similarity = similarity; + this.distance = distance; + this.ordinal = ordinal; + } + } + /** Partial implementation of {@link SqlNameMatcher}. */ private static class BaseMatcher implements SqlNameMatcher { private final boolean caseSensitive; diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java index dd66362819a7..ac70a7291959 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java @@ -10014,6 +10014,21 @@ void testGroupExpressionEquivalenceParams() { + "did you mean 'DEPTNO', 'deptNo'\\?"); } + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for spelling mistakes. */ + @Test void testDidYouMeanSpellingSuggestions() { + sql("select ^firts_name^ from (values (100, 'Bill')) as tbl(id, first_name)") + .fails("Column 'FIRTS_NAME' not found in any table; did you mean 'FIRST_NAME'\\?"); + sql("select tbl.^firts_name^ from (values (100, 'Bill')) as tbl(id, first_name)") + .fails("Column 'FIRTS_NAME' not found in table 'TBL'; did you mean 'FIRST_NAME'\\?"); + sql("select ^Alais^.\"name\" from sales.emp as \"Alias\"") + .fails("Table 'ALAIS' not found; did you mean 'Alias'\\?"); + sql("select * from ^sales.\"Empp\"^") + .fails("Object 'Empp' not found within 'SALES'; did you mean 'EMP'\\?"); + sql("select * from ^\"salse\".emp^") + .fails("Object 'salse' not found; did you mean 'SALES'\\?"); + } + /** Tests matching of built-in operator names. */ @Test void testUnquotedBuiltInFunctionNames() { final SqlValidatorFixture mysql = fixture() From f492e02f0930f678259ad2d1ff3d401cf18888b1 Mon Sep 17 00:00:00 2001 From: hongyu guo Date: Sat, 4 Apr 2026 16:26:24 +0800 Subject: [PATCH 2/7] [CALCITE-6539] Improve did-you-mean suggestions for misspelled SQL identifiers --- .../calcite/sql/validate/SqlNameMatchers.java | 2 ++ .../apache/calcite/test/SqlValidatorTest.java | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java index 9b338066fa2b..e13502d711af 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java @@ -82,6 +82,8 @@ static class NameSuggestion { /** Returns the best near-match suggestions for a name. */ public static List bestMatches(String name, Iterable candidateNames) { final String normalizedName = normalize(name); + // Keep the same thresholds as Ruby's did_you_mean spell checker: Jaro-Winkler + // broadens typo recall, and the length-scaled Levenshtein limit keeps hints conservative. final double similarityThreshold = normalizedName.length() > 3 ? 0.834D : 0.77D; final int distanceThreshold = (normalizedName.length() + 3) / 4; final List matches = new ArrayList<>(); diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java index ac70a7291959..69e1b26c79bd 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java @@ -10029,6 +10029,41 @@ void testGroupExpressionEquivalenceParams() { .fails("Object 'salse' not found; did you mean 'SALES'\\?"); } + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for spelling mistakes. */ + @Test void testDidYouMeanSpellingSuggestionsInNestedQueries() { + sql("select ^firts_name^ from (\n" + + " select first_name\n" + + " from (values (100, 'Bill')) as base_tbl(id, first_name)\n" + + ") as outer_tbl") + .fails("Column 'FIRTS_NAME' not found in any table; did you mean 'FIRST_NAME'\\?"); + sql("select outer_tbl.^firts_name^ from (\n" + + " select first_name\n" + + " from (values (100, 'Bill')) as base_tbl(id, first_name)\n" + + ") as outer_tbl") + .fails("Column 'FIRTS_NAME' not found in table 'OUTER_TBL'; " + + "did you mean 'FIRST_NAME'\\?"); + sql("with base_cte as (\n" + + " select first_name from (values (100, 'Bill')) as base_tbl(id, first_name)\n" + + "), nested_cte as (\n" + + " select first_name from base_cte\n" + + ")\n" + + "select ^firts_name^ from nested_cte") + .fails("Column 'FIRTS_NAME' not found in any table; did you mean 'FIRST_NAME'\\?"); + sql("select * from emp e\n" + + "where exists (\n" + + " select 1\n" + + " from dept d\n" + + " where d.^nmae^ = e.ename\n" + + ")") + .fails("Column 'NMAE' not found in table 'D'; did you mean 'NAME'\\?"); + sql("select ^midle_alias^.first_name from (\n" + + " select first_name\n" + + " from (values (100, 'Bill')) as base_tbl(id, first_name)\n" + + ") as middle_alias") + .fails("Table 'MIDLE_ALIAS' not found; did you mean 'MIDDLE_ALIAS'\\?"); + } + /** Tests matching of built-in operator names. */ @Test void testUnquotedBuiltInFunctionNames() { final SqlValidatorFixture mysql = fixture() From 4d4df9249a3e7ee91cb420235af13b0eafaafc37 Mon Sep 17 00:00:00 2001 From: hongyu guo Date: Sat, 4 Apr 2026 17:37:20 +0800 Subject: [PATCH 3/7] [CALCITE-6539] Improve did-you-mean suggestions for misspelled SQL identifiers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../calcite/sql/validate/DelegatingScope.java | 18 ++++++++++-------- .../calcite/sql/validate/SqlNameMatchers.java | 5 +++-- .../apache/calcite/test/SqlValidatorTest.java | 13 +++++++++++-- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java index bbdef67fc69c..66bf9e073bc3 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java @@ -226,15 +226,17 @@ private List findColumnSuggestions(String columnName) { return suggestion == null ? null : suggestion.suggestion; } - private static @Nullable String findFieldSuggestion(RelDataType rowType, - List names) { - RelDataType currentType = rowType; + private @Nullable String findFieldSuggestion(SqlValidatorNamespace namespace, + SqlNameMatcher nameMatcher, List names) { + SqlValidatorNamespace currentNamespace = namespace; for (String name : names) { - final RelDataTypeField field = currentType.getField(name, true, false); - if (field == null) { - return SqlNameMatchers.bestMatch(name, fieldNames(currentType)); + final ResolvedImpl resolved = new ResolvedImpl(); + resolveInNamespace(currentNamespace, false, ImmutableList.of(name), nameMatcher, + Path.EMPTY, resolved); + if (resolved.count() == 0) { + return SqlNameMatchers.bestMatch(name, fieldNames(currentNamespace.getRowType())); } - currentType = field.getType(); + currentNamespace = resolved.only().namespace; } return null; } @@ -498,7 +500,7 @@ private static List simpleNames(Iterable monikers) case 0: if (nameMatcher.isCaseSensitive()) { final @Nullable String suggestion = - findFieldSuggestion(requireNonNull(fromRowType, "fromRowType"), + findFieldSuggestion(requireNonNull(fromNs, "fromNs"), nameMatcher, suffix.names); if (suggestion != null) { int k = size - 1; diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java index e13502d711af..7ceb8e6df520 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java @@ -103,8 +103,9 @@ public static List bestMatches(String name, Iterable candidateNa ordinal++; continue; } - matches.add(new MatchResult(candidateName, normalizedCandidateName, - similarity, distance, ordinal)); + matches.add( + new MatchResult(candidateName, normalizedCandidateName, + similarity, distance, ordinal)); ordinal++; } if (matches.isEmpty()) { diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java index 69e1b26c79bd..8ebb5ff34379 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java @@ -10015,7 +10015,7 @@ void testGroupExpressionEquivalenceParams() { } /** Test case for [CALCITE-6539] - * Improve did-you-mean suggestions for spelling mistakes. */ + * Improve did-you-mean suggestions for misspelled SQL identifiers. */ @Test void testDidYouMeanSpellingSuggestions() { sql("select ^firts_name^ from (values (100, 'Bill')) as tbl(id, first_name)") .fails("Column 'FIRTS_NAME' not found in any table; did you mean 'FIRST_NAME'\\?"); @@ -10030,7 +10030,7 @@ void testGroupExpressionEquivalenceParams() { } /** Test case for [CALCITE-6539] - * Improve did-you-mean suggestions for spelling mistakes. */ + * Improve did-you-mean suggestions for misspelled SQL identifiers. */ @Test void testDidYouMeanSpellingSuggestionsInNestedQueries() { sql("select ^firts_name^ from (\n" + " select first_name\n" @@ -10064,6 +10064,15 @@ void testGroupExpressionEquivalenceParams() { .fails("Table 'MIDLE_ALIAS' not found; did you mean 'MIDDLE_ALIAS'\\?"); } + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for misspelled SQL identifiers. */ + @Test void testDidYouMeanSpellingSuggestionsForStructuredFields() { + sql("select t0.^f0.d1^ from struct.t t0") + .fails("Column 'F0\\.D1' not found in table 'T0'; did you mean 'C1'\\?"); + sql("select t0.^f1.a9^ from struct.t t0") + .fails("Column 'F1\\.A9' not found in table 'T0'; did you mean 'A0'\\?"); + } + /** Tests matching of built-in operator names. */ @Test void testUnquotedBuiltInFunctionNames() { final SqlValidatorFixture mysql = fixture() From 4113e7ec8a96dc2e65c1a94c736b16e0f68e571b Mon Sep 17 00:00:00 2001 From: hongyu guo Date: Mon, 6 Apr 2026 20:22:21 +0800 Subject: [PATCH 4/7] [CALCITE-6539] Improve did-you-mean suggestions for misspelled SQL identifiers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../calcite/sql/validate/DelegatingScope.java | 83 +++++++++++++++++- .../sql/validate/IdentifierNamespace.java | 2 +- .../calcite/sql/validate/SqlNameMatchers.java | 84 ++++++++++++++++++- 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java index 66bf9e073bc3..7b5dcf703250 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java @@ -193,6 +193,26 @@ protected void addColumnNames( } } + private List findCaseInsensitiveColumnSuggestions(String columnName, + SqlIdentifier identifier) { + final SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); + final Map map = + findQualifyingTableNames(columnName, identifier, liberalMatcher); + if (map.isEmpty()) { + return ImmutableList.of(); + } + final List suggestions = new ArrayList<>(); + for (ScopeChild entry : map.values()) { + final RelDataTypeField field = + liberalMatcher.field(entry.namespace.getRowType(), columnName); + if (field != null) { + suggestions.add(field.getName()); + } + } + suggestions.sort(String::compareTo); + return suggestions; + } + private List findColumnSuggestions(String columnName) { final List columnNames = new ArrayList<>(); findAllColumnNames(columnNames); @@ -217,31 +237,68 @@ private List findColumnSuggestions(String columnName) { return caseInsensitiveSuggestion; } if (prefix.names.size() == 1) { + if (prefix.names.get(0).length() <= 2) { + return null; + } final List aliases = new ArrayList<>(); findAliases(aliases); - return SqlNameMatchers.bestMatch(prefix.names.get(0), simpleNames(aliases)); + return SqlNameMatchers.bestObjectMatch(prefix.names.get(0), simpleNames(aliases)); } final SqlNameMatchers.NameSuggestion suggestion = SqlNameMatchers.bestObjectName(validator.catalogReader, prefix.names); return suggestion == null ? null : suggestion.suggestion; } + private @Nullable String findCaseInsensitiveFieldSuggestion( + SqlValidatorNamespace namespace, List names) { + final SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); + final ResolvedImpl resolved = new ResolvedImpl(); + resolveInNamespace(namespace, false, names, liberalMatcher, Path.EMPTY, resolved); + if (resolved.count() == 0) { + return null; + } + final Step step = Util.last(resolved.resolves.get(0).path.steps()); + return step.name; + } + private @Nullable String findFieldSuggestion(SqlValidatorNamespace namespace, SqlNameMatcher nameMatcher, List names) { SqlValidatorNamespace currentNamespace = namespace; + final SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); for (String name : names) { + final RelDataType rowType = currentNamespace.getRowType(); + if (!rowType.isStruct()) { + return null; + } final ResolvedImpl resolved = new ResolvedImpl(); resolveInNamespace(currentNamespace, false, ImmutableList.of(name), nameMatcher, Path.EMPTY, resolved); if (resolved.count() == 0) { - return SqlNameMatchers.bestMatch(name, fieldNames(currentNamespace.getRowType())); + final ResolvedImpl liberalResolved = new ResolvedImpl(); + resolveInNamespace(currentNamespace, false, ImmutableList.of(name), + liberalMatcher, Path.EMPTY, liberalResolved); + if (liberalResolved.count() == 1) { + currentNamespace = liberalResolved.only().namespace; + continue; + } + if (liberalResolved.count() == 0) { + return SqlNameMatchers.bestMatch(name, fieldNames(rowType)); + } + } else if (resolved.count() > 1) { + return null; + } else { + currentNamespace = resolved.only().namespace; + continue; } - currentNamespace = resolved.only().namespace; + return null; } return null; } private static List fieldNames(RelDataType rowType) { + if (!rowType.isStruct()) { + return ImmutableList.of(); + } final List names = new ArrayList<>(); for (RelDataTypeField field : rowType.getFieldList()) { names.add(field.getName()); @@ -333,6 +390,13 @@ private static List simpleNames(Iterable monikers) switch (map.size()) { case 0: if (nameMatcher.isCaseSensitive()) { + final List caseInsensitiveSuggestions = + findCaseInsensitiveColumnSuggestions(columnName, identifier); + if (!caseInsensitiveSuggestions.isEmpty()) { + throw validator.newValidationError(identifier, + RESOURCE.columnNotFoundDidYouMean(columnName, + Util.sepList(caseInsensitiveSuggestions, "', '"))); + } final List suggestions = findColumnSuggestions(columnName); if (!suggestions.isEmpty()) { throw validator.newValidationError(identifier, @@ -411,7 +475,7 @@ private static List simpleNames(Iterable monikers) if (nameMatcher.isCaseSensitive()) { final @Nullable String suggestion = fromNs instanceof SchemaNamespace - ? SqlNameMatchers.bestMatch(Util.last(prefix1.names), + ? SqlNameMatchers.bestObjectMatch(Util.last(prefix1.names), fieldNames(fromNs.getRowType())) : findTableSuggestion(prefix1); if (suggestion != null) { @@ -499,6 +563,17 @@ private static List simpleNames(Iterable monikers) switch (resolved.count()) { case 0: if (nameMatcher.isCaseSensitive()) { + final @Nullable String caseInsensitiveSuggestion = + findCaseInsensitiveFieldSuggestion(requireNonNull(fromNs, "fromNs"), + suffix.names); + if (caseInsensitiveSuggestion != null) { + int k = size - 1; + final SqlIdentifier prefix = identifier.getComponent(0, i); + final SqlIdentifier suffix3 = identifier.getComponent(i, k + 1); + throw validator.newValidationError(suffix3, + RESOURCE.columnNotFoundInTableDidYouMean(suffix3.toString(), + prefix.toString(), caseInsensitiveSuggestion)); + } final @Nullable String suggestion = findFieldSuggestion(requireNonNull(fromNs, "fromNs"), nameMatcher, suffix.names); diff --git a/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java b/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java index c1874a588148..87974c240239 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/IdentifierNamespace.java @@ -186,7 +186,7 @@ private SqlValidatorNamespace resolveImpl(SqlIdentifier id) { candidateNames.add(hintNames.get(hintNames.size() - 1)); } final String suggestion = - SqlNameMatchers.bestMatch(missingName, candidateNames); + SqlNameMatchers.bestObjectMatch(missingName, candidateNames); if (suggestion != null) { throw validator.newValidationError(id, RESOURCE.objectNotFoundWithinDidYouMean(missingName, diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java index 7ceb8e6df520..734fc73ca7e9 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java @@ -81,6 +81,11 @@ static class NameSuggestion { /** Returns the best near-match suggestions for a name. */ public static List bestMatches(String name, Iterable candidateNames) { + return bestMatches(name, candidateNames, true); + } + + private static List bestMatches(String name, Iterable candidateNames, + boolean allowDigitOnlyDifference) { final String normalizedName = normalize(name); // Keep the same thresholds as Ruby's did_you_mean spell checker: Jaro-Winkler // broadens typo recall, and the length-scaled Levenshtein limit keeps hints conservative. @@ -94,6 +99,11 @@ public static List bestMatches(String name, Iterable candidateNa continue; } final String normalizedCandidateName = normalize(candidateName); + if (!isEligibleEditDistanceMatch(name, candidateName, + normalizedName, normalizedCandidateName, allowDigitOnlyDifference)) { + ordinal++; + continue; + } final int distance = LEVENSHTEIN_DISTANCE.apply(normalizedCandidateName, normalizedName); final double similarity = @@ -124,7 +134,7 @@ public static List bestMatches(String name, Iterable candidateNa } } if (bestDistance != Integer.MAX_VALUE) { - final List corrections = new ArrayList<>(); + final Set corrections = new LinkedHashSet<>(); for (MatchResult match : matches) { if (match.distance == bestDistance) { corrections.add(match.candidateName); @@ -142,9 +152,45 @@ public static List bestMatches(String name, Iterable candidateNa return ImmutableList.of(); } + private static boolean isEligibleEditDistanceMatch( + String name, String candidateName, String normalizedName, + String normalizedCandidateName, boolean allowDigitOnlyDifference) { + if (normalizedName.length() <= 1 || normalizedCandidateName.length() <= 1) { + return false; + } + if (normalizedCandidateName.equals(normalizedName)) { + return false; + } + if (normalizedCandidateName.length() == normalizedName.length() + 1 + && (normalizedCandidateName.startsWith(normalizedName) + || normalizedCandidateName.endsWith(normalizedName))) { + return false; + } + if (!name.equals(name.trim()) || !candidateName.equals(candidateName.trim())) { + if (candidateName.trim().equalsIgnoreCase(name.trim())) { + return false; + } + } + if (!allowDigitOnlyDifference + && hasOnlyDigitDifference(normalizedName, normalizedCandidateName)) { + return false; + } + return digitCount(name) == digitCount(candidateName); + } + /** Returns the best near-match suggestion for a name. */ public static @Nullable String bestMatch(String name, Iterable candidateNames) { - final List matches = bestMatches(name, candidateNames); + return bestMatch(name, candidateNames, true); + } + + static @Nullable String bestObjectMatch(String name, Iterable candidateNames) { + return bestMatch(name, candidateNames, false); + } + + private static @Nullable String bestMatch(String name, Iterable candidateNames, + boolean allowDigitOnlyDifference) { + final List matches = bestMatches(name, candidateNames, + allowDigitOnlyDifference); return matches.isEmpty() ? null : matches.get(0); } @@ -179,7 +225,7 @@ public static List bestMatches(String name, Iterable candidateNa } } final @Nullable String firstSuggestion = - bestMatch(names.get(0), candidateNames); + bestObjectMatch(names.get(0), candidateNames); if (firstSuggestion != null) { return new NameSuggestion(ImmutableList.of(), names.get(0), firstSuggestion); } @@ -202,7 +248,7 @@ public static List bestMatches(String name, Iterable candidateNa ImmutableList.builder().addAll(prefixNames).add(exactMatch).build(); continue; } - final @Nullable String suggestion = bestMatch(name, candidateNames); + final @Nullable String suggestion = bestObjectMatch(name, candidateNames); return suggestion == null ? null : new NameSuggestion(prefixNames, name, suggestion); } @@ -244,6 +290,36 @@ private static String normalize(String name) { return name.toLowerCase(Locale.ROOT); } + private static int digitCount(String name) { + int digitCount = 0; + for (int i = 0; i < name.length(); i++) { + if (Character.isDigit(name.charAt(i))) { + digitCount++; + } + } + return digitCount; + } + + private static boolean hasOnlyDigitDifference(String name, String candidateName) { + if (name.length() != candidateName.length()) { + return false; + } + boolean sawDigitDifference = false; + for (int i = 0; i < name.length(); i++) { + final char c1 = name.charAt(i); + final char c2 = candidateName.charAt(i); + if (c1 == c2) { + continue; + } + if (!Character.isDigit(c1) || !Character.isDigit(c2)) { + return false; + } + sawDigitDifference = true; + } + return sawDigitDifference; + } + + /** Ranked candidate retained while sorting edit-distance suggestions. */ private static class MatchResult { private final String candidateName; private final String normalizedCandidateName; From 2a3affb5880f02bad63bede4ccf6e82b314fc599 Mon Sep 17 00:00:00 2001 From: hongyu guo Date: Mon, 6 Apr 2026 22:58:21 +0800 Subject: [PATCH 5/7] [CALCITE-6539] Improve did-you-mean suggestions for misspelled SQL identifiers --- .../calcite/sql/validate/SqlNameMatchers.java | 17 ++-- .../sql/validate/SqlNameMatchersTest.java | 91 +++++++++++++++++++ .../apache/calcite/test/SqlValidatorTest.java | 3 +- 3 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 core/src/test/java/org/apache/calcite/sql/validate/SqlNameMatchersTest.java diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java index 734fc73ca7e9..80e68941260a 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java @@ -161,21 +161,20 @@ private static boolean isEligibleEditDistanceMatch( if (normalizedCandidateName.equals(normalizedName)) { return false; } - if (normalizedCandidateName.length() == normalizedName.length() + 1 - && (normalizedCandidateName.startsWith(normalizedName) - || normalizedCandidateName.endsWith(normalizedName))) { - return false; - } if (!name.equals(name.trim()) || !candidateName.equals(candidateName.trim())) { if (candidateName.trim().equalsIgnoreCase(name.trim())) { return false; } } - if (!allowDigitOnlyDifference - && hasOnlyDigitDifference(normalizedName, normalizedCandidateName)) { - return false; + if (!allowDigitOnlyDifference) { + if (hasOnlyDigitDifference(normalizedName, normalizedCandidateName)) { + return false; + } + if (digitCount(name) != digitCount(candidateName)) { + return false; + } } - return digitCount(name) == digitCount(candidateName); + return true; } /** Returns the best near-match suggestion for a name. */ diff --git a/core/src/test/java/org/apache/calcite/sql/validate/SqlNameMatchersTest.java b/core/src/test/java/org/apache/calcite/sql/validate/SqlNameMatchersTest.java new file mode 100644 index 000000000000..da3858ccf9ef --- /dev/null +++ b/core/src/test/java/org/apache/calcite/sql/validate/SqlNameMatchersTest.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.calcite.sql.validate; + +import com.google.common.collect.ImmutableList; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Tests for {@link SqlNameMatchers}. + */ +class SqlNameMatchersTest { + + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for misspelled SQL identifiers. */ + @Test void testBestMatchesSkipCaseAndWhitespaceOnlyCandidates() { + assertThat(SqlNameMatchers.bestMatches("Path", ImmutableList.of("PATH")), + is(ImmutableList.of())); + assertThat(SqlNameMatchers.bestMatches("path", ImmutableList.of(" path ")), + is(ImmutableList.of())); + assertThat(SqlNameMatchers.bestMatches("path ", ImmutableList.of("path")), + is(ImmutableList.of())); + assertThat(SqlNameMatchers.bestMatches("path", ImmutableList.of(" patch ")), + is(ImmutableList.of(" patch "))); + } + + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for misspelled SQL identifiers. + * + *

Prefix/suffix expansions by one character are legitimate typos + * (e.g., "NAM" → "NAME") and should be suggested. */ + @Test void testBestMatchesSuggestOneCharacterPrefixOrSuffixExpansions() { + assertThat(SqlNameMatchers.bestMatches("ab", ImmutableList.of("abc")), + is(ImmutableList.of("abc"))); + assertThat(SqlNameMatchers.bestMatches("ab", ImmutableList.of("zab")), + is(ImmutableList.of("zab"))); + assertThat(SqlNameMatchers.bestMatches("nam", ImmutableList.of("name")), + is(ImmutableList.of("name"))); + assertThat(SqlNameMatchers.bestMatches("empn", ImmutableList.of("empno")), + is(ImmutableList.of("empno"))); + } + + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for misspelled SQL identifiers. + * + *

For object/table names: digit-only differences (TABLE2 → TABLE1) and + * different digit counts (TABLE2 → TABLEX) are suppressed. + * For column names: different digit counts are allowed. */ + @Test void testBestObjectMatchRejectsDigitOnlyDifferences() { + assertThat(SqlNameMatchers.bestObjectMatch("TABLE2", ImmutableList.of("TABLE1")), + nullValue()); + assertThat(SqlNameMatchers.bestObjectMatch("TABLE2", ImmutableList.of("TABLEX")), + nullValue()); + assertThat(SqlNameMatchers.bestObjectMatch("TABLE2", ImmutableList.of("TABLF2")), + is("TABLF2")); + } + + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for misspelled SQL identifiers. + * + *

For column/field names, digit count differences should not suppress + * suggestions (e.g., "empno" → "empno2" is valid). */ + @Test void testBestMatchAllowsDigitCountDifferencesForColumns() { + // Column names: digit count difference is allowed + assertThat(SqlNameMatchers.bestMatch("empno", ImmutableList.of("empno2")), + is("empno2")); + assertThat(SqlNameMatchers.bestMatch("col", ImmutableList.of("col1")), + is("col1")); + // Object names: digit count difference is still rejected + assertThat(SqlNameMatchers.bestObjectMatch("empno", ImmutableList.of("empno2")), + nullValue()); + } +} diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java index 8ebb5ff34379..ab14c32fe9ef 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java @@ -7043,7 +7043,8 @@ public boolean isBangEqualAllowed() { .withConformance(lenient).ok(); sql("select deptno as dno, ename name, sum(sal) from emp\n" + "group by grouping sets ((1), (^name^, deptno))") - .withConformance(strict).fails("Column 'NAME' not found in any table") + .withConformance(strict) + .fails("Column 'NAME' not found in any table; did you mean 'ENAME'\\?") .withConformance(lenient).ok(); sql("select ^e.deptno^ from emp as e\n" + "join dept as d on e.deptno = d.deptno group by 1") From 472d6dec2580f2a074065ad2f487476dae77d74f Mon Sep 17 00:00:00 2001 From: hongyu guo Date: Mon, 6 Apr 2026 23:20:10 +0800 Subject: [PATCH 6/7] [CALCITE-6539] Improve did-you-mean suggestions for misspelled SQL identifiers --- .../java/org/apache/calcite/sql/validate/SqlNameMatchers.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java index 80e68941260a..effd2e414cf1 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlNameMatchers.java @@ -188,8 +188,8 @@ private static boolean isEligibleEditDistanceMatch( private static @Nullable String bestMatch(String name, Iterable candidateNames, boolean allowDigitOnlyDifference) { - final List matches = bestMatches(name, candidateNames, - allowDigitOnlyDifference); + final List matches = + bestMatches(name, candidateNames, allowDigitOnlyDifference); return matches.isEmpty() ? null : matches.get(0); } From 2264855ea3280adadc42f8b107e0b81eeaef69a6 Mon Sep 17 00:00:00 2001 From: hongyu guo Date: Tue, 7 Apr 2026 00:33:48 +0800 Subject: [PATCH 7/7] [CALCITE-6539] Improve did-you-mean suggestions for misspelled SQL identifiers --- .../apache/calcite/sql/validate/DelegatingScope.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java index 7b5dcf703250..cfabb4361693 100644 --- a/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java +++ b/core/src/main/java/org/apache/calcite/sql/validate/DelegatingScope.java @@ -193,7 +193,7 @@ protected void addColumnNames( } } - private List findCaseInsensitiveColumnSuggestions(String columnName, + private ImmutableList findCaseInsensitiveColumnSuggestions(String columnName, SqlIdentifier identifier) { final SqlNameMatcher liberalMatcher = SqlNameMatchers.liberal(); final Map map = @@ -210,7 +210,7 @@ private List findCaseInsensitiveColumnSuggestions(String columnName, } } suggestions.sort(String::compareTo); - return suggestions; + return ImmutableList.copyOf(suggestions); } private List findColumnSuggestions(String columnName) { @@ -295,15 +295,15 @@ private List findColumnSuggestions(String columnName) { return null; } - private static List fieldNames(RelDataType rowType) { + private static ImmutableList fieldNames(RelDataType rowType) { if (!rowType.isStruct()) { return ImmutableList.of(); } - final List names = new ArrayList<>(); + final ImmutableList.Builder names = ImmutableList.builder(); for (RelDataTypeField field : rowType.getFieldList()) { names.add(field.getName()); } - return names; + return names.build(); } private static List simpleNames(Iterable monikers) {