Skip to content

Commit 3a1c83e

Browse files
author
peco-engineer-bot[bot]
committed
fix(csharp): address issue #525
1 parent d29a06a commit 3a1c83e

4 files changed

Lines changed: 195 additions & 18 deletions

File tree

csharp/src/StatementExecution/MetadataCommands/MetadataCommandBase.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,32 @@ protected static string QuoteIdentifier(string identifier)
3535
return $"`{identifier.Replace("`", "``")}`";
3636
}
3737

38+
/// <summary>
39+
/// Returns true if the pattern contains an unescaped SQL-LIKE wildcard
40+
/// (<c>_</c> or <c>%</c>). Centralizes the single wildcard rule shared by both
41+
/// protocols (see <see cref="ConvertPattern"/>): a backslash escapes the next
42+
/// character, so <c>\_</c>/<c>\%</c> are literals and not treated as wildcards.
43+
/// </summary>
44+
internal static bool ContainsWildcard(string? pattern)
45+
{
46+
if (pattern == null)
47+
return false;
48+
49+
for (int i = 0; i < pattern.Length; i++)
50+
{
51+
char c = pattern[i];
52+
if (c == '\\')
53+
{
54+
// Skip the escaped character; an escaped _ or % is a literal.
55+
i++;
56+
continue;
57+
}
58+
if (c == '%' || c == '_')
59+
return true;
60+
}
61+
return false;
62+
}
63+
3864
protected static string ConvertPattern(string? pattern)
3965
{
4066
if (pattern == null)

csharp/src/StatementExecution/StatementExecutionConnection.cs

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -701,13 +701,49 @@ async Task<IReadOnlyList<string>> IGetObjectsDataProvider.GetCatalogsAsync(strin
701701
return result;
702702
}
703703

704+
/// <summary>
705+
/// Resolves a catalog pattern (from GetObjects) to the concrete catalog names to query.
706+
/// Centralizes the single wildcard rule for the catalog argument so SEA matches the
707+
/// objects Thrift would (Thrift expands <c>_</c>/<c>%</c> server-side; SEA's SHOW commands
708+
/// take the catalog as a literal backtick identifier, so we expand wildcards client-side):
709+
/// - null → returns null, meaning "all catalogs" (SHOW ... IN ALL CATALOGS).
710+
/// - no wildcard → returns the literal name as-is (no extra round-trip).
711+
/// - contains _ or % → runs SHOW CATALOGS LIKE pattern and returns matched names.
712+
/// </summary>
713+
private async Task<IReadOnlyList<string>?> ResolveCatalogsAsync(string? catalogPattern, CancellationToken cancellationToken)
714+
{
715+
if (catalogPattern == null)
716+
return null;
717+
718+
if (!MetadataCommandBase.ContainsWildcard(catalogPattern))
719+
return new[] { catalogPattern };
720+
721+
return await ((IGetObjectsDataProvider)this).GetCatalogsAsync(catalogPattern, cancellationToken).ConfigureAwait(false);
722+
}
723+
704724
async Task<IReadOnlyList<(string catalog, string schema)>> IGetObjectsDataProvider.GetSchemasAsync(string? catalogPattern, string? schemaPattern, CancellationToken cancellationToken)
705725
{
706-
// Note: catalogPattern comes from GetObjectsResultBuilder which resolves individual
707-
// catalog names before calling this method. Despite the "pattern" name (from the
708-
// IGetObjectsDataProvider interface), the value passed to ShowSchemasCommand is used
709-
// as a literal catalog identifier (backtick-quoted), not a wildcard pattern.
710-
string sql = new ShowSchemasCommand(catalogPattern, schemaPattern).Build();
726+
var result = new List<(string, string)>();
727+
728+
// Expand catalog wildcards to concrete catalog names (see ResolveCatalogsAsync).
729+
// A null result means "all catalogs"; each concrete catalog is queried individually.
730+
var catalogs = await ResolveCatalogsAsync(catalogPattern, cancellationToken).ConfigureAwait(false);
731+
if (catalogs == null)
732+
{
733+
await CollectSchemasAsync(null, schemaPattern, result, cancellationToken).ConfigureAwait(false);
734+
}
735+
else
736+
{
737+
foreach (var catalog in catalogs)
738+
await CollectSchemasAsync(catalog, schemaPattern, result, cancellationToken).ConfigureAwait(false);
739+
}
740+
return result;
741+
}
742+
743+
private async Task CollectSchemasAsync(string? catalog, string? schemaPattern,
744+
List<(string, string)> result, CancellationToken cancellationToken)
745+
{
746+
string sql = new ShowSchemasCommand(catalog, schemaPattern).Build();
711747

712748
List<RecordBatch> batches;
713749
try
@@ -716,14 +752,13 @@ async Task<IReadOnlyList<string>> IGetObjectsDataProvider.GetCatalogsAsync(strin
716752
}
717753
catch (DatabricksException ex) when (ex.IsObjectNotFoundException())
718754
{
719-
return System.Array.Empty<(string, string)>();
755+
return;
720756
}
721757

722758
// SHOW SCHEMAS IN ALL CATALOGS returns 2 columns: databaseName, catalog
723759
// SHOW SCHEMAS IN `catalog` returns 1 column: databaseName
724-
bool showSchemasInAllCatalogs = catalogPattern == null;
760+
bool showSchemasInAllCatalogs = catalog == null;
725761

726-
var result = new List<(string, string)>();
727762
foreach (var batch in batches)
728763
{
729764
StringArray? catalogArray = null;
@@ -743,19 +778,38 @@ async Task<IReadOnlyList<string>> IGetObjectsDataProvider.GetCatalogsAsync(strin
743778
for (int i = 0; i < batch.Length; i++)
744779
{
745780
if (schemaArray.IsNull(i)) continue;
746-
string catalog = catalogArray != null && !catalogArray.IsNull(i)
781+
string catalogValue = catalogArray != null && !catalogArray.IsNull(i)
747782
? catalogArray.GetString(i)
748-
: catalogPattern ?? "";
749-
result.Add((catalog, schemaArray.GetString(i)));
783+
: catalog ?? "";
784+
result.Add((catalogValue, schemaArray.GetString(i)));
750785
}
751786
}
752-
return result;
753787
}
754788

755789
async Task<IReadOnlyList<(string catalog, string schema, string table, string tableType)>> IGetObjectsDataProvider.GetTablesAsync(
756790
string? catalogPattern, string? schemaPattern, string? tableNamePattern, IReadOnlyList<string>? tableTypes, CancellationToken cancellationToken)
757791
{
758-
string sql = new ShowTablesCommand(catalogPattern, schemaPattern, tableNamePattern).Build();
792+
var result = new List<(string, string, string, string)>();
793+
794+
// Expand catalog wildcards to concrete catalog names (see ResolveCatalogsAsync).
795+
// A null result means "all catalogs"; each concrete catalog is queried individually.
796+
var catalogs = await ResolveCatalogsAsync(catalogPattern, cancellationToken).ConfigureAwait(false);
797+
if (catalogs == null)
798+
{
799+
await CollectTablesAsync(null, schemaPattern, tableNamePattern, tableTypes, result, cancellationToken).ConfigureAwait(false);
800+
}
801+
else
802+
{
803+
foreach (var catalog in catalogs)
804+
await CollectTablesAsync(catalog, schemaPattern, tableNamePattern, tableTypes, result, cancellationToken).ConfigureAwait(false);
805+
}
806+
return result;
807+
}
808+
809+
private async Task CollectTablesAsync(string? catalog, string? schemaPattern, string? tableNamePattern,
810+
IReadOnlyList<string>? tableTypes, List<(string, string, string, string)> result, CancellationToken cancellationToken)
811+
{
812+
string sql = new ShowTablesCommand(catalog, schemaPattern, tableNamePattern).Build();
759813

760814
List<RecordBatch> batches;
761815
try
@@ -764,9 +818,8 @@ async Task<IReadOnlyList<string>> IGetObjectsDataProvider.GetCatalogsAsync(strin
764818
}
765819
catch (DatabricksException ex) when (ex.IsObjectNotFoundException())
766820
{
767-
return System.Array.Empty<(string, string, string, string)>();
821+
return;
768822
}
769-
var result = new List<(string, string, string, string)>();
770823
foreach (var batch in batches)
771824
{
772825
var catalogArray = TryGetColumn<StringArray>(batch, "catalogName");
@@ -795,18 +848,31 @@ async Task<IReadOnlyList<string>> IGetObjectsDataProvider.GetCatalogsAsync(strin
795848
tableArray.GetString(i), tableType));
796849
}
797850
}
798-
return result;
799851
}
800852

801853
async Task IGetObjectsDataProvider.PopulateColumnInfoAsync(string? catalogPattern, string? schemaPattern,
802854
string? tablePattern, string? columnPattern,
803855
Dictionary<string, Dictionary<string, Dictionary<string, TableInfo>>> catalogMap,
804856
CancellationToken cancellationToken)
805857
{
806-
List<RecordBatch> batches;
858+
// Expand catalog wildcards to concrete catalog names (see ResolveCatalogsAsync) so a
859+
// wildcard catalog isn't sent to SHOW COLUMNS as a literal backtick identifier. A null
860+
// resolution means "all catalogs" — ExecuteShowColumnsAsync(null, ...) handles that by
861+
// iterating every catalog itself.
862+
var catalogs = await ResolveCatalogsAsync(catalogPattern, cancellationToken).ConfigureAwait(false);
863+
864+
List<RecordBatch> batches = new List<RecordBatch>();
807865
try
808866
{
809-
batches = await ExecuteShowColumnsAsync(catalogPattern, schemaPattern, tablePattern, columnPattern, cancellationToken).ConfigureAwait(false);
867+
if (catalogs == null)
868+
{
869+
batches.AddRange(await ExecuteShowColumnsAsync(null, schemaPattern, tablePattern, columnPattern, cancellationToken).ConfigureAwait(false));
870+
}
871+
else
872+
{
873+
foreach (var catalog in catalogs)
874+
batches.AddRange(await ExecuteShowColumnsAsync(catalog, schemaPattern, tablePattern, columnPattern, cancellationToken).ConfigureAwait(false));
875+
}
810876
}
811877
catch (DatabricksException ex) when (ex.IsObjectNotFoundException())
812878
{

csharp/test/E2E/StatementExecution/StatementExecutionDriverE2ETests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
using System;
1818
using System.Collections.Generic;
1919
using System.Diagnostics;
20+
using System.Threading.Tasks;
21+
using Apache.Arrow;
2022
using AdbcDrivers.HiveServer2;
2123
using AdbcDrivers.HiveServer2.Spark;
2224
using Apache.Arrow.Adbc;
@@ -483,5 +485,73 @@ public void ExecuteQuery_PollTimeMs_DrivesSeaPollingCadence()
483485
$"completed in {sw.ElapsedMilliseconds}ms (expected >= 1800ms). " +
484486
$"This indicates polltime_ms is not wired into SEA's polling interval.");
485487
}
488+
489+
// Issue #525: the SEA path treated the catalog argument as a literal backtick
490+
// identifier, so SQL-LIKE wildcards (`_`, `%`) in the catalog pattern were not
491+
// honored when enumerating schemas/tables/columns — diverging from Thrift, which
492+
// expands them server-side. With "main" expressed as a single-char `_` wildcard
493+
// ("mai_"), GetObjects(DbSchemas) must still return main's schemas. Before the
494+
// fix the wildcard is quoted literally (SHOW SCHEMAS IN `mai_`), the backend
495+
// finds no catalog named "mai_", and zero schema rows come back.
496+
[SkippableFact]
497+
public async Task GetObjects_CatalogUnderscoreWildcard_MatchesCatalog()
498+
{
499+
SkipIfNotConfigured();
500+
501+
using var connection = CreateRestConnection(new Dictionary<string, string>
502+
{
503+
[DatabricksParameters.EnableMultipleCatalogSupport] = "true",
504+
});
505+
506+
// "mai_" uses the single-char `_` wildcard, which matches the catalog "main".
507+
var schemas = await CollectDbSchemas(connection, catalogPattern: "mai_");
508+
509+
Assert.Contains(
510+
schemas,
511+
s => string.Equals(s.catalog, "main", StringComparison.OrdinalIgnoreCase));
512+
}
513+
514+
private static async Task<List<(string catalog, string schema)>> CollectDbSchemas(
515+
AdbcConnection connection, string? catalogPattern)
516+
{
517+
var result = new List<(string, string)>();
518+
using var stream = connection.GetObjects(
519+
AdbcConnection.GetObjectsDepth.DbSchemas,
520+
catalogPattern: catalogPattern,
521+
dbSchemaPattern: null,
522+
tableNamePattern: null,
523+
tableTypes: null,
524+
columnNamePattern: null);
525+
526+
while (true)
527+
{
528+
using var batch = await stream.ReadNextRecordBatchAsync();
529+
if (batch == null) break;
530+
531+
var catalogArray = batch.Column("catalog_name") as StringArray;
532+
var dbSchemasArray = batch.Column("catalog_db_schemas") as ListArray;
533+
if (catalogArray == null) continue;
534+
535+
for (int catIdx = 0; catIdx < batch.Length; catIdx++)
536+
{
537+
if (catalogArray.IsNull(catIdx)) continue;
538+
string catalog = catalogArray.GetString(catIdx);
539+
540+
if (dbSchemasArray == null || dbSchemasArray.IsNull(catIdx)) continue;
541+
if (dbSchemasArray.GetSlicedValues(catIdx) is not StructArray schemaStruct) continue;
542+
543+
var schemaNameArray = schemaStruct.Fields.Count > 0 ? schemaStruct.Fields[0] as StringArray : null;
544+
if (schemaNameArray == null) continue;
545+
546+
for (int schemaIdx = 0; schemaIdx < schemaStruct.Length; schemaIdx++)
547+
{
548+
if (schemaNameArray.IsNull(schemaIdx)) continue;
549+
result.Add((catalog, schemaNameArray.GetString(schemaIdx)));
550+
}
551+
}
552+
}
553+
554+
return result;
555+
}
486556
}
487557
}

csharp/test/Unit/StatementExecution/ShowCommandTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,5 +188,20 @@ public void ShowCatalogs_EmptyPattern()
188188
{
189189
Assert.Equal("SHOW CATALOGS LIKE ''", new ShowCatalogsCommand("").Build());
190190
}
191+
192+
// ContainsWildcard — the single, shared wildcard rule (issue #525).
193+
194+
[Theory]
195+
[InlineData(null, false)]
196+
[InlineData("", false)]
197+
[InlineData("main", false)]
198+
[InlineData("comparator_tests", true)] // unescaped _ is a wildcard
199+
[InlineData("ma%", true)] // unescaped % is a wildcard
200+
[InlineData("comparator\\_tests", false)] // escaped \_ is a literal
201+
[InlineData("ma\\%", false)] // escaped \% is a literal
202+
public void ContainsWildcard_DetectsUnescapedWildcards(string? pattern, bool expected)
203+
{
204+
Assert.Equal(expected, MetadataCommandBase.ContainsWildcard(pattern));
205+
}
191206
}
192207
}

0 commit comments

Comments
 (0)