using System.Text.RegularExpressions;
using FrameworkBLL.Connection;
namespace FrameworkBLL.Engine
{
///
/// Generates SELECT SQL dynamically from MDATASETDETAIL configuration and watermarks.
/// Column names are not known at compile time — this is the documented exception to
/// the "all SQL in QB constants" rule.
///
internal static class SyncQueryBuilder
{
// ── Identifier quoting ────────────────────────────────────────────────
// Returns a properly quoted identifier for the target RDBMS.
internal static string Quote(string identifier, DatabaseType dbType) => dbType switch
{
DatabaseType.SqlServer => $"[{identifier}]",
DatabaseType.PostgreSQL => $"\"{identifier}\"",
DatabaseType.MySQL => $"`{identifier}`",
DatabaseType.Oracle => $"\"{identifier.ToUpper()}\"",
_ => $"[{identifier}]"
};
// Returns a fully-qualified [schema].[table] string appropriate for the RDBMS.
// MySQL has no schema prefix at the SQL level — returns just the quoted table name.
internal static string QuoteTable(string schema, string table, DatabaseType dbType) =>
string.IsNullOrEmpty(schema)
? Quote(table, dbType)
: $"{Quote(schema, dbType)}.{Quote(table, dbType)}";
// ── Paged SELECT ──────────────────────────────────────────────────────
// Builds a paged SELECT with watermark filters applied.
// schemaName: default schema for the source DB (dbo / public / empty for MySQL)
// tableName: the table from DBOBJECT
// pkColumn: MDATASETDETAIL.PRIMARYKEYCOLUMN — used for ORDER BY and conflict detection
// createdDateCol: MDATASETDETAIL.CREATEDDATECOLUMN (null → skip INSERT watermark)
// modifiedDateCol: MDATASETDETAIL.MODIFIEDDATECOLUMN (null → skip UPDATE watermark)
// lastInsertedTill, lastUpdatedTill: watermarks from TDSYNCJOB (null = full load)
// extraWhere: TDSYNCJOB.QUERYCONDITION (validated before injection — no DML/DDL/SELECT)
internal static string BuildSelectSql(
string schemaName,
string tableName,
string pkColumn,
string? createdDateCol,
string? modifiedDateCol,
DateTime? lastInsertedTill,
DateTime? lastUpdatedTill,
string? extraWhere,
DatabaseType dbType)
{
var where = new List();
// Insert-watermark and update-watermark conditions are alternatives, not a
// conjunction — a row qualifies for sync if it was EITHER created OR modified
// since the last run. Combining them with AND (as this previously did) meant a
// row whose CreatedDateColumn predates the insert watermark could never match
// even when it was genuinely updated afterward, since an old CREATEDON AND a new
// MODIFIEDON can never both be true at once — silently excluding every real
// update to a pre-existing row from every run after the first. Only a brand-new
// row (whose CreatedOn and ModifiedOn are both recent) ever happened to satisfy
// the old AND'd condition, which made initial full loads look correct while
// masking that updates never actually synced.
var watermarkConditions = new List();
// INSERT watermark: rows created since last run
if (!string.IsNullOrWhiteSpace(createdDateCol) && lastInsertedTill.HasValue)
watermarkConditions.Add($"{Quote(createdDateCol, dbType)} >= @LastInsertedTill");
// UPDATE watermark: rows modified since last run
if (!string.IsNullOrWhiteSpace(modifiedDateCol) && lastUpdatedTill.HasValue)
watermarkConditions.Add($"{Quote(modifiedDateCol, dbType)} >= @LastUpdatedTill");
if (watermarkConditions.Count > 0)
where.Add(watermarkConditions.Count > 1
? $"({string.Join(" OR ", watermarkConditions)})"
: watermarkConditions[0]);
// Custom condition from TDSYNCJOB.QUERYCONDITION (user-supplied extra filter)
if (!string.IsNullOrWhiteSpace(extraWhere))
{
ValidateExtraWhere(extraWhere);
where.Add($"({extraWhere})");
}
var whereClause = where.Count > 0
? $"WHERE {string.Join(" AND ", where)}"
: string.Empty;
var tableFqn = QuoteTable(schemaName, tableName, dbType);
var pkQuoted = Quote(pkColumn, dbType);
// MySQL uses LIMIT n OFFSET x; SQL Server / PostgreSQL use OFFSET x FETCH NEXT n;
// Oracle uses same OFFSET/FETCH syntax but with colon-prefixed bind variables.
return dbType switch
{
DatabaseType.SqlServer or DatabaseType.PostgreSQL =>
$@"SELECT * FROM {tableFqn}
{whereClause}
ORDER BY {pkQuoted}
OFFSET @ChunkOffset ROWS FETCH NEXT @ChunkSize ROWS ONLY",
DatabaseType.MySQL =>
$@"SELECT * FROM {tableFqn}
{whereClause}
ORDER BY {pkQuoted}
LIMIT @ChunkSize OFFSET @ChunkOffset",
DatabaseType.Oracle =>
$@"SELECT * FROM {tableFqn}
{whereClause}
ORDER BY {pkQuoted}
OFFSET :ChunkOffset ROWS FETCH NEXT :ChunkSize ROWS ONLY",
_ => throw new InvalidOperationException($"Unsupported DatabaseType {dbType}")
};
}
// ── CDC delete detection (SQL Server only) ────────────────────────────
// Builds the CDC delete-detection query.
// pkColumn: MDATASETDETAIL.PRIMARYKEYCOLUMN — avoids the hardcoded TABLEID assumption.
// SinceUtc is always passed as the @SinceUtc parameter, never interpolated.
internal static string BuildCdcSelectSql(string tableName, string pkColumn, DateTime sinceUtc)
{
// CDC table: cdc.dbo__CT (__$operation=1 means delete)
return $@"
SELECT [{pkColumn}] AS PkValue
FROM cdc.[dbo_{tableName}_CT]
WHERE __$operation = 1
AND __$start_lsn >= sys.fn_cdc_map_time_to_lsn('smallest greater than or equal', @SinceUtc)";
}
// ── QUERYCONDITION safety validation ──────────────────────────────────
// Blocks DML, DDL, and the most common SELECT-injection vectors.
// SELECT/FROM/UNION/INTO are blocked to prevent subquery injection and UNION-based exfiltration.
private static readonly Regex _dmlPattern =
new(@"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|DENY|UNION|SELECT|INTO|FROM)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static void ValidateExtraWhere(string extraWhere)
{
if (_dmlPattern.IsMatch(extraWhere))
throw new InvalidOperationException(
"QUERYCONDITION contains disallowed keywords (DML/DDL/SELECT/UNION).");
}
}
}