From c9098b3cea19609a1a3d2af36fdb03f69c28daa6 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:20:29 +0200 Subject: [PATCH 1/2] Support double-quoted identifiers and ALTER TABLE primary keys; report skipped tables --- .../SQLSchema-To-ALExtension.ps1 | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/samples/CloudMigration/GenerateALTablesFromSQLSchema/SQLSchema-To-ALExtension.ps1 b/samples/CloudMigration/GenerateALTablesFromSQLSchema/SQLSchema-To-ALExtension.ps1 index 97c4891e..45fc8e96 100644 --- a/samples/CloudMigration/GenerateALTablesFromSQLSchema/SQLSchema-To-ALExtension.ps1 +++ b/samples/CloudMigration/GenerateALTablesFromSQLSchema/SQLSchema-To-ALExtension.ps1 @@ -140,13 +140,18 @@ $script:CodeunitMappings = '' $script:SQLStatsQ = @() # A bracketed SQL identifier may contain spaces, '$', '.', '(' ... anything except ']'. -$identifier = "(?:\[[^\]]+\]|[A-Za-z0-9_@#\$]+)" +$identifier = "(?:\[[^\]]+\]|`"[^`"]+`"|[A-Za-z0-9_@#\$]+)" function Remove-Brackets($v) { $t = "$v".Trim() if ($t.StartsWith('[') -and $t.EndsWith(']')) { return $t.Substring(1, $t.Length - 2) } + # Legacy scripts (and anything generated with QUOTED_IDENTIFIER ON) delimit names with + # double quotes instead of brackets. + if (($t.Length -ge 2) -and $t.StartsWith('"') -and $t.EndsWith('"')) { + return $t.Substring(1, $t.Length - 2) + } return $t } @@ -274,19 +279,23 @@ function Get-UniqueALObjectName($candidate) { function Split-CommaParams($tablecontent) { $pCount = 0 $bCount = 0 + $inQuote = $false $current = '' $params = @() for ($i = 0; $i -lt $tablecontent.Length; $i++) { $c = $tablecontent[$i] - if (($c -eq ',') -and ($pCount -eq 0) -and ($bCount -eq 0)) { + if ($c -eq '"') { $inQuote = -not $inQuote } + if (($c -eq ',') -and ($pCount -eq 0) -and ($bCount -eq 0) -and (-not $inQuote)) { $params += $current $current = '' continue } - if ($c -eq '(') { $pCount++ } - elseif ($c -eq '[') { $bCount++ } - elseif ($c -eq ')') { $pCount-- } - elseif ($c -eq ']') { $bCount-- } + if (-not $inQuote) { + if ($c -eq '(') { $pCount++ } + elseif ($c -eq '[') { $bCount++ } + elseif ($c -eq ')') { $pCount-- } + elseif ($c -eq ']') { $bCount-- } + } $current += $c } if ($current.Trim() -ne '') { $params += $current } @@ -295,7 +304,7 @@ function Split-CommaParams($tablecontent) { $columnRegex = [Regex]::new("^\s*(?$identifier)\s+(?$identifier)\s*(\(\s*(?[^\)]*)\))?", 'IgnoreCase') $primKeyRegex = [Regex]::new("primary\s+key[^\(]*\(\s*(?[^\)]+)\)", 'IgnoreCase, Singleline') -$keyColRegex = [Regex]::new("(?\[[^\]]+\]|[A-Za-z0-9_@#\$]+)", 'IgnoreCase') +$keyColRegex = [Regex]::new("(?\[[^\]]+\]|`"[^`"]+`"|[A-Za-z0-9_@#\$]+)", 'IgnoreCase') function ConvertTo-ALTable($tableid, $tablecontent, $tableCount) { $sqlTableName = Get-CleanTableName $tableid @@ -387,6 +396,9 @@ function ConvertTo-ALTable($tableid, $tablecontent, $tableCount) { # A key can only reference fields that were actually emitted, and BLOB fields cannot be # part of a key. + if (($keyscontent.Count -eq 0) -and ($script:AlterTablePrimaryKeys.ContainsKey($sqlTableName))) { + $keyscontent = @($script:AlterTablePrimaryKeys[$sqlTableName]) + } $droppedKeyCols = @($keyscontent | Where-Object { ($emittedFields -notcontains $_) -or ($blobFields -contains $_) }) if ($droppedKeyCols.Count -gt 0) { Write-Host "Primary key of table $sqlTableName references unusable column(s): $($droppedKeyCols -join ', ')." @@ -409,6 +421,7 @@ function ConvertTo-ALTable($tableid, $tablecontent, $tableCount) { [void]$sb.AppendLine('}') $sb.ToString() | Out-File -FilePath "$tablesFolder$filename" -Encoding UTF8 + $script:GeneratedTableCount++ $pxml = $permissionXML -replace 'OBJECTTYPEHERE', 'TableData' $pxml = $pxml -replace 'OBJECTIDHERE', $id @@ -427,11 +440,37 @@ if ($schema -match $useDBregex) { $createTableRegex = [Regex]::new("(?i)\bcreate\s+table\s+(?$identifier(?:\s*\.\s*$identifier)*)\s*\(", 'IgnoreCase') $result = $createTableRegex.Matches($schema) +# Any CREATE TABLE the parser could not understand must be reported. Silently dropping a table +# would produce an extension that looks complete but is missing data. +$createTableCount = ([Regex]::Matches($schema, "(?i)\bcreate\s+table\b")).Count +if ($createTableCount -gt $result.Count) { + Write-Host "$($createTableCount - $result.Count) CREATE TABLE statement(s) could not be parsed and were skipped. Check the input schema." +} + if ($result.Count -eq 0) { Write-Host 'Unable to parse schema definitions' exit 1 } +# Primary keys are not always declared inside CREATE TABLE. SSMS 'Generate Scripts' emits them +# as a separate ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY, so collect those as a fallback. +$alterPKRegex = [Regex]::new("(?i)\balter\s+table\s+(?$identifier(?:\s*\.\s*$identifier)*)\s+(?:(?!\b(?:go|alter|create)\b)[\s\S])*?\bprimary\s+key\b[^\(]*\(\s*(?[^\)]+)\)", 'IgnoreCase, Singleline') +$script:AlterTablePrimaryKeys = @{} +foreach ($m in $alterPKRegex.Matches($schema)) { + $name = Get-CleanTableName $m.Groups['tableid'].Value + if (-not $script:AlterTablePrimaryKeys.ContainsKey($name)) { + $cols = @() + foreach ($km in $keyColRegex.Matches($m.Groups['colkeys'].Value)) { + $c = Remove-Brackets $km.Groups['c'].Value + if ($c -match '^(?i)(asc|desc)$') { continue } + $cols += $c + } + if ($cols.Count -gt 0) { $script:AlterTablePrimaryKeys[$name] = $cols } + } +} + +$script:GeneratedTableCount = 0 + for ($i = 0; $i -lt $result.Count; $i++) { $tableidValue = $result[$i].Groups['tableid'].Value $afterMatch = ($result[$i].Index) + ($result[$i].Length) @@ -478,4 +517,4 @@ if ($GenSQLStatsQuery) { $sqlscript | Out-File -FilePath "${extensionFolder}stats.sql" -Encoding UTF8 } -Write-Host "Generated $($result.Count) table definition(s) in $tablesFolder" +Write-Host "Generated $script:GeneratedTableCount of $($result.Count) table definition(s) in $tablesFolder" From 37609add423f562147e3144c085e8a8d985d0a95 Mon Sep 17 00:00:00 2001 From: Wenjie Fan <31087545+gggdttt@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:20:54 +0200 Subject: [PATCH 2/2] Document supported schema syntax --- .../GenerateALTablesFromSQLSchema/README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/samples/CloudMigration/GenerateALTablesFromSQLSchema/README.md b/samples/CloudMigration/GenerateALTablesFromSQLSchema/README.md index efe38601..fcdad1ce 100644 --- a/samples/CloudMigration/GenerateALTablesFromSQLSchema/README.md +++ b/samples/CloudMigration/GenerateALTablesFromSQLSchema/README.md @@ -1,9 +1,20 @@ # SQL Schema Definition to AL -Takes an SQL schema definition (as scripted by SSMS **Script Table as > CREATE To**) and generates the appropriate files to have this as a BC extension that can have its data imported by Cloud Migration. +Takes an SQL schema definition (as scripted by SSMS **Script Table as > CREATE To**, or by **Tasks > Generate Scripts**) and generates the appropriate files to have this as a BC extension that can have its data imported by Cloud Migration. Works with **NAV/Business Central on-premises** schemas, where object names contain spaces and `$` (for example `[dbo].[CRONUS Danmark A_S$Vendor]`), as well as with **Dynamics GP** schemas. +### Supported schema syntax + +Both identifier styles are recognised, including scripts that mix them: + +- Bracketed — `CREATE TABLE [dbo].[Vendor]([No_] [nvarchar](20) NOT NULL, ...)` +- Double-quoted — `CREATE TABLE "Orders"("OrderID" "int" NOT NULL, ...)`, produced when `QUOTED_IDENTIFIER` is on and by older sample scripts + +Primary keys are read from an inline `CONSTRAINT ... PRIMARY KEY` and, when the table has none, from a separate `ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY` statement. + +If a `CREATE TABLE` statement cannot be parsed, the script says so and reports how many were skipped. The closing line states how many tables were generated out of how many were found — check it before assuming the extension is complete. + ## Usage ```