using System; using System.Collections.Generic; using System.Linq; using Sarand.Net.Standard.Base.App; using Sarand.Net.Standard.Base.Core; using Sarand.Net.Standard.Base.Utils; namespace Sarand.Net.Standard.Base.Dbas { //======================================================================= // Definitions. public enum DbJoinType { Inner, LeftOuter, RightOuter, FullOuter, Cross, } public enum DbOrderByNullMode { Default, DemoteNulls, PromoteNulls, DateDemoteNulls, DatePromoteNulls } public enum DbDateDiffInterval { Year, Quarter, Month, DayOfYear, Day, Weekday, Week, Hour, Minute, Second, } //======================================================================= // Global DBMS. // When dealing with databases using different DBMSs, the methods below that // depend on the DBMS to correctly format query clauses need to ensure that // they are referencing the correct DBMS type. // This global value is designed to be populated whenever a Query is created // with a specific DBMS specified so that it can be made available to other // methods that depend on the value (especially static ones). public class DbGlobalDbms { private DbGlobalDbms () { } private static DbmsType dbms = DbmsType.General; public static DbmsType Type { get { return dbms; } set { if (value != DbmsType.General) dbms = value; } } } //======================================================================= public class DbQueryJoin { //------------------------------------------------------------------- // Data members. //------------------------------------------------------------------- // Construction. public DbQueryJoin (DbJoinType type = DbJoinType.Inner) { this.Type = type; } //------------------------------------------------------------------- // Properties. public DbJoinType Type { get; private set; } = DbJoinType.Inner; public String JoinType { get { return GetJoinType(Type); } } public StringPair Table { get; set; } = null; /* Second value is the optional alias. */ public List Filters { get; } = new List(); public List SubJoins { get; } = new List(); //------------------------------------------------------------------- public static String GetJoinType (DbJoinType type) { switch (type) { case DbJoinType.LeftOuter: return (DbGlobalDbms.Type == DbmsType.Sage ? "LEFT JOIN" : "LEFT OUTER JOIN"); case DbJoinType.RightOuter: return (DbGlobalDbms.Type == DbmsType.Sage ? "RIGHT JOIN" : "RIGHT OUTER JOIN"); case DbJoinType.FullOuter: return (DbGlobalDbms.Type == DbmsType.Sage ? "FULL JOIN" : "FULL OUTER JOIN"); case DbJoinType.Cross: return "CROSS JOIN"; case DbJoinType.Inner: default: return "INNER JOIN"; } } //------------------------------------------------------------------- public String BuildJoin (String enclosedJoin) { //--------------------------------------------------------------- // Select syntax. // SELECT [ DISTINCT | ALL ] // column_expression1, column_expression2, .... // [ FROM from_clause ] // [ WHERE where_expression ] // [ GROUP BY expression1, expression2, .... ] // [ HAVING having_expression ] // [ ORDER BY order_column_expr1, order_column_expr2, .... ] // column_expression ::= expression [ AS ] [ column_alias ] // from_clause ::= select_table1, select_table2, ... // from_clause ::= select_table1 LEFT [OUTER] JOIN select_table2 ON expr ... // from_clause ::= select_table1 RIGHT [OUTER] JOIN select_table2 ON expr ... // from_clause ::= select_table1 [INNER] JOIN select_table2 ... // select_table ::= table_name [ AS ] [ table_alias ] // select_table ::= ( sub_select_statement ) [ AS ] [ table_alias ] // order_column_expr ::= expression [ ASC | DESC ] // Issues. // MS Access - complex join expressions must be bracketed: ON (expr ...) //--------------------------------------------------------------- String joinData = String.Empty; //--------------------------------------------------------------- // Integrate multiple joins (nested). // // ( // // ( ON ) // ON ) // ON //--------------------------------------------------------------- // Sage doesn't use 'AS' to alias tables. String tableAliasOperator = (DbGlobalDbms.Type == DbmsType.Sage ? " " : " AS "); String joinType = GetJoinType(Type); String joinTable = Table.Concat (tableAliasOperator, false); String subJoinData = String.Empty; foreach (var subJoin in SubJoins) { // Add another bracket in front of the table: joinTable = "(" + joinTable; // Add the sub join. subJoinData += subJoin.BuildJoin("") + ")"; } // Build this join's list of filter conditions. // Take care not to add too many embedded brackets - confuses Access etc. // [Original version: lFilter.Concat (" AND ", "(", ")", true, true)]. String filter = String.Empty; foreach (var rawFilterClause in Filters) { // Retrieve the next filter clause. String filterClause = rawFilterClause.Trim(); if (filterClause.IsEmpty()) continue; // Embed the clause in brackets if it contains at least one 'OR' operator. if (filterClause.IndexOf(" OR ", StringComparison.OrdinalIgnoreCase) >= 0) filterClause = "(" + filterClause + ")"; filter += (!filter.IsEmpty() ? " AND " : "") + filterClause; } // Surround the entire filter in brackets (if not already done). if (filter.FindMatchingBrace(0) != filter.Length) filter = "(" + filter + ")"; // Build the join. if (enclosedJoin.IsEmpty() && subJoinData.IsEmpty()) { //----------------------------------------------------------- // Simple join syntax. // ON joinData = joinType + " " + joinTable + " ON " + filter; //----------------------------------------------------------- } else { //----------------------------------------------------------- // More complex join syntax - nested joins. // ( ) ON //----------------------------------------------------------- // Bracket the table, enclosing any enclosed or subordinate joins. joinData = joinType + " (" + joinTable; if (!subJoinData.IsEmpty()) joinData += " " + subJoinData; if (!enclosedJoin.IsEmpty()) joinData += " " + enclosedJoin; joinData += ") ON " + filter; //----------------------------------------------------------- } return joinData; } //------------------------------------------------------------------- // Debugging. public void Dump () { SaLogger.Debug($"{SysUtil.Fn}"); SaLogger.Debug("\tType = " + GetJoinType(Type)); SaLogger.Debug("\tTable = " + Table.Concat(" AS ")); SaLogger.Debug("\tFilters = " + Filters.Concat("", "\t\t", "\n")); if (SubJoins.Count > 0) { } SaLogger.Debug("\tJoin = " + BuildJoin("")); } //------------------------------------------------------------------- } //======================================================================= public static class DbQueryJoinList { //------------------------------------------------------------------- // List extensions. public static String BuildJoins (this List joins, String primaryTable) { String joinData = primaryTable; if (joins.Count > 0) { //----------------------------------------------------------- // Integrate multiple joins (nested). // // ( // // ( ON ) // ON ) // ON //----------------------------------------------------------- bool bBracketFromFirstToLast = true; if (bBracketFromFirstToLast) { // Bracketing from the first through to the last causes the // Main table name to be included in the brackets. // This appears to work for complex joins (multiple tables) // but may not be 100% compliant with the SQL specification. int joinCount = 0; foreach (var join in joins) { // Add the next join in the list. if (++joinCount > 1 && !joinData.IsEmpty()) joinData = "(" + joinData + ")"; joinData = joinData + " " + join.BuildJoin(""); joinData.Trim(); } } else { // Jul-04 Attempt to organise joins with innermost bracketed. // Start from the end of the list and work backwards. // NB Fails with multiple joins - check SQL specification! // Start with the last join and roll up towards the first. joinData = String.Empty; foreach (var join in joins) { // Add the next join in the list. joinData = join.BuildJoin(joinData); joinData.Trim(); } joinData = primaryTable + " " + joinData; } } return joinData; } //------------------------------------------------------------------- } //======================================================================= public class DbQuery { //------------------------------------------------------------------- // Results paging. public class Paging { public uint PageNo { get; set; } = 0; public ushort PageLength { get; set; } = 0; public virtual bool Paged { get { return PageNo > 0 && PageLength > 0; } } } //------------------------------------------------------------------- // Data members. //------------------------------------------------------------------- // Construction. public DbQuery (DbmsType dbms) { DbGlobalDbms.Type = dbms; } //------------------------------------------------------------------- // Properties. public bool DistinctRows { get; set; } = false; public uint RowLimit { get; set; } = 0; public List Tables { get; } = new List(); public List Columns { get; } = new List(); /* s1 = qualified column name, s2 = optional alias. */ public List Filters { get; } = new List(); public List GroupBy { get; } = new List(); public List OrderBy { get; } = new List(); public List Joins { get; } = new List(); /// Date value used as alternative to Null on database (ref. C++ ODBC defaults). public static DateTime NullDate { get; } = new DateTime(1970, 1, 1); //------------------------------------------------------------------- // S t r i n g V a l u e F o r m a t t i n g //------------------------------------------------------------------- /// /// Format the value so that it can be used in a conditional clause. /// - single quotes are duplicated. /// public static String FormatForCondition (String value) { return StringUtil.EmptyOnNull(value).Replace("'", "''"); } /// /// Format the specified string value so that it can be used in a 'LIKE' clause. /// - single quotes are duplicated /// - open square bracket ('[') is enclosed in square brackets ('[[]') /// - percent ('%') is enclosed in square brackets ('[%]') unless wildcards allowed. /// - asterisk ('*') converted to percent ('%') if wildcards allowed. /// public static String FormatForLike (String value, bool allowWildcards = false) { value = StringUtil.EmptyOnNull(value).Replace("'", "''").Replace("[", "[[]"); if (allowWildcards) { value = value.Replace("*", "%"); } else { value = value.Replace("%", "[%]"); } return value; } /// /// Format the list of values so that they it can be used in an 'In' clause. /// - Ignore blanks (if instructed), remove duplicates & pass string values through 'FormatForCondition'. /// - Format as: ('Value1','Value2','Value3') if string data, else (Value1,Value2,Value3) etc. /// - Returns empty string if list is empty. /// public static String FormatForIn (List rawValues, bool stringData = true, bool ignoreBlanks = true) { String result = String.Empty; List values = new List(); foreach (var rawValue in rawValues) { String value = rawValue; if (!rawValue.IsEmpty() || !ignoreBlanks) { if (stringData) value = "'" + FormatForCondition(value) + "'"; else value.Trim(); // Non string data can be cleansed. values.Add(value); } } values.RemoveDuplicatesCI(); if (values.Count > 0) result = "(" + values.Concat(",") + ")"; return result; } //------------------------------------------------------------------- // D a t e V a l u e F o r m a t t i n g //------------------------------------------------------------------- public static String GetDateDelim () { String delim = "#"; // MS Access only? if (DbGlobalDbms.Type == DbmsType.MsSql) delim = "'"; // MS SQL Server. return delim; } public static String FormatDateForCondition (DateTime date, bool delimit = true, bool includeMidnight = false) { String value = DateUtil.Format(date, "dd-MMM-yyyy"); if (includeMidnight) value += " 00:00:00"; if (delimit) { String delim = GetDateDelim(); value = delim + value + delim; } return value; } public static String FormatDateTimeForCondition (DateTime dateTime, bool delimit = true, bool precise = false) { String value = DateUtil.Format(dateTime, "yyyy-MM-dd HH:mm:ss"+(precise?".fff":"")); if (delimit) { String delim = GetDateDelim(); value = delim + value + delim; } return value; } public static String FormatTimeForCondition (DateTime time, bool delimit = true, bool zeroSeconds = false) { String value = String.Empty; if (zeroSeconds) value = DateUtil.Format(time, "HH:mm:00"); else value = DateUtil.Format(time, "HH:mm:ss"); if (delimit) { String delim = GetDateDelim(); value = delim + value + delim; } return value; } //------------------------------------------------------------------- // F o r m a t F o r F i l t e r //------------------------------------------------------------------- public static String FormatNullValueFilter (String expression, bool excludeNulls) { String clause = String.Empty; expression = StringUtil.EmptyOnNull(expression).Trim(); if (!expression.IsEmpty()) { // Surround the expression in brackets if not present. if (expression.Left(1) != "(") expression = "(" + expression + ")"; // Logic to include or exclude null values. if (excludeNulls) { // Explicitly exclude Null values. //if (DbGlobalDbms.Type == DbmsType.MsSql) // sClause = "(CASE WHEN "+sExpr+" IS NULL THEN 0 ELSE 1 END) = 1"; //else clause = expression + " IS NOT NULL"; } else { // Explicitly include only Null values. //if (DbGlobalDbms.Type == DbmsType.MsSql) // sClause = "(CASE WHEN "+sExpr+" IS NULL THEN 0 ELSE 1 END) = 0"; //else clause = expression + " IS NULL"; } } return clause; } // Construct 'Order By' clauses, allowing direction (ASC/DESC) and whether // to demote NULL values (push to end of sequence) into account. public static List FormatForOrderBy (String expression, bool ascending, DbOrderByNullMode nullMode = DbOrderByNullMode.Default, bool treatZeroAsNull = false) { List orderBy = new List(); expression = StringUtil.EmptyOnNull(expression).Trim(); if (!expression.IsEmpty()) { // Surround the expression in brackets if not present. if (expression.Left(1) != "(") expression = "(" + expression + ")"; // Logic to promote null values to start of sequence or demote null values to the end of the sequence. String nullsClause = String.Empty; if (nullMode == DbOrderByNullMode.PromoteNulls || nullMode == DbOrderByNullMode.DatePromoteNulls) { if (DbGlobalDbms.Type == DbmsType.MsSql) { // Treat NULLs before other values. List nullsFilters = new List(); nullsFilters.Add(expression + " IS NULL"); if (treatZeroAsNull) nullsFilters.Add(expression + "=0"); if (nullMode == DbOrderByNullMode.DatePromoteNulls) nullsFilters.Add(expression + "<=" + FormatDateForCondition(NullDate)); nullsClause = "(CASE WHEN " + nullsFilters.Concat(" OR ", "(", ")") + " THEN 0 ELSE 1 END)"; } else nullsClause = expression + " IS NULL"; } else if (nullMode == DbOrderByNullMode.DemoteNulls || nullMode == DbOrderByNullMode.DateDemoteNulls) { if (DbGlobalDbms.Type == DbmsType.MsSql) { // Treat NULLs after other values. List nullsFilters = new List(); nullsFilters.Add(expression + " IS NULL"); if (treatZeroAsNull) nullsFilters.Add(expression + "=0"); if (nullMode == DbOrderByNullMode.DateDemoteNulls) nullsFilters.Add(expression + "<=" + FormatDateForCondition(NullDate)); nullsClause = "(CASE WHEN " + nullsFilters.Concat(" OR ", "(", ")") + " THEN 1 ELSE 0 END)"; } else nullsClause = expression + " IS NOT NULL"; } if (!nullsClause.IsEmpty()) orderBy.Add(nullsClause); // Main clause for this expression. orderBy.Add(expression + " " + (ascending ? "ASC" : "DESC")); } return orderBy; } //------------------------------------------------------------------- // S Q L F u n c t i o n F o r m a t t i n g //------------------------------------------------------------------- // NB Many functions depend on the DBMS in use. // Default versions are for Access/SQL. public static String FormatFn_Len (String column) { if (String.IsNullOrEmpty(column)) return "''"; // MS Access. return "Len(" + column + ")"; } public static String FormatFn_Left (String column, int length) { if (String.IsNullOrEmpty(column) || length < 1) return "''"; // MS Access. return "Left(" + column + "," + length.ToString() + ")"; } public static String FormatFn_Right (String column, int length) { if (String.IsNullOrEmpty(column) || length < 1) return "''"; // MS Access. return "Right(" + column + "," + length.ToString() + ")"; } public static String FormatFn_TimeValue (String expression) { if (DbGlobalDbms.Type == DbmsType.MsSql) { // Convert with Date/Time values. // Param 3 - Date/Time format. // 1 = mm/dd/yy // 2 = yy.mm.dd // 3 = dd/mm/yy // 4 = dd.mm.yy // 5 = dd-mm-yy // 6 = dd mmm yy // 7 = mmm dd, yy // 8 = hh:mm:ss // 9 = mmm dd yyyy hh:mm:ss:000AP return "Convert(VarChar(10)," + expression + ",8)"; // CONVERT(varchar(10), DateClosed, 8) } return "TimeValue(" + expression + ")"; } //------------------------------------------------------------------- // N u m e r i c F u n c t i o n s //------------------------------------------------------------------- public static String FormatFn_Abs (String sExpr) { if (sExpr.IsEmpty()) return "''"; return "Abs(" + sExpr + ")"; } public static String FormatFn_Round (String expression, int decimals = 0) { if (String.IsNullOrEmpty(expression) || decimals < 0) return "''"; if (DbGlobalDbms.Type == DbmsType.Access) { // MS Access. // NB Uses the 'round-to-even' logic - 0.5 values go to the even whole value rather than always up. // Solution to get it behave consistently - add a fraction to make it 0.5001. return "Round(" + expression + ((long)(10 ^ -(decimals + 2))).ToString() + "," + decimals.ToString() + ")"; } return "Round(" + expression + "," + decimals.ToString() + ")"; } public static String FormatFn_Truncate (String expression, int decimals = 0) { if (String.IsNullOrEmpty(expression) || decimals < 0) return "''"; if (DbGlobalDbms.Type == DbmsType.MsSql) return "Round(" + expression + "," + decimals.ToString() + ",1)"; if (DbGlobalDbms.Type == DbmsType.Access) // MS Access. if (decimals > 0) { String factor = ((long)(10^decimals)).ToString(); return "Int(" + expression + "*" + factor + "," + decimals.ToString() + ")/" + factor; } return "Int(" + expression + "," + decimals.ToString() + ")"; } public static String FormatFn_FormatNumber (String expression, int decimals) { if (String.IsNullOrEmpty(expression) || decimals < 0) return "''"; // MS Access. return "FormatNumber(" + expression + "," + decimals.ToString() + ")"; } //------------------------------------------------------------------- // D a t e D i f f e r e n c e //------------------------------------------------------------------- // Date difference: eg. DateDiff('n',Col1,Col2). public static String FormatFn_DateDiff (DbDateDiffInterval dateInterval, String column1, String column2) { if (column1.IsEmpty() || column2.IsEmpty()) return "''"; String interval = String.Empty; if (DbGlobalDbms.Type == DbmsType.MsSql) { // SQL Server. switch (dateInterval) { // NB No delimiter. case DbDateDiffInterval.Year: interval = "yyyy"; break; case DbDateDiffInterval.Quarter: interval = "q"; break; case DbDateDiffInterval.Month: interval = "m"; break; case DbDateDiffInterval.DayOfYear: interval = "y"; break; case DbDateDiffInterval.Day: interval = "d"; break; //case Weekday: interval = "w"; break; // Not supported by SQL Server. case DbDateDiffInterval.Week: interval = "ww"; break; case DbDateDiffInterval.Hour: interval = "h"; break; case DbDateDiffInterval.Minute: interval = "n"; break; case DbDateDiffInterval.Second: interval = "s"; break; //case MilliSecond: interval = "ms"; break; // Not yet defined. } } else { // MS Access. switch (dateInterval) { case DbDateDiffInterval.Year: interval = "'yyyy'"; break; case DbDateDiffInterval.Quarter: interval = "'q'"; break; case DbDateDiffInterval.Month: interval = "'m'"; break; case DbDateDiffInterval.DayOfYear: interval = "'y'"; break; case DbDateDiffInterval.Day: interval = "'d'"; break; case DbDateDiffInterval.Weekday: interval = "'w'"; break; case DbDateDiffInterval.Week: interval = "'ww'"; break; case DbDateDiffInterval.Hour: interval = "'h'"; break; case DbDateDiffInterval.Minute: interval = "'n'"; break; case DbDateDiffInterval.Second: interval = "'s'"; break; } if (interval.IsEmpty()) return "''"; } return "DateDiff(" + interval + "," + column1 + "," + column2 + ")"; } //------------------------------------------------------------------- // B i t M a p p e d V a l u e s //------------------------------------------------------------------- public static String BuildBitMappedValue (String column, ushort bit, bool match) { // Filter clause to test for specific bit set in field. String clause = String.Empty; if (!String.IsNullOrEmpty(column)) { if (bit > 0 && bit <= 32) { // The operation depends on whether the value should be matched or not. String condition = (match? "<>" : "="); // Test is achieved by shifting the value so as to drop the target bit, shift back one bit // and identify those value that then do not match the original value shifted by one place less. // Example match: abcdefgh, Test bit 3: abcde0 <> abcdef clause = "(" + FormatFn_Truncate(column + "/" + Math.Pow(2, bit).ToString()) + "*2)" + condition + FormatFn_Truncate(column + "/" + Math.Pow(2, bit - 1).ToString()); if (DbGlobalDbms.Type == DbmsType.MsSql) clause = "(Case When " + clause + " Then 1 Else 0 End)"; } #if DEBUG else { SaLogger.Debug("BuildBitMappedFilter - Invalid bit (" + bit + ")."); } #endif } #if DEBUG else { SaLogger.Debug("BuildBitMappedFilter - NULL column name."); } #endif return clause; } public static String BuildBitMappedFilter (String column, ushort bit, bool match) { String clause = BuildBitMappedValue(column, bit, match); if (!clause.IsEmpty()) { if (DbGlobalDbms.Type == DbmsType.MsSql) clause = "(" + clause + " <> 0)"; } return clause; } //------------------------------------------------------------------- // A t t r i b u t e s //------------------------------------------------------------------- public void Clear () { Tables.Clear(); Columns.Clear(); Filters.Clear(); GroupBy.Clear(); OrderBy.Clear(); Joins.Clear(); } public bool IsValid () { return (Tables.Count > 0); } //------------------------------------------------------------------- // B u i l d Q u e r y //------------------------------------------------------------------- public String BuildQuery (Paging paging = null) { if (Tables.Count < 1) return ""; //--------------------------------------------------------------------- // Start with the column names. String query = "SELECT "; if (DistinctRows) query += "DISTINCT "; if (RowLimit > 0) query += "TOP " + RowLimit.ToString() + " "; if (Columns.Count < 1) query += "* "; else query += Columns.Concat(" AS ", false).Concat(","); //--------------------------------------------------------------------- // Add the tables and joins. String tableAliasOperator = DbGlobalDbms.Type == DbmsType.Sage? " " : " AS "; if (Tables.Count > 1 || Joins.Count < 1) { query += " FROM " + Tables.Concat(tableAliasOperator, false).Concat(",") + " " + Joins.BuildJoins(""); } else if (Tables.Count == 1) { // Table name is bracketed with multiple joins... query += " FROM " + Joins.BuildJoins(Tables.Concat(tableAliasOperator, false)[0]); } query.TrimEnd(' '); //--------------------------------------------------------------------- // Filters. String filter = Filters.Concat (" AND ", "(", ")", true, true); // Jan2019 Not required: if (Filters.Count > 1) filter = "(" + filter + ")"; if (!filter.IsEmpty()) query += " WHERE " + filter; // Groupings. String groupByData = GroupBy.Concat(",", "", "", true, true); if (!groupByData.IsEmpty()) query += " GROUP BY " + groupByData; // Sort sequence. String orderByData = OrderBy.Concat(",", "", "", true, true); if (!orderByData.IsEmpty()) query += " ORDER BY " + orderByData; // Paging. if (paging != null && paging.Paged) { // Must have an 'Order By' clause to use 'Offset ... Fetch Next ...' if (orderByData.IsEmpty()) throw new NotSupportedException("Query paging requires sort order to be specified"); if (DbGlobalDbms.Type != DbmsType.MsSql) throw new NotSupportedException("Query paging only supported for MS SQL"); query += " OFFSET " + ((paging.PageNo - 1) * paging.PageLength) + " ROWS FETCH NEXT " + paging.PageLength + " ROWS ONLY"; } //--------------------------------------------------------------------- #if DEBUG bool bDisplayDebugMsg = false; if (bDisplayDebugMsg) SaLogger.Debug("Query: " + query); #endif //--------------------------------------------------------------------- return query; } //------------------------------------------------------------------- } //======================================================================= }