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
13 changes: 12 additions & 1 deletion samples/CloudMigration/GenerateALTablesFromSQLSchema/README.md
Original file line number Diff line number Diff line change
@@ -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

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 }
Expand All @@ -295,7 +304,7 @@ function Split-CommaParams($tablecontent) {

$columnRegex = [Regex]::new("^\s*(?<colid>$identifier)\s+(?<colty>$identifier)\s*(\(\s*(?<len>[^\)]*)\))?", 'IgnoreCase')
$primKeyRegex = [Regex]::new("primary\s+key[^\(]*\(\s*(?<colkeys>[^\)]+)\)", 'IgnoreCase, Singleline')
$keyColRegex = [Regex]::new("(?<c>\[[^\]]+\]|[A-Za-z0-9_@#\$]+)", 'IgnoreCase')
$keyColRegex = [Regex]::new("(?<c>\[[^\]]+\]|`"[^`"]+`"|[A-Za-z0-9_@#\$]+)", 'IgnoreCase')

function ConvertTo-ALTable($tableid, $tablecontent, $tableCount) {
$sqlTableName = Get-CleanTableName $tableid
Expand Down Expand Up @@ -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 ', ')."
Expand All @@ -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
Expand All @@ -427,11 +440,37 @@ if ($schema -match $useDBregex) {
$createTableRegex = [Regex]::new("(?i)\bcreate\s+table\s+(?<tableid>$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+(?<tableid>$identifier(?:\s*\.\s*$identifier)*)\s+(?:(?!\b(?:go|alter|create)\b)[\s\S])*?\bprimary\s+key\b[^\(]*\(\s*(?<colkeys>[^\)]+)\)", '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)
Expand Down Expand Up @@ -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"