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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ syncsql catalog build [--output-root] [--objects-root <path>] [--output <path>]
[--repo-root] [--path-prefix] [--history-limit]
[--max-versions-per-object] [--max-history-content-calls]
[--max-co-change-commit-size] [--metrics-root]
[--no-dynamic-sql]
syncsql metrics update [--output-root] [--snapshot-root <path>] [--history-root <path>]
[--history-limit]
syncsql lint [--output-root] [--path <file-or-dir>...] [--config <sql-style.json>] [--fail-on warning|error]
Expand Down Expand Up @@ -1088,7 +1087,7 @@ Object references retain database links, and qualified column references keep
their alias bindings within each dynamic statement. Variables, function-built
strings and concatenations containing unknown values are not evaluated.

`syncsql catalog build --no-dynamic-sql` turns the whole thing off.
Dynamic SQL investigation always runs during catalog builds.

### System objects

Expand Down
2 changes: 1 addition & 1 deletion cli/src/SyncSql.Catalog/CatalogBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public sealed class CatalogBuilder(
Dictionary<string, CatalogNode> nodesById = nodes.ToDictionary(n => n.Id, StringComparer.OrdinalIgnoreCase);

logger.LogInformation("Inferring lineage edges and column references");
LineageInferenceResult lineage = InferLineage(nodes, nodesById, nodeIndex, new LineageAnalysisOptions { DynamicSql = request.DynamicSql }, request.Progress, cancellationToken);
LineageInferenceResult lineage = InferLineage(nodes, nodesById, nodeIndex, LineageAnalysisOptions.Default, request.Progress, cancellationToken);
List<CatalogEdge> edges = lineage.Edges;
List<CatalogOrphanedReference> orphanedReferences = lineage.OrphanedReferences;
if (orphanedReferences.Count > 0)
Expand Down
6 changes: 0 additions & 6 deletions cli/src/SyncSql.Cli/Commands/CatalogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,6 @@ public static Command Build(IServiceProvider services)
{
Description = "Override the metrics history root for all inputs. Default: each input's metrics/ directory when present.",
};
Option<bool> noDynamicSqlOption = new("--no-dynamic-sql")
{
Description = "Skip recovering references from SQL built as a string at runtime (OPENQUERY, EXEC of a string, a variable assembled then executed). Those references are tagged `dynamic` in catalog.json rather than mixed in with the rest, so the default is to collect them.",
};

Option<bool> pruneOption = new("--prune") { Description = "Remove unreferenced hashed payloads after publishing the manifest. Use only in a dedicated catalog directory." };

Expand All @@ -76,7 +72,6 @@ public static Command Build(IServiceProvider services)
maxHistoryCallsOption,
maxCoChangeOption,
metricsRootOption,
noDynamicSqlOption,
pruneOption,
};

Expand Down Expand Up @@ -112,7 +107,6 @@ public static Command Build(IServiceProvider services)
MaxHistoryContentCalls = parseResult.GetValue(maxHistoryCallsOption),
MaxCoChangeCommitSize = parseResult.GetValue(maxCoChangeOption),
MetricsRoot = ToFullPathOrNull(parseResult.GetValue(metricsRootOption)),
DynamicSql = !parseResult.GetValue(noDynamicSqlOption),
Progress = progress,
};

Expand Down
8 changes: 0 additions & 8 deletions cli/src/SyncSql.Core/Abstractions/Catalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ public sealed record CatalogBuildRequest
/// <summary>Root of the accumulating metrics history tree. Omit to skip - node.metrics is simply left empty.</summary>
public string? MetricsRoot { get; init; }

/// <summary>
/// Whether lineage inference also recovers references from SQL built as a string at runtime
/// (<c>OPENQUERY</c>, <c>EXEC</c> of a string, a variable assembled and then executed). On by default;
/// <c>--no-dynamic-sql</c> turns it off for a build that wants only what the parse tree states
/// outright. See <see cref="LineageAnalysisOptions.DynamicSql"/>.
/// </summary>
public bool DynamicSql { get; init; } = true;

/// <summary>Optional synchronous observer for build phases and object-level progress.</summary>
public IProgress<CatalogProgress>? Progress { get; init; }
}
Expand Down
12 changes: 1 addition & 11 deletions cli/src/SyncSql.Core/Abstractions/Lineage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,10 @@ public interface ILineageAnalyzer
}

/// <summary>
/// Knobs for one analysis pass. Kept as a record rather than constructor-injected options so the same
/// analyzer instance can serve a build that wants dynamic-SQL scanning and one that doesn't, and so a test
/// can flip a switch without going through DI.
/// Context for one analysis pass, supplied per object rather than through dependency injection.
/// </summary>
public sealed record LineageAnalysisOptions
{
/// <summary>
/// Whether to recover references from SQL built as a string at runtime (see
/// <see cref="ReferenceOrigin.Dynamic"/>). On by default: a large amount of real T-SQL reaches other
/// objects only through <c>OPENQUERY</c>/<c>EXEC</c>, and leaving that invisible is a bigger error than
/// the occasional over-eager match, which is tagged and never reported as an orphan anyway.
/// </summary>
public bool DynamicSql { get; init; } = true;

/// <summary>The current SQL database's Broker identity, when supplied by extraction metadata.</summary>
public Guid? ServiceBrokerGuid { get; init; }

Expand Down
2 changes: 1 addition & 1 deletion cli/src/SyncSql.Lineage.MsSql/MsSqlLineageAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public LineageAnalysisResult Analyze(string ddl, LineageAnalysisOptions? options
context.SourceObjectId, errors.Count, errors[0].Line, errors[0].Column, errors[0].Message);
}

TSqlLineageVisitor visitor = new(context.DynamicSql, context.ServiceBrokerGuid);
TSqlLineageVisitor visitor = new(context.ServiceBrokerGuid);
fragment.Accept(visitor);

return new LineageAnalysisResult
Expand Down
8 changes: 2 additions & 6 deletions cli/src/SyncSql.Lineage.MsSql/TSqlLineageVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ namespace SyncSql.Lineage.MsSql;
/// content is looked at, and only because they are the places T-SQL actually executes it: an ordinary
/// literal in a SELECT list is still never treated as SQL.
/// </summary>
/// <param name="dynamicSql">Whether to scan dynamically-built SQL at all (see <see cref="Core.Abstractions.LineageAnalysisOptions"/>).</param>
/// <param name="serviceBrokerGuid">The current database's extracted Broker identity, when available.</param>
internal sealed class TSqlLineageVisitor(bool dynamicSql = true, Guid? serviceBrokerGuid = null) : TSqlFragmentVisitor
internal sealed class TSqlLineageVisitor(Guid? serviceBrokerGuid = null) : TSqlFragmentVisitor
{
/// <summary>One budget per object, shared by every nested scan this walk starts.</summary>
private readonly DynamicSqlScanner.Budget _budget = new();
Expand Down Expand Up @@ -377,10 +376,7 @@ private void ScanParameters(ExecutableEntity entity, string? linkedServer)

private void ScanDynamic(string? sql, string? linkedServer)
{
if (dynamicSql)
{
DynamicSqlScanner.Scan(sql, linkedServer, 0, _budget, ObjectRefs);
}
DynamicSqlScanner.Scan(sql, linkedServer, 0, _budget, ObjectRefs);
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions cli/src/SyncSql.Lineage.Oracle/OracleLineageAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,10 @@ private LineageAnalysisResult AnalyzeCore(string ddl, LineageAnalysisOptions opt
options.SourceObjectId, errorListener.Errors.Count, errorListener.Errors[0]);
}

PlSqlLineageVisitor visitor = new(options.DynamicSql ? sql =>
PlSqlLineageVisitor visitor = new(sql =>
depth < 4 && sql.Length <= 65536 && budget.Remaining-- > 0
? AnalyzeCore(sql.EndsWith(';') ? sql : sql + ";", options, depth + 1, budget, cache)
: LineageAnalysisResult.Empty : null);
: LineageAnalysisResult.Empty);
visitor.Visit(tree);

return new LineageAnalysisResult
Expand Down
4 changes: 2 additions & 2 deletions cli/src/SyncSql.Lineage.Oracle/PlSqlLineageVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace SyncSql.Lineage.Oracle;
/// VisitChildren(context) - so every override here that still wants its subtree walked must explicitly
/// call VisitChildren(context) itself, or traversal silently stops there.
/// </summary>
internal sealed class PlSqlLineageVisitor(Func<string, LineageAnalysisResult>? analyzeDynamic = null) : PlSqlParserBaseVisitor<object?>
internal sealed class PlSqlLineageVisitor(Func<string, LineageAnalysisResult> analyzeDynamic) : PlSqlParserBaseVisitor<object?>
{
public List<ObjectRef> ObjectRefs { get; } = [];
public Dictionary<string, ObjectRef> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -247,7 +247,7 @@ private void BindTableAlias(ObjectRef? objRef, PlSqlParser.Table_aliasContext? t

private void AnalyzeDynamicExpression(PlSqlParser.ExpressionContext? expression)
{
if (analyzeDynamic is null || EvaluateLiteralExpression(expression) is not { } sql)
if (EvaluateLiteralExpression(expression) is not { } sql)
{
return;
}
Expand Down
4 changes: 2 additions & 2 deletions cli/tests/SyncSql.Catalog.Tests/CatalogBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,11 @@ public async Task BuildAsync_Passes_each_objects_Broker_context_to_its_analyzer(
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, ExtractedObjectFile.Write(obj));
}
Core.Domain.Catalog catalog = await CreateBuilder().BuildAsync(new CatalogBuildRequest { ObjectsRoot = _objectsRoot, DynamicSql = false }, CancellationToken.None);
Core.Domain.Catalog catalog = await CreateBuilder().BuildAsync(new CatalogBuildRequest { ObjectsRoot = _objectsRoot }, CancellationToken.None);
foreach (CatalogNode node in catalog.Nodes)
{
Assert.Equal(node.Database == "App" ? brokerGuid : (Guid?)null, node.ServiceBrokerGuid);
_mssqlAnalyzer.Received(1).Analyze(node.Ddl, Arg.Is<LineageAnalysisOptions>(o => o.ServiceBrokerGuid == node.ServiceBrokerGuid && !o.DynamicSql));
_mssqlAnalyzer.Received(1).Analyze(node.Ddl, Arg.Is<LineageAnalysisOptions>(o => o.ServiceBrokerGuid == node.ServiceBrokerGuid));
}
}

Expand Down
18 changes: 18 additions & 0 deletions cli/tests/SyncSql.Cli.Tests/CatalogDynamicSqlOptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using SyncSql.Cli.Commands;

namespace SyncSql.Cli.Tests;

public sealed class CatalogDynamicSqlOptionTests
{
[Fact]
public void CatalogRejectsRemovedDynamicSqlOptOut()
{
using ServiceProvider services = new ServiceCollection().BuildServiceProvider();
var parsed = new RootCommand { CatalogCommand.Build(services) }
.Parse(["catalog", "build", "--no-dynamic-sql"]);

Assert.NotEmpty(parsed.Errors);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,15 @@ CREATE PROCEDURE dbo.RunReport @table sysname AS
}

[Fact]
public void Analyze_DynamicSqlDisabled_PreservesStaticHalfOfMixedBatch()
public void Analyze_MixedBatch_PreservesStaticAndDynamicReferences()
{
LineageAnalysisResult result = _analyzer.Analyze("""
SELECT o.OrderId FROM dbo.Orders o;
EXEC (N'SELECT * FROM archive.Orders');
""", new LineageAnalysisOptions { DynamicSql = false });
""");

Assert.Equal(new ObjectRef("dbo", "Orders"), Assert.Single(result.ObjectRefs));
Assert.Contains(new ObjectRef("dbo", "Orders"), result.ObjectRefs);
Assert.Contains(result.ObjectRefs, r => r is { Schema: "archive", Name: "Orders", Origin: ReferenceOrigin.Dynamic });
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ public void Analyze_StaticReference_IsNotMarkedDynamic()
}

[Fact]
public void Analyze_WithDynamicSqlDisabled_LeavesBuiltStringsAlone()
public void Analyze_WithExplicitOptions_RecoversBuiltSqlReferences()
{
LineageAnalysisResult result = _analyzer.Analyze(
"""
Expand All @@ -255,9 +255,9 @@ CREATE PROCEDURE [dbo].[Rebuild] AS
EXEC ('SELECT * FROM dbo.Orders');
END
""",
new LineageAnalysisOptions { DynamicSql = false });
new LineageAnalysisOptions());

Assert.Empty(result.ObjectRefs);
Assert.Contains(result.ObjectRefs, r => r is { Schema: "dbo", Name: "Orders", Origin: ReferenceOrigin.Dynamic });
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,21 +171,19 @@ SELECT JSON_VALUE(e.payload, '$.customer.id') AS customer_id
Assert.DoesNotContain(result.ObjectRefs, r => r.Schema == "customer" || r.Name == "id");
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void Analyze_ExecuteImmediate_RespectsDynamicSqlOption(bool dynamicSql)
[Fact]
public void Analyze_ExecuteImmediate_PreservesStaticAndDynamicReferences()
{
LineageAnalysisResult result = _analyzer.Analyze("""
CREATE OR REPLACE PROCEDURE app.refresh_report(p_id NUMBER) AS
BEGIN
INSERT INTO app.audit_log (id) VALUES (p_id);
EXECUTE IMMEDIATE 'DELETE FROM archive.orders WHERE order_id = :id' USING p_id;
END;
""", new LineageAnalysisOptions { DynamicSql = dynamicSql });
""");

Assert.Contains(new ObjectRef("app", "audit_log"), result.ObjectRefs);
Assert.Equal(dynamicSql, result.ObjectRefs.Any(r =>
r is { Schema: "archive", Name: "orders", Origin: ReferenceOrigin.Dynamic }));
Assert.Contains(result.ObjectRefs, r =>
r is { Schema: "archive", Name: "orders", Origin: ReferenceOrigin.Dynamic });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,6 @@ public void KnownSql_PreservesObjectsAndColumnBindings(string statement)
Assert.Contains(result.ColumnRefs, c => c.Column == "order_id"
&& result.Aliases.TryGetValue(c.AliasOrTable, out var target)
&& target is { Schema: "app", Name: "orders", Server: "remote", Origin: ReferenceOrigin.Dynamic });

var disabled = _analyzer.Analyze($"BEGIN {statement}; END;", new LineageAnalysisOptions { DynamicSql = false });
Assert.DoesNotContain(disabled.ObjectRefs, r => r.Origin == ReferenceOrigin.Dynamic);
Assert.DoesNotContain(disabled.ColumnRefs, c => c.Column == "order_id");
}

[Fact]
Expand Down Expand Up @@ -102,10 +98,9 @@ public void UnknownExpressionsAndMessages_DoNotInventDependencies(string stateme
}

[Fact]
public void StaticOpenFor_RemainsStaticWhenDynamicSqlIsDisabled()
public void StaticOpenFor_RemainsStatic()
{
var result = _analyzer.Analyze("BEGIN OPEN result_cursor FOR SELECT o.order_id FROM app.orders o; END;",
new LineageAnalysisOptions { DynamicSql = false });
var result = _analyzer.Analyze("BEGIN OPEN result_cursor FOR SELECT o.order_id FROM app.orders o; END;");
Assert.Contains(new ObjectRef("app", "orders"), result.ObjectRefs);
Assert.Contains(new ColumnRef("o", "order_id"), result.ColumnRefs);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ public void Literal_dynamic_sql_retains_link_and_origin(string literal)
string ddl = $"CREATE PROCEDURE PROCUREMENT.P AS N NUMBER; BEGIN EXECUTE IMMEDIATE {literal} INTO N; END;";
Assert.Contains(_analyzer.Analyze(ddl).ObjectRefs,
r => r is { Schema: "INVENTORY", Name: "ITEMS", Server: "REMOTE", Origin: ReferenceOrigin.Dynamic });
Assert.DoesNotContain(_analyzer.Analyze(ddl, new LineageAnalysisOptions { DynamicSql = false }).ObjectRefs,
r => r.Server == "REMOTE");
}

[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public void Analyze_PredictionCachesAreCollectibleAfterBoundedReuseOrFailure(boo

for (int i = 0; i < OracleLineageAnalyzer.MaxCachedScripts; i++)
{
var result = analyzer.Analyze("SELECT o.id FROM app.orders o;", new LineageAnalysisOptions { DynamicSql = false });
var result = analyzer.Analyze("SELECT o.id FROM app.orders o;");
if (!failParsing)
{
Assert.Contains(result.ObjectRefs, reference => reference.Name == "orders");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ private static PlSqlParser Parser(string sql)
[Fact]
public void Visitor_HandlesIncompleteTableRoutineAndExpressionNodes()
{
var visitor = new PlSqlLineageVisitor();
var visitor = new PlSqlLineageVisitor(sql => new OracleLineageAnalyzer(NullLogger<OracleLineageAnalyzer>.Instance).Analyze(sql));
visitor.VisitTableview_name(new PlSqlParser.Tableview_nameContext(null, 0));
visitor.VisitRoutine_name(new PlSqlParser.Routine_nameContext(null, 0));
visitor.VisitGeneral_element(new PlSqlParser.General_elementContext(null, 0));
Expand Down Expand Up @@ -75,7 +75,7 @@ public void DynamicSql_EnforcesLengthCountAndDepthBudgets()
[Fact]
public void GeneralElements_HandleFlatAndParenthesizedGrammarAlternatives()
{
var visitor = new PlSqlLineageVisitor();
var visitor = new PlSqlLineageVisitor(sql => new OracleLineageAnalyzer(NullLogger<OracleLineageAnalyzer>.Instance).Analyze(sql));
var flat = new PlSqlParser.General_elementContext(null, 0);
foreach (string part in new[] { "app", "pkg", "run()" }) { flat.AddChild(Parser(part).general_element_part()); }
visitor.VisitGeneral_element(flat);
Expand Down
Loading