Skip to content

Commit e4d1696

Browse files
feat(migration): Convert Remove-DbaAgDatabase, Remove-DbaAgListener, Remove-DbaAgReplica, Remove-DbaAvailabilityGroup to C# binary cmdlets
All four ShouldProcess with ConfirmImpact.High, static ScriptBlocks for SMO operations. - Remove-DbaAgDatabase: InputObject accepts Database+AgDatabase types, wired AvailabilityGroup filter (PS1 bug fix) - Remove-DbaAgListener: Wired AvailabilityGroup filter to Get-DbaAgListener call (PS1 bug fix) - Remove-DbaAgReplica: Fixed null AvailabilityGroup output (PS1 Parent.AvailabilityGroup bug) - Remove-DbaAvailabilityGroup: Bracket-quoted AG name in DROP T-SQL (SQL injection fix) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent daf3590 commit e4d1696

6 files changed

Lines changed: 1139 additions & 4 deletions

File tree

dbatools.library.psd1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@
8888
'New-DbatoolsSupportPackage',
8989
'New-DbaScriptingOption',
9090
'New-DbaSqlParameter',
91+
'Remove-DbaAgDatabase',
92+
'Remove-DbaAgListener',
93+
'Remove-DbaAgReplica',
94+
'Remove-DbaAvailabilityGroup',
9195
'Resolve-DbaNetworkName',
9296
'Resolve-DbaPath',
9397
'Set-DbaAgListener',

docs/plan/TRACKER-MIGRATE-AG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@
2525
| 16 | Add-DbaAgReplica | DONE | AddDbaAgReplicaCommand.cs | OK | 100% | 25/25 unit pass | ShouldProcess, ConfirmImpact.Low, 20+ params, config-backed defaults, endpoint auto-creation, WSFC permissions, XE session config, Passthru, anchored regex (ReDoS fix), ValidateRange on BackupPriority/SessionTimeout, fixed PS1 $second.Name bug |
2626
| 17 | Disable-DbaAgHadr | DONE | DisableDbaAgHadrCommand.cs | OK | 100% | 1/1 param (common param diff expected); integration pre-existing infra issue | ShouldProcess, ConfirmImpact.High, WMI-based (DbaBaseCmdlet), static ScriptBlocks, Force restart, TestElevation |
2727
| 18 | Enable-DbaAgHadr | DONE | EnableDbaAgHadrCommand.cs | OK | 100% | 1/1 param (common param diff expected); integration pre-existing infra issue | ShouldProcess, ConfirmImpact.High, WMI-based (DbaBaseCmdlet), static ScriptBlocks, Force restart, TestElevation |
28-
| 19 | Remove-DbaAgDatabase | PENDING | | | | | ShouldProcess required, depends on Get-DbaAgDatabase |
29-
| 20 | Remove-DbaAgListener | PENDING | | | | | ShouldProcess required, depends on Get-DbaAgListener |
30-
| 21 | Remove-DbaAgReplica | PENDING | | | | | ShouldProcess required, depends on Get-DbaAgReplica |
31-
| 22 | Remove-DbaAvailabilityGroup | PENDING | | | | | ShouldProcess required, depends on Get-DbaAvailabilityGroup |
28+
| 19 | Remove-DbaAgDatabase | DONE | RemoveDbaAgDatabaseCommand.cs | OK | 100% | 1/1 param (common param diff expected); integration pre-existing infra issue | ShouldProcess, ConfirmImpact.High, InputObject (Database+AgDb), wired AvailabilityGroup filter (PS1 bug fix) |
29+
| 20 | Remove-DbaAgListener | DONE | RemoveDbaAgListenerCommand.cs | OK | 100% | 1/1 param (common param diff expected); integration pre-existing infra issue | ShouldProcess, ConfirmImpact.High, wired AvailabilityGroup filter (PS1 bug fix) |
30+
| 21 | Remove-DbaAgReplica | DONE | RemoveDbaAgReplicaCommand.cs | OK | 100% | 1/1 param (common param diff expected); integration pre-existing infra issue | ShouldProcess, ConfirmImpact.High, fixed PS1 null AvailabilityGroup output (Parent.AvailabilityGroup bug) |
31+
| 22 | Remove-DbaAvailabilityGroup | DONE | RemoveDbaAvailabilityGroupCommand.cs | OK | 100% | 1/1 param (common param diff expected); integration pre-existing infra issue | ShouldProcess, ConfirmImpact.High, T-SQL DROP with bracket-quoted name (SQL injection fix), AllAvailabilityGroups switch |
3232
| 23 | Compare-DbaAgReplicaAgentJob | PENDING | | | | | |
3333
| 24 | Compare-DbaAgReplicaCredential | PENDING | | | | | |
3434
| 25 | Compare-DbaAgReplicaLogin | PENDING | | | | | |
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.Management.Automation;
5+
using Dataplat.Dbatools.Message;
6+
using Dataplat.Dbatools.Parameter;
7+
8+
namespace Dataplat.Dbatools.Commands
9+
{
10+
/// <summary>
11+
/// Removes databases from availability groups on SQL Server instances.
12+
/// This stops replication and high availability protection for those databases while preserving
13+
/// the actual database files on each replica. Supports pipeline input from Get-DbaAgDatabase.
14+
/// </summary>
15+
[Cmdlet("Remove", "DbaAgDatabase", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
16+
public class RemoveDbaAgDatabaseCommand : DbaBaseCmdlet
17+
{
18+
#region Parameters
19+
20+
/// <summary>
21+
/// The target SQL Server instance or instances. Server version must be SQL Server version 2012 or higher.
22+
/// </summary>
23+
[Parameter()]
24+
public DbaInstanceParameter[] SqlInstance { get; set; }
25+
26+
/// <summary>
27+
/// Login to the target instance using alternative credentials. Accepts PowerShell credentials (Get-Credential).
28+
/// Windows Authentication, SQL Server Authentication, Active Directory - Password, and Active Directory - Integrated are all supported.
29+
/// For MFA support, please use Connect-DbaInstance.
30+
/// </summary>
31+
[Parameter()]
32+
public PSCredential SqlCredential { get; set; }
33+
34+
/// <summary>
35+
/// Specifies which databases to remove from their availability groups. Accepts multiple database names as an array.
36+
/// Required when using the SqlInstance parameter.
37+
/// </summary>
38+
[Parameter()]
39+
public string[] Database { get; set; }
40+
41+
/// <summary>
42+
/// Limits the operation to databases within specific availability groups. When specified, only databases
43+
/// belonging to these availability groups will be removed.
44+
/// </summary>
45+
[Parameter()]
46+
public string[] AvailabilityGroup { get; set; }
47+
48+
/// <summary>
49+
/// Accepts availability group database objects from Get-DbaAgDatabase or database objects from Get-DbaDatabase
50+
/// through the pipeline. This enables efficient batch operations and complex filtering scenarios.
51+
/// </summary>
52+
[Parameter(ValueFromPipeline = true)]
53+
public object[] InputObject { get; set; }
54+
55+
#endregion Parameters
56+
57+
#region Static ScriptBlocks
58+
59+
/// <summary>
60+
/// Calls Get-DbaAgDatabase with appropriate parameters.
61+
/// </summary>
62+
private static readonly ScriptBlock _getDbaAgDatabaseScript = ScriptBlock.Create(@"
63+
param($si, $sc, $db, $ag, $hasCred, $hasDb, $hasAg)
64+
$params = @{ SqlInstance = $si }
65+
if ($hasCred) { $params['SqlCredential'] = $sc }
66+
if ($hasDb) { $params['Database'] = $db }
67+
if ($hasAg) { $params['AvailabilityGroup'] = $ag }
68+
Get-DbaAgDatabase @params
69+
");
70+
71+
/// <summary>
72+
/// Gets the server name from the database's AG parent hierarchy.
73+
/// AvailabilityDatabase.Parent = AvailabilityGroup, AvailabilityGroup.Parent = Server.
74+
/// </summary>
75+
private static readonly ScriptBlock _getServerNameScript = ScriptBlock.Create(@"
76+
param($db) $db.Parent.Parent.Name
77+
");
78+
79+
/// <summary>
80+
/// Drops the database from the availability group and returns the AG name.
81+
/// Uses the Parent.AvailabilityDatabases collection to ensure correct object reference.
82+
/// </summary>
83+
private static readonly ScriptBlock _dropAgDatabaseScript = ScriptBlock.Create(@"
84+
param($db)
85+
$agName = $db.Parent.Name
86+
$db.Parent.AvailabilityDatabases[$db.Name].Drop()
87+
$agName
88+
");
89+
90+
#endregion Static ScriptBlocks
91+
92+
/// <summary>
93+
/// Processes each pipeline item or SqlInstance-based query to remove databases from availability groups.
94+
/// </summary>
95+
protected override void ProcessRecord()
96+
{
97+
if (TestFunctionInterrupt())
98+
return;
99+
100+
// Validate: need SqlInstance or InputObject
101+
if (TestBoundNot("SqlInstance", "InputObject"))
102+
{
103+
StopFunction("You must supply either -SqlInstance or an Input Object");
104+
return;
105+
}
106+
107+
// Validate: SqlInstance requires Database
108+
if (TestBound("SqlInstance") && TestBoundNot("Database"))
109+
{
110+
StopFunction("You must specify one or more databases and one or more Availability Groups when using the SqlInstance parameter.");
111+
return;
112+
}
113+
114+
// Handle Database-type InputObject: extract names to Database array
115+
// PS1: if ($InputObject[0].GetType().Name -eq 'Database') { $Database += $InputObject.Name }
116+
if (InputObject != null && InputObject.Length > 0)
117+
{
118+
string typeName = GetBaseTypeName(InputObject[0]);
119+
if (string.Equals(typeName, "Database", StringComparison.OrdinalIgnoreCase))
120+
{
121+
List<string> dbNames = new List<string>();
122+
if (Database != null)
123+
{
124+
dbNames.AddRange(Database);
125+
}
126+
foreach (object obj in InputObject)
127+
{
128+
string name = GetPropertyString(PSObject.AsPSObject(obj), "Name");
129+
if (name != null)
130+
{
131+
dbNames.Add(name);
132+
}
133+
}
134+
Database = dbNames.ToArray();
135+
}
136+
}
137+
138+
// If SqlInstance provided, fetch AG databases and add to InputObject
139+
// PS1: $InputObject += Get-DbaAgDatabase -SqlInstance $SqlInstance -SqlCredential $SqlCredential -Database $Database
140+
if (TestBound("SqlInstance"))
141+
{
142+
List<object> items = new List<object>();
143+
if (InputObject != null)
144+
{
145+
items.AddRange(InputObject);
146+
}
147+
148+
Collection<PSObject> agDbs = GetDbaAgDatabase();
149+
if (agDbs != null)
150+
{
151+
foreach (PSObject db in agDbs)
152+
{
153+
if (db != null)
154+
{
155+
items.Add(db.BaseObject ?? db);
156+
}
157+
}
158+
}
159+
InputObject = items.ToArray();
160+
}
161+
162+
if (InputObject == null)
163+
return;
164+
165+
foreach (object dbObj in InputObject)
166+
{
167+
if (dbObj == null)
168+
continue;
169+
ProcessDatabase(dbObj);
170+
}
171+
}
172+
173+
/// <summary>
174+
/// Processes a single database object: validates via ShouldProcess, drops from AG, and outputs result.
175+
/// </summary>
176+
private void ProcessDatabase(object dbObj)
177+
{
178+
PSObject db = PSObject.AsPSObject(dbObj);
179+
string dbName = GetPropertyString(db, "Name");
180+
string serverName = GetServerName(dbObj);
181+
182+
// PS1: if ($Pscmdlet.ShouldProcess($db.Parent.Parent.Name, "Removing availability group database $db"))
183+
if (ShouldProcess(serverName ?? dbName ?? "Unknown",
184+
String.Format("Removing availability group database {0}", dbName ?? "Unknown")))
185+
{
186+
try
187+
{
188+
string agName = DropAgDatabase(dbObj);
189+
190+
PSObject output = new PSObject();
191+
output.Properties.Add(new PSNoteProperty("ComputerName", GetPropertyString(db, "ComputerName")));
192+
output.Properties.Add(new PSNoteProperty("InstanceName", GetPropertyString(db, "InstanceName")));
193+
output.Properties.Add(new PSNoteProperty("SqlInstance", GetPropertyString(db, "SqlInstance")));
194+
output.Properties.Add(new PSNoteProperty("AvailabilityGroup", agName));
195+
output.Properties.Add(new PSNoteProperty("Database", dbName));
196+
output.Properties.Add(new PSNoteProperty("Status", "Removed"));
197+
WriteObject(output);
198+
}
199+
catch (Exception ex)
200+
{
201+
StopFunction(
202+
String.Format("Failed to remove {0} from availability group", dbName),
203+
errorRecord: new ErrorRecord(ex, "RemoveDbaAgDatabase", ErrorCategory.InvalidOperation, dbObj),
204+
target: dbObj, isContinue: true);
205+
TestFunctionInterrupt();
206+
}
207+
}
208+
}
209+
210+
#region Helpers
211+
212+
/// <summary>
213+
/// Gets the simple type name of an object's base type, unwrapping PSObject if necessary.
214+
/// </summary>
215+
private static string GetBaseTypeName(object obj)
216+
{
217+
if (obj == null)
218+
return null;
219+
object baseObj = obj;
220+
if (obj is PSObject pso && pso.BaseObject != null)
221+
baseObj = pso.BaseObject;
222+
return baseObj.GetType().Name;
223+
}
224+
225+
/// <summary>
226+
/// Gets the server name from the AG database's parent hierarchy via ScriptBlock.
227+
/// </summary>
228+
private string GetServerName(object dbObj)
229+
{
230+
try
231+
{
232+
Collection<PSObject> results = InvokeCommand.InvokeScript(
233+
false, _getServerNameScript, null, new object[] { dbObj });
234+
if (results != null && results.Count > 0 && results[0] != null)
235+
return results[0].BaseObject as string ?? results[0].ToString();
236+
}
237+
catch (Exception ex)
238+
{
239+
WriteMessageAtLevel(
240+
String.Format("Could not resolve server name from object hierarchy: {0}", ex.Message),
241+
MessageLevel.Debug, null);
242+
}
243+
return null;
244+
}
245+
246+
/// <summary>
247+
/// Drops the database from the availability group and returns the AG name.
248+
/// </summary>
249+
private string DropAgDatabase(object dbObj)
250+
{
251+
Collection<PSObject> results = InvokeCommand.InvokeScript(
252+
false, _dropAgDatabaseScript, null, new object[] { dbObj });
253+
if (results != null && results.Count > 0 && results[0] != null)
254+
return results[0].BaseObject as string ?? results[0].ToString();
255+
return null;
256+
}
257+
258+
/// <summary>
259+
/// Calls Get-DbaAgDatabase with the current SqlInstance, SqlCredential, Database, and AvailabilityGroup parameters.
260+
/// </summary>
261+
private Collection<PSObject> GetDbaAgDatabase()
262+
{
263+
try
264+
{
265+
return InvokeCommand.InvokeScript(false, _getDbaAgDatabaseScript, null,
266+
new object[]
267+
{
268+
SqlInstance,
269+
SqlCredential,
270+
Database,
271+
AvailabilityGroup,
272+
SqlCredential != null,
273+
Database != null && Database.Length > 0,
274+
TestBound("AvailabilityGroup")
275+
});
276+
}
277+
catch (Exception ex)
278+
{
279+
StopFunction(
280+
"Failed to get availability group databases",
281+
errorRecord: new ErrorRecord(ex, "RemoveDbaAgDatabase_GetAgDb", ErrorCategory.ConnectionError, SqlInstance),
282+
target: SqlInstance, isContinue: true);
283+
TestFunctionInterrupt();
284+
return null;
285+
}
286+
}
287+
288+
/// <summary>
289+
/// Gets a string property value from a PSObject.
290+
/// </summary>
291+
internal static string GetPropertyString(PSObject obj, string propertyName)
292+
{
293+
if (obj == null)
294+
return null;
295+
try
296+
{
297+
PSPropertyInfo prop = obj.Properties[propertyName];
298+
if (prop != null && prop.Value != null)
299+
return prop.Value.ToString();
300+
}
301+
catch (Exception)
302+
{
303+
// Property may not exist
304+
}
305+
return null;
306+
}
307+
308+
#endregion Helpers
309+
}
310+
}

0 commit comments

Comments
 (0)