26ff8d9b4f
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
224 lines
8.0 KiB
C#
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}")
|
|
};
|
|
}
|
|
}
|