Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ name: Utils
on:
push:
branches:
- master
- release
- '**'
pull_request:
branches:
- '**'

jobs:
build:
Expand Down
3 changes: 3 additions & 0 deletions Utils.Data/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("UtilsTest")]
98 changes: 98 additions & 0 deletions Utils.Data/Sql/ClauseStartKeywordRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System.Collections.Generic;

namespace Utils.Data.Sql;

/// <summary>
/// Provides keyword metadata for clause boundaries so <see cref="SqlParser"/> can detect clause transitions
/// without hardcoded keyword checks.
/// </summary>
internal static class ClauseStartKeywordRegistry
{
/// <summary>
/// Gets the default mapping between clause identifiers and the keyword sequences that start the clause.
/// </summary>
public static IReadOnlyDictionary<ClauseStart, IReadOnlyList<IReadOnlyList<string>>> KnownClauseKeywords { get; } =
BuildKnownClauseKeywords();

/// <summary>
/// Attempts to retrieve the keyword sequences that mark the beginning of the specified clause.
/// </summary>
/// <param name="clauseStart">The clause identifier.</param>
/// <param name="keywordSequences">The keyword sequences associated with the clause.</param>
/// <returns><c>true</c> when the clause metadata is available; otherwise, <c>false</c>.</returns>
public static bool TryGetClauseKeywords(
ClauseStart clauseStart,
out IReadOnlyList<IReadOnlyList<string>> keywordSequences)
{
return KnownClauseKeywords.TryGetValue(clauseStart, out keywordSequences!);
}

private static IReadOnlyDictionary<ClauseStart, IReadOnlyList<IReadOnlyList<string>>> BuildKnownClauseKeywords()
{
var definitions = new List<ClauseKeywordDefinition>
{
SelectPartReader.KeywordDefinition,
FromPartReader.KeywordDefinition,
IntoPartReader.KeywordDefinition,
WherePartReader.KeywordDefinition,
GroupByPartReader.KeywordDefinition,
HavingPartReader.KeywordDefinition,
OrderByPartReader.KeywordDefinition,
LimitPartReader.KeywordDefinition,
OffsetPartReader.KeywordDefinition,
ValuesPartReader.KeywordDefinition,
OutputPartReader.KeywordDefinition,
ReturningPartReader.KeywordDefinition,
SetOperatorPartReader.KeywordDefinition,
ClauseKeywordDefinition.FromKeywords(ClauseStart.Using, new[] { "USING" }),
};

var map = new Dictionary<ClauseStart, IReadOnlyList<IReadOnlyList<string>>>();
foreach (var definition in definitions)
{
map[definition.ClauseKeyword] = definition.KeywordSequences;
}

return map;
}
}

/// <summary>
/// Represents the keyword sequences that start a specific SQL clause.
/// </summary>
internal sealed class ClauseKeywordDefinition
{
/// <summary>
/// Initializes a new instance of the <see cref="ClauseKeywordDefinition"/> class.
/// </summary>
/// <param name="clauseKeyword">The clause identifier.</param>
/// <param name="keywordSequences">The keyword sequences that open the clause.</param>
public ClauseKeywordDefinition(
ClauseStart clauseKeyword,
IReadOnlyList<IReadOnlyList<string>> keywordSequences)
{
ClauseKeyword = clauseKeyword;
KeywordSequences = keywordSequences;
}

/// <summary>
/// Gets the clause identifier associated with the keywords.
/// </summary>
public ClauseStart ClauseKeyword { get; }

/// <summary>
/// Gets the keyword sequences that mark the start of the clause.
/// </summary>
public IReadOnlyList<IReadOnlyList<string>> KeywordSequences { get; }

/// <summary>
/// Creates a clause keyword definition from the specified keyword sequences.
/// </summary>
/// <param name="clauseKeyword">The clause identifier.</param>
/// <param name="keywordSequences">The keyword sequences that start the clause.</param>
/// <returns>The created <see cref="ClauseKeywordDefinition"/>.</returns>
public static ClauseKeywordDefinition FromKeywords(ClauseStart clauseKeyword, params string[][] keywordSequences)
{
return new ClauseKeywordDefinition(clauseKeyword, keywordSequences);
}
}
80 changes: 80 additions & 0 deletions Utils.Data/Sql/DeleteStatementParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;

namespace Utils.Data.Sql;

#nullable enable

/// <summary>
/// Parses DELETE statements relying on a shared <see cref="SqlParser"/> instance.
/// </summary>
internal sealed class DeleteStatementParser
{
private readonly SqlParser parser;

/// <summary>
/// Initializes a new instance of the <see cref="DeleteStatementParser"/> class.
/// </summary>
/// <param name="parser">The underlying parser providing token access.</param>
public DeleteStatementParser(SqlParser parser)
{
this.parser = parser ?? throw new ArgumentNullException(nameof(parser));
}

/// <summary>
/// Parses a DELETE statement.
/// </summary>
/// <param name="withClause">The WITH clause bound to the statement, if present.</param>
/// <returns>The parsed <see cref="SqlDeleteStatement"/>.</returns>
public SqlDeleteStatement Parse(WithClause? withClause)
{
parser.ExpectKeyword("DELETE");
var deleteTargetReader = new DeletePartReader(parser);
var fromReader = new FromPartReader(parser);
var whereReader = new WherePartReader(parser);
var outputReader = new OutputPartReader(parser);
var returningReader = new ReturningPartReader(parser);

var targetSegment = deleteTargetReader.TryReadDeleteTarget();

var fromSegment = fromReader.TryReadFromPart(
outputReader.ClauseKeyword,
ClauseStart.Using,
whereReader.ClauseKeyword,
returningReader.ClauseKeyword,
ClauseStart.StatementEnd);
if (fromSegment == null)
{
throw new SqlParseException("Expected FROM clause in DELETE statement.");
}

SqlSegment? usingSegment = null;
SqlSegment? whereSegment = null;
SqlSegment? outputSegment = null;
SqlSegment? returningSegment = null;

if (parser.TryConsumeKeyword("OUTPUT"))
{
outputSegment = outputReader.ReadOutputPart(
"Output",
ClauseStart.Using,
whereReader.ClauseKeyword,
returningReader.ClauseKeyword,
ClauseStart.StatementEnd);
}

if (parser.TryConsumeKeyword("USING"))
{
var usingTokens = parser.ReadSectionTokens(ClauseStart.Where, ClauseStart.Returning, ClauseStart.StatementEnd);
usingSegment = parser.BuildSegment("Using", usingTokens);
}

whereSegment = whereReader.TryReadWherePart(returningReader.ClauseKeyword, ClauseStart.StatementEnd);

if (parser.TryConsumeKeyword("RETURNING"))
{
returningSegment = returningReader.ReadReturningPart(ClauseStart.StatementEnd);
}

return new SqlDeleteStatement(targetSegment, fromSegment, usingSegment, whereSegment, outputSegment, returningSegment, withClause);
}
}
57 changes: 57 additions & 0 deletions Utils.Data/Sql/ExpressionListReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;

namespace Utils.Data.Sql;

#nullable enable

/// <summary>
/// Reads comma-separated SQL expressions using an underlying <see cref="ExpressionReader"/>.
/// </summary>
internal sealed class ExpressionListReader
{
private readonly SqlParser parser;
private readonly ExpressionReader expressionReader;

/// <summary>
/// Initializes a new instance of the <see cref="ExpressionListReader"/> class.
/// </summary>
/// <param name="parser">The parser supplying token access.</param>
public ExpressionListReader(SqlParser parser)
{
this.parser = parser ?? throw new ArgumentNullException(nameof(parser));
expressionReader = new ExpressionReader(this.parser);
}

/// <summary>
/// Reads a sequence of expressions separated by commas until a clause boundary is reached.
/// </summary>
/// <param name="segmentNamePrefix">Prefix used to name expression segments.</param>
/// <param name="allowAliases">Indicates whether expressions may declare aliases.</param>
/// <param name="clauseTerminators">Clause boundaries that stop the list.</param>
/// <returns>The parsed expressions with their aliases.</returns>
public IReadOnlyList<ExpressionReadResult> ReadExpressions(string segmentNamePrefix, bool allowAliases, params ClauseStart[] clauseTerminators)
{
if (string.IsNullOrWhiteSpace(segmentNamePrefix))
{
throw new ArgumentException("Segment name prefix cannot be null or whitespace.", nameof(segmentNamePrefix));
}

var results = new List<ExpressionReadResult>();
int index = 1;
while (true)
{
results.Add(expressionReader.ReadExpression($"{segmentNamePrefix}{index}", allowAliases, clauseTerminators));
index++;

if (parser.IsAtEnd || parser.Peek().Text != ",")
{
break;
}

parser.Read();
}

return results;
}
}
Loading
Loading