From a34a66d4df4b56d09a6ce764a6feef93112df715 Mon Sep 17 00:00:00 2001 From: David Boone Date: Sat, 5 Sep 2026 21:54:59 -0700 Subject: [PATCH] feat: accept schema qualified names in SchemaReaderOptions.Tables Table filters matched on the unqualified name alone, so "Orders" pulled in every schema's Orders and there was no way to ask for just one of them. Entries may now be written as "sales.orders", which pins the table to that schema, while bare names keep matching in any schema. That is the convention EF Core scaffolding uses for DatabaseModelFactoryOptions.Tables, so filters written for it carry over unchanged. Entries are read with SchemaQualifiedName.Parse, so the first dot separates schema from name here as it does everywhere else. Names reach the database as written and are compared with its own identifier rules; only the in-memory SQLite path folds case, matching how SQLite looks objects up. The four SQL providers share the condition builder and supply their own set membership syntax, which differs in escaping and, on Oracle, in case handling. --- README.md | 11 +- .../Internal/TableFilter.cs | 101 ++++++++++++++++++ .../Provider/SchemaReaderOptions.cs | 9 +- src/SchemaSaurus.MySql/MySqlSchemaReader.cs | 12 ++- src/SchemaSaurus.Oracle/OracleSchemaReader.cs | 5 +- .../PostgreSqlSchemaReader.cs | 12 ++- .../SqlServerSchemaReader.cs | 13 ++- .../SqliteSchemaReader.Tables.cs | 5 +- .../SqliteSchemaReader.Views.cs | 3 +- src/SchemaSaurus.Sqlite/SqliteSchemaReader.cs | 3 + .../Internal/TableFilterTests.cs | 53 +++++++++ .../TableSchemaTests.cs | 29 +++++ .../TableSchemaTests.cs | 29 +++++ .../TableSchemaTests.cs | 45 ++++++++ .../TableSchemaTests.cs | 45 ++++++++ .../TableSchemaTests.cs | 27 +++++ 16 files changed, 378 insertions(+), 24 deletions(-) create mode 100644 src/SchemaSaurus.Metadata/Internal/TableFilter.cs create mode 100644 test/SchemaSaurus.Metadata.Tests/Internal/TableFilterTests.cs diff --git a/README.md b/README.md index 745d6cb..f159acd 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Console.WriteLine($"Tables: {model.Tables.Count}, Views: {model.Views.Count}"); var options = new SchemaReaderOptions { Schemas = ["dbo", "Sales"], + Tables = ["Customer", "Sales.Orders"], IncludeStoredProcedures = false, IncludeScalarFunctions = false, }; @@ -77,6 +78,14 @@ var options = new SchemaReaderOptions var model = await reader.ReadAsync(connectionString, options); ``` +`Tables` entries may be schema qualified (`"Sales.Orders"`), which pins the table to that +schema; the first dot separates the schema from the table name. Unqualified entries +(`"Customer"`) match that name in any schema. + +Names are passed to the database as written and compared with its own identifier rules. +A PostgreSQL table created unquoted as `Sales.Orders` is stored as `sales.orders` and has +to be filtered with that spelling; quoted identifiers keep their case. + ### JSON Serialization The model uses source-generated `System.Text.Json` serialization via `MetadataJsonContext` for AOT-friendly, allocation-light round-tripping. @@ -114,7 +123,7 @@ Supported providers are `SqlServer`, `PostgreSQL`, `MySQL`, `Oracle`, and `SQLit Filter the exported metadata with `--schema` and `--table`, and exclude object types with boolean switches: ```powershell -SchemaSaurus export -c "Server=.;Database=AdventureWorks;Integrated Security=true;TrustServerCertificate=true" -p SqlServer -o .\schema.json --schema dbo --table Customer --exclude-stored-procedures --exclude-sequences +SchemaSaurus export -c "Server=.;Database=AdventureWorks;Integrated Security=true;TrustServerCertificate=true" -p SqlServer -o .\schema.json --schema dbo --table Customer --table Sales.Orders --exclude-stored-procedures --exclude-sequences ``` Available exclusion switches are `--exclude-views`, `--exclude-stored-procedures`, `--exclude-scalar-functions`, `--exclude-table-valued-functions`, `--exclude-sequences`, and `--exclude-user-defined-types`. diff --git a/src/SchemaSaurus.Metadata/Internal/TableFilter.cs b/src/SchemaSaurus.Metadata/Internal/TableFilter.cs new file mode 100644 index 0000000..04eed33 --- /dev/null +++ b/src/SchemaSaurus.Metadata/Internal/TableFilter.cs @@ -0,0 +1,101 @@ +namespace SchemaSaurus.Metadata.Internal; + +/// +/// Builds provider filters from entries, +/// which may be bare names ("orders") or schema qualified ("sales.orders"). +/// +/// +/// Entries are split with . Schema names are carried +/// through to the generated SQL verbatim, so the database decides how they compare; +/// , which filters in memory, compares ordinal case-insensitively. +/// +public static class TableFilter +{ + /// + /// Builds a SQL condition that matches the specified table entries, pinning schema + /// qualified entries to their schema. + /// + /// The table entries to match; must not be empty. + /// The SQL expression yielding the schema name. + /// The SQL expression yielding the object name. + /// + /// Builds a provider specific set membership condition for the specified values and expression. + /// + /// A SQL condition such as (t.name IN ('orders') OR (s.name = 'sales' AND t.name IN ('orders'))). + public static string Build( + IReadOnlyCollection tables, + string schemaExpression, + string tableExpression, + Func, string, string> inClauseBuilder) + { + ArgumentNullException.ThrowIfNull(tables); + ArgumentNullException.ThrowIfNull(inClauseBuilder); + + var unqualified = new List(); + + // Schemas are kept in first-seen order so the generated condition is stable across calls. + var schemas = new List(); + var qualified = new Dictionary>(StringComparer.Ordinal); + + foreach (var table in tables) + { + var entry = SchemaQualifiedName.Parse(table); + var schema = entry.Schema; + + if (string.IsNullOrEmpty(schema)) + { + unqualified.Add(entry.Name); + continue; + } + + if (!qualified.TryGetValue(schema, out var names)) + { + names = []; + qualified[schema] = names; + schemas.Add(schema); + } + + names.Add(entry.Name); + } + + var conditions = new List(schemas.Count + 1); + + if (unqualified.Count > 0) + conditions.Add(inClauseBuilder(unqualified, tableExpression)); + + foreach (var schema in schemas) + conditions.Add($"({inClauseBuilder([schema], schemaExpression)} AND {inClauseBuilder(qualified[schema], tableExpression)})"); + + return conditions.Count == 1 + ? conditions[0] + : $"({string.Join(" OR ", conditions)})"; + } + + /// + /// Determines whether an object is matched by the specified table entries. + /// + /// The table entries to match; an empty collection matches everything. + /// The schema of the object, or when the provider has none. + /// The unqualified object name. + /// when the object is included; otherwise . + public static bool IsMatch(IReadOnlyCollection tables, string? schema, string name) + { + ArgumentNullException.ThrowIfNull(tables); + + if (tables.Count == 0) + return true; + + foreach (var table in tables) + { + var entry = SchemaQualifiedName.Parse(table); + + if (!string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)) + continue; + + if (string.IsNullOrEmpty(entry.Schema) || string.Equals(entry.Schema, schema, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/SchemaSaurus.Metadata/Provider/SchemaReaderOptions.cs b/src/SchemaSaurus.Metadata/Provider/SchemaReaderOptions.cs index fc3b64d..297fc3f 100644 --- a/src/SchemaSaurus.Metadata/Provider/SchemaReaderOptions.cs +++ b/src/SchemaSaurus.Metadata/Provider/SchemaReaderOptions.cs @@ -5,7 +5,8 @@ namespace SchemaSaurus.Metadata.Provider; /// reads a database schema. /// /// -/// All filter lists use ordinal case-insensitive matching. An empty list means "include all." +/// Filter list entries are passed to the provider as written and compared with the +/// database's own identifier rules. An empty list means "include all." /// Boolean flags default to so that a default-constructed instance /// captures a complete snapshot. /// @@ -19,8 +20,10 @@ public sealed class SchemaReaderOptions /// /// Table names to include. When empty, all tables are included. - /// Names are matched without schema qualification; combine with - /// for scoped filtering. + /// Entries may be schema qualified ("sales.orders"), which pins the table to that + /// schema; the first dot separates the schema from the table name. Unqualified entries + /// ("orders") match the name in any schema; combine with for + /// scoped filtering. /// public IReadOnlyList Tables { get; init; } = []; diff --git a/src/SchemaSaurus.MySql/MySqlSchemaReader.cs b/src/SchemaSaurus.MySql/MySqlSchemaReader.cs index 2bf2424..7b41284 100644 --- a/src/SchemaSaurus.MySql/MySqlSchemaReader.cs +++ b/src/SchemaSaurus.MySql/MySqlSchemaReader.cs @@ -6,6 +6,7 @@ using SchemaSaurus.Metadata; using SchemaSaurus.Metadata.Builders; using SchemaSaurus.Metadata.Extensions; +using SchemaSaurus.Metadata.Internal; using SchemaSaurus.Metadata.Provider; namespace SchemaSaurus.MySql; @@ -205,14 +206,17 @@ private static string BuildInformationSchemaTableFilter(SchemaReaderOptions opti conditions.Add(schemaFilter); if (options.Tables.Count > 0) - { - var list = string.Join(", ", options.Tables.Select(value => value.EscapeLiteral())); - conditions.Add($"{tableExpression} IN ({list})"); - } + conditions.Add(TableFilter.Build(options.Tables, schemaExpression, tableExpression, BuildInClause)); return string.Join("\n AND ", conditions); } + private static string BuildInClause(IReadOnlyCollection values, string expression) + { + var list = string.Join(", ", values.Select(value => value.EscapeLiteral())); + return $"{expression} IN ({list})"; + } + private static string? BuildSchemaFilter(IReadOnlyCollection schemas, string schemaExpression) { if (schemas.Count == 0) diff --git a/src/SchemaSaurus.Oracle/OracleSchemaReader.cs b/src/SchemaSaurus.Oracle/OracleSchemaReader.cs index 20b574b..86f5f88 100644 --- a/src/SchemaSaurus.Oracle/OracleSchemaReader.cs +++ b/src/SchemaSaurus.Oracle/OracleSchemaReader.cs @@ -190,10 +190,7 @@ private static string BuildObjectFilter(SchemaReaderOptions options, string sche List conditions = [BuildSchemaFilter(options.Schemas, schemaExpression)]; if (options.Tables.Count > 0) - { - var tableFilter = BuildCaseInsensitiveFilter(options.Tables, objectExpression); - conditions.Add(tableFilter); - } + conditions.Add(TableFilter.Build(options.Tables, schemaExpression, objectExpression, BuildCaseInsensitiveFilter)); return string.Join("\n AND ", conditions); } diff --git a/src/SchemaSaurus.PostgreSql/PostgreSqlSchemaReader.cs b/src/SchemaSaurus.PostgreSql/PostgreSqlSchemaReader.cs index 44b081b..3aa742e 100644 --- a/src/SchemaSaurus.PostgreSql/PostgreSqlSchemaReader.cs +++ b/src/SchemaSaurus.PostgreSql/PostgreSqlSchemaReader.cs @@ -5,6 +5,7 @@ using SchemaSaurus.Metadata; using SchemaSaurus.Metadata.Builders; using SchemaSaurus.Metadata.Extensions; +using SchemaSaurus.Metadata.Internal; using SchemaSaurus.Metadata.Provider; namespace SchemaSaurus.PostgreSql; @@ -244,14 +245,17 @@ private static string BuildTableFilter(SchemaReaderOptions options, string schem conditions.Add(schemaFilter); if (options.Tables.Count > 0) - { - var list = string.Join(", ", options.Tables.Select(value => value.EscapeLiteral())); - conditions.Add($"{tableExpression} IN ({list})"); - } + conditions.Add(TableFilter.Build(options.Tables, schemaExpression, tableExpression, BuildInClause)); return string.Join("\n AND ", conditions); } + private static string BuildInClause(IReadOnlyCollection values, string expression) + { + var list = string.Join(", ", values.Select(value => value.EscapeLiteral())); + return $"{expression} IN ({list})"; + } + private static string? BuildSchemaFilter(IReadOnlyCollection schemas, string schemaExpression) { if (schemas.Count == 0) diff --git a/src/SchemaSaurus.SqlServer/SqlServerSchemaReader.cs b/src/SchemaSaurus.SqlServer/SqlServerSchemaReader.cs index 932b4c8..e7c814b 100644 --- a/src/SchemaSaurus.SqlServer/SqlServerSchemaReader.cs +++ b/src/SchemaSaurus.SqlServer/SqlServerSchemaReader.cs @@ -6,6 +6,7 @@ using SchemaSaurus.Metadata; using SchemaSaurus.Metadata.Builders; using SchemaSaurus.Metadata.Extensions; +using SchemaSaurus.Metadata.Internal; using SchemaSaurus.Metadata.Provider; namespace SchemaSaurus.SqlServer; @@ -276,10 +277,7 @@ private static string BuildTableFilter(SchemaReaderOptions options) // If specific tables are specified in the options, add a filter condition to include only those tables. if (options.Tables.Count > 0) - { - var list = string.Join(", ", options.Tables.Select(EscapeUnicodeLiteral)); - conditions.Add($"t.name IN ({list})"); - } + conditions.Add(TableFilter.Build(options.Tables, "SCHEMA_NAME(t.schema_id)", "t.name", BuildInClause)); // Combine all conditions into a single WHERE clause string, joining them with "AND". return string.Join("\n AND ", conditions); @@ -301,6 +299,13 @@ private static string BuildTableFilter(SchemaReaderOptions options) } + private static string BuildInClause(IReadOnlyCollection values, string expression) + { + var list = string.Join(", ", values.Select(EscapeUnicodeLiteral)); + return $"{expression} IN ({list})"; + } + + private static string EscapeUnicodeLiteral(string value) => $"N{value.EscapeLiteral()}"; diff --git a/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Tables.cs b/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Tables.cs index 27b96f2..0f8ccf3 100644 --- a/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Tables.cs +++ b/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Tables.cs @@ -1,6 +1,7 @@ using Microsoft.Data.Sqlite; using SchemaSaurus.Metadata.Builders; +using SchemaSaurus.Metadata.Internal; using SchemaSaurus.Metadata.Provider; namespace SchemaSaurus.Sqlite; @@ -68,9 +69,7 @@ ORDER BY name continue; } - // Keep the provider-side filter case-insensitive to match SQLite object lookup behavior. - if (options.Tables.Count > 0 - && !options.Tables.Contains(name, StringComparer.OrdinalIgnoreCase)) + if (!TableFilter.IsMatch(options.Tables, MainSchemaName, name)) { continue; } diff --git a/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Views.cs b/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Views.cs index fb925ec..10dc7a2 100644 --- a/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Views.cs +++ b/src/SchemaSaurus.Sqlite/SqliteSchemaReader.Views.cs @@ -1,6 +1,7 @@ using Microsoft.Data.Sqlite; using SchemaSaurus.Metadata.Builders; +using SchemaSaurus.Metadata.Internal; using SchemaSaurus.Metadata.Extensions; using SchemaSaurus.Metadata.Provider; @@ -34,7 +35,7 @@ ORDER BY name { var viewName = reader.GetString(viewNameOrdinal); if (IsSpatialiteObject(viewName) - || (options.Tables.Count > 0 && !options.Tables.Contains(viewName, StringComparer.OrdinalIgnoreCase))) + || !TableFilter.IsMatch(options.Tables, MainSchemaName, viewName)) { continue; } diff --git a/src/SchemaSaurus.Sqlite/SqliteSchemaReader.cs b/src/SchemaSaurus.Sqlite/SqliteSchemaReader.cs index 4c10a66..09e7b93 100644 --- a/src/SchemaSaurus.Sqlite/SqliteSchemaReader.cs +++ b/src/SchemaSaurus.Sqlite/SqliteSchemaReader.cs @@ -110,6 +110,9 @@ private static async Task ReadDatabasePragmasAsync( // Note: SQLite does not support sequences, stored procedures, functions, or user-defined types. + // Schema qualified filter entries are matched against SQLite's default attached database. + private const string MainSchemaName = "main"; + private static bool IsSpatialiteObject(string name) => SpatialiteObjectNames.Contains(name, StringComparer.Ordinal); } diff --git a/test/SchemaSaurus.Metadata.Tests/Internal/TableFilterTests.cs b/test/SchemaSaurus.Metadata.Tests/Internal/TableFilterTests.cs new file mode 100644 index 0000000..cefc53c --- /dev/null +++ b/test/SchemaSaurus.Metadata.Tests/Internal/TableFilterTests.cs @@ -0,0 +1,53 @@ +using SchemaSaurus.Metadata.Extensions; +using SchemaSaurus.Metadata.Internal; + +namespace SchemaSaurus.Metadata.Tests.Internal; + +public class TableFilterTests +{ + [Fact] + public void WhenTablesQualifiedThenSchemaPinned() + { + var filter = TableFilter.Build(["sales.orders", "sales.customers"], "ns.nspname", "cls.relname", BuildInClause); + + filter.Should().Be("(ns.nspname IN ('sales') AND cls.relname IN ('orders', 'customers'))"); + } + + [Fact] + public void WhenTablesMixedThenConditionsCombinedWithOr() + { + var filter = TableFilter.Build(["orders", "shipping.orders"], "ns.nspname", "cls.relname", BuildInClause); + + filter.Should().Be("(cls.relname IN ('orders') OR (ns.nspname IN ('shipping') AND cls.relname IN ('orders')))"); + } + + [Fact] + public void WhenSchemasDifferOnlyByCaseThenEachKeepsItsOwnSpelling() + { + var filter = TableFilter.Build(["Sales.orders", "sales.customers"], "ns.nspname", "cls.relname", BuildInClause); + + filter.Should().Be( + "((ns.nspname IN ('Sales') AND cls.relname IN ('orders')) " + + "OR (ns.nspname IN ('sales') AND cls.relname IN ('customers')))"); + } + + [Fact] + public void WhenEntryHasMultipleDotsThenFirstDotSeparatesSchema() + { + var filter = TableFilter.Build(["catalog.sales.orders"], "ns.nspname", "cls.relname", BuildInClause); + + filter.Should().Be("(ns.nspname IN ('catalog') AND cls.relname IN ('sales.orders'))"); + } + + [Fact] + public void WhenEntryQualifiedThenOnlyMatchingSchemaMatched() + { + TableFilter.IsMatch([], "main", "orders").Should().BeTrue(); + TableFilter.IsMatch(["orders"], "main", "ORDERS").Should().BeTrue(); + TableFilter.IsMatch(["main.orders"], "main", "orders").Should().BeTrue(); + TableFilter.IsMatch(["sales.orders"], "main", "orders").Should().BeFalse(); + } + + private static string BuildInClause(IReadOnlyCollection values, string expression) + => $"{expression} IN ({string.Join(", ", values.Select(value => value.EscapeLiteral()))})"; +} diff --git a/test/SchemaSaurus.MySql.Tests/TableSchemaTests.cs b/test/SchemaSaurus.MySql.Tests/TableSchemaTests.cs index c24c124..012d3a5 100644 --- a/test/SchemaSaurus.MySql.Tests/TableSchemaTests.cs +++ b/test/SchemaSaurus.MySql.Tests/TableSchemaTests.cs @@ -261,6 +261,35 @@ public async Task WhenFilteringByTableNameThenOnlyMatchingTablesReturned() model.Tables[0].QualifiedName.Name.Should().Be("Status"); } + [Fact] + public async Task WhenFilteringBySchemaQualifiedTableNameThenOnlyThatSchemaMatched() + { + var expected = (await GetDatabaseModelAsync()).Tables.First(t => t.QualifiedName.Name == "Status"); + + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = [$"{expected.QualifiedName.Schema}.Status"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().ContainSingle() + .Which.QualifiedName.Should().Be(expected.QualifiedName); + } + + [Fact] + public async Task WhenFilteringByUnknownSchemaThenNoTablesReturned() + { + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = ["Missing.Status"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().BeEmpty(); + } + [Fact] public async Task WhenReadingColumnsOrdinalPositionsArePopulated() { diff --git a/test/SchemaSaurus.Oracle.Tests/TableSchemaTests.cs b/test/SchemaSaurus.Oracle.Tests/TableSchemaTests.cs index ad483aa..9ececf0 100644 --- a/test/SchemaSaurus.Oracle.Tests/TableSchemaTests.cs +++ b/test/SchemaSaurus.Oracle.Tests/TableSchemaTests.cs @@ -227,6 +227,35 @@ public async Task WhenFilteringByTableNameThenOnlyMatchingTablesReturned() model.Tables[0].QualifiedName.Name.Should().Be(StatusTableName); } + [Fact] + public async Task WhenFilteringBySchemaQualifiedTableNameThenOnlyThatSchemaMatched() + { + var expected = (await GetDatabaseModelAsync()).Tables.First(t => t.QualifiedName.Name == StatusTableName); + + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = [$"{expected.QualifiedName.Schema}.Status"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().ContainSingle() + .Which.QualifiedName.Should().Be(expected.QualifiedName); + } + + [Fact] + public async Task WhenFilteringByUnknownSchemaThenNoTablesReturned() + { + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = ["Missing.Status"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().BeEmpty(); + } + [Fact] public async Task WhenReadingColumnsOrdinalPositionsArePopulated() { diff --git a/test/SchemaSaurus.PostgreSql.Tests/TableSchemaTests.cs b/test/SchemaSaurus.PostgreSql.Tests/TableSchemaTests.cs index ef44edc..9971b20 100644 --- a/test/SchemaSaurus.PostgreSql.Tests/TableSchemaTests.cs +++ b/test/SchemaSaurus.PostgreSql.Tests/TableSchemaTests.cs @@ -249,6 +249,51 @@ public async Task WhenFilteringByTableNameThenOnlyMatchingTablesReturned() model.Tables[0].QualifiedName.Name.Should().Be("Status"); } + [Fact] + public async Task WhenFilteringByUnqualifiedTableNameThenAllSchemasMatched() + { + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = ["Duplicate"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().HaveCount(2); + model.Tables.Should().Contain(t => t.QualifiedName.Schema == "public"); + model.Tables.Select(t => t.QualifiedName.Schema).Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task WhenFilteringBySchemaQualifiedTableNameThenOnlyThatSchemaMatched() + { + var expected = (await GetDatabaseModelAsync()).Tables + .First(t => t.QualifiedName.Name == "Duplicate" && t.QualifiedName.Schema != "public"); + + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = [$"{expected.QualifiedName.Schema}.Duplicate"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().ContainSingle() + .Which.QualifiedName.Should().Be(expected.QualifiedName); + } + + [Fact] + public async Task WhenFilteringByUnknownSchemaThenNoTablesReturned() + { + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = ["Missing.Duplicate"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().BeEmpty(); + } + [Fact] public async Task WhenReadingColumnsOrdinalPositionsArePopulated() { diff --git a/test/SchemaSaurus.SqlServer.Tests/TableSchemaTests.cs b/test/SchemaSaurus.SqlServer.Tests/TableSchemaTests.cs index 288e6f5..cda6925 100644 --- a/test/SchemaSaurus.SqlServer.Tests/TableSchemaTests.cs +++ b/test/SchemaSaurus.SqlServer.Tests/TableSchemaTests.cs @@ -245,6 +245,51 @@ public async Task WhenFilteringByTableNameThenOnlyMatchingTablesReturned() model.Tables[0].QualifiedName.Name.Should().Be("Status"); } + [Fact] + public async Task WhenFilteringByUnqualifiedTableNameThenAllSchemasMatched() + { + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = ["Duplicate"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().HaveCount(2); + model.Tables.Should().Contain(t => t.QualifiedName.Schema == "dbo"); + model.Tables.Select(t => t.QualifiedName.Schema).Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task WhenFilteringBySchemaQualifiedTableNameThenOnlyThatSchemaMatched() + { + var expected = (await GetDatabaseModelAsync()).Tables + .First(t => t.QualifiedName.Name == "Duplicate" && t.QualifiedName.Schema != "dbo"); + + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = [$"{expected.QualifiedName.Schema}.Duplicate"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().ContainSingle() + .Which.QualifiedName.Should().Be(expected.QualifiedName); + } + + [Fact] + public async Task WhenFilteringByUnknownSchemaThenNoTablesReturned() + { + var options = new Metadata.Provider.SchemaReaderOptions + { + Tables = ["Missing.Duplicate"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().BeEmpty(); + } + [Fact] public async Task WhenReadingColumnsOrdinalPositionsArePopulated() { diff --git a/test/SchemaSaurus.Sqlite.Tests/TableSchemaTests.cs b/test/SchemaSaurus.Sqlite.Tests/TableSchemaTests.cs index dce4315..a6594cf 100644 --- a/test/SchemaSaurus.Sqlite.Tests/TableSchemaTests.cs +++ b/test/SchemaSaurus.Sqlite.Tests/TableSchemaTests.cs @@ -223,6 +223,33 @@ public async Task WhenFilteringByTableNameThenOnlyMatchingTablesReturned() model.Tables[0].QualifiedName.Name.Should().Be("Status"); } + [Fact] + public async Task WhenFilteringByMainSchemaQualifiedTableNameThenTableReturned() + { + var options = new SchemaReaderOptions + { + Tables = ["main.Status"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().ContainSingle() + .Which.QualifiedName.Name.Should().Be("Status"); + } + + [Fact] + public async Task WhenFilteringByOtherSchemaQualifiedTableNameThenNoTablesReturned() + { + var options = new SchemaReaderOptions + { + Tables = ["other.Status"] + }; + + var model = await GetDatabaseModelAsync(options); + + model.Tables.Should().BeEmpty(); + } + [Fact] public async Task WhenReadingColumnsOrdinalPositionsArePopulated() {