From ae76ac89891b44ee026c2921713dea6c8e205709 Mon Sep 17 00:00:00 2001 From: Kevin Bibelhausen Date: Mon, 18 May 2026 04:31:34 -0400 Subject: [PATCH 1/2] feat(analyzer): add PX1121 MissingSchemaMutationGuard New Roslyn analyzer detecting unguarded schema mutations in PXDatabase.Execute calls. Catches the bug pattern that breaks CustomizationPlugin re-publish: PXDatabase.Execute("ALTER TABLE SOOrder ADD UsrFoo int NULL") // Error PX1121: this ALTER will fail on the second publish Recognized guard patterns: - IF NOT EXISTS (SELECT ... FROM sys.columns/tables/indexes ...) - IF EXISTS (...) - IF COL_LENGTH('', '') IS NULL - IF OBJECT_ID('', '') IS NULL - IF INDEXPROPERTY(OBJECT_ID('
'), '', 'IndexID') IS NULL Implementation: - Per-mutation guard detection (action-keyword tracking) - Comment + string literal stripping before regex match - Constant string concatenation via SemanticModel.GetConstantValue - Interpolated string analysis with hole placeholders - Severity: Warning, Category: Acuminator Documented limitations (heuristic-based, not full T-SQL parse): - Dynamic SQL silently skipped - Interpolation holes standing in for DDL keywords not modeled - SELECT-as-then before mutation = rare false negative - ELSE-branch mutations after a then-branch action = false positive Codifies Studio B's CLAUDE.md Rule #24 (Acumatica scripts and PXDatabase.Execute do NOT re-execute idempotently across publishes unless guarded explicitly). Codex review gate: PASS (3 iterations). Files: - src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs (310 LOC) - docs/diagnostics/PX1121.md (159 LOC) - Resource + descriptor wiring across 5 support files --- docs/Summary.md | 1 + docs/diagnostics/PX1121.md | 159 +++++++++ .../DiagnosticsShortName.Designer.cs | 9 + .../DiagnosticsShortName.resx | 3 + .../Resources.Designer.cs | 9 + .../Acuminator.Analyzers/Resources.resx | 3 + .../StaticAnalysis/Descriptors.cs | 4 + .../MissingSchemaMutationGuardAnalyzer.cs | 310 ++++++++++++++++++ 8 files changed, 498 insertions(+) create mode 100644 docs/diagnostics/PX1121.md create mode 100644 src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs diff --git a/docs/Summary.md b/docs/Summary.md index a9e15e62c..c89ab7b86 100644 --- a/docs/Summary.md +++ b/docs/Summary.md @@ -120,3 +120,4 @@ Acuminator does not perform static analysis of projects whose names contain `Tes | [PX1116](diagnostics/PX1116.md) | A graph extension or a DAC extension has a circular reference in its type hierarchy. | Error | Unavailable | | [PX1117](diagnostics/PX1117.md) | The type hierarchy of the DAC extension contains an extension that extends multiple independent DAC extensions. Extending multiple independent DAC extensions is forbidden for DAC extensions. | Error | Unavailable | | [PX1120](diagnostics/PX1120.md) | Incorrect work with the `Task` types in the Acumatica asynchronous code. You should not store the `Task` instance in a local variable or parameter. The `Task`-typed expressions should be awaited or immediately returned, and a method returning a `Task`-typed expression should have the `Task` type as its return type. | Warning | Unavailable | +| [PX1121](diagnostics/PX1121.md) | A `PXDatabase.Execute` call performs an unguarded schema mutation (`ALTER TABLE ADD`, `CREATE TABLE`, or `CREATE INDEX`). Customization plugins re-run `UpdateDatabase` on every publish; wrap the mutation in an existence guard (`IF NOT EXISTS`, `IF COL_LENGTH`, `IF OBJECT_ID`, or `IF INDEXPROPERTY`). | Warning | Unavailable | diff --git a/docs/diagnostics/PX1121.md b/docs/diagnostics/PX1121.md new file mode 100644 index 000000000..f345bb667 --- /dev/null +++ b/docs/diagnostics/PX1121.md @@ -0,0 +1,159 @@ +# PX1121 +This document describes the PX1121 diagnostic. + +## Summary + +| Code | Short Description | Type | Code Fix | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------- | +| PX1121 | A `PXDatabase.Execute` call performs an unguarded schema mutation (`ALTER TABLE ADD`, `CREATE TABLE`, or `CREATE INDEX`). Wrap the mutation in an existence guard. | Warning | Unavailable | + +## Diagnostic Description + +Acumatica customization plugins (classes derived from `Customization.CustomizationPlugin`) re-run their `UpdateDatabase` method on every publish. If `UpdateDatabase` performs a schema mutation without first checking whether the change already exists, the second publish will fail with a SQL error such as `Column names in each table must be unique` or `There is already an object named ...`. The failed publish leaves the customization in a partial state and blocks deployment. + +The PX1121 diagnostic inspects calls to `PXDatabase.Execute` and reports a warning when the SQL argument contains a schema mutation that is not preceded by an existence guard. Recognized guard patterns: + +- `IF NOT EXISTS (SELECT ... FROM sys.columns/tables/indexes ...)` +- `IF EXISTS (...)` +- `IF COL_LENGTH('
', '') IS NULL` +- `IF OBJECT_ID('', '') IS NULL` +- `IF INDEXPROPERTY(OBJECT_ID('
'), '', 'IndexID') IS NULL` + +The check is per-mutation: a guard somewhere else in the SQL batch does not satisfy a different mutation. Comments (`--` line and `/* */` block) and string literals (`'...'`) inside the SQL are stripped before analysis, so keywords that happen to appear inside them do not produce false positives or false negatives. + +This diagnostic is not gated to ISV mode because customer-written customization plugins are equally affected by the double-publish failure mode. + +## Example of Incorrect Code + +### Bare ALTER TABLE ADD (canonical anti-pattern) + +```C# +using PX.Data; +using Customization; + +public class MyPlugin : CustomizationPlugin +{ + public override void UpdateDatabase() + { + // Error PX1121: this ALTER will fail on the second publish. + PXDatabase.Execute(@" + ALTER TABLE SOOrder ADD UsrPriority int NULL + "); + } +} +``` + +### Multiple unguarded mutations in one batch + +```C# +PXDatabase.Execute(@" + ALTER TABLE SOOrder ADD UsrPriority int NULL; + ALTER TABLE POOrder ADD UsrPriority int NULL; +"); +// Error PX1121: both mutations are unguarded. +``` + +### Unrelated guard does not satisfy the mutation + +```C# +PXDatabase.Execute(@" + IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'OtherTable') + PRINT 'logging note'; + ALTER TABLE SOOrder ADD UsrPriority int NULL +"); +// Error PX1121: the IF EXISTS guards the PRINT, not the ALTER. +``` + +### Constant string concatenation is still analyzed + +```C# +PXDatabase.Execute("ALTER TABLE SOOrder " + "ADD UsrPriority int NULL"); +// Error PX1121: the constant-folded SQL is unguarded. +``` + +### Interpolated identifiers are still analyzed + +```C# +string tableName = "SOOrder"; +string columnName = "UsrPriority"; +PXDatabase.Execute($"ALTER TABLE {tableName} ADD {columnName} int NULL"); +// Error PX1121: the literal segments contain an unguarded ALTER TABLE ADD. +``` + +## Example of Correct Code + +### `IF COL_LENGTH` guard (column existence) + +```C# +PXDatabase.Execute(@" + IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL + ALTER TABLE SOOrder ADD UsrPriority int NULL +"); +``` + +### `IF NOT EXISTS` against `sys.columns` + +```C# +PXDatabase.Execute(@" + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE Name = 'UsrPriority' AND Object_ID = OBJECT_ID('SOOrder') + ) + ALTER TABLE SOOrder ADD UsrPriority int NULL +"); +``` + +### `IF OBJECT_ID` guard (table existence) + +```C# +PXDatabase.Execute(@" + IF OBJECT_ID('UsrCustomLookup', 'U') IS NULL + CREATE TABLE UsrCustomLookup ( + Id int IDENTITY(1,1) PRIMARY KEY, + Code nvarchar(32) NOT NULL, + Description nvarchar(256) NULL + ) +"); +``` + +### `IF INDEXPROPERTY` guard (index existence) + +```C# +PXDatabase.Execute(@" + IF INDEXPROPERTY(OBJECT_ID('SOOrder'), 'IX_SOOrder_UsrPriority', 'IndexID') IS NULL + CREATE INDEX IX_SOOrder_UsrPriority ON SOOrder(UsrPriority) +"); +``` + +### `BEGIN...END` block under a guard + +```C# +PXDatabase.Execute(@" + IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL + BEGIN + ALTER TABLE SOOrder ADD UsrPriority int NULL; + END +"); +``` + +## Suppression + +If a specific call is intentionally unguarded (for example, a one-shot migration plugin that is removed after a single publish), suppress the diagnostic with a comment: + +```C# +// Acuminator disable once PX1121 — one-shot migration; plugin is removed after first publish. +PXDatabase.Execute("ALTER TABLE LegacyTable DROP COLUMN OldColumn"); +``` + +Or use a suppression file when Acuminator is installed as a VSIX extension. + +## Heuristic Limitations + +The diagnostic is regex-based and does not perform a full T-SQL parse. The following patterns are known limitations: + +- **Dynamic SQL built at runtime** (for example, `StringBuilder`-composed SQL with non-constant fragments) cannot be analyzed statically; PX1121 silently skips these calls. +- **Interpolation holes that stand in for DDL keywords or modifiers** (for example, `$"CREATE {kind} INDEX ..."`) are not modeled. +- **`SELECT` inside an `IF`'s then-branch followed by an unguarded mutation** (for example, `IF cond SELECT 1 ALTER TABLE ... ADD`) is treated as guarded because `SELECT` is excluded from the action-keyword set; this avoids false positives on `IF EXISTS (SELECT ... FROM sys.X)` guards but is a rare false negative. +- **`ELSE`-branch mutations after a then-branch action** (for example, `IF cond PRINT 'x' ELSE ALTER TABLE ... ADD`) are flagged because the `PRINT` occludes the guard for the `ELSE`-branch mutation. Suppress with a comment for this pattern. + +These edge cases are documented rather than fixed because a heuristic implementation catches the canonical anti-patterns at near-zero false-positive cost. A full T-SQL parse (for example, via `Microsoft.SqlServer.TransactSql.ScriptDom`) would resolve them; if Acuminator adopts that dependency in the future, this analyzer can be upgraded to use the parser. diff --git a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs index 55752d8be..53ab4780f 100644 --- a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs +++ b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.Designer.cs @@ -1193,5 +1193,14 @@ public static string PX1120_StoreTaskInVariable { return ResourceManager.GetString("PX1120_StoreTaskInVariable", resourceCulture); } } + + /// + /// Looks up a localized string similar to MissingSchemaMutationGuard. + /// + public static string PX1121 { + get { + return ResourceManager.GetString("PX1121", resourceCulture); + } + } } } diff --git a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx index 4db12c833..81d4d240b 100644 --- a/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx +++ b/src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx @@ -495,4 +495,7 @@ StoreTaskInVariableOrParameter + + MissingSchemaMutationGuard + \ No newline at end of file diff --git a/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs b/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs index 6eb821427..fcb0580e3 100644 --- a/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs +++ b/src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs @@ -2491,5 +2491,14 @@ public static string SuppressDiagnosticWithCommentNonNestedCodeActionTitle { return ResourceManager.GetString("SuppressDiagnosticWithCommentNonNestedCodeActionTitle", resourceCulture); } } + + /// + /// Looks up a localized string similar to The PXDatabase.Execute call performs an unguarded schema mutation. + /// + public static string PX1121Title { + get { + return ResourceManager.GetString("PX1121Title", resourceCulture); + } + } } } diff --git a/src/Acuminator/Acuminator.Analyzers/Resources.resx b/src/Acuminator/Acuminator.Analyzers/Resources.resx index 0aa7a4029..d7a3356b6 100644 --- a/src/Acuminator/Acuminator.Analyzers/Resources.resx +++ b/src/Acuminator/Acuminator.Analyzers/Resources.resx @@ -935,4 +935,7 @@ Reason: {2} Suppress the {0} diagnostic with Acuminator in a comment + + The PXDatabase.Execute call performs an unguarded schema mutation (ALTER TABLE ADD, CREATE TABLE, or CREATE INDEX). Acumatica customization plugins re-run UpdateDatabase on every publish; wrap the mutation in an existence guard (IF NOT EXISTS, IF COL_LENGTH, IF OBJECT_ID) so subsequent publishes do not fail. + \ No newline at end of file diff --git a/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs index e1be04a09..ded4fbd86 100644 --- a/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs +++ b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/Descriptors.cs @@ -582,5 +582,9 @@ private static DiagnosticDescriptor Rule(string id, LocalizableString title, Cat public static DiagnosticDescriptor PX1120_IncorrectTaskUsageInAsyncCode_NotAwaitedTaskReturningExpression { get; } = Rule("PX1120", nameof(Resources.PX1120Title_NotAwaitedTaskReturningExpression).GetLocalized(), Category.Acuminator, DiagnosticSeverity.Warning, DiagnosticsShortName.PX1120_NotAwaitedTaskReturningExpression); + + public static DiagnosticDescriptor PX1121_MissingSchemaMutationGuard { get; } = + Rule("PX1121", nameof(Resources.PX1121Title).GetLocalized(), Category.Acuminator, DiagnosticSeverity.Warning, + DiagnosticsShortName.PX1121); } } diff --git a/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs new file mode 100644 index 000000000..0c45dd63e --- /dev/null +++ b/src/Acuminator/Acuminator.Analyzers/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardAnalyzer.cs @@ -0,0 +1,310 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +using Acuminator.Utilities; +using Acuminator.Utilities.DiagnosticSuppression; +using Acuminator.Utilities.Roslyn.Semantic; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Acuminator.Analyzers.StaticAnalysis.MissingSchemaMutationGuard; + +/// +/// PX1121: Detects calls to PXDatabase.Execute with schema-mutation SQL +/// (ALTER TABLE ... ADD, CREATE TABLE, CREATE INDEX) that lack +/// an existence guard preceding that specific mutation. +/// +/// Acumatica customization plugins re-run UpdateDatabase on every publish; +/// an unguarded schema mutation will fail with "column already exists" / "table +/// already exists" on the second publish, breaking the deploy. +/// +/// Guard-scope detection is per-mutation: for each mutation match in the SQL, we +/// look at the preceding text (scoped to the current semicolon chunk) and check +/// whether the *latest* match is a guard pattern (IF NOT EXISTS, +/// IF COL_LENGTH, IF OBJECT_ID, IF INDEXPROPERTY, +/// FROM sys.columns/tables/indexes) or another action statement keyword +/// (PRINT, EXEC, INSERT, etc.). A guard occluded by a later +/// action-statement keyword no longer counts for that mutation, so the canonical +/// false-negative pattern IF EXISTS(...) PRINT 'x' ALTER TABLE ... ADD ... +/// (where the IF guards the PRINT and the ALTER is unguarded) is flagged correctly +/// even without semicolons between statements. +/// +/// Comments and string literals inside the SQL are stripped before matching so +/// their contents do not produce false positives or false negatives. +/// +/// This rule is not gated to ISVs because customer-written customization plugins +/// are equally affected by the double-publish failure mode. +/// +/// Known limitations (heuristic-based; full T-SQL parse would resolve them): +/// - Nested block comments and bracketed-identifier escape sequences +/// ([a]]b]) are not modeled. +/// - For interpolated strings with holes, literal segments are scanned; +/// a hole that stands in for a DDL keyword/modifier (e.g. +/// $"CREATE {kind} INDEX ...") is not modeled. +/// - Runtime-computed SQL (StringBuilder-concatenated, non-constant) +/// cannot be analyzed statically and is silently skipped. +/// - SELECT is excluded from action keywords so it doesn't break +/// guards like IF EXISTS (SELECT 1 FROM sys.X); consequence is that +/// IF cond SELECT 1 ALTER TABLE ... ADD is treated as guarded +/// (false negative on a rare pattern). +/// - ELSE branches: code like IF cond PRINT 'x' ELSE ALTER TABLE ... +/// ADD is flagged because PRINT occludes the guard for the +/// ELSE-branch ALTER. Suppress via comment for this pattern. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class MissingSchemaMutationGuardAnalyzer : PXDiagnosticAnalyzer +{ + private const string ExecuteMethodName = "Execute"; + + private const string InterpolationHolePlaceholder = " __pxsb_hole__ "; + + // Schema-mutation patterns. [^;] keeps each match inside one statement. + private static readonly Regex AlterTableAddPattern = new( + @"\bALTER\s+TABLE\b[^;]*?\bADD\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex CreateTablePattern = new( + @"\bCREATE\s+TABLE\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex CreateIndexPattern = new( + @"\bCREATE\s+(UNIQUE\s+)?(NONCLUSTERED\s+|CLUSTERED\s+)?INDEX\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // Existence guards. Optional parens + whitespace handle styles like + // IF (COL_LENGTH(...) IS NULL) and IF NOT EXISTS(SELECT ...). + private static readonly Regex GuardPattern = new( + @"\bIF\s*\(?\s*(NOT\s+)?EXISTS\b" + + @"|\bIF\s*\(?\s*COL_LENGTH\b" + + @"|\bIF\s*\(?\s*OBJECT_ID\b" + + @"|\bIF\s*\(?\s*INDEXPROPERTY\b" + + @"|\bFROM\s+SYS\.(COLUMNS|TABLES|INDEXES)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // Action-statement keywords that BREAK a guard's scope when they appear + // between the guard and the mutation. Intentionally excludes: + // - SELECT (appears inside IF EXISTS guard conditions) + // - IF, THEN, ELSE, BEGIN, END (control flow that preserves guard scope) + // Includes ALTER/CREATE because a second mutation breaks the guard for the + // first one — each mutation needs its own guard. + private static readonly Regex ActionStatementKeywords = new( + @"\b(ALTER|CREATE|DROP|TRUNCATE|INSERT|UPDATE|DELETE|MERGE|EXEC|EXECUTE|PRINT|RAISERROR|WAITFOR)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(Descriptors.PX1121_MissingSchemaMutationGuard); + + public MissingSchemaMutationGuardAnalyzer() : base() { } + + public MissingSchemaMutationGuardAnalyzer(CodeAnalysisSettings codeAnalysisSettings) : base(codeAnalysisSettings) { } + + protected override void AnalyzeCompilation(CompilationStartAnalysisContext compilationStartContext, PXContext pxContext) + { + compilationStartContext.RegisterSyntaxNodeAction( + syntaxContext => AnalyzeInvocation(syntaxContext, pxContext), + SyntaxKind.InvocationExpression); + } + + private void AnalyzeInvocation(SyntaxNodeAnalysisContext syntaxContext, PXContext pxContext) + { + syntaxContext.CancellationToken.ThrowIfCancellationRequested(); + + var invocation = (InvocationExpressionSyntax)syntaxContext.Node; + + if (!IsPxDatabaseExecuteCall(invocation, syntaxContext.SemanticModel, pxContext, syntaxContext.CancellationToken)) + return; + + var sqlArgument = invocation.ArgumentList.Arguments.FirstOrDefault(); + if (sqlArgument is null) + return; + + string? sqlText = TryGetStringValue(sqlArgument.Expression, syntaxContext.SemanticModel, syntaxContext.CancellationToken); + if (sqlText is null) + return; + + if (!ContainsUnguardedSchemaMutation(sqlText)) + return; + + syntaxContext.ReportDiagnosticWithSuppressionCheck( + Diagnostic.Create(Descriptors.PX1121_MissingSchemaMutationGuard, invocation.GetLocation()), + pxContext.CodeAnalysisSettings); + } + + private static bool IsPxDatabaseExecuteCall( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + PXContext pxContext, + System.Threading.CancellationToken cancellationToken) + { + var symbolInfo = semanticModel.GetSymbolInfo(invocation, cancellationToken); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + return false; + + if (methodSymbol.Name != ExecuteMethodName) + return false; + + var pxDatabaseType = pxContext.PXDatabase.Type; + if (pxDatabaseType is null) + return false; + + return SymbolEqualityComparer.Default.Equals(methodSymbol.ContainingType, pxDatabaseType); + } + + private static string? TryGetStringValue(ExpressionSyntax expression, SemanticModel semanticModel, System.Threading.CancellationToken cancellationToken) + { + var constant = semanticModel.GetConstantValue(expression, cancellationToken); + if (constant.HasValue && constant.Value is string constStr) + return constStr; + + if (expression is InterpolatedStringExpressionSyntax interp) + { + var sb = new StringBuilder(); + bool any = false; + foreach (var content in interp.Contents) + { + if (content is InterpolatedStringTextSyntax text) + { + sb.Append(text.TextToken.ValueText); + any = true; + } + else + { + sb.Append(InterpolationHolePlaceholder); + any = true; + } + } + return any ? sb.ToString() : null; + } + + return null; + } + + /// + /// Per-mutation guard detection. For each schema mutation in the sanitized SQL, + /// scan the preceding text and find the latest match of either a guard pattern + /// or an action-statement keyword. A guard occluded by a later action keyword + /// (e.g. an unrelated PRINT) no longer counts. + /// + private static bool ContainsUnguardedSchemaMutation(string sql) + { + string sanitized = StripCommentsAndStringLiterals(sql); + + var mutations = new List(); + foreach (Match m in AlterTableAddPattern.Matches(sanitized)) mutations.Add(m); + foreach (Match m in CreateTablePattern.Matches(sanitized)) mutations.Add(m); + foreach (Match m in CreateIndexPattern.Matches(sanitized)) mutations.Add(m); + + foreach (var mutation in mutations) + { + if (!IsGuardedAt(sanitized, mutation.Index)) + return true; + } + + return false; + } + + private static bool IsGuardedAt(string sanitized, int mutationIndex) + { + // Scope the search to the current ;-chunk (defensive boundary). + int chunkStart = mutationIndex > 0 + ? sanitized.LastIndexOf(';', mutationIndex - 1) + 1 + : 0; + if (chunkStart < 0) chunkStart = 0; + if (chunkStart >= mutationIndex) return false; + + string chunk = sanitized.Substring(chunkStart, mutationIndex - chunkStart); + + int latestGuardIdx = -1; + foreach (Match m in GuardPattern.Matches(chunk)) + { + if (m.Index > latestGuardIdx) latestGuardIdx = m.Index; + } + + if (latestGuardIdx == -1) return false; + + int latestActionIdx = -1; + foreach (Match m in ActionStatementKeywords.Matches(chunk)) + { + if (m.Index > latestActionIdx) latestActionIdx = m.Index; + } + + // Guard applies only if it appears AFTER the last action keyword. + return latestGuardIdx > latestActionIdx; + } + + /// + /// Strip SQL comments (-- line, /* block */) and string literals ('...') + /// so keyword matching does not match keywords appearing inside them. + /// Replaces stripped characters with whitespace (preserving newlines). + /// + private static string StripCommentsAndStringLiterals(string sql) + { + var sb = new StringBuilder(sql.Length); + int i = 0; + while (i < sql.Length) + { + char c = sql[i]; + + if (c == '-' && i + 1 < sql.Length && sql[i + 1] == '-') + { + while (i < sql.Length && sql[i] != '\n') + { + sb.Append(' '); + i++; + } + continue; + } + + if (c == '/' && i + 1 < sql.Length && sql[i + 1] == '*') + { + sb.Append(" "); + i += 2; + while (i + 1 < sql.Length && !(sql[i] == '*' && sql[i + 1] == '/')) + { + sb.Append(sql[i] == '\n' ? '\n' : ' '); + i++; + } + if (i + 1 < sql.Length) + { + sb.Append(" "); + i += 2; + } + continue; + } + + if (c == '\'') + { + sb.Append(' '); + i++; + while (i < sql.Length) + { + if (sql[i] == '\'') + { + if (i + 1 < sql.Length && sql[i + 1] == '\'') + { + sb.Append(" "); + i += 2; + continue; + } + sb.Append(' '); + i++; + break; + } + sb.Append(sql[i] == '\n' ? '\n' : ' '); + i++; + } + continue; + } + + sb.Append(c); + i++; + } + return sb.ToString(); + } +} From c0ad11991a480b7a6ac1c401f4a28875b152f408 Mon Sep 17 00:00:00 2001 From: Kevin Bibelhausen Date: Wed, 20 May 2026 14:44:36 -0400 Subject: [PATCH 2/2] test(analyzer): add PX1121 MissingSchemaMutationGuard tests - 7 positive cases: BareAlterTableAdd, MultipleUnguardedMutations, UnrelatedGuardDoesNotSatisfyMutation, ConstantStringConcatenation, InterpolatedIdentifiers, BareCreateTable, BareCreateIndex - 7 negative cases: ColLengthGuard, IfNotExistsSysColumns, ObjectIdGuardCreateTable, IndexPropertyGuardCreateIndex, BeginEndBlockUnderGuard, NonSchemaMutationPlugin, AlterInCommentIsIgnored All source files use `using PX.Data;` only (no CustomizationPlugin reference) so they compile cleanly in the test harness. Build verification deferred to upstream Windows CI (macOS lacks net48). Codex review: CLEAR TO SHIP (no actionable issues found). --- .../MissingSchemaMutationGuardTests.cs | 108 ++++++++++++++++++ .../Sources/Diagnostic_BareAlterTableAdd.cs | 14 +++ .../Sources/Diagnostic_BareCreateIndex.cs | 14 +++ .../Sources/Diagnostic_BareCreateTable.cs | 17 +++ .../Diagnostic_ConstantStringConcatenation.cs | 12 ++ .../Diagnostic_InterpolatedIdentifiers.cs | 14 +++ .../Diagnostic_MultipleUnguardedMutations.cs | 15 +++ ...ic_UnrelatedGuardDoesNotSatisfyMutation.cs | 16 +++ .../NoDiagnostic_AlterInCommentIsIgnored.cs | 18 +++ .../NoDiagnostic_BeginEndBlockUnderGuard.cs | 17 +++ .../Sources/NoDiagnostic_ColLengthGuard.cs | 15 +++ .../NoDiagnostic_IfNotExistsSysColumns.cs | 18 +++ ...iagnostic_IndexPropertyGuardCreateIndex.cs | 15 +++ .../NoDiagnostic_NonSchemaMutationPlugin.cs | 16 +++ .../NoDiagnostic_ObjectIdGuardCreateTable.cs | 19 +++ 15 files changed, 328 insertions(+) create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardTests.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareAlterTableAdd.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateIndex.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateTable.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_ConstantStringConcatenation.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_InterpolatedIdentifiers.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_MultipleUnguardedMutations.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_AlterInCommentIsIgnored.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_BeginEndBlockUnderGuard.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ColLengthGuard.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IfNotExistsSysColumns.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IndexPropertyGuardCreateIndex.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_NonSchemaMutationPlugin.cs create mode 100644 src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ObjectIdGuardCreateTable.cs diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardTests.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardTests.cs new file mode 100644 index 000000000..74ef8e47f --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/MissingSchemaMutationGuardTests.cs @@ -0,0 +1,108 @@ +using System.Threading.Tasks; + +using Acuminator.Analyzers.StaticAnalysis; +using Acuminator.Analyzers.StaticAnalysis.MissingSchemaMutationGuard; +using Acuminator.Tests.Helpers; +using Acuminator.Tests.Verification; +using Acuminator.Utilities; + +using Microsoft.CodeAnalysis.Diagnostics; + +using Xunit; + +namespace Acuminator.Tests.Tests.StaticAnalysis.MissingSchemaMutationGuard +{ + public class MissingSchemaMutationGuardTests : DiagnosticVerifier + { + protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() => + new MissingSchemaMutationGuardAnalyzer( + CodeAnalysisSettings.Default + .WithStaticAnalysisEnabled() + .WithSuppressionMechanismDisabled()); + + #region Positive cases — diagnostic expected + + [Theory] + [EmbeddedFileData("Diagnostic_BareAlterTableAdd.cs")] + public Task BareAlterTableAdd_ShouldReport(string source) => + VerifyCSharpDiagnosticAsync(source, + Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4)); + + [Theory] + [EmbeddedFileData("Diagnostic_MultipleUnguardedMutations.cs")] + public Task MultipleUnguardedMutations_ShouldReport(string source) => + VerifyCSharpDiagnosticAsync(source, + Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4)); + + [Theory] + [EmbeddedFileData("Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs")] + public Task UnrelatedGuardDoesNotSatisfyMutation_ShouldReport(string source) => + VerifyCSharpDiagnosticAsync(source, + Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4)); + + [Theory] + [EmbeddedFileData("Diagnostic_ConstantStringConcatenation.cs")] + public Task ConstantStringConcatenation_ShouldReport(string source) => + VerifyCSharpDiagnosticAsync(source, + Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4)); + + [Theory] + [EmbeddedFileData("Diagnostic_InterpolatedIdentifiers.cs")] + public Task InterpolatedIdentifiers_ShouldReport(string source) => + VerifyCSharpDiagnosticAsync(source, + Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(11, 4)); + + [Theory] + [EmbeddedFileData("Diagnostic_BareCreateTable.cs")] + public Task BareCreateTable_ShouldReport(string source) => + VerifyCSharpDiagnosticAsync(source, + Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4)); + + [Theory] + [EmbeddedFileData("Diagnostic_BareCreateIndex.cs")] + public Task BareCreateIndex_ShouldReport(string source) => + VerifyCSharpDiagnosticAsync(source, + Descriptors.PX1121_MissingSchemaMutationGuard.CreateFor(9, 4)); + + #endregion + + #region Negative cases — no diagnostic expected + + [Theory] + [EmbeddedFileData("NoDiagnostic_ColLengthGuard.cs")] + public Task ColLengthGuard_ShouldNotReport(string source) => + VerifyCSharpDiagnosticAsync(source); + + [Theory] + [EmbeddedFileData("NoDiagnostic_IfNotExistsSysColumns.cs")] + public Task IfNotExistsSysColumns_ShouldNotReport(string source) => + VerifyCSharpDiagnosticAsync(source); + + [Theory] + [EmbeddedFileData("NoDiagnostic_ObjectIdGuardCreateTable.cs")] + public Task ObjectIdGuardCreateTable_ShouldNotReport(string source) => + VerifyCSharpDiagnosticAsync(source); + + [Theory] + [EmbeddedFileData("NoDiagnostic_IndexPropertyGuardCreateIndex.cs")] + public Task IndexPropertyGuardCreateIndex_ShouldNotReport(string source) => + VerifyCSharpDiagnosticAsync(source); + + [Theory] + [EmbeddedFileData("NoDiagnostic_BeginEndBlockUnderGuard.cs")] + public Task BeginEndBlockUnderGuard_ShouldNotReport(string source) => + VerifyCSharpDiagnosticAsync(source); + + [Theory] + [EmbeddedFileData("NoDiagnostic_NonSchemaMutationPlugin.cs")] + public Task NonSchemaMutation_ShouldNotReport(string source) => + VerifyCSharpDiagnosticAsync(source); + + [Theory] + [EmbeddedFileData("NoDiagnostic_AlterInCommentIsIgnored.cs")] + public Task AlterInCommentIsIgnored_ShouldNotReport(string source) => + VerifyCSharpDiagnosticAsync(source); + + #endregion + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareAlterTableAdd.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareAlterTableAdd.cs new file mode 100644 index 000000000..c7656ffaf --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareAlterTableAdd.cs @@ -0,0 +1,14 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_BareAlterTableAdd + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + ALTER TABLE SOOrder ADD UsrPriority int NULL + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateIndex.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateIndex.cs new file mode 100644 index 000000000..f2c3cf53f --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateIndex.cs @@ -0,0 +1,14 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_BareCreateIndex + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + CREATE INDEX IX_SOOrder_UsrPriority ON SOOrder(UsrPriority) + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateTable.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateTable.cs new file mode 100644 index 000000000..badde6124 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_BareCreateTable.cs @@ -0,0 +1,17 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_BareCreateTable + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + CREATE TABLE UsrCustomLookup ( + Id int IDENTITY(1,1) PRIMARY KEY, + Code nvarchar(32) NOT NULL + ) + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_ConstantStringConcatenation.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_ConstantStringConcatenation.cs new file mode 100644 index 000000000..a10af60d5 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_ConstantStringConcatenation.cs @@ -0,0 +1,12 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_ConstantStringConcatenation + { + public void UpdateDatabase() + { + PXDatabase.Execute("ALTER TABLE SOOrder " + "ADD UsrPriority int NULL"); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_InterpolatedIdentifiers.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_InterpolatedIdentifiers.cs new file mode 100644 index 000000000..7e22984e2 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_InterpolatedIdentifiers.cs @@ -0,0 +1,14 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_InterpolatedIdentifiers + { + public void UpdateDatabase() + { + string tableName = "SOOrder"; + string columnName = "UsrPriority"; + PXDatabase.Execute($"ALTER TABLE {tableName} ADD {columnName} int NULL"); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_MultipleUnguardedMutations.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_MultipleUnguardedMutations.cs new file mode 100644 index 000000000..0fde65a70 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_MultipleUnguardedMutations.cs @@ -0,0 +1,15 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_MultipleUnguardedMutations + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + ALTER TABLE SOOrder ADD UsrPriority int NULL; + ALTER TABLE POOrder ADD UsrPriority int NULL; + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs new file mode 100644 index 000000000..1197c48f5 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/Diagnostic_UnrelatedGuardDoesNotSatisfyMutation.cs @@ -0,0 +1,16 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_UnrelatedGuard + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'OtherTable') + PRINT 'logging note'; + ALTER TABLE SOOrder ADD UsrPriority int NULL + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_AlterInCommentIsIgnored.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_AlterInCommentIsIgnored.cs new file mode 100644 index 000000000..783fd7a6a --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_AlterInCommentIsIgnored.cs @@ -0,0 +1,18 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + // The word ALTER appears only inside a SQL comment and a string literal — + // neither should trigger the diagnostic. + public class MyPlugin_AlterInCommentIsIgnored + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + -- ALTER TABLE SOOrder ADD UsrPriority int NULL (commented out) + /* ALTER TABLE POOrder ADD UsrPriority int NULL (block comment) */ + INSERT INTO UsrAuditLog (Message) VALUES ('ALTER TABLE is documented here') + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_BeginEndBlockUnderGuard.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_BeginEndBlockUnderGuard.cs new file mode 100644 index 000000000..f6c28a1d3 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_BeginEndBlockUnderGuard.cs @@ -0,0 +1,17 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_BeginEndBlockUnderGuard + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL + BEGIN + ALTER TABLE SOOrder ADD UsrPriority int NULL; + END + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ColLengthGuard.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ColLengthGuard.cs new file mode 100644 index 000000000..9d5788333 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ColLengthGuard.cs @@ -0,0 +1,15 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_ColLengthGuard + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + IF COL_LENGTH('SOOrder', 'UsrPriority') IS NULL + ALTER TABLE SOOrder ADD UsrPriority int NULL + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IfNotExistsSysColumns.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IfNotExistsSysColumns.cs new file mode 100644 index 000000000..db926038f --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IfNotExistsSysColumns.cs @@ -0,0 +1,18 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_IfNotExistsSysColumns + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE Name = 'UsrPriority' AND Object_ID = OBJECT_ID('SOOrder') + ) + ALTER TABLE SOOrder ADD UsrPriority int NULL + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IndexPropertyGuardCreateIndex.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IndexPropertyGuardCreateIndex.cs new file mode 100644 index 000000000..fa214ebb7 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_IndexPropertyGuardCreateIndex.cs @@ -0,0 +1,15 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_IndexPropertyGuardCreateIndex + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + IF INDEXPROPERTY(OBJECT_ID('SOOrder'), 'IX_SOOrder_UsrPriority', 'IndexID') IS NULL + CREATE INDEX IX_SOOrder_UsrPriority ON SOOrder(UsrPriority) + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_NonSchemaMutationPlugin.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_NonSchemaMutationPlugin.cs new file mode 100644 index 000000000..cc5dad0c5 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_NonSchemaMutationPlugin.cs @@ -0,0 +1,16 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + // Plugin that calls PXDatabase.Execute with non-DDL SQL — no schema mutation. + public class MyPlugin_NonSchemaMutation + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + INSERT INTO UsrAuditLog (EventTime, UserID, Action) + VALUES (GETDATE(), 'system', 'plugin-init') + "); + } + } +} diff --git a/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ObjectIdGuardCreateTable.cs b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ObjectIdGuardCreateTable.cs new file mode 100644 index 000000000..c13d3c056 --- /dev/null +++ b/src/Acuminator/Acuminator.Tests/Tests/StaticAnalysis/MissingSchemaMutationGuard/Sources/NoDiagnostic_ObjectIdGuardCreateTable.cs @@ -0,0 +1,19 @@ +using PX.Data; + +namespace Acuminator.Tests.Sources +{ + public class MyPlugin_ObjectIdGuardCreateTable + { + public void UpdateDatabase() + { + PXDatabase.Execute(@" + IF OBJECT_ID('UsrCustomLookup', 'U') IS NULL + CREATE TABLE UsrCustomLookup ( + Id int IDENTITY(1,1) PRIMARY KEY, + Code nvarchar(32) NOT NULL, + Description nvarchar(256) NULL + ) + "); + } + } +}