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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/Summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
159 changes: 159 additions & 0 deletions docs/diagnostics/PX1121.md
Original file line number Diff line number Diff line change
@@ -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('<table>', '<column>') IS NULL`
- `IF OBJECT_ID('<object>', '<type>') IS NULL`
- `IF INDEXPROPERTY(OBJECT_ID('<table>'), '<index>', '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.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Acuminator/Acuminator.Analyzers/DiagnosticsShortName.resx
Original file line number Diff line number Diff line change
Expand Up @@ -495,4 +495,7 @@
<data name="PX1120_StoreTaskInVariable" xml:space="preserve">
<value>StoreTaskInVariableOrParameter</value>
</data>
<data name="PX1121" xml:space="preserve">
<value>MissingSchemaMutationGuard</value>
</data>
</root>
9 changes: 9 additions & 0 deletions src/Acuminator/Acuminator.Analyzers/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Acuminator/Acuminator.Analyzers/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -935,4 +935,7 @@ Reason: {2}</value>
<data name="SuppressDiagnosticWithCommentNonNestedCodeActionTitle" xml:space="preserve">
<value>Suppress the {0} diagnostic with Acuminator in a comment</value>
</data>
<data name="PX1121Title" xml:space="preserve">
<value>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.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading