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

HicServices / RDMP / 6245535001

20 Sep 2023 07:44AM UTC coverage: 57.013%. First build
6245535001

push

github

web-flow
8.1.0 Release (#1628)

* Bump Newtonsoft.Json from 13.0.1 to 13.0.2

Bumps [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json) from 13.0.1 to 13.0.2.
- [Release notes](https://github.com/JamesNK/Newtonsoft.Json/releases)
- [Commits](https://github.com/JamesNK/Newtonsoft.Json/compare/13.0.1...13.0.2)

---
updated-dependencies:
- dependency-name: Newtonsoft.Json
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump NLog from 5.0.5 to 5.1.0

Bumps [NLog](https://github.com/NLog/NLog) from 5.0.5 to 5.1.0.
- [Release notes](https://github.com/NLog/NLog/releases)
- [Changelog](https://github.com/NLog/NLog/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/NLog/NLog/compare/v5.0.5...v5.1.0)

---
updated-dependencies:
- dependency-name: NLog
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump NLog from 5.0.5 to 5.1.0

* Fix -r flag - should have been --results-directory all along

* Bump Newtonsoft.Json from 13.0.1 to 13.0.2

* Bump YamlDotNet from 12.0.2 to 12.1.0

Bumps [YamlDotNet](https://github.com/aaubry/YamlDotNet) from 12.0.2 to 12.1.0.
- [Release notes](https://github.com/aaubry/YamlDotNet/releases)
- [Commits](https://github.com/aaubry/YamlDotNet/compare/v12.0.2...v12.1.0)

---
updated-dependencies:
- dependency-name: YamlDotNet
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump Moq from 4.18.2 to 4.18.3

Bumps [Moq](https://github.com/moq/moq4) from 4.18.2 to 4.18.3.
- [Release notes](https://github.com/moq/moq4/releases)
- [Changelog](https://github.com/moq/moq4/blob/main/CHANGELOG.md)
- [Commits](https://github.com/moq/moq4/compare/v4.18.2...v4.18.3)

---
updated-dependencies:
- dependency-name: Moq
... (continued)

10732 of 20257 branches covered (0.0%)

Branch coverage included in aggregate %.

48141 of 48141 new or added lines in 1086 files covered. (100.0%)

30685 of 52388 relevant lines covered (58.57%)

7387.88 hits per line

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

75.56
/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
            for (var i = 0; i < qb.JoinsUsedInQuery.Count; i++)
28✔
404
                if (qb.JoinsUsedInQuery[i].PrimaryKey.TableInfo_ID == qb.PrimaryExtractionTable.ID)
10✔
405
                {
406
                    var fkTableId = qb.JoinsUsedInQuery[i].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.Contains(fkTableId))
6✔
410
                    {
411
                        //we are joining to a table where the PrimaryExtractionTable is the PK in the relationship so join into the foreign key side
412
                        toReturn += JoinHelper.GetJoinSQLForeignKeySideOnly(qb.JoinsUsedInQuery[i]) +
6✔
413
                                    Environment.NewLine;
6✔
414
                        tablesAddedSoFar.Add(fkTableId);
6✔
415
                    }
416
                }
417
                else if (qb.JoinsUsedInQuery[i].ForeignKey.TableInfo_ID == qb.PrimaryExtractionTable.ID)
4!
418
                {
419
                    var pkTableId = qb.JoinsUsedInQuery[i].PrimaryKey.TableInfo_ID;
×
420

421
                    //don't double JOIN to the same table twice even using different routes (see Test_FourTables_MultipleRoutes)
422
                    if (!tablesAddedSoFar.Contains(pkTableId))
×
423
                    {
424
                        //we are joining to a table where the PrimaryExtractionTable is the FK in the relationship so join into the primary key side
425
                        toReturn += JoinHelper.GetJoinSQLPrimaryKeySideOnly(qb.JoinsUsedInQuery[i]) +
×
426
                                    Environment.NewLine;
×
427
                        tablesAddedSoFar.Add(pkTableId);
×
428
                    }
429
                }
430

431
            //now add any joins which don't involve the primary table
432
            for (var i = 0; i < qb.JoinsUsedInQuery.Count; i++)
28✔
433
                if (qb.JoinsUsedInQuery[i].ForeignKey.TableInfo_ID != qb.PrimaryExtractionTable.ID &&
10✔
434
                    qb.JoinsUsedInQuery[i].PrimaryKey.TableInfo_ID != qb.PrimaryExtractionTable.ID)
10✔
435
                {
436
                    var pkTableID = qb.JoinsUsedInQuery[i].PrimaryKey.TableInfo_ID;
4✔
437
                    var fkTableID = qb.JoinsUsedInQuery[i].ForeignKey.TableInfo_ID;
4✔
438

439

440
                    //if we have already seen foreign key table before
441
                    //if we already have
442
                    if (tablesAddedSoFar.Contains(fkTableID) && tablesAddedSoFar.Contains(pkTableID))
4✔
443
                    {
444
                        unneededJoins.Add(qb.JoinsUsedInQuery[i]);
2✔
445
                    }
446
                    else if (tablesAddedSoFar.Contains(fkTableID))
2!
447
                    {
448
                        toReturn += JoinHelper.GetJoinSQLPrimaryKeySideOnly(qb.JoinsUsedInQuery[i]) +
×
449
                                    Environment.NewLine; //add primary
×
450
                        tablesAddedSoFar.Add(pkTableID);
×
451
                    }
452
                    else
453
                    //else if we have already seen primary key table before
454
                    if (tablesAddedSoFar.Contains(pkTableID))
2!
455
                    {
456
                        toReturn += JoinHelper.GetJoinSQLForeignKeySideOnly(qb.JoinsUsedInQuery[i]) +
2✔
457
                                    Environment.NewLine; //add foreign instead
2✔
458
                        tablesAddedSoFar.Add(fkTableID);
2✔
459
                    }
460

461
                    else
462
                    {
463
                        throw new NotImplementedException(
×
464
                            "We are having to add a Join for a table that is not 1 level down from the PrimaryExtractionTable");
×
465
                    }
466
                }
467
        }
468
        else
469
        {
470
            //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)
471
            toReturn += JoinHelper.GetJoinSQL(qb.JoinsUsedInQuery[0]) +
68✔
472
                        Environment.NewLine; //"FROM ForeignKeyTable JOIN PrimaryKeyTable ON ..."
68✔
473

474
            //any subsequent joins
475
            for (var i = 1; i < qb.JoinsUsedInQuery.Count; i++)
136!
476
                toReturn += JoinHelper.GetJoinSQLForeignKeySideOnly(qb.JoinsUsedInQuery[i]) +
×
477
                            Environment.NewLine; //right side only (ForeignKeyTable)
×
478
        }
479

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

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

493
        return toReturn;
1,086✔
494
    }
495

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

505

506
    /// <summary>
507
    /// 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
508
    /// you add other columns etc then your code will still be included at the appropriate position.
509
    /// </summary>
510
    /// <param name="builder"></param>
511
    /// <param name="text"></param>
512
    /// <param name="positionToInsert"></param>
513
    public static CustomLine AddCustomLine(ISqlQueryBuilder builder, string text, QueryComponent positionToInsert)
514
    {
515
        var toAdd = new CustomLine(text, positionToInsert);
420✔
516

517
        if (positionToInsert == QueryComponent.GroupBy || positionToInsert == QueryComponent.OrderBy ||
420!
518
            positionToInsert == QueryComponent.FROM || positionToInsert == QueryComponent.Having)
420✔
519
            throw new QueryBuildingException(
×
520
                $"Cannot inject custom lines into QueryBuilders at location {positionToInsert}");
×
521

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

527
        builder.CustomLines.Add(toAdd);
420✔
528
        return toAdd;
420✔
529
    }
530

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

540
        //if the root filter container is disabled don't render it
541
        if (!IsEnabled(qb.RootFilterContainer))
1,086!
542
            return "";
×
543

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

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

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

555
            if (!string.IsNullOrWhiteSpace(filtersSql))
340✔
556
            {
557
                toReturn += Environment.NewLine;
340✔
558
                toReturn += $"WHERE{Environment.NewLine}";
340✔
559
                toReturn += filtersSql;
340✔
560
            }
561
        }
562

563
        return toReturn;
1,086✔
564
    }
565

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

574
        //get all the filters in the current container
575
        var filtersInContainer = currentContainer.GetFilters().Where(IsEnabled).ToArray();
344✔
576

577
        //see if we have subcontainers
578
        var subcontainers = currentContainer.GetSubContainers().Where(IsEnabled).ToArray();
344✔
579

580
        //if there are no filters or subcontainers return nothing
581
        if (!filtersInContainer.Any() && !subcontainers.Any())
344!
582
            return "";
×
583

584
        //output starting bracket
585
        toReturn += $"{tabs}({Environment.NewLine}";
344✔
586

587
        //write out subcontainers
588
        for (var i = 0; i < subcontainers.Length; i++)
696✔
589
        {
590
            toReturn = WriteContainerTreeRecursively(toReturn, tabDepth + 1, subcontainers[i], qb);
4✔
591

592
            //there are more subcontainers to come
593
            if (i + 1 < subcontainers.Length)
4✔
594
                toReturn += tabs + currentContainer.Operation + Environment.NewLine;
2✔
595
        }
596

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

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

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

609
            // 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
610
            var trimmedFilters = (filtersInContainer[i].WhereSQL ?? "")
384!
611
                .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
384✔
612
                .Select(s => s.Trim());
776✔
613
            var singleLineWhereSQL = string.Join(" ", trimmedFilters);
384✔
614
            toReturn += tabs + singleLineWhereSQL + Environment.NewLine;
384✔
615

616
            //if there are more filters to come
617
            if (i + 1 < filtersInContainer.Length)
384✔
618
                toReturn += tabs + currentContainer.Operation + Environment.NewLine;
42✔
619
        }
620

621
        toReturn += $"{tabs}){Environment.NewLine}";
344✔
622

623
        return toReturn;
344✔
624
    }
625

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

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

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

656

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

664
    /// <summary>
665
    /// 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
666
    /// and puts in in the correct location in the query (<see cref="QueryComponent"/>)
667
    /// </summary>
668
    /// <param name="queryBuilder"></param>
669
    /// <param name="syntaxHelper"></param>
670
    /// <param name="topX"></param>
671
    public static void HandleTopX(ISqlQueryBuilder queryBuilder, IQuerySyntaxHelper syntaxHelper, int topX)
672
    {
673
        //if we have a lingering custom line from last time
674
        ClearTopX(queryBuilder);
68✔
675

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

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

696
    /// <summary>
697
    /// 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
698
    /// adding the word WHERE/AND depending on whether there is an existing <see cref="ISqlQueryBuilder.RootFilterContainer"/>.
699
    /// </summary>
700
    /// <param name="queryBuilder"></param>
701
    /// <param name="stage"></param>
702
    /// <returns></returns>
703
    public static IEnumerable<CustomLine> GetCustomLinesSQLForStage(ISqlQueryBuilder queryBuilder, QueryComponent stage)
704
    {
705
        var lines = queryBuilder.CustomLines.Where(c => c.LocationToInsert == stage).ToArray();
9,322✔
706

707
        if (!lines.Any()) //no lines
6,626✔
708
            yield break;
6,190✔
709

710

711
        //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
712
        //if there are custom lines being rammed into the Filter section
713
        if (stage == QueryComponent.WHERE)
436✔
714
        {
715
            //if we haven't put a WHERE yet, put one in
716
            if (queryBuilder.Filters.Count == 0)
126✔
717
                yield return new CustomLine("WHERE", QueryComponent.WHERE);
116✔
718
            else
719
                yield return
10✔
720
                    new CustomLine("AND",
10✔
721
                        QueryComponent
10✔
722
                            .WHERE); //otherwise just AND it with every other filter we currently have configured
10✔
723

724
            //add user custom Filter lines
725
            for (var i = 0; i < lines.Length; i++)
520✔
726
            {
727
                yield return lines[i];
134✔
728

729
                if (i + 1 < lines.Length)
134✔
730
                    yield return new CustomLine("AND", QueryComponent.WHERE);
8✔
731
            }
732

733
            yield break;
126✔
734
        }
735

736
        //not a custom filter (which requires ANDing - see above) so this is the rest of the cases
737
        foreach (var line in lines)
1,240✔
738
            yield return line;
310✔
739
    }
310✔
740
}
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