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..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 @@ -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,127 @@ protected void addColumnNames( } } + private ImmutableList 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 ImmutableList.copyOf(suggestions); + } + + 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) { + if (prefix.names.get(0).length() <= 2) { + return null; + } + final List aliases = new ArrayList<>(); + findAliases(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) { + 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; + } + return null; + } + return null; + } + + private static ImmutableList fieldNames(RelDataType rowType) { + if (!rowType.isStruct()) { + return ImmutableList.of(); + } + final ImmutableList.Builder names = ImmutableList.builder(); + for (RelDataTypeField field : rowType.getFieldList()) { + names.add(field.getName()); + } + return names.build(); + } + + 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 +390,18 @@ 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 caseInsensitiveSuggestions = + findCaseInsensitiveColumnSuggestions(columnName, identifier); + if (!caseInsensitiveSuggestions.isEmpty()) { throw validator.newValidationError(identifier, RESOURCE.columnNotFoundDidYouMean(columnName, - Util.sepList(list, "', '"))); + Util.sepList(caseInsensitiveSuggestions, "', '"))); + } + final List suggestions = findColumnSuggestions(columnName); + if (!suggestions.isEmpty()) { + throw validator.newValidationError(identifier, + RESOURCE.columnNotFoundDidYouMean(columnName, + Util.sepList(suggestions, "', '"))); } } throw validator.newValidationError(identifier, @@ -342,14 +456,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 +472,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.bestObjectMatch(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 +562,28 @@ 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 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); + 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..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 @@ -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.bestObjectMatch(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..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 @@ -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,278 @@ 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) { + 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. + 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); + if (!isEligibleEditDistanceMatch(name, candidateName, + normalizedName, normalizedCandidateName, allowDigitOnlyDifference)) { + ordinal++; + continue; + } + 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 Set corrections = new LinkedHashSet<>(); + 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(); + } + + 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 (!name.equals(name.trim()) || !candidateName.equals(candidateName.trim())) { + if (candidateName.trim().equalsIgnoreCase(name.trim())) { + return false; + } + } + if (!allowDigitOnlyDifference) { + if (hasOnlyDigitDifference(normalizedName, normalizedCandidateName)) { + return false; + } + if (digitCount(name) != digitCount(candidateName)) { + return false; + } + } + return true; + } + + /** Returns the best near-match suggestion for a name. */ + public static @Nullable String bestMatch(String name, Iterable 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); + } + + /** 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 = + bestObjectMatch(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 = bestObjectMatch(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 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; + 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/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 dd66362819a7..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") @@ -10014,6 +10015,65 @@ void testGroupExpressionEquivalenceParams() { + "did you mean 'DEPTNO', 'deptNo'\\?"); } + /** Test case for [CALCITE-6539] + * 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'\\?"); + 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'\\?"); + } + + /** Test case for [CALCITE-6539] + * Improve did-you-mean suggestions for misspelled SQL identifiers. */ + @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'\\?"); + } + + /** 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()