diff --git a/BackEnd/Data Access/Tables/DefaultTable.vb b/BackEnd/Data Access/Tables/DefaultTable.vb index c8e448b..9b6398f 100644 --- a/BackEnd/Data Access/Tables/DefaultTable.vb +++ b/BackEnd/Data Access/Tables/DefaultTable.vb @@ -36,6 +36,11 @@ Public Class DefaultTable Dim fileName As String = _table.GetStringProperty(TableProperty.FileName) Dim filePath As String = GetFilePath(fileName) + ' Validate that the XML file exists before attempting to load + If Not System.IO.File.Exists(filePath) Then + Throw New System.IO.FileNotFoundException($"XML file not found: {filePath}", fileName) + End If + Dim sourceName As String = _table.GetStringProperty(TableProperty.SourceTableName) If sourceName Is Nothing Then 'JMich Adding support for different rank levels of equipment diff --git a/BackEnd/Data Access/XmlDB.vb b/BackEnd/Data Access/XmlDB.vb index 0ba679a..10a49f2 100644 --- a/BackEnd/Data Access/XmlDB.vb +++ b/BackEnd/Data Access/XmlDB.vb @@ -154,25 +154,61 @@ Public Class XmlDB Protected Sub EndInit() ds.AcceptChanges() + + ' Proactive validation before enforcing constraints + Dim validator As New XmlValidator(ds) + If Not validator.ValidateAll() Then + ' Show detailed validation errors + Dim errorMessage As String = validator.GetFormattedErrors() + ErrorHandler.ShowError(errorMessage, "XML Validation Failed", MessageBoxIcon.Error) + ErrorHandler.TriggerFatalError() + Return + End If + Try ds.EnforceConstraints = True Catch ex As ConstraintException - ErrorHandler.ShowError("One or more of your files contain invalid data. Please fix the data and restart the editor.", "Error Loading Files", ex) + ' This is now a fallback - should rarely be hit due to proactive validation + Dim errMsg As New Text.StringBuilder() + errMsg.AppendLine("Constraint validation failed after proactive checks.") + errMsg.AppendLine("This may indicate a data race condition or complex constraint issue.") + errMsg.AppendLine() + errMsg.AppendLine("Error Details:") + errMsg.AppendLine(ex.Message) + errMsg.AppendLine() + For Each t As DataTable In ds.Tables If t.HasErrors Then - Dim errStr As New Text.StringBuilder("Details:" & vbCrLf & vbCrLf) Dim fileName As String = t.GetStringProperty(TableProperty.FileName) If Not String.IsNullOrEmpty(fileName) Then - errStr.Append("File: " & fileName & vbCrLf) + errMsg.AppendLine("File: " & fileName) Else - errStr.Append("Table: " & t.TableName & vbCrLf) + errMsg.AppendLine("Table: " & t.TableName) End If - For i As Integer = 0 To t.GetErrors.GetUpperBound(0) - errStr.Append(vbCrLf & t.GetErrors(i).RowError) + + ' Enhanced error reporting with row numbers and column info + Dim errorRows As DataRow() = t.GetErrors() + For i As Integer = 0 To Math.Min(errorRows.GetUpperBound(0), 9) ' Limit to 10 errors + Dim row As DataRow = errorRows(i) + Dim rowIndex As Integer = t.Rows.IndexOf(row) + errMsg.AppendLine($" Row {rowIndex + 1}: {row.RowError}") + + ' Show which columns have errors + For Each col As DataColumn In t.Columns + If Not String.IsNullOrEmpty(row.GetColumnError(col)) Then + errMsg.AppendLine($" Column '{col.ColumnName}': {row.GetColumnError(col)}") + End If + Next Next - ErrorHandler.ShowError(errStr.ToString, "Error Loading Files", MessageBoxIcon.Exclamation) + + If errorRows.Length > 10 Then + errMsg.AppendLine($" ... and {errorRows.Length - 10} more error(s)") + End If + errMsg.AppendLine() End If Next + + ErrorHandler.ShowError(errMsg.ToString(), "Error Loading Files", MessageBoxIcon.Error, ex) ErrorHandler.TriggerFatalError() End Try ds.EndInit() @@ -211,8 +247,19 @@ Public Class XmlDB End If If loadData Then - RaiseEvent LoadingTable(Me, fileName) - table.GetTableHandler.LoadData() + Try + RaiseEvent LoadingTable(Me, fileName) + table.GetTableHandler.LoadData() + Catch ex As System.IO.FileNotFoundException + ErrorHandler.ShowError($"Failed to load '{fileName}': File not found.{vbCrLf}{vbCrLf}Path: {ex.FileName}", "File Not Found", MessageBoxIcon.Error, ex) + Throw + Catch ex As Xml.XmlException + ErrorHandler.ShowError($"Failed to load '{fileName}': Invalid XML format.{vbCrLf}{vbCrLf}Line {ex.LineNumber}, Position {ex.LinePosition}: {ex.Message}", "XML Format Error", MessageBoxIcon.Error, ex) + Throw + Catch ex As Exception + ErrorHandler.ShowError($"Failed to load '{fileName}': {ex.Message}{vbCrLf}{vbCrLf}Table: {table.TableName}", "Error Loading Table", MessageBoxIcon.Error, ex) + Throw + End Try End If End If diff --git a/BackEnd/Data Access/XmlValidator.vb b/BackEnd/Data Access/XmlValidator.vb new file mode 100644 index 0000000..494ce74 --- /dev/null +++ b/BackEnd/Data Access/XmlValidator.vb @@ -0,0 +1,276 @@ +Imports System.Data +Imports System.Text + +Public Class XmlValidator + Private _dataSet As DataSet + Private _validationErrors As New List(Of ValidationError) + + Public Sub New(dataSet As DataSet) + _dataSet = dataSet + End Sub + + Public ReadOnly Property HasErrors As Boolean + Get + Return _validationErrors.Count > 0 + End Get + End Property + + Public ReadOnly Property Errors As List(Of ValidationError) + Get + Return _validationErrors + End Get + End ReadOnly Property + + ''' + ''' Validates all data in the dataset before constraints are enforced + ''' + Public Function ValidateAll() As Boolean + _validationErrors.Clear() + + ' Check for null/empty required fields + ValidateRequiredFields() + + ' Check for duplicate primary keys + ValidatePrimaryKeys() + + ' Check for foreign key violations + ValidateForeignKeys() + + ' Check for unique constraint violations + ValidateUniqueConstraints() + + Return Not HasErrors + End Function + + ''' + ''' Validates a single table before loading + ''' + Public Function ValidateTable(table As DataTable) As Boolean + Dim startErrorCount = _validationErrors.Count + + ValidateTableRequiredFields(table) + ValidateTablePrimaryKeys(table) + ValidateTableForeignKeys(table) + ValidateTableUniqueConstraints(table) + + Return _validationErrors.Count = startErrorCount + End Function + + Private Sub ValidateRequiredFields() + For Each table As DataTable In _dataSet.Tables + ValidateTableRequiredFields(table) + Next + End Sub + + Private Sub ValidateTableRequiredFields(table As DataTable) + For Each col As DataColumn In table.Columns + If Not col.AllowDBNull AndAlso Not col.AutoIncrement Then + For i As Integer = 0 To table.Rows.Count - 1 + Dim row As DataRow = table.Rows(i) + If row.RowState <> DataRowState.Deleted AndAlso IsDBNull(row(col)) Then + AddError(table, i, col.ColumnName, + "Required field is empty", + ValidationErrorType.RequiredField) + End If + Next + End If + Next + End Sub + + Private Sub ValidatePrimaryKeys() + For Each table As DataTable In _dataSet.Tables + ValidateTablePrimaryKeys(table) + Next + End Sub + + Private Sub ValidateTablePrimaryKeys(table As DataTable) + If table.PrimaryKey.Length = 0 Then Return + + Dim pkValues As New Dictionary(Of String, Integer) + Dim pkColumns As String = String.Join(", ", table.PrimaryKey.Select(Function(c) c.ColumnName)) + + For i As Integer = 0 To table.Rows.Count - 1 + Dim row As DataRow = table.Rows(i) + If row.RowState = DataRowState.Deleted Then Continue For + + Dim keyValue As String = GetPrimaryKeyValue(row) + If pkValues.ContainsKey(keyValue) Then + AddError(table, i, pkColumns, + $"Duplicate primary key value: {keyValue}. First occurrence at row {pkValues(keyValue) + 1}", + ValidationErrorType.DuplicatePrimaryKey) + Else + pkValues.Add(keyValue, i) + End If + Next + End Sub + + Private Sub ValidateForeignKeys() + For Each table As DataTable In _dataSet.Tables + ValidateTableForeignKeys(table) + Next + End Sub + + Private Sub ValidateTableForeignKeys(table As DataTable) + For Each fk As ForeignKeyConstraint In table.Constraints.OfType(Of ForeignKeyConstraint)() + Dim parentTable As DataTable = fk.RelatedTable + Dim childColumns As String = String.Join(", ", fk.Columns.Select(Function(c) c.ColumnName)) + Dim parentColumns As String = String.Join(", ", fk.RelatedColumns.Select(Function(c) c.ColumnName)) + + For i As Integer = 0 To table.Rows.Count - 1 + Dim row As DataRow = table.Rows(i) + If row.RowState = DataRowState.Deleted Then Continue For + + ' Build foreign key value + Dim fkValues(fk.Columns.Length - 1) As Object + For j As Integer = 0 To fk.Columns.Length - 1 + fkValues(j) = row(fk.Columns(j)) + Next + + ' Check if the value exists in parent table + Dim parentRow As DataRow = parentTable.Rows.Find(fkValues) + If parentRow Is Nothing AndAlso Not IsNullForeignKey(fkValues) Then + Dim fkValueStr As String = String.Join(", ", fkValues.Select(Function(v) If(v Is Nothing, "NULL", v.ToString()))) + AddError(table, i, childColumns, + $"Foreign key violation: Value '{fkValueStr}' does not exist in table '{parentTable.TableName}' column(s) '{parentColumns}'", + ValidationErrorType.ForeignKeyViolation) + End If + Next + Next + End Sub + + Private Sub ValidateUniqueConstraints() + For Each table As DataTable In _dataSet.Tables + ValidateTableUniqueConstraints(table) + Next + End Sub + + Private Sub ValidateTableUniqueConstraints(table As DataTable) + For Each uc As UniqueConstraint In table.Constraints.OfType(Of UniqueConstraint)() + If uc.IsPrimaryKey Then Continue For ' Already checked + + Dim uniqueValues As New Dictionary(Of String, Integer) + Dim columnNames As String = String.Join(", ", uc.Columns.Select(Function(c) c.ColumnName)) + + For i As Integer = 0 To table.Rows.Count - 1 + Dim row As DataRow = table.Rows(i) + If row.RowState = DataRowState.Deleted Then Continue For + + Dim keyValue As String = GetUniqueConstraintValue(row, uc.Columns) + If uniqueValues.ContainsKey(keyValue) Then + AddError(table, i, columnNames, + $"Unique constraint violation: Duplicate value '{keyValue}'. First occurrence at row {uniqueValues(keyValue) + 1}", + ValidationErrorType.UniqueConstraintViolation) + Else + uniqueValues.Add(keyValue, i) + End If + Next + Next + End Sub + + Private Function GetPrimaryKeyValue(row As DataRow) As String + Dim values As New List(Of String) + For Each col As DataColumn In row.Table.PrimaryKey + values.Add(If(row(col) Is Nothing, "NULL", row(col).ToString())) + Next + Return String.Join("|", values) + End Function + + Private Function GetUniqueConstraintValue(row As DataRow, columns As DataColumn()) As String + Dim values As New List(Of String) + For Each col As DataColumn In columns + values.Add(If(row(col) Is Nothing, "NULL", row(col).ToString())) + Next + Return String.Join("|", values) + End Function + + Private Function IsNullForeignKey(values As Object()) As Boolean + For Each v In values + If v Is Nothing OrElse IsDBNull(v) Then Return True + Next + Return False + End Function + + Private Sub AddError(table As DataTable, rowIndex As Integer, columnName As String, message As String, errorType As ValidationErrorType) + Dim fileName As String = table.GetStringProperty(TableProperty.FileName) + If String.IsNullOrEmpty(fileName) Then fileName = table.TableName + + _validationErrors.Add(New ValidationError() With { + .FileName = fileName, + .TableName = table.TableName, + .RowIndex = rowIndex, + .ColumnName = columnName, + .Message = message, + .ErrorType = errorType + }) + End Sub + + Public Function GetFormattedErrors() As String + If Not HasErrors Then Return String.Empty + + Dim sb As New StringBuilder() + sb.AppendLine("XML Validation Errors Found:") + sb.AppendLine() + + ' Group errors by file + Dim errorsByFile = _validationErrors.GroupBy(Function(e) e.FileName) + + For Each fileGroup In errorsByFile + sb.AppendLine($"File: {fileGroup.Key}") + sb.AppendLine(New String("-"c, 60)) + + ' Group by error type + Dim errorsByType = fileGroup.GroupBy(Function(e) e.ErrorType) + + For Each typeGroup In errorsByType + sb.AppendLine($" {GetErrorTypeDescription(typeGroup.Key)} ({typeGroup.Count()} error(s)):") + + Dim maxErrors As Integer = 10 ' Limit display to first 10 errors per type + Dim displayCount As Integer = Math.Min(typeGroup.Count(), maxErrors) + + For i As Integer = 0 To displayCount - 1 + Dim err = typeGroup.ElementAt(i) + sb.AppendLine($" Row {err.RowIndex + 1}, Column '{err.ColumnName}': {err.Message}") + Next + + If typeGroup.Count() > maxErrors Then + sb.AppendLine($" ... and {typeGroup.Count() - maxErrors} more error(s)") + End If + sb.AppendLine() + Next + Next + + Return sb.ToString() + End Function + + Private Function GetErrorTypeDescription(errorType As ValidationErrorType) As String + Select Case errorType + Case ValidationErrorType.RequiredField + Return "Required Field Errors" + Case ValidationErrorType.DuplicatePrimaryKey + Return "Duplicate Primary Key Errors" + Case ValidationErrorType.ForeignKeyViolation + Return "Foreign Key Violations" + Case ValidationErrorType.UniqueConstraintViolation + Return "Unique Constraint Violations" + Case Else + Return "Other Errors" + End Select + End Function +End Class + +Public Class ValidationError + Public Property FileName As String + Public Property TableName As String + Public Property RowIndex As Integer + Public Property ColumnName As String + Public Property Message As String + Public Property ErrorType As ValidationErrorType +End Class + +Public Enum ValidationErrorType + RequiredField + DuplicatePrimaryKey + ForeignKeyViolation + UniqueConstraintViolation + Other +End Enum