Skip to content
Merged
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
326 changes: 326 additions & 0 deletions src/SqlInliner.Tests/JoinHintTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using NUnit.Framework;
using Shouldly;

namespace SqlInliner.Tests;

public class JoinHintTests
{
private readonly InlinerOptions options = InlinerOptions.Recommended();

[Test]
public void LeftJoinUniqueHint_StrippedWhenUnused()
{
// LEFT JOIN with @join:unique is safe to remove — at most 1 match, all left-side rows preserved.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a LEFT JOIN /* @join:unique */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void InnerJoinUniqueRequiredHint_StrippedWhenUnused()
{
// INNER JOIN with @join:unique @join:required is safe — exactly 1 match per row.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a INNER JOIN /* @join:unique @join:required */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void InnerJoinUniqueOnly_KeptWhenUnused()
{
// INNER JOIN with @join:unique but NOT @join:required — removing could filter rows.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a INNER JOIN /* @join:unique */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// B should be KEPT because hint says it's not safe to remove an INNER JOIN without @required
inliner.Result!.ConvertedSql.ShouldContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void NoHint_StrippedWhenUnused_BackwardCompat()
{
// Without hints, existing behavior: unused joins are still stripped.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name FROM dbo.A a INNER JOIN dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// Backward compat: no hint → old behavior → stripped
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void LeftJoinUniqueHint_KeptWhenUsed()
{
// Even with @join:unique, if columns ARE used, the join is kept.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, b.Code FROM dbo.A a LEFT JOIN /* @join:unique */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Code FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
inliner.Result!.ConvertedSql.ShouldContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void HintAfterAlias_Parsed()
{
// Hint comment placed after the table alias (before ON) should also work.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a LEFT JOIN dbo.B b /* @join:unique */ ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void SeparateHintComments_BothParsed()
{
// Two separate comments for @join:unique and @join:required.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a INNER JOIN /* @join:unique */ /* @join:required */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// Both hints present → safe to remove INNER JOIN
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void MultipleJoins_IndependentHints()
{
// Two joins with different hints — each handled independently.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
@"CREATE VIEW dbo.VItems AS
SELECT a.Id, a.Name, b.Code, c.Value
FROM dbo.A a
LEFT JOIN /* @join:unique */ dbo.B b ON a.BId = b.Id
INNER JOIN /* @join:unique */ dbo.C c ON a.CId = c.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// B: LEFT + @unique → safe, stripped
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
// C: INNER + @unique (no @required) → not safe, kept
inliner.Result.ConvertedSql.ShouldContain("dbo.C");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void HintOnViewReference_StrippedWhenSafe()
{
// Hints work on view references (in the outer view's joins), not just table references.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VLookup"),
"CREATE VIEW dbo.VLookup AS SELECT l.Id, l.Code FROM dbo.Lookup l");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT a.Id FROM dbo.A a LEFT JOIN /* @join:unique */ dbo.VLookup vl ON a.LookupId = vl.Id";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// VLookup is LEFT JOINed with @unique, no columns used → safe to remove
inliner.Result!.ConvertedSql.ShouldNotContain("VLookup");
inliner.Result.ConvertedSql.ShouldNotContain("dbo.Lookup");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void HintOnViewReference_InnerJoinWithoutRequired_Kept()
{
// View reference with INNER JOIN @unique (no @required) should be kept.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VLookup"),
"CREATE VIEW dbo.VLookup AS SELECT l.Id, l.Code FROM dbo.Lookup l");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT a.Id FROM dbo.A a INNER JOIN /* @join:unique */ dbo.VLookup vl ON a.LookupId = vl.Id";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// INNER + @unique without @required → not safe → kept
inliner.Result!.ConvertedSql.ShouldContain("dbo.Lookup");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
inliner.Warnings.ShouldContain(w => w.Contains("not safe"));
}

[Test]
public void ParseJoinHints_CaseInsensitive()
{
// Hints should be parsed case-insensitively.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a LEFT JOIN /* @JOIN:UNIQUE */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void RequiredWithoutUnique_KeptWhenUnused()
{
// @join:required alone (without @unique) should NOT allow removal — the join could fan out.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a LEFT JOIN /* @join:required */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// @required without @unique → not safe (could fan out) → kept
inliner.Result!.ConvertedSql.ShouldContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void RightJoinUniqueHint_KeptWhenUnused()
{
// RIGHT JOIN + @unique is not handled as safe — kept even when unused.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a RIGHT JOIN /* @join:unique */ dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// RIGHT JOIN falls into the default "not safe" case → kept
inliner.Result!.ConvertedSql.ShouldContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void SingleLineCommentHint_Parsed()
{
// Single-line comment syntax (-- @join:unique) should also be recognized.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
@"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name, b.Code FROM dbo.A a
LEFT JOIN -- @join:unique
dbo.B b ON a.BId = b.Id");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var inliner = new DatabaseViewInliner(connection, viewSql, options);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void AggressiveStripping_RespectsHints()
{
// AggressiveJoinStripping should still respect hints — INNER + @unique without @required is kept.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name FROM dbo.A a INNER JOIN /* @join:unique */ dbo.B b ON a.BId = b.Id AND b.Type = 'X'");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var aggressiveOptions = new InlinerOptions { StripUnusedColumns = true, StripUnusedJoins = true, AggressiveJoinStripping = true };
var inliner = new DatabaseViewInliner(connection, viewSql, aggressiveOptions);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
// Aggressive mode would normally strip B, but the hint says INNER without @required → not safe → kept
inliner.Result!.ConvertedSql.ShouldContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void AggressiveStripping_StripsWhenHintAllows()
{
// AggressiveJoinStripping + INNER @unique @required should strip.
var connection = new DatabaseConnection();
connection.AddViewDefinition(DatabaseConnection.ToObjectName("dbo", "VItems"),
"CREATE VIEW dbo.VItems AS SELECT a.Id, a.Name FROM dbo.A a INNER JOIN /* @join:unique @join:required */ dbo.B b ON a.BId = b.Id AND b.Type = 'X'");

const string viewSql = "CREATE VIEW dbo.VTest AS SELECT v.Id, v.Name FROM dbo.VItems v";

var aggressiveOptions = new InlinerOptions { StripUnusedColumns = true, StripUnusedJoins = true, AggressiveJoinStripping = true };
var inliner = new DatabaseViewInliner(connection, viewSql, aggressiveOptions);
inliner.Errors.ShouldBeEmpty();
inliner.Result.ShouldNotBeNull();
inliner.Result!.ConvertedSql.ShouldNotContain("dbo.B");
inliner.Result.ConvertedSql.ShouldContain("dbo.A");
}

[Test]
public void JoinHintsParsedFromReferencesVisitor()
{
// Verify that hint parsing populates the JoinHints and JoinTypes dictionaries.
var connection = new DatabaseConnection();
const string viewSql = @"CREATE VIEW dbo.VTest AS
SELECT a.Id, b.Code
FROM dbo.A a
LEFT JOIN /* @join:unique @join:required */ dbo.B b ON a.BId = b.Id";

var (view, errors) = DatabaseView.FromSql(connection, viewSql);
errors.Count.ShouldBe(0);
view.ShouldNotBeNull();

view!.References.Tables.Count.ShouldBe(2);
var tableB = view.References.Tables[1]; // B is the second table
tableB.SchemaObject.BaseIdentifier.Value.ShouldBe("B");

view.References.JoinHints.ShouldContainKey(tableB);
view.References.JoinHints[tableB].ShouldBe(JoinHint.Unique | JoinHint.Required);
view.References.JoinTypes[tableB].ShouldBe(QualifiedJoinType.LeftOuter);
}
}
39 changes: 38 additions & 1 deletion src/SqlInliner/DatabaseViewInliner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,17 @@ void RemoveAt(int idx)
});
break;

case 1 when options.StripUnusedJoins: // TODO: Allow configuration per specific join/alias, to keep certain joins that would be used as filter
case 1 when options.StripUnusedJoins:
// If the join has cardinality hints, verify removal is safe before stripping.
if (references.JoinHints.TryGetValue(referenced, out var viewHint))
{
references.JoinTypes.TryGetValue(referenced, out var viewJoinType);
if (!IsJoinSafeToRemove(viewHint, viewJoinType))
{
Warnings.Add($"Only 1 column is selected from {viewName} {alias} in {view.ViewName}, but join hint indicates removal is not safe.");
break;
}
}
toRemove.Add(referenced);
TotalJoinsStripped++;
continue;
Expand Down Expand Up @@ -343,13 +353,40 @@ private void DetectUnusedTablesToStrip(ReferencesVisitor references, List<TableR
var threshold = joinConditionRefs != null ? 0 : 1;
if (columns.Count() <= threshold)
{
// If the join has cardinality hints, verify removal is safe before stripping.
if (references.JoinHints.TryGetValue(referenced, out var hint))
{
references.JoinTypes.TryGetValue(referenced, out var joinType);
if (!IsJoinSafeToRemove(hint, joinType))
continue;
}
toRemove.Add(referenced);
TotalJoinsStripped++;
}
}
}
}

/// <summary>
/// Determines whether a join with the specified hint and type can be safely removed
/// without affecting the query's row count.
/// </summary>
private static bool IsJoinSafeToRemove(JoinHint hint, QualifiedJoinType joinType)
{
if (!hint.HasFlag(JoinHint.Unique))
return false; // May fan out — not safe

return joinType switch
{
// LEFT JOIN + unique: at most 1 match, all left-side rows preserved
QualifiedJoinType.LeftOuter => true,
// INNER JOIN + unique + required: exactly 1 match per row, no filtering
QualifiedJoinType.Inner when hint.HasFlag(JoinHint.Required) => true,
// Other cases (INNER without required, RIGHT, FULL) are not safe
_ => false,
};
}

private static HashSet<ColumnReferenceExpression> CollectColumnReferences(TSqlFragment fragment)
{
var collector = new ColumnReferenceCollector();
Expand Down
Loading
Loading