From 3796e8811e714204443ace02308decb1f8eb8a7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 22:19:43 +0000 Subject: [PATCH 1/3] feat: Add @join:unique and @join:required hints for safe join removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SQL comment-based join cardinality hints that let view authors declare whether a join follows a foreign key (unique match) and whether that FK is required (NOT NULL). The tool uses these hints combined with the join type to determine if removal is safe: - LEFT JOIN + @join:unique → safe (at most 1 match, all rows preserved) - INNER JOIN + @join:unique + @join:required → safe (exactly 1 match) - INNER JOIN + @join:unique (no @required) → not safe (may filter rows) - No hint → backward-compatible existing behavior Hints are placed as SQL comments near the JOIN clause: LEFT JOIN /* @join:unique */ dbo.B b ON a.BId = b.Id INNER JOIN /* @join:unique @join:required */ dbo.Status s ON s.Id = p.StatusId https://claude.ai/code/session_01QmAhVuiQRDaotn1cAXBbrJ --- src/SqlInliner.Tests/JoinHintTests.cs | 234 ++++++++++++++++++++++++++ src/SqlInliner/DatabaseViewInliner.cs | 39 ++++- src/SqlInliner/JoinHint.cs | 47 ++++++ src/SqlInliner/ReferencesVisitor.cs | 53 +++++- 4 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 src/SqlInliner.Tests/JoinHintTests.cs create mode 100644 src/SqlInliner/JoinHint.cs diff --git a/src/SqlInliner.Tests/JoinHintTests.cs b/src/SqlInliner.Tests/JoinHintTests.cs new file mode 100644 index 0000000..69171d5 --- /dev/null +++ b/src/SqlInliner.Tests/JoinHintTests.cs @@ -0,0 +1,234 @@ +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 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); + } +} diff --git a/src/SqlInliner/DatabaseViewInliner.cs b/src/SqlInliner/DatabaseViewInliner.cs index 69295ea..8581d42 100644 --- a/src/SqlInliner/DatabaseViewInliner.cs +++ b/src/SqlInliner/DatabaseViewInliner.cs @@ -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; @@ -343,6 +353,13 @@ private void DetectUnusedTablesToStrip(ReferencesVisitor references, List + /// Determines whether a join with the specified hint and type can be safely removed + /// without affecting the query's row count. + /// + 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 CollectColumnReferences(TSqlFragment fragment) { var collector = new ColumnReferenceCollector(); diff --git a/src/SqlInliner/JoinHint.cs b/src/SqlInliner/JoinHint.cs new file mode 100644 index 0000000..dca302b --- /dev/null +++ b/src/SqlInliner/JoinHint.cs @@ -0,0 +1,47 @@ +using System; + +namespace SqlInliner; + +/// +/// Describes hints about join cardinality that can be specified via SQL comments +/// to enable safe join removal. +/// +/// +/// +/// Place hints as SQL comments on or near the JOIN clause: +/// +/// LEFT JOIN /* @join:unique */ dbo.Address a ON a.PersonId = p.Id +/// INNER JOIN /* @join:unique @join:required */ dbo.Status s ON s.Id = p.StatusId +/// +/// +/// +/// Safety rules for join removal when columns are unused: +/// +/// LEFT JOIN @join:unique — Safe: at most 1 match, all left-side rows preserved. +/// INNER JOIN @join:unique @join:required — Safe: exactly 1 match per row, no filtering. +/// INNER JOIN @join:unique (without required) — Not safe: may filter rows without a match. +/// +/// +/// +[Flags] +public enum JoinHint +{ + /// + /// No hint specified. + /// + None = 0, + + /// + /// The join produces at most one matching row per source row (the join condition + /// references a unique or primary key on the joined table). + /// Specified via /* @join:unique */ in SQL. + /// + Unique = 1, + + /// + /// Every source row has a matching row in the joined table (the foreign key is NOT NULL + /// and referential integrity is enforced). + /// Specified via /* @join:required */ in SQL. + /// + Required = 2, +} diff --git a/src/SqlInliner/ReferencesVisitor.cs b/src/SqlInliner/ReferencesVisitor.cs index c9ef210..bfcefc7 100644 --- a/src/SqlInliner/ReferencesVisitor.cs +++ b/src/SqlInliner/ReferencesVisitor.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Microsoft.SqlServer.TransactSql.ScriptDom; namespace SqlInliner; @@ -57,6 +58,16 @@ internal ReferencesVisitor(DatabaseConnection connection) /// public Dictionary JoinConditions { get; } = new(); + /// + /// Maps named table references that are the second table in a qualified join to their join type (Inner, LeftOuter, etc.). + /// + public Dictionary JoinTypes { get; } = new(); + + /// + /// Maps named table references that are the second table in a qualified join to any parsed from SQL comments. + /// + public Dictionary JoinHints { get; } = new(); + /// public override void ExplicitVisit(FunctionCall node) { @@ -106,7 +117,47 @@ public override void ExplicitVisit(QualifiedJoin node) base.ExplicitVisit(node); if (node.SecondTableReference is NamedTableReference namedTable) + { JoinConditions[namedTable] = node.SearchCondition; + JoinTypes[namedTable] = node.QualifiedJoinType; + + var hints = ParseJoinHints(node); + if (hints != JoinHint.None) + JoinHints[namedTable] = hints; + } + } + + /// + /// Scans the token stream between the first table reference and the search condition + /// of a for SQL comments containing join hints. + /// + private static JoinHint ParseJoinHints(QualifiedJoin node) + { + var hints = JoinHint.None; + var tokens = node.ScriptTokenStream; + if (tokens == null) + return hints; + + // Scan tokens between the end of the first table reference and the start of the + // search condition. This range covers the JOIN keyword, any hint comments, and the + // second table reference — but not tokens from nested joins or the ON clause body. + var startIndex = node.FirstTableReference.LastTokenIndex + 1; + var endIndex = node.SearchCondition?.FirstTokenIndex ?? node.LastTokenIndex + 1; + + for (var i = startIndex; i < endIndex; i++) + { + var token = tokens[i]; + if (token.TokenType is TSqlTokenType.MultilineComment or TSqlTokenType.SingleLineComment) + { + var text = token.Text; + if (text.IndexOf("@join:unique", StringComparison.OrdinalIgnoreCase) >= 0) + hints |= JoinHint.Unique; + if (text.IndexOf("@join:required", StringComparison.OrdinalIgnoreCase) >= 0) + hints |= JoinHint.Required; + } + } + + return hints; } /// From 5b1a76af4a2b76e3469fc1a6905f56b84c4bb5c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 06:22:49 +0000 Subject: [PATCH 2/3] refactor: Extract join hint marker strings into constants Move "@join:unique" and "@join:required" strings to JoinHintMarkers static class so they're defined once and referenceable. https://claude.ai/code/session_01QmAhVuiQRDaotn1cAXBbrJ --- src/SqlInliner/JoinHint.cs | 13 +++++++++++++ src/SqlInliner/ReferencesVisitor.cs | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/SqlInliner/JoinHint.cs b/src/SqlInliner/JoinHint.cs index dca302b..2446d40 100644 --- a/src/SqlInliner/JoinHint.cs +++ b/src/SqlInliner/JoinHint.cs @@ -23,6 +23,19 @@ namespace SqlInliner; /// /// /// +public static class JoinHintMarkers +{ + /// + /// SQL comment marker indicating the join produces at most one matching row. + /// + public const string Unique = "@join:unique"; + + /// + /// SQL comment marker indicating every source row has a matching row. + /// + public const string Required = "@join:required"; +} + [Flags] public enum JoinHint { diff --git a/src/SqlInliner/ReferencesVisitor.cs b/src/SqlInliner/ReferencesVisitor.cs index bfcefc7..7b3f256 100644 --- a/src/SqlInliner/ReferencesVisitor.cs +++ b/src/SqlInliner/ReferencesVisitor.cs @@ -150,9 +150,9 @@ private static JoinHint ParseJoinHints(QualifiedJoin node) if (token.TokenType is TSqlTokenType.MultilineComment or TSqlTokenType.SingleLineComment) { var text = token.Text; - if (text.IndexOf("@join:unique", StringComparison.OrdinalIgnoreCase) >= 0) + if (text.IndexOf(JoinHintMarkers.Unique, StringComparison.OrdinalIgnoreCase) >= 0) hints |= JoinHint.Unique; - if (text.IndexOf("@join:required", StringComparison.OrdinalIgnoreCase) >= 0) + if (text.IndexOf(JoinHintMarkers.Required, StringComparison.OrdinalIgnoreCase) >= 0) hints |= JoinHint.Required; } } From 3c3d561017c8d81b83de672a7cd3087f9503ca32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 06:24:37 +0000 Subject: [PATCH 3/3] test: Add missing join hint test paths Cover additional scenarios: - @required without @unique (fan-out possible, kept) - RIGHT JOIN + @unique (not handled as safe, kept) - Single-line comment syntax (-- @join:unique) - AggressiveJoinStripping respects hints (INNER+@unique kept) - AggressiveJoinStripping strips when hint allows (INNER+@unique+@required) https://claude.ai/code/session_01QmAhVuiQRDaotn1cAXBbrJ --- src/SqlInliner.Tests/JoinHintTests.cs | 92 +++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/SqlInliner.Tests/JoinHintTests.cs b/src/SqlInliner.Tests/JoinHintTests.cs index 69171d5..87e4f52 100644 --- a/src/SqlInliner.Tests/JoinHintTests.cs +++ b/src/SqlInliner.Tests/JoinHintTests.cs @@ -209,6 +209,98 @@ public void ParseJoinHints_CaseInsensitive() 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() {