using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; using SyncEngine.Access; namespace SyncEngine.SqlServer { public static class SqlViewTranslator { private static readonly string[] DateFormats = { "yyyy/MM/dd", "yyyy/M/d", "yyyy-MM-dd", "M/d/yyyy", "MM/dd/yyyy", "d/M/yyyy", "dd/MM/yyyy" }; public static string Translate(string accessSql, ViewColumnCatalog catalog = null) { if (string.IsNullOrWhiteSpace(accessSql)) { return null; } var sql = accessSql.Trim(); sql = StripParametersBlock(sql); sql = TranslateAccessLogicalOperators(sql); sql = TranslateIIfCalls(sql); sql = NormalizeAccessDoubleQuotedStrings(sql); sql = Regex.Replace(sql, @"#([^#]+)#", QuoteAccessDateLiteral); sql = QuoteBareDateLiterals(sql); sql = Regex.Replace(sql, @"'(\d{4}/\d{1,2}/\d{1,2})'", m => QuoteDateString(m.Groups[1].Value)); sql = Regex.Replace(sql, @"\bNz\s*\(\s*([^',()]+)\s*\)", "ISNULL($1, '')", RegexOptions.IgnoreCase); sql = Regex.Replace(sql, @"\bNz\s*\(", "ISNULL(", RegexOptions.IgnoreCase); sql = FixSingleArgumentIsNull(sql); sql = FixIsNullUsedAsBooleanCondition(sql); sql = FixMalformedQuotedComparisons(sql); sql = Regex.Replace(sql, @"\bFirst\s*\(", "MIN(", RegexOptions.IgnoreCase); sql = StripTrailingOrderBy(sql); sql = ExpandTableStarInSelectList(sql, catalog); sql = Regex.Replace(sql, @"\bIs Null\b", "IS NULL", RegexOptions.IgnoreCase); sql = Regex.Replace(sql, @"\bIs Not Null\b", "IS NOT NULL", RegexOptions.IgnoreCase); sql = Regex.Replace(sql, @"\s&\s", " + ", RegexOptions.None); sql = Regex.Replace(sql, @"\bTrue\b", "1", RegexOptions.IgnoreCase); sql = Regex.Replace(sql, @"\bFalse\b", "0", RegexOptions.IgnoreCase); sql = Regex.Replace(sql, @"\[([^\]]+)\]", "[$1]"); return sql; } public static bool IsLikelyTranslatable(string sql) { if (string.IsNullOrWhiteSpace(sql)) { return false; } return sql.IndexOf("TRANSFORM", StringComparison.OrdinalIgnoreCase) < 0 && sql.IndexOf("CROSSTAB", StringComparison.OrdinalIgnoreCase) < 0; } private static string StripParametersBlock(string sql) { if (!sql.StartsWith("PARAMETERS", StringComparison.OrdinalIgnoreCase)) { return sql; } var semi = sql.IndexOf(';'); return semi > 0 ? sql.Substring(semi + 1).Trim() : sql; } private static string TranslateAccessLogicalOperators(string sql) { sql = Regex.Replace(sql, @"\bAnd\b", "AND", RegexOptions.IgnoreCase); sql = Regex.Replace(sql, @"\bOr\b", "OR", RegexOptions.IgnoreCase); sql = Regex.Replace(sql, @"\bNot\b", "NOT", RegexOptions.IgnoreCase); return sql; } private static string TranslateIIfCalls(string sql) { while (true) { var iifIndex = FindInnermostIIfIndex(sql); if (iifIndex < 0) { break; } var openParen = sql.IndexOf('(', iifIndex); string inner; int closeParen; if (!TryExtractParenthesesContent(sql, openParen, out inner, out closeParen)) { break; } var parts = SplitTopLevelArguments(inner); if (parts.Count != 3) { break; } var condition = FormatIIfCondition(parts[0].Trim()); var replacement = string.Format( "CASE WHEN {0} THEN {1} ELSE {2} END", condition, parts[1].Trim(), parts[2].Trim()); sql = sql.Substring(0, iifIndex) + replacement + sql.Substring(closeParen + 1); } return sql; } /// Access allows numeric/string expressions as IIf conditions; SQL Server needs boolean. private static string FormatIIfCondition(string condition) { if (ContainsComparisonOperator(condition)) { return condition; } return string.Format("({0} IS NOT NULL AND {0} <> 0 AND {0} <> '')", condition); } private static bool ContainsComparisonOperator(string condition) { return condition.IndexOf('=') >= 0 || condition.IndexOf('<') >= 0 || condition.IndexOf('>') >= 0 || condition.IndexOf(" LIKE ", StringComparison.OrdinalIgnoreCase) >= 0 || condition.IndexOf(" IS ", StringComparison.OrdinalIgnoreCase) >= 0 || condition.IndexOf(" IN ", StringComparison.OrdinalIgnoreCase) >= 0 || condition.IndexOf(" BETWEEN ", StringComparison.OrdinalIgnoreCase) >= 0 || condition.IndexOf(" AND ", StringComparison.OrdinalIgnoreCase) >= 0 || condition.IndexOf(" OR ", StringComparison.OrdinalIgnoreCase) >= 0; } private static int FindInnermostIIfIndex(string sql) { var searchFrom = 0; while (searchFrom < sql.Length) { var iifIndex = IndexOfIgnoreCase(sql, "IIf", searchFrom); if (iifIndex < 0) { return -1; } var openParen = sql.IndexOf('(', iifIndex); string inner; int closeParen; if (!TryExtractParenthesesContent(sql, openParen, out inner, out closeParen)) { searchFrom = iifIndex + 3; continue; } if (IndexOfIgnoreCase(inner, "IIf", 0) < 0) { return iifIndex; } searchFrom = iifIndex + 3; } return -1; } private static string NormalizeAccessDoubleQuotedStrings(string sql) { return Regex.Replace(sql, @"""([^""]*)""", m => QuoteDateString(m.Groups[1].Value.Trim())); } /// Access Nz(field) in OR/AND is truthy; ISNULL(field,'') alone is not boolean in SQL Server. private static string FixIsNullUsedAsBooleanCondition(string sql) { return Regex.Replace( sql, @"\(\s*(ISNULL\([^)]+\))\s*\)(?=\s*(?:AND|OR|\)|;|$))", "($1 <> '')", RegexOptions.IgnoreCase); } /// Fix (field)<"'2025-01-01'" from mixed Access quoting. private static string FixMalformedQuotedComparisons(string sql) { return Regex.Replace( sql, @"([<>=!]+)\s*""'([^']*)'""", "$1 '$2'"); } /// Expand table.* to explicit columns using Access schema (dedupes columns already in SELECT). private static string ExpandTableStarInSelectList(string sql, ViewColumnCatalog catalog) { if (catalog == null) { return sql; } int selectListStart; int fromIndex; if (!TryExtractSelectListBounds(sql, out selectListStart, out fromIndex)) { return sql; } var selectList = sql.Substring(selectListStart, fromIndex - selectListStart); var starPattern = new Regex(@",?\s*((?:\[[^\]]+\]|\w+))\.\*", RegexOptions.IgnoreCase); var matches = starPattern.Matches(selectList); if (matches.Count == 0) { return sql; } var result = selectList; for (var i = matches.Count - 1; i >= 0; i--) { var match = matches[i]; var tableRef = match.Groups[1].Value; IReadOnlyList columns; if (!catalog.TryGetColumns(tableRef, out columns)) { continue; } var listed = GetAlreadyListedColumns(selectList, tableRef, match.Index); var expanded = columns .Where(col => !listed.Contains(col)) .Select(col => QualifyColumn(tableRef, col)) .ToList(); var hasLeadingComma = match.Value.TrimStart().StartsWith(",", StringComparison.Ordinal); var replacement = expanded.Count == 0 ? string.Empty : (hasLeadingComma ? ", " : string.Empty) + string.Join(", ", expanded); result = result.Remove(match.Index, match.Length).Insert(match.Index, replacement); } result = Regex.Replace(result, @",\s*,", ","); result = Regex.Replace(result, @",\s*$", string.Empty); result = Regex.Replace(result, @"^\s*,\s*", string.Empty); if (string.Equals(result, selectList, StringComparison.Ordinal)) { return sql; } return sql.Substring(0, selectListStart) + result + sql.Substring(fromIndex); } private static HashSet GetAlreadyListedColumns(string selectList, string tableRef, int beforeIndex) { var listed = new HashSet(StringComparer.OrdinalIgnoreCase); var portion = selectList.Substring(0, beforeIndex); var prefix = Regex.Escape(tableRef) + @"\.(?:\[([^\]]+)\]|(\w+))"; foreach (Match match in Regex.Matches(portion, prefix, RegexOptions.IgnoreCase)) { var col = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; listed.Add(col); } return listed; } private static string QualifyColumn(string tableRef, string columnName) { var colNeedsBrackets = columnName.IndexOf(' ') >= 0; if (tableRef.StartsWith("[", StringComparison.Ordinal)) { return colNeedsBrackets ? tableRef + ".[" + columnName + "]" : tableRef + "." + columnName; } return colNeedsBrackets ? tableRef + ".[" + columnName + "]" : tableRef + "." + columnName; } private static bool TryExtractSelectListBounds(string sql, out int selectListStart, out int fromIndex) { selectListStart = -1; fromIndex = -1; var selectMatch = Regex.Match(sql, @"^\s*SELECT\s+", RegexOptions.IgnoreCase); if (!selectMatch.Success) { return false; } selectListStart = selectMatch.Index + selectMatch.Length; fromIndex = FindTopLevelFromIndex(sql, selectListStart); return fromIndex > selectListStart; } private static int FindTopLevelFromIndex(string sql, int start) { var searchFrom = start; while (searchFrom < sql.Length) { var idx = IndexOfIgnoreCase(sql, "FROM", searchFrom); if (idx < 0) { return -1; } if (IsTopLevelPosition(sql, idx)) { return idx; } searchFrom = idx + 4; } return -1; } /// SQL Server views cannot end with ORDER BY unless TOP/OFFSET is present. private static string StripTrailingOrderBy(string sql) { var trimmed = sql.Trim(); var hadSemicolon = trimmed.EndsWith(";", StringComparison.Ordinal); if (hadSemicolon) { trimmed = trimmed.Substring(0, trimmed.Length - 1).TrimEnd(); } var orderByIndex = FindTopLevelOrderByIndex(trimmed); if (orderByIndex < 0) { return sql; } var result = trimmed.Substring(0, orderByIndex).TrimEnd(); return hadSemicolon ? result + ";" : result; } private static int FindTopLevelOrderByIndex(string sql) { var lastIndex = -1; var searchFrom = 0; while (searchFrom < sql.Length) { var idx = IndexOfIgnoreCase(sql, "ORDER BY", searchFrom); if (idx < 0) { break; } if (IsTopLevelPosition(sql, idx)) { lastIndex = idx; } searchFrom = idx + 8; } return lastIndex; } private static bool IsTopLevelPosition(string sql, int keywordStart) { var depth = 0; var inString = false; for (var i = 0; i < keywordStart; i++) { var ch = sql[i]; if (ch == '\'') { inString = !inString; } else if (!inString) { if (ch == '(') { depth++; } else if (ch == ')') { depth--; } } } return depth == 0 && !inString; } private static string QuoteAccessDateLiteral(Match match) { return QuoteDateString(match.Groups[1].Value.Trim()); } private static string QuoteBareDateLiterals(string sql) { // Skip dates already inside single-quoted literals ('2025/01/01'). return Regex.Replace( sql, @"(? QuoteDateString(m.Groups[1].Value)); } private static string QuoteDateString(string inner) { if (inner.StartsWith("'", StringComparison.Ordinal) && inner.EndsWith("'", StringComparison.Ordinal)) { inner = inner.Substring(1, inner.Length - 2); } DateTime parsed; if (DateTime.TryParseExact(inner, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed) || DateTime.TryParse(inner, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed) || DateTime.TryParse(inner, out parsed)) { return string.Format("'{0:yyyy-MM-dd}'", parsed); } return string.Format("'{0}'", inner.Replace("'", "''")); } private static string FixSingleArgumentIsNull(string sql) { return Regex.Replace( sql, @"\bISNULL\s*\(\s*([^',()]+)\s*\)", "ISNULL($1, '')", RegexOptions.IgnoreCase); } private static int IndexOfIgnoreCase(string text, string value, int start) { return text.IndexOf(value, start, StringComparison.OrdinalIgnoreCase); } private static bool TryExtractParenthesesContent(string sql, int openParen, out string inner, out int closeParen) { inner = null; closeParen = -1; if (openParen < 0 || openParen >= sql.Length || sql[openParen] != '(') { return false; } var depth = 0; for (var i = openParen; i < sql.Length; i++) { var ch = sql[i]; if (ch == '(') { depth++; } else if (ch == ')') { depth--; if (depth == 0) { closeParen = i; inner = sql.Substring(openParen + 1, i - openParen - 1); return true; } } } return false; } private static IList SplitTopLevelArguments(string args) { var parts = new List(); var current = new StringBuilder(); var depth = 0; var inString = false; for (var i = 0; i < args.Length; i++) { var ch = args[i]; if (ch == '\'') { inString = !inString; current.Append(ch); } else if (!inString && ch == '(') { depth++; current.Append(ch); } else if (!inString && ch == ')') { depth--; current.Append(ch); } else if (!inString && ch == ',' && depth == 0) { parts.Add(current.ToString()); current.Clear(); } else { current.Append(ch); } } if (current.Length > 0) { parts.Add(current.ToString()); } return parts; } } }