using System.Data; using System.Reflection; using Dapper; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Exceptions; using JdeScoping.DataSync.Models; namespace JdeScoping.DataSync.Services; /// /// Validates data against database table schema. /// internal sealed class SchemaValidator : ISchemaValidator { /// public async Task> 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( new CommandDefinition( sql, new { Schema = schemaName, Table = table }, cancellationToken: cancellationToken)); return columns.ToList(); } /// public IReadOnlyList ValidateBatch( IReadOnlyList data, IReadOnlyList 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(); 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}") }; } }