Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,22 @@ 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,
};

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.
Expand Down Expand Up @@ -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`.
Expand Down
101 changes: 101 additions & 0 deletions src/SchemaSaurus.Metadata/Internal/TableFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
namespace SchemaSaurus.Metadata.Internal;

/// <summary>
/// Builds provider filters from <see cref="Provider.SchemaReaderOptions.Tables"/> entries,
/// which may be bare names (<c>"orders"</c>) or schema qualified (<c>"sales.orders"</c>).
/// </summary>
/// <remarks>
/// Entries are split with <see cref="SchemaQualifiedName.Parse"/>. Schema names are carried
/// through to the generated SQL verbatim, so the database decides how they compare;
/// <see cref="IsMatch"/>, which filters in memory, compares ordinal case-insensitively.
/// </remarks>
public static class TableFilter
{
/// <summary>
/// Builds a SQL condition that matches the specified table entries, pinning schema
/// qualified entries to their schema.
/// </summary>
/// <param name="tables">The table entries to match; must not be empty.</param>
/// <param name="schemaExpression">The SQL expression yielding the schema name.</param>
/// <param name="tableExpression">The SQL expression yielding the object name.</param>
/// <param name="inClauseBuilder">
/// Builds a provider specific set membership condition for the specified values and expression.
/// </param>
/// <returns>A SQL condition such as <c>(t.name IN ('orders') OR (s.name = 'sales' AND t.name IN ('orders')))</c>.</returns>
public static string Build(
IReadOnlyCollection<string> tables,
string schemaExpression,
string tableExpression,
Func<IReadOnlyCollection<string>, string, string> inClauseBuilder)
{
ArgumentNullException.ThrowIfNull(tables);
ArgumentNullException.ThrowIfNull(inClauseBuilder);

var unqualified = new List<string>();

// Schemas are kept in first-seen order so the generated condition is stable across calls.
var schemas = new List<string>();
var qualified = new Dictionary<string, List<string>>(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))

Check warning on line 51 in src/SchemaSaurus.Metadata/Internal/TableFilter.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'key' in 'bool Dictionary<string, List<string>>.TryGetValue(string key, out List<string> value)'.

Check warning on line 51 in src/SchemaSaurus.Metadata/Internal/TableFilter.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'key' in 'bool Dictionary<string, List<string>>.TryGetValue(string key, out List<string> value)'.
{
names = [];
qualified[schema] = names;
schemas.Add(schema);
}

names.Add(entry.Name);
}

var conditions = new List<string>(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)})";
}

/// <summary>
/// Determines whether an object is matched by the specified table entries.
/// </summary>
/// <param name="tables">The table entries to match; an empty collection matches everything.</param>
/// <param name="schema">The schema of the object, or <see langword="null"/> when the provider has none.</param>
/// <param name="name">The unqualified object name.</param>
/// <returns><see langword="true"/> when the object is included; otherwise <see langword="false"/>.</returns>
public static bool IsMatch(IReadOnlyCollection<string> 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;
}
}
9 changes: 6 additions & 3 deletions src/SchemaSaurus.Metadata/Provider/SchemaReaderOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace SchemaSaurus.Metadata.Provider;
/// <see cref="IDatabaseSchemaReader"/> reads a database schema.
/// </summary>
/// <remarks>
/// 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 <see langword="true"/> so that a default-constructed instance
/// captures a complete snapshot.
/// </remarks>
Expand All @@ -19,8 +20,10 @@ public sealed class SchemaReaderOptions

/// <summary>
/// Table names to include. When empty, all tables are included.
/// Names are matched without schema qualification; combine with
/// <see cref="Schemas"/> for scoped filtering.
/// Entries may be schema qualified (<c>"sales.orders"</c>), which pins the table to that
/// schema; the first dot separates the schema from the table name. Unqualified entries
/// (<c>"orders"</c>) match the name in any schema; combine with <see cref="Schemas"/> for
/// scoped filtering.
/// </summary>
public IReadOnlyList<string> Tables { get; init; } = [];

Expand Down
12 changes: 8 additions & 4 deletions src/SchemaSaurus.MySql/MySqlSchemaReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> values, string expression)
{
var list = string.Join(", ", values.Select(value => value.EscapeLiteral()));
return $"{expression} IN ({list})";
}

private static string? BuildSchemaFilter(IReadOnlyCollection<string> schemas, string schemaExpression)
{
if (schemas.Count == 0)
Expand Down
5 changes: 1 addition & 4 deletions src/SchemaSaurus.Oracle/OracleSchemaReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,7 @@ private static string BuildObjectFilter(SchemaReaderOptions options, string sche
List<string> 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);
}
Expand Down
12 changes: 8 additions & 4 deletions src/SchemaSaurus.PostgreSql/PostgreSqlSchemaReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> values, string expression)
{
var list = string.Join(", ", values.Select(value => value.EscapeLiteral()));
return $"{expression} IN ({list})";
}

private static string? BuildSchemaFilter(IReadOnlyCollection<string> schemas, string schemaExpression)
{
if (schemas.Count == 0)
Expand Down
13 changes: 9 additions & 4 deletions src/SchemaSaurus.SqlServer/SqlServerSchemaReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -301,6 +299,13 @@ private static string BuildTableFilter(SchemaReaderOptions options)
}


private static string BuildInClause(IReadOnlyCollection<string> values, string expression)
{
var list = string.Join(", ", values.Select(EscapeUnicodeLiteral));
return $"{expression} IN ({list})";
}


private static string EscapeUnicodeLiteral(string value)
=> $"N{value.EscapeLiteral()}";

Expand Down
5 changes: 2 additions & 3 deletions src/SchemaSaurus.Sqlite/SqliteSchemaReader.Tables.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;

using SchemaSaurus.Metadata.Builders;
using SchemaSaurus.Metadata.Internal;
using SchemaSaurus.Metadata.Provider;

namespace SchemaSaurus.Sqlite;
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/SchemaSaurus.Sqlite/SqliteSchemaReader.Views.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;

using SchemaSaurus.Metadata.Builders;
using SchemaSaurus.Metadata.Internal;
using SchemaSaurus.Metadata.Extensions;
using SchemaSaurus.Metadata.Provider;

Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions src/SchemaSaurus.Sqlite/SqliteSchemaReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
53 changes: 53 additions & 0 deletions test/SchemaSaurus.Metadata.Tests/Internal/TableFilterTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> values, string expression)
=> $"{expression} IN ({string.Join(", ", values.Select(value => value.EscapeLiteral()))})";
}
29 changes: 29 additions & 0 deletions test/SchemaSaurus.MySql.Tests/TableSchemaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading