• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

HicServices / RDMP / 9118172652

16 May 2024 07:38PM UTC coverage: 56.911% (+0.009%) from 56.902%
9118172652

push

github

jas88
Fix multiple enumerations

A

10817 of 20482 branches covered (52.81%)

Branch coverage included in aggregate %.

6 of 13 new or added lines in 5 files covered. (46.15%)

39 existing lines in 6 files now uncovered.

30824 of 52687 relevant lines covered (58.5%)

7403.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.47
/Rdmp.Core/QueryBuilding/SqlQueryBuilderHelper.cs
1
// Copyright (c) The University of Dundee 2018-2019
2
// This file is part of the Research Data Management Platform (RDMP).
3
// RDMP is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
4
// RDMP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
5
// You should have received a copy of the GNU General Public License along with RDMP. If not, see <https://www.gnu.org/licenses/>.
6

7
using System;
8
using System.Collections.Generic;
9
using System.Linq;
10
using FAnsi.Discovery.QuerySyntax;
11
using Rdmp.Core.Curation.Data;
12
using Rdmp.Core.Curation.DataHelper;
13
using Rdmp.Core.MapsDirectlyToDatabaseTable;
14
using Rdmp.Core.Repositories;
15
using Rdmp.Core.Repositories.Managers;
16
using Rdmp.Core.ReusableLibraryCode;
17
using Rdmp.Core.ReusableLibraryCode.Checks;
18

19
namespace Rdmp.Core.QueryBuilding;
20

21
/// <summary>
22
/// Helps generate SELECT and GROUP By queries for ISqlQueryBuilders.  This includes all the shared functionality such as finding all IFilters, Lookups,
23
/// which tables to JOIN on etc.  Also handles CustomLine injection which is where you inject arbitrary lines into the query at specific points (See CustomLine).
24
/// </summary>
25
public class SqlQueryBuilderHelper
26
{
27
    /// <summary>
28
    /// Returns all IFilters that are in the root IContainer or any subcontainers
29
    /// </summary>
30
    /// <param name="currentContainer"></param>
31
    /// <returns></returns>
32
    public static List<IFilter> GetAllFiltersUsedInContainerTreeRecursively(IContainer currentContainer)
33
    {
34
        //Note: This returns IsDisabled objects since it is used by cloning systems
35

36
        var toAdd = new List<IFilter>();
1,102✔
37

38
        //if there is a container
39
        if (currentContainer != null)
1,102✔
40
        {
41
            if (currentContainer.GetSubContainers() != null)
348✔
42
                foreach (var subContainer in currentContainer.GetSubContainers())
704✔
43
                    //recursively add all subcontainers' filters
44
                    toAdd.AddRange(GetAllFiltersUsedInContainerTreeRecursively(subContainer));
4✔
45

46
            //add filters
47
            if (currentContainer.GetFilters() != null)
348✔
48
                toAdd.AddRange(currentContainer.GetFilters());
348✔
49
        }
50

51
        return toAdd;
1,102✔
52
    }
53

54
    /// <inheritdoc cref="QueryTimeColumn.SetLookupStatus"/>
55
    public static void FindLookups(ISqlQueryBuilder qb)
56
    {
57
        //if there is only one table then user us selecting stuff from the lookup table only
58
        if (qb.TablesUsedInQuery.Count == 1)
1,088✔
59
            return;
992✔
60

61
        QueryTimeColumn.SetLookupStatus(qb.SelectColumns.ToArray(), qb.TablesUsedInQuery);
96✔
62
    }
96✔
63

64

65
    /// <summary>
66
    /// Must be called only after the ISqlQueryBuilder.TablesUsedInQuery has been set (see GetTablesUsedInQuery).  This method will resolve how
67
    /// the various tables can be linked together.  Throws QueryBuildingException if it is not possible to join the tables with any known
68
    /// JoinInfos / Lookup knowledge
69
    /// </summary>
70
    /// <param name="qb"></param>
71
    /// <returns></returns>
72
    public static List<JoinInfo> FindRequiredJoins(ISqlQueryBuilder qb)
73
    {
74
        var Joins = new List<JoinInfo>();
1,088✔
75

76
        if (qb.TablesUsedInQuery == null)
1,088!
77
            throw new NullReferenceException(
×
78
                "You must populate TablesUsedInQuery before calling FindRequiredJoins, try calling GetTablesUsedInQuery");
×
79

80
        //there are no tables so how could there be any joins!
81
        if (!qb.TablesUsedInQuery.Any())
1,088!
82
            throw new QueryBuildingException(
×
83
                "Query has no TableInfos! Make sure your query has at least one column with an underlying ColumnInfo / TableInfo set - possibly you have deleted the TableInfo? this would result in orphan CatalogueItem");
×
84

85
        ICatalogueRepository cataRepository;
86
        try
87
        {
88
            cataRepository = (ICatalogueRepository)qb.TablesUsedInQuery.Select(t => t.Repository).Distinct().Single();
2,282✔
89
        }
1,088✔
90
        catch (Exception e)
×
91
        {
92
            throw new Exception(
×
93
                $"Tables ({string.Join(",", qb.TablesUsedInQuery)}) do not seem to come from the same repository", e);
×
94
        }
95

96
        foreach (TableInfo table1 in qb.TablesUsedInQuery)
4,564✔
97
            foreach (TableInfo table2 in qb.TablesUsedInQuery)
5,248✔
98
                if (table1.ID != table2.ID) //each table must join with a single other table
1,430✔
99
                {
100
                    //figure out which of the users columns is from table 1 to join using
101
                    var availableJoins = cataRepository.JoinManager.GetAllJoinInfosBetweenColumnInfoSets(
236✔
102
                        table1.ColumnInfos.ToArray(),
236✔
103
                        table2.ColumnInfos.ToArray());
236✔
104

105
                if (availableJoins.Length == 0)
236✔
106
                    continue; //try another table
107

108
                var comboJoinResolved = false;
156✔
109

110
                    //if there are more than 1 join info between the two tables then we need to either do a combo join or complain to user
111
                    if (availableJoins.Length > 1)
156!
112
                    {
113
                        var additionalErrorMessageWhyWeCantDoComboJoin = "";
×
114
                        //if there are multiple joins but they all join between the same 2 tables in the same direction
115
                        if (availableJoins.Select(j => j.PrimaryKey.TableInfo_ID).Distinct().Count() == 1
×
116
                            &&
×
117
                            availableJoins.Select(j => j.ForeignKey.TableInfo_ID).Distinct().Count() == 1)
×
118
                            if (availableJoins.Select(j => j.ExtractionJoinType).Distinct().Count() == 1)
×
119
                            {
120
                                //add as combo join
121
                                for (var i = 1; i < availableJoins.Length; i++)
×
122
                                    availableJoins[0].AddQueryBuildingTimeComboJoinDiscovery(availableJoins[i]);
×
123
                                comboJoinResolved = true;
×
124
                            }
125
                            else
126
                            {
127
                                additionalErrorMessageWhyWeCantDoComboJoin =
×
128
                                    " Although joins are all between the same tables in the same direction, the ExtractionJoinTypes are different (e.g. LEFT and RIGHT) which prevents forming a Combo AND based join using both relationships";
×
129
                            }
130
                        else
131
                            additionalErrorMessageWhyWeCantDoComboJoin =
×
132
                                " The Joins do not go in the same direction e.g. Table1.FK=>Table=2.PK and then a reverse relationship Table2.FK=>Table1.PK, in this case the system cannot do a Combo AND based join";
×
133

134
                        var possibleJoinsWere = availableJoins.Select(s => $"JoinInfo[{s}]")
×
135
                            .Aggregate((a, b) => a + Environment.NewLine + b);
×
136

137
                        if (!comboJoinResolved)
×
138
                            throw new QueryBuildingException(
×
139
                                $"Found {availableJoins.Length} possible Joins for {table1.Name} and {table2.Name}, did not know which to use.  Available joins were:{Environment.NewLine}{possibleJoinsWere}{Environment.NewLine} It was not possible to configure a Composite Join because:{Environment.NewLine}{additionalErrorMessageWhyWeCantDoComboJoin}");
×
140
                    }
141

142
                    if (!Joins.Contains(availableJoins[0]))
156✔
143
                        Joins.Add(availableJoins[0]);
78✔
144
                }
145

146
        if (qb.TablesUsedInQuery.Count - GetDistinctRequiredLookups(qb).Count() - Joins.Count > 1)
1,088!
147
            throw new QueryBuildingException(
×
148
                $"There were {qb.TablesUsedInQuery.Count} Tables involved in assembling this query ( {qb.TablesUsedInQuery.Aggregate("", (s, n) => $"{s}{n},").TrimEnd(',')}) of which  {GetDistinctRequiredLookups(qb).Count()} were Lookups and {Joins.Count} were JoinInfos, this leaves 2+ tables unjoined (no JoinInfo found)");
×
149

150

151
        //make sure there are not multiple primary key tables (those should be configured as lookups
152
        if (Joins.Count > 0 && qb.PrimaryExtractionTable == null)
1,088✔
153
        {
154
            var primaryKeyTables = new List<string>(Joins.Select(p => p.PrimaryKey.TableInfo.Name).Distinct());
136✔
155

156
            if (primaryKeyTables.Count > 1)
68!
157
            {
158
                //there are multiple primary key tables... see if we are configured to support them
159
                var primaryKeyTablesAsString = primaryKeyTables.Aggregate((a, b) => $"{a},{b}");
×
160
                throw new QueryBuildingException(
×
161
                    $"Found {primaryKeyTables.Count} primary key tables but PrimaryExtractionTable (Fix this by setting one TableInfo as 'IsPrimaryExtractionTable'), primary key tables identified include: {primaryKeyTablesAsString}");
×
162
            }
163
        }
164

165
        return qb.PrimaryExtractionTable != null && qb.TablesUsedInQuery.Contains(qb.PrimaryExtractionTable) == false
1,088!
166
            ? throw new QueryBuildingException(
1,088✔
167
                "Specified PrimaryExtractionTable was not found amongst the chosen extraction columns")
1,088✔
168
            : Joins;
1,088✔
169
    }
170

171
    /// <summary>
172
    /// Returns all <see cref="Lookup"/> linked to for the FROM section of the query
173
    /// </summary>
174
    /// <param name="qb"></param>
175
    /// <returns></returns>
176
    public static IEnumerable<Lookup> GetDistinctRequiredLookups(ISqlQueryBuilder qb) =>
177
        //from all columns
178
        from column in qb.SelectColumns
1,162✔
179
        where
1,162✔
180
            (
7,064✔
181
                column.IsLookupForeignKey
7,064✔
182
                &&
7,064✔
183
                column.IsLookupForeignKeyActuallyUsed(qb.SelectColumns)
7,064✔
184
            )
7,064✔
185
            ||
7,064✔
186
            column.IsIsolatedLookupDescription //this is when there are no foreign key columns in the SelectedColumns set but there is still a lookup description field so we have to link to the table anyway
7,064✔
187
        select column.LookupTable;
1,208✔
188

189
    /// <summary>
190
    /// Make sure you have set your Filters and SelectColumns properties before calling this method so that it can find table dependencies
191
    /// </summary>
192
    /// <param name="qb"></param>
193
    /// <param name="primaryExtractionTable"></param>
194
    /// <param name="forceJoinsToTheseTables"></param>
195
    /// <returns></returns>
196
    public static List<ITableInfo> GetTablesUsedInQuery(ISqlQueryBuilder qb, out ITableInfo primaryExtractionTable,
197
        ITableInfo[] forceJoinsToTheseTables = null)
198
    {
199
        if (qb.SelectColumns == null)
1,110!
200
            throw new QueryBuildingException("ISqlQueryBuilder.SelectedColumns is null");
×
201

202
        if (qb.SelectColumns.Count == 0)
1,110✔
203
            throw new QueryBuildingException("There are no columns in the SELECT query");
6✔
204

205
        var toReturn = new List<ITableInfo>(forceJoinsToTheseTables ?? Array.Empty<ITableInfo>());
1,104✔
206

207
        if (forceJoinsToTheseTables != null)
1,104✔
208
        {
209
            if (forceJoinsToTheseTables.Count(t => t.IsPrimaryExtractionTable) > 1)
748!
210
                primaryExtractionTable = PickBestPrimaryExtractionTable(qb,
×
211
                                             forceJoinsToTheseTables.Where(t => t.IsPrimaryExtractionTable).ToArray())
×
212
                                         ?? throw new QueryBuildingException(
×
213
                                             "Found 2+ tables marked IsPrimaryExtractionTable in force joined tables");
×
214
            else
215
                primaryExtractionTable = forceJoinsToTheseTables.SingleOrDefault(t => t.IsPrimaryExtractionTable);
748✔
216
        }
217
        else
218
        {
219
            primaryExtractionTable = null;
404✔
220
        }
221

222

223
        //get all the tables based on selected columns
224
        foreach (var toExtract in qb.SelectColumns)
13,570✔
225
        {
226
            if (toExtract.UnderlyingColumn == null)
5,682✔
227
                continue;
228

229
            if (qb.CheckSyntax)
5,006✔
230
                toExtract.CheckSyntax();
4,130✔
231

232
            var table = toExtract.UnderlyingColumn.TableInfo;
5,006✔
233

234
            if (!toReturn.Contains(table))
5,006✔
235
            {
236
                toReturn.Add(table);
1,158✔
237

238
                if (table.IsPrimaryExtractionTable)
1,158✔
239
                    if (primaryExtractionTable == null)
8✔
240
                        primaryExtractionTable = table;
6✔
241
                    else
242
                        primaryExtractionTable = PickBestPrimaryExtractionTable(qb, primaryExtractionTable, table)
2!
243
                                                 ?? throw new QueryBuildingException(
2✔
244
                                                     $"There are multiple tables marked as IsPrimaryExtractionTable:{primaryExtractionTable.Name}(ID={primaryExtractionTable.ID}) and {table.Name}(ID={table.ID})");
2✔
245
            }
246
        }
247

248
        //get other tables we might need because they are referenced by filters
249
        if (qb.Filters != null && qb.Filters.Any())
1,102✔
250
        {
251
            foreach (var filter in qb.Filters)
144✔
252
            {
253
                var col = filter.GetColumnInfoIfExists();
38✔
254
                if (col != null)
38!
255
                {
256
                    var tableInfoOfFilter = col.TableInfo;
×
257
                    if (!toReturn.Contains(tableInfoOfFilter))
×
258
                        toReturn.Add(tableInfoOfFilter);
×
259
                }
260
            }
261

262
            toReturn = AddOpportunisticJoins(toReturn, qb.Filters);
34✔
263
        }
264

265
        //Some TableInfos might be TableValuedFunctions or for some other reason have a paramter associated with them
266
        qb.ParameterManager.AddParametersFor(toReturn);
1,102✔
267

268

269
        return toReturn;
1,102✔
270
    }
271

272
    /// <summary>
273
    /// Picks between two <see cref="ITableInfo"/> both of which are <see cref="TableInfo.IsPrimaryExtractionTable"/> and returns
274
    /// the 'winner' (best to start joining from).  returns null if there is no clear better one
275
    /// </summary>
276
    /// <param name="qb"></param>
277
    /// <param name="tables"></param>
278
    /// <returns></returns>
279
    /// <exception cref="QueryBuildingException"></exception>
280
    private static ITableInfo PickBestPrimaryExtractionTable(ISqlQueryBuilder qb, params ITableInfo[] tables)
281
    {
282
        if (tables.Length == 0)
2!
283
            throw new ArgumentException(
×
284
                $"At least one table must be provided to {nameof(PickBestPrimaryExtractionTable)}");
×
285

286
        // if there is only one choice
287
        if (tables.Length == 1)
2!
288
            return tables[0]; // go with that
×
289

290
        // what tables have IsExtractionIdentifier column(s)?
291
        var extractionIdentifierTables = qb.SelectColumns
2✔
292
            .Where(c => c.IColumn?.IsExtractionIdentifier ?? false)
4!
293
            .Select(t => t.UnderlyingColumn?.TableInfo_ID)
×
294
            .Where(id => id != null)
×
295
            .ToArray();
2✔
296

297
        if (extractionIdentifierTables.Length == 1)
2!
298
        {
299
            var id = extractionIdentifierTables[0];
×
300

301
            foreach (var t in tables)
×
302
                if (id == t.ID)
×
303
                    return t;
×
304

305
            // IsExtractionIdentifier column is from neither of these tables, bad times
306
        }
307

308
        // no clear winner
309
        return null;
2✔
310
    }
311

312
    private static List<ITableInfo> AddOpportunisticJoins(List<ITableInfo> toReturn, List<IFilter> filters)
313
    {
314
        //there must be at least one TableInfo here to do this... but we are going to look up all available JoinInfos from these tables to identify opportunistic joins
315
        foreach (var table in toReturn.ToArray())
140✔
316
        {
317
            var available =
36✔
318
                table.CatalogueRepository.JoinManager.GetAllJoinInfosWhereTableContains(table, JoinInfoType.AnyKey);
36✔
319

320
            foreach (var newAvailableJoin in available)
88✔
321
                foreach (var availableTable in new TableInfo[]
48✔
322
                             { newAvailableJoin.PrimaryKey.TableInfo, newAvailableJoin.ForeignKey.TableInfo })
8✔
323
                    //if it's a never before seen table
324
                    if (!toReturn.Contains(availableTable))
16✔
325
                        //are there any filters which reference the available TableInfo
326
                        if (filters.Any(f => f.WhereSQL != null && f.WhereSQL.ToLower().Contains(
8!
327
                                $"{availableTable.Name.ToLower()}.")))
8✔
328
                            toReturn.Add(availableTable);
4✔
329
        }
330

331

332
        return toReturn;
34✔
333
    }
334

335
    /// <summary>
336
    /// Generates the FROM sql including joins for all the <see cref="TableInfo"/> required by the <see cref="ISqlQueryBuilder"/>.  <see cref="JoinInfo"/> must exist for
337
    /// this process to work
338
    /// </summary>
339
    /// <param name="qb"></param>
340
    /// <returns></returns>
341
    public static string GetFROMSQL(ISqlQueryBuilder qb)
342
    {
343
        //add the from bit
344
        var toReturn = $"FROM {Environment.NewLine}";
1,086✔
345

346
        if (qb.TablesUsedInQuery.Count == 0)
1,086!
347
            throw new QueryBuildingException(
×
348
                "There are no tables involved in the query: We were asked to compute the FROM SQL but qb.TablesUsedInQuery was of length 0");
×
349

350
        //IDs of tables we already have in our FROM section
351
        var tablesAddedSoFar = new HashSet<int>();
1,086✔
352

353
        //sometimes we find joins between tables that turn out not to be needed e.g. if there are multiple
354
        //routes through the system e.g. Test_FourTables_MultipleRoutes
355
        var unneededJoins = new HashSet<JoinInfo>();
1,086✔
356

357
        if (qb.JoinsUsedInQuery.Count == 0)
1,086✔
358
        {
359
            ITableInfo firstTable = null;
1,014✔
360

361
            //is there only one table involved in the query?
362
            if (qb.TablesUsedInQuery.Count == 1)
1,014✔
363
            {
364
                firstTable = qb.TablesUsedInQuery[0];
990✔
365
            }
366
            else if (qb.TablesUsedInQuery.Count(t => t.IsPrimaryExtractionTable) ==
72!
367
                     1) //has the user picked one to be primary?
24✔
368
            {
369
                firstTable = qb.TablesUsedInQuery.Single(t => t.IsPrimaryExtractionTable);
×
370

371
                //has user tried to make a lookup table the primary table!
372
                if (TableIsLookupTable(firstTable, qb))
×
373
                    throw new QueryBuildingException(
×
374
                        $"Lookup tables cannot be marked IsPrimaryExtractionTable (Offender ={firstTable})");
×
375
            }
376
            else
377
            {
378
                //User has not picked one and there are multiple!
379

380
                //can we discard all tables but one based on the fact that they are look up tables?
381
                //maybe! lookup tables are tables where there is an underlying column from that table that is a lookup description
382
                var winners =
24✔
383
                    qb.TablesUsedInQuery.Where(t =>
24✔
384
                            !TableIsLookupTable(t, qb))
48✔
385
                        .ToArray();
24✔
386

387
                //if we have discarded all but 1 it is the only table that does not have any lookup descriptions in it so clearly the correct table to start joins from
388
                if (winners.Length == 1)
24!
389
                    firstTable = winners[0];
24✔
390
                else
391
                    throw new QueryBuildingException(
×
392
                        $"There were {qb.TablesUsedInQuery.Count} Tables ({string.Join(",", qb.TablesUsedInQuery)}) involved in the query, some of them might have been lookup tables but there was no clear table to start joining from, either mark one of the TableInfos IsPrimaryExtractionTable or refine your query columns / create new lookup relationships");
×
393
            }
394

395
            toReturn += firstTable.Name; //simple case "FROM tableX"
1,014✔
396
        }
397
        else if (qb.PrimaryExtractionTable != null)
72✔
398
        {
399
            //user has specified which table to start from
400
            toReturn += qb.PrimaryExtractionTable.Name;
4✔
401

402
            //now find any joins which involve the primary extraction table
403
            foreach (var join in qb.JoinsUsedInQuery)
28✔
404
                if (join.PrimaryKey.TableInfo_ID == qb.PrimaryExtractionTable.ID)
10✔
405
                {
406
                    var fkTableId = join.ForeignKey.TableInfo_ID;
6✔
407

408
                    //don't double JOIN to the same table twice even using different routes (see Test_FourTables_MultipleRoutes)
409
                    if (tablesAddedSoFar.Add(fkTableId))
6✔
410
                        //we are joining to a table where the PrimaryExtractionTable is the PK in the relationship so join into the foreign key side
411
                        toReturn += JoinHelper.GetJoinSQLForeignKeySideOnly(join) +
6✔
412
                                    Environment.NewLine;
6✔
413
                }
414
                else if (join.ForeignKey.TableInfo_ID == qb.PrimaryExtractionTable.ID)
4!
415
                {
UNCOV
416
                    var pkTableId = join.PrimaryKey.TableInfo_ID;
×
417

418
                    //don't double JOIN to the same table twice even using different routes (see Test_FourTables_MultipleRoutes)
419
                    if (tablesAddedSoFar.Add(pkTableId))
×
420
                    {
421
                        //we are joining to a table where the PrimaryExtractionTable is the FK in the relationship so join into the primary key side
422
                        toReturn += JoinHelper.GetJoinSQLPrimaryKeySideOnly(join) +
×
UNCOV
423
                                    Environment.NewLine;
×
424
                    }
425
                }
426

427
            //now add any joins which don't involve the primary table
428
            foreach (var join in qb.JoinsUsedInQuery)
28✔
429
                if (join.ForeignKey.TableInfo_ID != qb.PrimaryExtractionTable.ID &&
10✔
430
                    join.PrimaryKey.TableInfo_ID != qb.PrimaryExtractionTable.ID)
10✔
431
                {
432
                    var pkTableID = join.PrimaryKey.TableInfo_ID;
4✔
433
                    var fkTableID = join.ForeignKey.TableInfo_ID;
4✔
434

435

436
                    //if we have already seen foreign key table before
437
                    //if we already have
438
                    if (tablesAddedSoFar.Contains(fkTableID) && tablesAddedSoFar.Contains(pkTableID))
4✔
439
                    {
440
                        unneededJoins.Add(join);
2✔
441
                    }
442
                    else if (tablesAddedSoFar.Contains(fkTableID))
2!
443
                    {
UNCOV
444
                        toReturn += JoinHelper.GetJoinSQLPrimaryKeySideOnly(join) +
×
UNCOV
445
                                    Environment.NewLine; //add primary
×
UNCOV
446
                        tablesAddedSoFar.Add(pkTableID);
×
447
                    }
448
                    else
449
                        //else if we have already seen primary key table before
450
                    if (tablesAddedSoFar.Contains(pkTableID))
2!
451
                    {
452
                        toReturn += JoinHelper.GetJoinSQLForeignKeySideOnly(join) +
2✔
453
                                    Environment.NewLine; //add foreign instead
2✔
454
                        tablesAddedSoFar.Add(fkTableID);
2✔
455
                    }
456

457
                    else
458
                    {
UNCOV
459
                        throw new NotImplementedException(
×
UNCOV
460
                            "We are having to add a Join for a table that is not 1 level down from the PrimaryExtractionTable");
×
461
                    }
462
                }
463
        }
464
        else
465
        {
466
            //user has not specified which table to start from so just output them all in a random order (hopefully FindRequiredJoins bombed out if they tried to do anything too mental)
467
            toReturn += JoinHelper.GetJoinSQL(qb.JoinsUsedInQuery[0]) +
68✔
468
                        Environment.NewLine; //"FROM ForeignKeyTable JOIN PrimaryKeyTable ON ..."
68✔
469

470
            //any subsequent joins
471
            for (var i = 1; i < qb.JoinsUsedInQuery.Count; i++)
136!
UNCOV
472
                toReturn += JoinHelper.GetJoinSQLForeignKeySideOnly(qb.JoinsUsedInQuery[i]) +
×
UNCOV
473
                            Environment.NewLine; //right side only (ForeignKeyTable)
×
474
        }
475

476
        //any subsequent lookup joins
477
        foreach (var column in qb.SelectColumns)
13,488✔
478
            if (
5,658✔
479
                (column.IsLookupForeignKey && column.IsLookupForeignKeyActuallyUsed(qb.SelectColumns))
5,658✔
480
                ||
5,658✔
481
                column.IsIsolatedLookupDescription)
5,658✔
482
                toReturn += JoinHelper.GetJoinSQLPrimaryKeySideOnly(column.LookupTable, column.LookupTableAlias) +
30✔
483
                            Environment.NewLine;
30✔
484

485
        //remove any joins that didn't turn out to be needed when joining the tables
486
        foreach (var j in unneededJoins)
2,176✔
487
            qb.JoinsUsedInQuery.Remove(j);
2✔
488

489
        return toReturn;
1,086✔
490
    }
491

492
    private static bool TableIsLookupTable(ITableInfo tableInfo, ISqlQueryBuilder qb)
493
    {
494
        return
48✔
495
            //tables where there is any columns which
48✔
496
            qb.SelectColumns.Any(
48✔
497
                //are lookup descriptions and belong to this table
48✔
498
                c => c.IsLookupDescription && c.UnderlyingColumn.TableInfo_ID == tableInfo.ID);
926✔
499
    }
500

501

502
    /// <summary>
503
    /// Add a custom line of code into the query at the specified position.  This will be maintained throughout the lifespan of the object such that if
504
    /// you add other columns etc then your code will still be included at the appropriate position.
505
    /// </summary>
506
    /// <param name="builder"></param>
507
    /// <param name="text"></param>
508
    /// <param name="positionToInsert"></param>
509
    public static CustomLine AddCustomLine(ISqlQueryBuilder builder, string text, QueryComponent positionToInsert)
510
    {
511
        var toAdd = new CustomLine(text, positionToInsert);
420✔
512

513
        if (positionToInsert == QueryComponent.GroupBy || positionToInsert == QueryComponent.OrderBy ||
420!
514
            positionToInsert == QueryComponent.FROM || positionToInsert == QueryComponent.Having)
420✔
UNCOV
515
            throw new QueryBuildingException(
×
UNCOV
516
                $"Cannot inject custom lines into QueryBuilders at location {positionToInsert}");
×
517

518
        if (positionToInsert == QueryComponent.WHERE)
420✔
519
            if (text.Trim().StartsWith("AND ") || text.Trim().StartsWith("OR "))
124!
520
                throw new Exception(
×
UNCOV
521
                    $"Custom filters are always AND, you should not specify the operator AND/OR, you passed\"{text}\"");
×
522

523
        builder.CustomLines.Add(toAdd);
420✔
524
        return toAdd;
420✔
525
    }
526

527
    /// <summary>
528
    /// Generates the WHERE section of the query for the <see cref="ISqlQueryBuilder"/> based on recursively processing the <see cref="ISqlQueryBuilder.RootFilterContainer"/>
529
    /// </summary>
530
    /// <param name="qb"></param>
531
    /// <returns>WHERE block or empty string if there are no <see cref="IContainer"/></returns>
532
    public static string GetWHERESQL(ISqlQueryBuilder qb)
533
    {
534
        var toReturn = "";
1,086✔
535

536
        //if the root filter container is disabled don't render it
537
        if (!IsEnabled(qb.RootFilterContainer))
1,086!
UNCOV
538
            return "";
×
539

540
        var emptyFilters = qb.Filters.Where(f => string.IsNullOrWhiteSpace(f.WhereSQL)).ToArray();
1,472✔
541

542
        if (emptyFilters.Any())
1,086!
UNCOV
543
            throw new QueryBuildingException(
×
UNCOV
544
                $"The following empty filters were found in the query:{Environment.NewLine}{string.Join(Environment.NewLine, emptyFilters.Select(f => f.Name))}");
×
545

546
        //recursively iterate the filter containers joining them up with their operation (AND or OR) and doing tab indentation etc
547
        if (qb.Filters.Any())
1,086✔
548
        {
549
            var filtersSql = WriteContainerTreeRecursively(toReturn, 0, qb.RootFilterContainer, qb);
340✔
550

551
            if (!string.IsNullOrWhiteSpace(filtersSql))
340✔
552
            {
553
                toReturn += Environment.NewLine;
340✔
554
                toReturn += $"WHERE{Environment.NewLine}";
340✔
555
                toReturn += filtersSql;
340✔
556
            }
557
        }
558

559
        return toReturn;
1,086✔
560
    }
561

562
    private static string WriteContainerTreeRecursively(string toReturn, int tabDepth, IContainer currentContainer,
563
        ISqlQueryBuilder qb)
564
    {
565
        var tabs = "";
344✔
566
        //see how far we have to tab in
567
        for (var i = 0; i < tabDepth; i++)
696✔
568
            tabs += "\t";
4✔
569

570
        //get all the filters in the current container
571
        var filtersInContainer = currentContainer.GetFilters().Where(IsEnabled).ToArray();
344✔
572

573
        //see if we have subcontainers
574
        var subcontainers = currentContainer.GetSubContainers().Where(IsEnabled).ToArray();
344✔
575

576
        //if there are no filters or subcontainers return nothing
577
        if (!filtersInContainer.Any() && !subcontainers.Any())
344!
UNCOV
578
            return "";
×
579

580
        //output starting bracket
581
        toReturn += $"{tabs}({Environment.NewLine}";
344✔
582

583
        //write out subcontainers
584
        for (var i = 0; i < subcontainers.Length; i++)
696✔
585
        {
586
            toReturn = WriteContainerTreeRecursively(toReturn, tabDepth + 1, subcontainers[i], qb);
4✔
587

588
            //there are more subcontainers to come
589
            if (i + 1 < subcontainers.Length)
4✔
590
                toReturn += tabs + currentContainer.Operation + Environment.NewLine;
2✔
591
        }
592

593
        //if there are both filters and containers we need to join the trees with the operator (e.g. AND)
594
        if (subcontainers.Length >= 1 && filtersInContainer.Length >= 1)
344!
UNCOV
595
            toReturn += currentContainer.Operation + Environment.NewLine;
×
596

597
        //output each filter also make sure it is tabbed in correctly
598
        for (var i = 0; i < filtersInContainer.Length; i++)
1,456✔
599
        {
600
            if (qb.CheckSyntax)
384✔
601
                filtersInContainer[i].Check(ThrowImmediatelyCheckNotifier.Quiet);
36✔
602

603
            toReturn += $@"{tabs}/*{filtersInContainer[i].Name}*/{Environment.NewLine}";
384✔
604

605
            // the filter may span multiple lines, so collapse it to a single line cleaning up any whitespace issues, e.g. to avoid double spaces in the collapsed version
606
            var trimmedFilters = (filtersInContainer[i].WhereSQL ?? "")
384!
607
                .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
384✔
608
                .Select(s => s.Trim());
776✔
609
            var singleLineWhereSQL = string.Join(" ", trimmedFilters);
384✔
610
            toReturn += tabs + singleLineWhereSQL + Environment.NewLine;
384✔
611

612
            //if there are more filters to come
613
            if (i + 1 < filtersInContainer.Length)
384✔
614
                toReturn += tabs + currentContainer.Operation + Environment.NewLine;
42✔
615
        }
616

617
        toReturn += $"{tabs}){Environment.NewLine}";
344✔
618

619
        return toReturn;
344✔
620
    }
621

622
    /// <summary>
623
    /// Containers are enabled if they do not support disabling (<see cref="IDisableable"/>) or are <see cref="IDisableable.IsDisabled"/> = false
624
    /// </summary>
625
    /// <param name="container"></param>
626
    /// <returns></returns>
627
    private static bool IsEnabled(IContainer container) =>
628
        //skip disabled containers
629
        container is not IDisableable { IsDisabled: true };
1,090✔
630

631
    /// <summary>
632
    /// Filters are enabled if they do not support disabling (<see cref="IDisableable"/>) or are <see cref="IDisableable.IsDisabled"/> = false
633
    /// </summary>
634
    /// <param name="filter"></param>
635
    /// <returns></returns>
636
    private static bool IsEnabled(IFilter filter) =>
637
        //skip disabled filters
638
        filter is not IDisableable { IsDisabled: true };
386✔
639

640
    /// <summary>
641
    /// Returns the unique database server type <see cref="IQuerySyntaxHelper"/> by evaluating the <see cref="TableInfo"/> used in the query.
642
    /// <para>Throws <see cref="QueryBuildingException"/> if the tables are from mixed server types (e.g. MySql mixed with Oracle)</para>
643
    /// </summary>
644
    /// <param name="tablesUsedInQuery"></param>
645
    /// <returns></returns>
646
    public static IQuerySyntaxHelper GetSyntaxHelper(List<ITableInfo> tablesUsedInQuery)
647
    {
648
        if (!tablesUsedInQuery.Any())
1,088!
UNCOV
649
            throw new QueryBuildingException(
×
UNCOV
650
                "Could not pick an IQuerySyntaxHelper because the there were no TableInfos used in the query");
×
651

652

653
        var databaseTypes = tablesUsedInQuery.Select(t => t.DatabaseType).Distinct().ToArray();
2,282✔
654
        return databaseTypes.Length > 1
1,088!
655
            ? throw new QueryBuildingException(
1,088✔
UNCOV
656
                $"Cannot build query because there are multiple DatabaseTypes involved in the query:{string.Join(",", tablesUsedInQuery.Select(t => $"{t.GetRuntimeName()}({t.DatabaseType})"))}")
×
657
            : DatabaseCommandHelper.For(databaseTypes.Single()).GetQuerySyntaxHelper();
1,088✔
658
    }
659

660
    /// <summary>
661
    /// Applies <paramref name="topX"/> to the <see cref="ISqlQueryBuilder"/> as a <see cref="CustomLine"/> based on the database engine syntax e.g. LIMIT vs TOP
662
    /// and puts in in the correct location in the query (<see cref="QueryComponent"/>)
663
    /// </summary>
664
    /// <param name="queryBuilder"></param>
665
    /// <param name="syntaxHelper"></param>
666
    /// <param name="topX"></param>
667
    public static void HandleTopX(ISqlQueryBuilder queryBuilder, IQuerySyntaxHelper syntaxHelper, int topX)
668
    {
669
        //if we have a lingering custom line from last time
670
        ClearTopX(queryBuilder);
68✔
671

672
        //if we are expected to have a topx
673
        var response = syntaxHelper.HowDoWeAchieveTopX(topX);
68✔
674
        queryBuilder.TopXCustomLine = AddCustomLine(queryBuilder, response.SQL, response.Location);
68✔
675
        queryBuilder.TopXCustomLine.Role = CustomLineRole.TopX;
68✔
676
    }
68✔
677

678
    /// <summary>
679
    /// Removes the SELECT TOP X logic from the supplied <see cref="ISqlQueryBuilder"/>
680
    /// </summary>
681
    /// <param name="queryBuilder"></param>
682
    public static void ClearTopX(ISqlQueryBuilder queryBuilder)
683
    {
684
        //if we have a lingering custom line from last time
685
        if (queryBuilder.TopXCustomLine != null)
1,088✔
686
        {
687
            queryBuilder.CustomLines.Remove(queryBuilder.TopXCustomLine); //remove it
4✔
688
            queryBuilder.SQLOutOfDate = true;
4✔
689
        }
690
    }
1,088✔
691

692
    /// <summary>
693
    /// Returns all <see cref="CustomLine"/> declared in <see cref="ISqlQueryBuilder.CustomLines"/> for the given stage but also adds some new ones to ensure valid syntax (for example
694
    /// adding the word WHERE/AND depending on whether there is an existing <see cref="ISqlQueryBuilder.RootFilterContainer"/>.
695
    /// </summary>
696
    /// <param name="queryBuilder"></param>
697
    /// <param name="stage"></param>
698
    /// <returns></returns>
699
    public static IEnumerable<CustomLine> GetCustomLinesSQLForStage(ISqlQueryBuilder queryBuilder, QueryComponent stage)
700
    {
701
        var lines = queryBuilder.CustomLines.Where(c => c.LocationToInsert == stage).ToArray();
9,322✔
702

703
        if (!lines.Any()) //no lines
6,626✔
704
            yield break;
6,190✔
705

706

707
        //Custom Filters (for people who can't be bothered to implement IFilter or when IContainer doesnt support ramming in additional Filters at runtime because you feel like it ) - these all get AND together and a WHERE is put at the start if needed
708
        //if there are custom lines being rammed into the Filter section
709
        if (stage == QueryComponent.WHERE)
436✔
710
        {
711
            //if we haven't put a WHERE yet, put one in
712
            if (queryBuilder.Filters.Count == 0)
126✔
713
                yield return new CustomLine("WHERE", QueryComponent.WHERE);
116✔
714
            else
715
                yield return
10✔
716
                    new CustomLine("AND",
10✔
717
                        QueryComponent
10✔
718
                            .WHERE); //otherwise just AND it with every other filter we currently have configured
10✔
719

720
            //add user custom Filter lines
721
            for (var i = 0; i < lines.Length; i++)
520✔
722
            {
723
                yield return lines[i];
134✔
724

725
                if (i + 1 < lines.Length)
134✔
726
                    yield return new CustomLine("AND", QueryComponent.WHERE);
8✔
727
            }
728

729
            yield break;
126✔
730
        }
731

732
        //not a custom filter (which requires ANDing - see above) so this is the rest of the cases
733
        foreach (var line in lines)
1,240✔
734
            yield return line;
310✔
735
    }
310✔
736
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc