Files
jdescopingtool/NEW/src/JdeScoping.DataSync/Services/SchemaValidator.cs
T
Joseph Doherty 26ff8d9b4f Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
2026-01-02 07:43:29 -05:00

224 lines
8.0 KiB
C#

using System.Data;
using System.Reflection;
using Dapper;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Exceptions;
using JdeScoping.DataSync.Models;
namespace JdeScoping.DataSync.Services;
/// <summary>
/// Validates data against database table schema.
/// </summary>
internal sealed class SchemaValidator : ISchemaValidator
{
/// <inheritdoc />
public async Task<IReadOnlyList<ColumnSchema>> GetTableSchemaAsync(
IDbConnection connection,
string tableName,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
// Parse schema and table name
var (schemaName, table) = ParseTableName(tableName);
const string sql = """
SELECT
COLUMN_NAME AS Name,
DATA_TYPE AS DataType,
CHARACTER_MAXIMUM_LENGTH AS MaxLength,
NUMERIC_PRECISION AS [Precision],
NUMERIC_SCALE AS Scale,
CASE WHEN IS_NULLABLE = 'YES' THEN 1 ELSE 0 END AS IsNullable,
ORDINAL_POSITION AS OrdinalPosition
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @Schema AND TABLE_NAME = @Table
ORDER BY ORDINAL_POSITION
""";
var columns = await connection.QueryAsync<ColumnSchema>(
new CommandDefinition(
sql,
new { Schema = schemaName, Table = table },
cancellationToken: cancellationToken));
return columns.ToList();
}
/// <inheritdoc />
public IReadOnlyList<ValidationError> ValidateBatch<T>(
IReadOnlyList<T> data,
IReadOnlyList<ColumnSchema> schema,
int maxErrors = 100) where T : class
{
ArgumentNullException.ThrowIfNull(data);
ArgumentNullException.ThrowIfNull(schema);
if (data.Count == 0 || schema.Count == 0)
return [];
var errors = new List<ValidationError>();
var schemaLookup = schema.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
for (int rowIndex = 0; rowIndex < data.Count; rowIndex++)
{
var row = data[rowIndex];
if (row == null) continue;
foreach (var column in schema)
{
if (!properties.TryGetValue(column.Name, out var property))
continue;
var value = property.GetValue(row);
var error = ValidateValue(rowIndex, column, value);
if (error != null)
{
errors.Add(error);
if (maxErrors > 0 && errors.Count >= maxErrors)
return errors;
}
}
}
return errors;
}
private static ValidationError? ValidateValue(int rowIndex, ColumnSchema column, object? value)
{
// Check nullability
if (value == null || (value is string s && string.IsNullOrEmpty(s)))
{
if (!column.IsNullable && !IsIdentityOrComputed(column))
{
return new ValidationError(
rowIndex,
column.Name,
value,
$"Column '{column.Name}' does not allow null values.");
}
return null;
}
// Check string length
if (column.MaxLength.HasValue && column.MaxLength.Value > 0)
{
var stringValue = value as string ?? value.ToString();
if (stringValue != null && stringValue.Length > column.MaxLength.Value)
{
return new ValidationError(
rowIndex,
column.Name,
value,
$"Value for '{column.Name}' exceeds maximum length of {column.MaxLength}. Actual length: {stringValue.Length}.");
}
}
// Check decimal precision/scale
if (column.Precision.HasValue && column.Scale.HasValue && IsDecimalType(column.DataType))
{
if (value is decimal decimalValue)
{
var error = ValidateDecimal(rowIndex, column, decimalValue);
if (error != null)
return error;
}
else if (value is double || value is float)
{
// Convert to decimal for validation
try
{
var decVal = Convert.ToDecimal(value);
var error = ValidateDecimal(rowIndex, column, decVal);
if (error != null)
return error;
}
catch
{
return new ValidationError(
rowIndex,
column.Name,
value,
$"Value for '{column.Name}' cannot be converted to decimal.");
}
}
}
return null;
}
private static ValidationError? ValidateDecimal(int rowIndex, ColumnSchema column, decimal value)
{
// Calculate actual precision and scale
var sqlString = value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var parts = sqlString.TrimStart('-').Split('.');
var integerDigits = parts[0].TrimStart('0').Length;
var decimalDigits = parts.Length > 1 ? parts[1].TrimEnd('0').Length : 0;
// Handle zero case
if (integerDigits == 0 && decimalDigits == 0)
integerDigits = 1;
var totalDigits = integerDigits + decimalDigits;
var maxTotalDigits = column.Precision!.Value;
var maxDecimalDigits = column.Scale!.Value;
var maxIntegerDigits = maxTotalDigits - maxDecimalDigits;
if (integerDigits > maxIntegerDigits)
{
return new ValidationError(
rowIndex,
column.Name,
value,
$"Value {value} for '{column.Name}' exceeds maximum integer digits. " +
$"Maximum: {maxIntegerDigits}, Actual: {integerDigits}.");
}
// Note: We don't validate decimal digits as SQL Server will truncate/round them
return null;
}
private static bool IsDecimalType(string dataType)
{
return dataType.Equals("decimal", StringComparison.OrdinalIgnoreCase) ||
dataType.Equals("numeric", StringComparison.OrdinalIgnoreCase) ||
dataType.Equals("money", StringComparison.OrdinalIgnoreCase) ||
dataType.Equals("smallmoney", StringComparison.OrdinalIgnoreCase);
}
private static bool IsIdentityOrComputed(ColumnSchema column)
{
// Identity columns and computed columns can be null in the source data
// This is a simple heuristic - actual identity check would require additional metadata
return column.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) ||
column.Name.EndsWith("Id", StringComparison.OrdinalIgnoreCase);
}
private static (string Schema, string Table) ParseTableName(string tableName)
{
// Remove brackets if present
var cleaned = tableName.Replace("[", "").Replace("]", "");
// Check for temp table
if (cleaned.StartsWith('#'))
{
return ("dbo", cleaned);
}
// Split by dot
var parts = cleaned.Split('.');
return parts.Length switch
{
1 => ("dbo", parts[0]),
2 => (parts[0], parts[1]),
_ => throw new ArgumentException($"Invalid table name format: {tableName}")
};
}
}