chore: initial git load of code space
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
using System.Collections;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions;
|
||||
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
|
||||
using CommandVisitor = Strata.SqlTools.Visitors.PostgreSql.CommandVisitor;
|
||||
using SqlClause = Strata.SqlTools.SqlBreakdown.Classes.SqlClause;
|
||||
using SqlExpressionClause = Strata.SqlTools.SqlBreakdown.Classes.SqlExpressionClause;
|
||||
using SqlServerCommandVisitor = Strata.SqlTools.Visitors.SqlServer.CommandVisitor;
|
||||
using SqlServerQueryBreakdown = Strata.SqlTools.Breakdowns.SqlServer.QueryBreakdown;
|
||||
using StatementParser = Strata.SqlTools.Statements.PostgreSql.StatementParser;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a PostgreSQL query breakdown with all clauses, following PostgreSQL SQL standards.
|
||||
/// Handles positional parameters using $1, $2, ... syntax for parameterized queries.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class QueryBreakdown : SqlServerQueryBreakdown
|
||||
{
|
||||
private const string ExpressionNullErrorMessage = "Expression cannot be null.";
|
||||
private static readonly StatementParser PostgreSqlParserInstance = new StatementParser();
|
||||
private int _parameterIndex = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class.
|
||||
/// </summary>
|
||||
public QueryBreakdown() : base()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class with SELECT and FROM clauses.
|
||||
/// </summary>
|
||||
/// <param name="selectClause">The SELECT clause.</param>
|
||||
/// <param name="fromClause">The FROM clause.</param>
|
||||
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses PostgreSQL parsing rules. Defaults to false.</param>
|
||||
public QueryBreakdown(string selectClause, string fromClause, bool isMicrosoftSql = false) : base()
|
||||
{
|
||||
var parser = isMicrosoftSql ? Parser : PostgreSqlParserInstance;
|
||||
|
||||
var cleanSelect = parser.ExtractSqlComments(selectClause, out var selectComments);
|
||||
SelectClause.Clause = cleanSelect.Trim();
|
||||
SelectClause.Comment = selectComments.Count > 0 ? string.Join(" ", selectComments) : null;
|
||||
|
||||
var cleanFrom = parser.ExtractSqlComments(fromClause, out var fromComments);
|
||||
FromClause.Clause = cleanFrom.Trim();
|
||||
FromClause.Comment = fromComments.Count > 0 ? string.Join(" ", fromComments) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class with SELECT, FROM, and WHERE clauses.
|
||||
/// </summary>
|
||||
/// <param name="selectClause">The SELECT clause.</param>
|
||||
/// <param name="fromClause">The FROM clause.</param>
|
||||
/// <param name="whereClause">The WHERE clause.</param>
|
||||
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses PostgreSQL parsing rules. Defaults to false.</param>
|
||||
public QueryBreakdown(string selectClause, string fromClause, string whereClause, bool isMicrosoftSql = false)
|
||||
: this(selectClause, fromClause, isMicrosoftSql)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(whereClause))
|
||||
{
|
||||
var parser = isMicrosoftSql ? Parser : PostgreSqlParserInstance;
|
||||
|
||||
var cleanWhere = parser.ExtractSqlComments(whereClause, out var whereComments);
|
||||
WhereClause.Clause = cleanWhere.Trim();
|
||||
WhereClause.Comment = whereComments.Count > 0 ? string.Join(" ", whereComments) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdown"/> class with SELECT, FROM, WHERE, and ORDER BY clauses.
|
||||
/// </summary>
|
||||
/// <param name="selectClause">The SELECT clause.</param>
|
||||
/// <param name="fromClause">The FROM clause.</param>
|
||||
/// <param name="whereClause">The WHERE clause.</param>
|
||||
/// <param name="orderByClause">The ORDER BY clause.</param>
|
||||
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses PostgreSQL parsing rules. Defaults to false.</param>
|
||||
public QueryBreakdown(string selectClause, string fromClause, string whereClause, string orderByClause, bool isMicrosoftSql = false)
|
||||
: this(selectClause, fromClause, whereClause, isMicrosoftSql)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(orderByClause))
|
||||
{
|
||||
var parser = isMicrosoftSql ? Parser : PostgreSqlParserInstance;
|
||||
|
||||
var cleanOrderBy = parser.ExtractSqlComments(orderByClause, out var orderByComments);
|
||||
OrderByClause.Clause = cleanOrderBy.Trim();
|
||||
OrderByClause.Comment = orderByComments.Count > 0 ? string.Join(" ", orderByComments) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a parameter to the query using PostgreSQL's positional parameter format ($1, $2, ...).
|
||||
/// </summary>
|
||||
/// <param name="parameterName">The parameter name (can be any name; PostgreSQL uses positions).</param>
|
||||
/// <param name="value">The parameter value.</param>
|
||||
public new void AddParameter(string parameterName, object value)
|
||||
{
|
||||
// For PostgreSQL, we track the parameter position and store by name
|
||||
var cleanName = parameterName.TrimStart('@', ':');
|
||||
|
||||
// Use base class internal list
|
||||
base.AddParameter(cleanName, value);
|
||||
|
||||
// Store with PostgreSQL position syntax for reference
|
||||
Parameters[$"${_parameterIndex}"] = value;
|
||||
Parameters[cleanName] = value;
|
||||
Parameters[$"@{cleanName}"] = value;
|
||||
|
||||
_parameterIndex++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the value of a parameter using PostgreSQL's positional format.
|
||||
/// </summary>
|
||||
/// <param name="parameterName">The parameter name (can be any name; PostgreSQL uses positions).</param>
|
||||
/// <param name="value">The parameter value.</param>
|
||||
public new void SetParameterValue(string parameterName, object value)
|
||||
{
|
||||
var cleanName = parameterName.TrimStart('@', ':');
|
||||
Parameters[cleanName] = value;
|
||||
Parameters[$"@{cleanName}"] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an expression to the SELECT clause.
|
||||
/// </summary>
|
||||
/// <param name="expression">The expression to add.</param>
|
||||
/// <param name="comment">Optional comment to add with the expression.</param>
|
||||
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL formatting. If false, uses PostgreSQL formatting. Defaults to false.</param>
|
||||
public void AddSelectExpression(Expression expression, string? comment = null, bool isMicrosoftSql = false)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(expression), ExpressionNullErrorMessage);
|
||||
}
|
||||
|
||||
var visitor = isMicrosoftSql
|
||||
? (IVisitor<string>)new SqlServerCommandVisitor()
|
||||
: new CommandVisitor();
|
||||
var sql = expression.Accept(visitor);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SelectClause.Clause))
|
||||
{
|
||||
SelectClause.Clause = sql;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectClause.Clause += ", " + sql;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comment))
|
||||
{
|
||||
SelectClause.Comment = string.IsNullOrEmpty(SelectClause.Comment)
|
||||
? comment
|
||||
: $"{SelectClause.Comment} {comment}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an expression to the WHERE clause.
|
||||
/// </summary>
|
||||
/// <param name="expression">The expression to add.</param>
|
||||
/// <param name="comment">Optional comment to add with the expression.</param>
|
||||
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL formatting. If false, uses PostgreSQL formatting. Defaults to false.</param>
|
||||
public void AddWhereExpression(Expression expression, string? comment = null, bool isMicrosoftSql = false)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(expression), ExpressionNullErrorMessage);
|
||||
}
|
||||
|
||||
var visitor = isMicrosoftSql
|
||||
? (IVisitor<string>)new SqlServerCommandVisitor()
|
||||
: new CommandVisitor();
|
||||
var sql = expression.Accept(visitor);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(WhereClause.Clause))
|
||||
{
|
||||
WhereClause.Clause = sql;
|
||||
}
|
||||
else
|
||||
{
|
||||
WhereClause.Clause += " AND " + sql;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comment))
|
||||
{
|
||||
WhereClause.Comment = string.IsNullOrEmpty(WhereClause.Comment)
|
||||
? comment
|
||||
: $"{WhereClause.Comment} {comment}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a PostgreSQL SELECT statement and populates the query breakdown.
|
||||
/// </summary>
|
||||
/// <param name="sql">The SQL statement to parse.</param>
|
||||
/// <returns>A new QueryBreakdown instance with parsed components.</returns>
|
||||
public static new QueryBreakdown Parse(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sql), "SQL statement cannot be null or empty.");
|
||||
}
|
||||
|
||||
if (!TryParse(sql, out var result, out var error))
|
||||
{
|
||||
throw new FormatException($"Failed to parse SQL statement: {error}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a PostgreSQL SELECT statement.
|
||||
/// </summary>
|
||||
/// <param name="sql">The SQL statement to parse.</param>
|
||||
/// <param name="result">The resulting QueryBreakdown if successful.</param>
|
||||
/// <param name="errorMessage">The error message if parsing fails.</param>
|
||||
/// <returns>True if parsing succeeded; false otherwise.</returns>
|
||||
public static bool TryParse(string sql, out QueryBreakdown result, out string errorMessage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
result = new QueryBreakdown();
|
||||
errorMessage = "SQL statement cannot be null or empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parser = PostgreSqlParserInstance;
|
||||
var setupClauses = new List<string>();
|
||||
sql = parser.ExtractSetupClauses(sql, setupClauses);
|
||||
|
||||
var finishClauses = new ArrayList();
|
||||
sql = parser.ExtractFinishClauses(sql, finishClauses);
|
||||
|
||||
if (!parser.TryParseSelectStatement(sql, out var clauses, out errorMessage))
|
||||
{
|
||||
result = new QueryBreakdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new QueryBreakdown
|
||||
{
|
||||
SelectClause = clauses?.SelectClause ?? new SqlExpressionClause(splitOnComma: true),
|
||||
FromClause = clauses?.FromClause ?? new SqlClause(),
|
||||
WhereClause = clauses?.WhereClause ?? new SqlExpressionClause(splitOnComma: false),
|
||||
GroupByClause = clauses?.GroupByClause ?? new SqlExpressionClause(splitOnComma: true),
|
||||
HavingClause = clauses?.HavingClause ?? new SqlExpressionClause(splitOnComma: false),
|
||||
OrderByClause = clauses?.OrderByClause ?? new SqlExpressionClause(splitOnComma: true),
|
||||
SetupClauses = setupClauses,
|
||||
FinishClauses = finishClauses,
|
||||
RawSql = sql
|
||||
};
|
||||
|
||||
// Extract parameters using PostgreSQL parser
|
||||
parser.ExtractParameters(result.Parameters, sql);
|
||||
|
||||
errorMessage = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new QueryBreakdown();
|
||||
errorMessage = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a LINQ to SQL query of the specified type based on this breakdown.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The entity type for the query.</typeparam>
|
||||
/// <returns>null by default, as QueryBreakdown operates on SQL. Override in derived classes to provide LINQ query reconstruction.</returns>
|
||||
/// <remarks>
|
||||
/// This PostgreSQL-specific implementation returns null since PostgreSQL QueryBreakdown represents parsed SQL statements.
|
||||
/// Derived classes can override this method to reconstruct LINQ queries from the analyzed components.
|
||||
/// </remarks>
|
||||
public override IQueryable<T>? GetQuery<T>() where T : class
|
||||
{
|
||||
// PostgreSQL breakdown represents parsed SQL statements and does not have a built-in way to create LINQ queries
|
||||
// Override in derived classes to provide LINQ query reconstruction if needed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Text;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.SqlBreakdown.Interfaces;
|
||||
|
||||
namespace Strata.SqlTools.Breakdowns.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-specific collection for managing multiple QueryBreakdown objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class extends SqlBreakdownCollection with PostgreSQL-specific functionality,
|
||||
/// including support for PostgreSQL features like schema-qualified identifiers,
|
||||
/// LIMIT/OFFSET clauses, parameterized queries using $1, $2 syntax, and CTEs.
|
||||
/// </remarks>
|
||||
[Serializable]
|
||||
public class QueryBreakdownCollection : SqlBreakdownCollection
|
||||
{
|
||||
private readonly List<QueryBreakdown> _queryBreakdowns;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdownCollection"/> class for PostgreSQL.
|
||||
/// </summary>
|
||||
public QueryBreakdownCollection() : base()
|
||||
{
|
||||
_queryBreakdowns = new List<QueryBreakdown>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QueryBreakdownCollection"/> class with initial query breakdowns.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdowns">The initial collection of query breakdowns.</param>
|
||||
public QueryBreakdownCollection(IEnumerable<QueryBreakdown> queryBreakdowns)
|
||||
: base(queryBreakdowns?.Cast<ISqlBreakdown>() ?? Enumerable.Empty<ISqlBreakdown>())
|
||||
{
|
||||
_queryBreakdowns = queryBreakdowns?.ToList() ?? new List<QueryBreakdown>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of QueryBreakdown objects.
|
||||
/// </summary>
|
||||
public IReadOnlyList<QueryBreakdown> QueryBreakdowns => _queryBreakdowns.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a QueryBreakdown to the collection.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdown">The QueryBreakdown to add.</param>
|
||||
public void Add(QueryBreakdown queryBreakdown)
|
||||
{
|
||||
if (queryBreakdown != null)
|
||||
{
|
||||
_queryBreakdowns.Add(queryBreakdown);
|
||||
base.Add(queryBreakdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple QueryBreakdowns to the collection.
|
||||
/// </summary>
|
||||
/// <param name="queryBreakdowns">The QueryBreakdowns to add.</param>
|
||||
public void AddRange(IEnumerable<QueryBreakdown> queryBreakdowns)
|
||||
{
|
||||
foreach (var qb in queryBreakdowns ?? new List<QueryBreakdown>())
|
||||
{
|
||||
Add(qb);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a QueryBreakdown from the collection.
|
||||
/// </summary>
|
||||
/// <returns>True if removed; otherwise, false.</returns>
|
||||
public bool Remove(QueryBreakdown queryBreakdown)
|
||||
{
|
||||
base.Remove(queryBreakdown);
|
||||
return _queryBreakdowns.Remove(queryBreakdown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all query breakdowns from the collection.
|
||||
/// </summary>
|
||||
public new void Clear()
|
||||
{
|
||||
_queryBreakdowns.Clear();
|
||||
base.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL SQL batch representation with proper statement separation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Generates PostgreSQL SQL with proper semi-colon separation for multiple statements.
|
||||
/// </remarks>
|
||||
/// <returns>The complete SQL batch as a single string.</returns>
|
||||
public string GetPostgreSqlBatch()
|
||||
{
|
||||
if (_queryBreakdowns.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
var sql = query.GetSql();
|
||||
if (!string.IsNullOrEmpty(sql))
|
||||
{
|
||||
sb.AppendLine(sql);
|
||||
if (!sql.TrimEnd().EndsWith(';'))
|
||||
{
|
||||
sb.AppendLine(";");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a batch of PostgreSQL SQL statements into a collection.
|
||||
/// </summary>
|
||||
/// <param name="sqlBatch">The SQL batch to parse.</param>
|
||||
/// <returns>True if parsing succeeded; false otherwise.</returns>
|
||||
public bool ParseBatch(string sqlBatch)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sqlBatch))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Clear();
|
||||
var statements = sqlBatch.Split(';');
|
||||
|
||||
foreach (var statement in statements)
|
||||
{
|
||||
var trimmedStatement = statement.Trim();
|
||||
if (string.IsNullOrEmpty(trimmedStatement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (QueryBreakdown.TryParse(statement, out var queryBreakdown, out _))
|
||||
{
|
||||
Add(queryBreakdown);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary of all queries including their types and basic composition.
|
||||
/// </summary>
|
||||
/// <returns>Summary information for each query.</returns>
|
||||
public IEnumerable<SqlServer.QuerySummary> GetQuerySummaries()
|
||||
{
|
||||
return _queryBreakdowns.Select((q, index) => new SqlServer.QuerySummary
|
||||
{
|
||||
Index = index,
|
||||
HasSelectClause = !string.IsNullOrWhiteSpace(q.SelectClause?.Clause),
|
||||
HasFromClause = !string.IsNullOrWhiteSpace(q.FromClause?.Clause),
|
||||
HasWhereClause = !string.IsNullOrWhiteSpace(q.WhereClause?.Clause),
|
||||
HasGroupByClause = !string.IsNullOrWhiteSpace(q.GroupByClause?.Clause),
|
||||
HasHavingClause = !string.IsNullOrWhiteSpace(q.HavingClause?.Clause),
|
||||
HasOrderByClause = !string.IsNullOrWhiteSpace(q.OrderByClause?.Clause),
|
||||
HasJoins = false,
|
||||
HasCTE = q.WithClauses.Count > 0,
|
||||
ColumnCount = !string.IsNullOrWhiteSpace(q.SelectClause?.Clause) ? q.SelectClause.Clause.Split(',').Length : 0,
|
||||
ParameterCount = q.ParameterList.Count(),
|
||||
JoinCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of selected columns across all queries.
|
||||
/// </summary>
|
||||
/// <returns>Total column count.</returns>
|
||||
public int GetTotalSelectedColumns()
|
||||
{
|
||||
return _queryBreakdowns.Sum(q =>
|
||||
!string.IsNullOrWhiteSpace(q.SelectClause?.Clause)
|
||||
? q.SelectClause.Clause.Split(',').Length
|
||||
: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique table names referenced across all queries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This provides a quick overview of which tables are being queried.
|
||||
/// Note: This is a best-effort extraction and may not capture all table references,
|
||||
/// especially in complex subqueries or with aliasing.
|
||||
/// </remarks>
|
||||
/// <returns>List of unique table names.</returns>
|
||||
public IEnumerable<string> GetUniqueTableReferences()
|
||||
{
|
||||
var tables = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tableNames = _queryBreakdowns
|
||||
.Where(q => !string.IsNullOrWhiteSpace(q.FromClause?.Clause))
|
||||
.SelectMany(q => ExtractTableNames(q.FromClause!.Clause!));
|
||||
|
||||
foreach (var table in tableNames)
|
||||
{
|
||||
tables.Add(table);
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets parameter usage information across all queries.
|
||||
/// </summary>
|
||||
/// <returns>Parameter usage information.</returns>
|
||||
public IEnumerable<ParameterUsageReport> GetParameterUsageReport()
|
||||
{
|
||||
// Collect all unique parameter names from both ParameterList and Parameters dictionary
|
||||
var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
// Add from ParameterList (parsed parameters)
|
||||
foreach (var param in query.ParameterList)
|
||||
{
|
||||
allParamNames.Add(param.Name);
|
||||
}
|
||||
|
||||
// Add from Parameters dictionary (manually added parameters)
|
||||
foreach (var paramName in query.Parameters.Keys)
|
||||
{
|
||||
allParamNames.Add(paramName);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var paramName in allParamNames)
|
||||
{
|
||||
var queriesUsing = 0;
|
||||
object? lastValue = null;
|
||||
|
||||
foreach (var query in _queryBreakdowns)
|
||||
{
|
||||
// Check ParameterList first (parsed)
|
||||
var param = query.ParameterList.FirstOrDefault(p => p.Name.Equals(paramName, StringComparison.OrdinalIgnoreCase));
|
||||
if (param != null)
|
||||
{
|
||||
queriesUsing++;
|
||||
lastValue = param.Value;
|
||||
}
|
||||
// Also check Parameters dictionary (manually added)
|
||||
else if (query.Parameters.TryGetValue(paramName, out var dictValue))
|
||||
{
|
||||
queriesUsing++;
|
||||
lastValue = dictValue;
|
||||
}
|
||||
}
|
||||
|
||||
yield return new ParameterUsageReport
|
||||
{
|
||||
ParameterName = paramName,
|
||||
Value = lastValue,
|
||||
UsedInQueryCount = queriesUsing,
|
||||
TotalQueries = _queryBreakdowns.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to extract table names from a FROM clause.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> ExtractTableNames(string fromClause)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fromClause))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Simple extraction: split by comma and clean up aliases
|
||||
var parts = fromClause.Split(',');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
|
||||
// Remove alias (assuming format: table AS alias or table alias)
|
||||
var tokens = trimmed.Split(new[] { " AS ", " " }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length > 0)
|
||||
{
|
||||
var tableName = tokens[0].Trim();
|
||||
if (!string.IsNullOrWhiteSpace(tableName))
|
||||
{
|
||||
yield return tableName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents parameter usage information for a specific parameter across all queries in a collection.
|
||||
/// </summary>
|
||||
public class ParameterUsageReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the parameter name.
|
||||
/// </summary>
|
||||
public string ParameterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parameter value.
|
||||
/// </summary>
|
||||
public object? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of queries using this parameter.
|
||||
/// </summary>
|
||||
public int UsedInQueryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of queries in the collection.
|
||||
/// </summary>
|
||||
public int TotalQueries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the parameter is used in all queries.
|
||||
/// </summary>
|
||||
public bool IsUsedInAllQueries => UsedInQueryCount == TotalQueries;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string representation of the parameter usage report for PostgreSQL parameters.
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
var usagePercentage = TotalQueries > 0 ? (UsedInQueryCount / (decimal)TotalQueries * 100) : 0;
|
||||
var paramSyntax = int.TryParse(ParameterName, out _) ? $"${ParameterName}" : $":{ParameterName}";
|
||||
return $"{paramSyntax}: {UsedInQueryCount}/{TotalQueries} queries ({usagePercentage:F1}%) - Value: {Value?.ToString() ?? "NULL"}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Strata.SqlTools.PostgreSql.ExpressionFactory;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-specific factory class for creating boolean expressions and SQL filter conditions from Filter objects.
|
||||
/// Inherits from the SQL Server implementation and extends it with PostgreSQL-specific syntax support.
|
||||
/// </summary>
|
||||
public abstract class ExpressionFactory : SqlServer.ExpressionFactory.ExpressionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExpressionFactory"/> class with the default system time provider.
|
||||
/// </summary>
|
||||
protected ExpressionFactory() : base()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExpressionFactory"/> class with the specified time provider.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">The time provider implementation for date/time operations.</param>
|
||||
protected ExpressionFactory(TimeProvider timeProvider) : base(timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
// PostgreSQL-specific expression methods can be added here as needed
|
||||
// For example, support for PostgreSQL-specific date functions, parameter syntax ($1, $2, etc.), ILIKE operator, etc.
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
# Strata.SqlTools.PostgreSQL
|
||||
|
||||
A PostgreSQL dialect-specific implementation of the QueryBreakdown SQL parsing and generation framework. This project extends the core SQL Tools functionality with PostgreSQL-native syntax support, including positional parameters ($1, $2, etc.), double-quoted identifiers, LIMIT/OFFSET clauses, and RETURNING clauses.
|
||||
|
||||
## Overview
|
||||
|
||||
Strata.SqlTools.PostgreSQL extends the SQL Tools framework to provide PostgreSQL-specific functionality while maintaining compatibility with the core QueryBreakdown patterns used throughout the sql-utilities ecosystem. It's built on top of the SqlServer implementation and follows the same architectural patterns as the Snowflake dialect module.
|
||||
|
||||
## Features
|
||||
|
||||
- **Positional Parameters**: Native support for PostgreSQL positional parameters ($1, $2, ..., $N)
|
||||
- **Double-Quoted Identifiers**: Case-sensitive identifier handling using PostgreSQL's double-quote syntax
|
||||
- **LIMIT and OFFSET**: Full support for PostgreSQL's LIMIT/OFFSET pagination syntax
|
||||
- **RETURNING Clause**: DML statement result retrieval via RETURNING
|
||||
- **CTE Support**: Common Table Expressions (WITH clause) for recursive and non-recursive queries
|
||||
- **Parameter Normalization**: Automatic conversion of @name and :name parameter styles to positional format
|
||||
- **Batch Operations**: Multi-statement batch processing with semicolon separation
|
||||
|
||||
## Installation
|
||||
|
||||
Add the package to your project:
|
||||
|
||||
```bash
|
||||
dotnet add package Strata.SqlTools.PostgreSQL
|
||||
```
|
||||
|
||||
Or via NuGet Package Manager:
|
||||
|
||||
```
|
||||
Install-Package Strata.SqlTools.PostgreSQL
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Query Parsing
|
||||
|
||||
```csharp
|
||||
using Strata.SqlTools.Breakdowns.PostgreSql;
|
||||
|
||||
// Parse an existing PostgreSQL query
|
||||
var sql = "SELECT id, name FROM users WHERE status = $1 ORDER BY name DESC LIMIT 10";
|
||||
var queryBreakdown = QueryBreakdown.Parse(sql, isMicrosoftSql: false);
|
||||
|
||||
// Access individual clauses
|
||||
Console.WriteLine($"Select: {queryBreakdown.SelectClause.Clause}");
|
||||
Console.WriteLine($"From: {queryBreakdown.FromClause.Clause}");
|
||||
Console.WriteLine($"Where: {queryBreakdown.WhereClause.Clause}");
|
||||
Console.WriteLine($"Limit: {queryBreakdown.LimitClause.Clause}");
|
||||
```
|
||||
|
||||
### Building Queries Programmatically
|
||||
|
||||
```csharp
|
||||
var query = new QueryBreakdown("id, name, email", "users");
|
||||
query.WhereClause.Clause = "status = $1 AND created_at > $2";
|
||||
query.OrderByClause.Clause = "created_at DESC";
|
||||
query.LimitClause.Clause = "50";
|
||||
query.OffsetClause.Clause = "0";
|
||||
|
||||
// Add parameters by name (automatically converted to positional $1, $2, etc.)
|
||||
query.AddParameter("status", "active");
|
||||
query.AddParameter("startDate", new DateTime(2025, 1, 1));
|
||||
|
||||
// Generate PostgreSQL SQL
|
||||
var generatedSql = query.GetSql();
|
||||
Console.WriteLine(generatedSql);
|
||||
```
|
||||
|
||||
### Working with CTEs (Common Table Expressions)
|
||||
|
||||
```csharp
|
||||
// Create main query
|
||||
var mainQuery = new QueryBreakdown("*", "recent_users");
|
||||
|
||||
// Create CTE
|
||||
var cteQuery = new QueryBreakdown(
|
||||
"id, name, created_at",
|
||||
"users"
|
||||
);
|
||||
cteQuery.WhereClause.Clause = "created_at > NOW() - INTERVAL '30 days'";
|
||||
cteQuery.OrderByClause.Clause = "created_at DESC";
|
||||
|
||||
// Add CTE to main query
|
||||
mainQuery.AddWithClause("recent_users", cteQuery);
|
||||
|
||||
// Generate SQL
|
||||
var sql = mainQuery.GetSql();
|
||||
```
|
||||
|
||||
### Batch Statement Processing
|
||||
|
||||
```csharp
|
||||
var collection = new QueryBreakdownCollection();
|
||||
|
||||
// Add multiple queries to batch
|
||||
var query1 = new QueryBreakdown("id, name", "users");
|
||||
query1.WhereClause.Clause = "active = true";
|
||||
collection.Add(query1);
|
||||
|
||||
var query2 = new QueryBreakdown("id, amount", "orders");
|
||||
query2.OrderByClause.Clause = "created_at DESC";
|
||||
query2.LimitClause.Clause = "100";
|
||||
collection.Add(query2);
|
||||
|
||||
// Generate batch SQL with semicolon separation
|
||||
var batchSql = collection.GetPostgreSqlBatch();
|
||||
// Result: "SELECT \"id\", \"name\" FROM \"users\" WHERE active = true; SELECT \"id\", \"amount\" FROM \"orders\" ORDER BY created_at DESC LIMIT 100;"
|
||||
```
|
||||
|
||||
## Parameter Handling
|
||||
|
||||
PostgreSQL uses positional parameters ($1, $2, etc.) instead of named parameters. The PostgreSQL dialect automatically converts named parameters to positional format:
|
||||
|
||||
```csharp
|
||||
var query = new QueryBreakdown("id, name", "users");
|
||||
|
||||
// Add parameters by name
|
||||
query.AddParameter("userId", 123);
|
||||
query.AddParameter("status", "active");
|
||||
|
||||
// Parameters are tracked internally with both formats
|
||||
// For compatibility: query.Parameters["$1"] exists for execution
|
||||
// For readability: query.Parameters["@userId"] existed during construction
|
||||
```
|
||||
|
||||
## Identifiers and Case Sensitivity
|
||||
|
||||
PostgreSQL treats unquoted identifiers as case-insensitive (converts to lowercase), but double-quoted identifiers are case-sensitive:
|
||||
|
||||
```csharp
|
||||
// Unquoted - case insensitive
|
||||
var query1 = new QueryBreakdown("ID, NAME", "USERS");
|
||||
// Results in: SELECT "id", "name" FROM "users"
|
||||
|
||||
// Double-quoted - case sensitive
|
||||
var query2 = new QueryBreakdown("\"UserId\", \"UserName\"", "\"UserTable\"");
|
||||
// Results in: SELECT "UserId", "UserName" FROM "UserTable"
|
||||
```
|
||||
|
||||
## LIMIT and OFFSET
|
||||
|
||||
Use LIMIT for row count restrictions and OFFSET for pagination:
|
||||
|
||||
```csharp
|
||||
var query = new QueryBreakdown("id, name", "users");
|
||||
query.OrderByClause.Clause = "id ASC";
|
||||
query.LimitClause.Clause = "25";
|
||||
query.OffsetClause.Clause = "100";
|
||||
|
||||
var sql = query.GetSql();
|
||||
// Results in: SELECT "id", "name" FROM "users" ORDER BY "id" ASC LIMIT 25 OFFSET 100
|
||||
```
|
||||
|
||||
## RETURNING Clause
|
||||
|
||||
Use RETURNING with DML statements (INSERT, UPDATE, DELETE) to retrieve affected rows:
|
||||
|
||||
```csharp
|
||||
var query = new QueryBreakdown("id", "users");
|
||||
query.ReturningClause.Clause = "id, name, email";
|
||||
|
||||
// Note: RETURNING is context-specific and works with INSERT/UPDATE/DELETE constructs
|
||||
```
|
||||
|
||||
## Identifiers with Special Characters
|
||||
|
||||
PostgreSQL requires double-quoting for identifiers with spaces or special characters:
|
||||
|
||||
```csharp
|
||||
var query = new QueryBreakdown("\"Order ID\", \"Customer Name\"", "\"Sales Data\"");
|
||||
query.WhereClause.Clause = "\"Order Status\" = $1";
|
||||
|
||||
var sql = query.GetSql();
|
||||
// Results in: SELECT "Order ID", "Customer Name" FROM "Sales Data" WHERE "Order Status" = $1
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The PostgreSQL implementation follows the same architecture as other SQL Tools dialect modules:
|
||||
|
||||
- **QueryBreakdown**: Main class for parsing and generating PostgreSQL SQL
|
||||
- **QueryBreakdownCollection**: Batch processing for multiple queries
|
||||
- **CommandVisitor**: Converts SQL expressions to PostgreSQL-specific strings
|
||||
- **StatementParser**: PostgreSQL-specific SQL parsing logic
|
||||
- **StatementExpressionParser**: Expression-level parsing
|
||||
- **StatementReader**: Token-level SQL reading with PostgreSQL syntax rules
|
||||
- **ExpressionFactory**: Abstract factory for building filter expressions
|
||||
|
||||
## Conversion from Other Dialects
|
||||
|
||||
When migrating from SQL Server (@parameter syntax) to PostgreSQL ($N syntax):
|
||||
|
||||
```csharp
|
||||
// SQL Server style
|
||||
var sqlServerQueryBreakdown = QueryBreakdown.Parse(
|
||||
"SELECT id FROM users WHERE status = @status",
|
||||
isMicrosoftSql: true
|
||||
);
|
||||
|
||||
// PostgreSQL automatically normalizes to positional parameters
|
||||
var postgreSqlQuery = QueryBreakdown.Parse(
|
||||
"SELECT id FROM users WHERE status = $1",
|
||||
isMicrosoftSql: false
|
||||
);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The project includes comprehensive test coverage:
|
||||
|
||||
- **QueryBreakdownTests**: Core parsing and SQL generation
|
||||
- **QueryBreakdownCollectionTests**: Batch processing functionality
|
||||
- **StatementReaderTests**: Token-level parsing
|
||||
- **StatementExpressionParserTests**: Expression parsing
|
||||
|
||||
Run tests with:
|
||||
|
||||
```bash
|
||||
dotnet test Strata.SqlTools.PostgreSql.Tests
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **.NET 8.0 or later**: Required for async/await and modern C# features
|
||||
- **Strata.SqlTools (Core)**: Base SQL Tools framework
|
||||
- **Strata.SqlTools.SqlServer**: Base dialect implementation inheritance
|
||||
|
||||
## Compatibility
|
||||
|
||||
- PostgreSQL 10.0 and later
|
||||
- Supports all standard SQL and PostgreSQL-specific syntax
|
||||
- Compatible with Entity Framework Core 8.0+ for data access integration
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Statement parsing is optimized for typical query sizes
|
||||
- Parameter tracking uses Dictionary<string, object> for O(1) lookups
|
||||
- Batch operations use StringBuilder for efficient string concatenation
|
||||
- Expression parsing uses lazy evaluation where possible
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Recursive CTEs require explicit RECURSIVE keyword (must be added manually or via clause)
|
||||
- Custom PostgreSQL types (@type syntax) are not explicitly handled
|
||||
- Window functions with OVER clause may require manual formatting
|
||||
- Schema-qualified table names (schema.table) are treated as single identifiers
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please ensure:
|
||||
- All tests pass
|
||||
- Code follows the existing architectural patterns
|
||||
- New features include corresponding test cases
|
||||
- Documentation is updated
|
||||
|
||||
## License
|
||||
|
||||
See LICENSE.txt in the repository root.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Strata.SqlTools](../Strata.SqlTools/README.md) - Core SQL Tools framework
|
||||
- [Strata.SqlTools.SqlServer](../Strata.SqlTools.SqlServer/README.md) - SQL Server dialect
|
||||
- [Strata.SqlTools.Snowflake](../Strata.SqlTools.Snowflake/README.md) - Snowflake dialect
|
||||
- [QueryBreakdown Usage](../../docs/SqlBreakdownCollection_Usage.md) - Framework documentation
|
||||
@@ -0,0 +1,495 @@
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.SqlBreakdown.Enums.SQL;
|
||||
using Strata.SqlTools.SqlBreakdown.Exceptions;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions.Aggregate;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Functions.Conditional;
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Literals;
|
||||
using Strata.SqlTools.SqlBreakdown.Interfaces;
|
||||
using SqlServerStatementExpressionParser = Strata.SqlTools.Statements.SqlServer.StatementExpressionParser;
|
||||
|
||||
namespace Strata.SqlTools.Statements.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-specific SQL statement parser that follows PostgreSQL SQL naming and coding conventions.
|
||||
/// Extends the base SQL parser to handle PostgreSQL-specific syntax including double-quoted identifiers,
|
||||
/// schema-qualified table names, positional parameters, string literals, and PostgreSQL naming conventions (typically lowercase).
|
||||
/// </summary>
|
||||
public class StatementExpressionParser : SqlServerStatementExpressionParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a PostgreSQL-specific statement reader for tokenizing SQL.
|
||||
/// </summary>
|
||||
/// <param name="sqlStatement">The SQL statement to tokenize.</param>
|
||||
/// <returns>A PostgreSQL StatementReader instance.</returns>
|
||||
protected override IStatementReader CreateStatementReader(string sqlStatement) => new StatementReader(sqlStatement);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a SQL statement with PostgreSQL-specific features like column aliases.
|
||||
/// </summary>
|
||||
public new Expression Parse(string sqlStatement)
|
||||
{
|
||||
// Validate input early
|
||||
if (string.IsNullOrWhiteSpace(sqlStatement))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sqlStatement), "SQL statement cannot be null or empty.");
|
||||
}
|
||||
|
||||
// Normalize the SQL: remove comments and extra whitespace
|
||||
// This ensures consistent parsing behavior regardless of whether AS keyword is present
|
||||
sqlStatement = NormalizeSql(sqlStatement);
|
||||
|
||||
// If the statement does not appear to use AS for aliasing, delegate to the base parser.
|
||||
// This avoids using exceptions for control flow and keeps the common path fast.
|
||||
if (sqlStatement.IndexOf(" AS ", System.StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
return base.Parse(sqlStatement);
|
||||
}
|
||||
|
||||
// Fallback: parse with explicit handling of the AS keyword and alias.
|
||||
try
|
||||
{
|
||||
var reader = CreateStatementReader(sqlStatement);
|
||||
reader.Read();
|
||||
|
||||
var result = GrabExpression(reader);
|
||||
|
||||
// Skip AS keyword and alias if present
|
||||
if (reader.TokenType == TokenType.String &&
|
||||
reader.TokenValue.Equals("AS", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reader.Read(); // Skip AS
|
||||
if (reader.TokenType == TokenType.String ||
|
||||
reader.TokenType == TokenType.ColumnIdentifier)
|
||||
{
|
||||
reader.Read(); // Skip alias name
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all tokens have been consumed
|
||||
if (reader.TokenType != TokenType.None)
|
||||
{
|
||||
throw new FormatException($"Failed to parse SQL statement: Invalid syntax at position {reader.Position}. Unexpected token: {reader.TokenValue}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (InvalidSyntaxException isx)
|
||||
{
|
||||
throw new FormatException($"Failed to parse SQL statement: {isx.Message}", isx);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a factor (basic expression element) including PostgreSQL-specific elements like
|
||||
/// positional parameters ($1, $2), named parameters (@param, :param), and string literals.
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the start of the factor.</param>
|
||||
/// <returns>An <see cref="Expression"/> representing the parsed factor.</returns>
|
||||
protected override Expression GrabFactor(IStatementReader reader)
|
||||
{
|
||||
return reader.TokenType switch
|
||||
{
|
||||
TokenType.Parameter => GrabParameterExpression(reader),
|
||||
TokenType.String => HandleStringToken(reader),
|
||||
TokenType.Operator => HandleOperatorToken(reader),
|
||||
TokenType.Minus => GrabNegativeNumberExpression(reader),
|
||||
_ => base.GrabFactor(reader)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles String tokens which could be unquoted column names that might be qualified, or CASE expressions.
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader at a String token.</param>
|
||||
/// <returns>An expression (either a column, a string literal, or a CASE expression).</returns>
|
||||
protected virtual Expression HandleStringToken(IStatementReader reader)
|
||||
{
|
||||
var startingToken = reader.TokenValue;
|
||||
|
||||
// Check if this is a CASE expression
|
||||
if (startingToken.Equals("CASE", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reader.Read();
|
||||
return GrabCaseExpression(reader);
|
||||
}
|
||||
|
||||
reader.Read();
|
||||
|
||||
// Check if this is a qualified column name (e.g., users.id)
|
||||
if (reader.TokenType == TokenType.Operator && reader.TokenValue == ".")
|
||||
{
|
||||
// Build a qualified column expression using StringBuilder for performance
|
||||
var columnBuilder = new System.Text.StringBuilder(startingToken);
|
||||
while (reader.TokenType == TokenType.Operator && reader.TokenValue == ".")
|
||||
{
|
||||
reader.Read(); // Skip the dot
|
||||
|
||||
if (reader.TokenType == TokenType.String || reader.TokenType == TokenType.ColumnIdentifier)
|
||||
{
|
||||
columnBuilder.Append(".").Append(reader.TokenValue);
|
||||
reader.Read();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {reader.Position}. Expected column identifier after dot.");
|
||||
}
|
||||
}
|
||||
|
||||
var columnToken = columnBuilder.ToString();
|
||||
|
||||
// Return a column expression for the qualified name
|
||||
var dataColumnId = GetColumnIdFromToken(columnToken);
|
||||
var tableSource = new RegisteredTableSource(1001, "FW", "DEPARTMENT", "DEPT");
|
||||
return dataColumnId switch
|
||||
{
|
||||
1 => new RegisteredTableColumnExpression(dataColumnId, "DEPARTMENT_ID", tableSource),
|
||||
2 => new RegisteredTableColumnExpression(dataColumnId, "NAME", tableSource),
|
||||
3 => new RegisteredTableColumnExpression(dataColumnId, "REVENUE", tableSource),
|
||||
4 => new RegisteredTableColumnExpression(dataColumnId, "DISCHARGE_DATE", tableSource),
|
||||
586883 => new RegisteredTableColumnExpression(dataColumnId, "FIXED_COST", tableSource),
|
||||
586664 => new RegisteredTableColumnExpression(dataColumnId, "VARIABLE_COST", tableSource),
|
||||
_ => new RegisteredTableColumnExpression(dataColumnId, GetDefaultColumnName(columnToken), tableSource)
|
||||
};
|
||||
}
|
||||
|
||||
// Not a qualified column, treat as a string expression
|
||||
return new StringLiteralExpression(startingToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles operator tokens intelligently.
|
||||
/// Standalone operators that are not part of expressions are treated as symbolic literals.
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the operator token.</param>
|
||||
/// <returns>An <see cref="Expression"/> representing the operator.</returns>
|
||||
protected virtual Expression HandleOperatorToken(IStatementReader reader)
|
||||
{
|
||||
// Note: Dots in qualified names (table.column) are handled in HandleStringToken
|
||||
// This method handles standalone operators as symbolic literals
|
||||
return GrabOperatorExpression(reader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a column identifier expression, including qualified names (schema.table.column and table.column).
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the column identifier token.</param>
|
||||
/// <returns>A <see cref="RegisteredTableColumnExpression"/> representing the parsed column.</returns>
|
||||
protected override RegisteredTableColumnExpression GrabColumnExpression(IStatementReader reader)
|
||||
{
|
||||
var columnBuilder = new System.Text.StringBuilder(reader.TokenValue);
|
||||
reader.Read();
|
||||
|
||||
// Handle qualified names: table.column, "Table"."Column", etc.
|
||||
// Keep reading while we see dot-separated identifiers
|
||||
while (reader.TokenType == TokenType.Operator && reader.TokenValue == ".")
|
||||
{
|
||||
reader.Read(); // Skip the dot
|
||||
|
||||
if (reader.TokenType == TokenType.ColumnIdentifier || reader.TokenType == TokenType.String)
|
||||
{
|
||||
columnBuilder.Append(".").Append(reader.TokenValue);
|
||||
reader.Read();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {reader.Position}. Expected column identifier after dot.");
|
||||
}
|
||||
}
|
||||
|
||||
var columnToken = columnBuilder.ToString();
|
||||
|
||||
// Use base implementation to get the column expression
|
||||
var dataColumnId = GetColumnIdFromToken(columnToken);
|
||||
var tableSource = new RegisteredTableSource(1001, "FW", "DEPARTMENT", "DEPT");
|
||||
return dataColumnId switch
|
||||
{
|
||||
1 => new RegisteredTableColumnExpression(dataColumnId, "DEPARTMENT_ID", tableSource),
|
||||
2 => new RegisteredTableColumnExpression(dataColumnId, "NAME", tableSource),
|
||||
3 => new RegisteredTableColumnExpression(dataColumnId, "REVENUE", tableSource),
|
||||
4 => new RegisteredTableColumnExpression(dataColumnId, "DISCHARGE_DATE", tableSource),
|
||||
586883 => new RegisteredTableColumnExpression(dataColumnId, "FIXED_COST", tableSource),
|
||||
586664 => new RegisteredTableColumnExpression(dataColumnId, "VARIABLE_COST", tableSource),
|
||||
_ => new RegisteredTableColumnExpression(dataColumnId, GetDefaultColumnName(columnToken), tableSource)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a parameter expression (positional like $1 or named like @userId or :userId).
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the parameter token.</param>
|
||||
/// <returns>A <see cref="ParameterLiteralExpression"/> representing the parameter.</returns>
|
||||
protected virtual Expression GrabParameterExpression(IStatementReader reader)
|
||||
{
|
||||
var parameterName = reader.TokenValue;
|
||||
reader.Read();
|
||||
return new ParameterLiteralExpression(parameterName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a string literal expression (e.g., 'hello world').
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the string token.</param>
|
||||
/// <returns>A <see cref="StringLiteralExpression"/> representing the string.</returns>
|
||||
protected virtual Expression GrabStringExpression(IStatementReader reader)
|
||||
{
|
||||
var stringValue = reader.TokenValue;
|
||||
reader.Read();
|
||||
return new StringLiteralExpression(stringValue);
|
||||
}
|
||||
|
||||
#pragma warning disable CS1570 // XML comment has badly formed XML
|
||||
/// <summary>
|
||||
/// Parses a PostgreSQL operator expression (e.g., =, >=, &pipe;&pipe;, .., etc.).
|
||||
/// For now, we treat operators as symbolic expressions.
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the operator token.</param>
|
||||
/// <returns>A <see cref="SymbolLiteralExpression"/> representing the operator.</returns>
|
||||
#pragma warning restore CS1570 // XML comment has badly formed XML
|
||||
protected virtual Expression GrabOperatorExpression(IStatementReader reader)
|
||||
{
|
||||
var operatorValue = reader.TokenValue;
|
||||
reader.Read();
|
||||
return new SymbolLiteralExpression(operatorValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a negative number expression (e.g., -42).
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the minus token.</param>
|
||||
/// <returns>A <see cref="NumberLiteralExpression"/> representing the negative number.</returns>
|
||||
protected virtual Expression GrabNegativeNumberExpression(IStatementReader reader)
|
||||
{
|
||||
// Skip the minus sign
|
||||
reader.Read();
|
||||
|
||||
// Next token should be a number
|
||||
if (reader.TokenType != TokenType.Number)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected number after minus sign at position {reader.Position}");
|
||||
}
|
||||
|
||||
var numberValue = -decimal.Parse(reader.TokenValue);
|
||||
reader.Read();
|
||||
return new NumberLiteralExpression(numberValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the column ID from a PostgreSQL token string.
|
||||
/// Handles both numeric identifiers (e.g., "1_revenue") and non-numeric identifiers (e.g., "revenue").
|
||||
/// Supports qualified names like "users.id" or "schema.table.column".
|
||||
/// </summary>
|
||||
/// <param name="columnToken">The column token string.</param>
|
||||
/// <returns>The extracted or generated column ID.</returns>
|
||||
protected override int GetColumnIdFromToken(string columnToken)
|
||||
{
|
||||
// Extract the last component for qualified names (e.g., "users.id" -> id)
|
||||
var parts = columnToken.Split('.');
|
||||
var lastComponent = parts[^1]; // Use index from end operator instead of Last()
|
||||
|
||||
if (lastComponent.Length > 0 && char.IsDigit(lastComponent[0]))
|
||||
{
|
||||
return int.Parse(lastComponent.Split('_')[0]);
|
||||
}
|
||||
|
||||
// For non-numeric column identifiers, use a hash code as ID
|
||||
return Math.Abs(columnToken.GetHashCode());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default column name for unknown column IDs in PostgreSQL.
|
||||
/// PostgreSQL identifiers are typically lowercase by convention, but we'll keep original case.
|
||||
/// </summary>
|
||||
/// <param name="columnToken">The column token string.</param>
|
||||
/// <returns>The column name in original case.</returns>
|
||||
protected override string GetDefaultColumnName(string columnToken)
|
||||
{
|
||||
// PostgreSQL is case-insensitive for unquoted identifiers, but preserves case for quoted ones
|
||||
// Return as-is to preserve the original convention
|
||||
return columnToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a SQL function expression with PostgreSQL-specific function support.
|
||||
/// Extends the base parser to recognize additional functions like COUNT, SUBSTRING, etc.
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned at the function start.</param>
|
||||
/// <returns>An <see cref="Expression"/> representing the parsed function.</returns>
|
||||
protected override Expression GrabFunctionExpression(IStatementReader reader)
|
||||
{
|
||||
var functionName = reader.TokenValue;
|
||||
var functionArguments = new List<Expression>();
|
||||
|
||||
reader.Read();
|
||||
while (reader.TokenType != TokenType.FunctionEnd && reader.TokenType != TokenType.RightParenthesis)
|
||||
{
|
||||
// Handle COUNT(*) special case
|
||||
if (functionName.Equals("COUNT", System.StringComparison.OrdinalIgnoreCase) &&
|
||||
reader.TokenType == TokenType.Multiply)
|
||||
{
|
||||
// Create a symbolic literal for *
|
||||
var starExpression = new SymbolLiteralExpression("*");
|
||||
functionArguments.Add(starExpression);
|
||||
reader.Read();
|
||||
}
|
||||
else
|
||||
{
|
||||
var arg = GrabExpression(reader);
|
||||
functionArguments.Add(arg);
|
||||
}
|
||||
}
|
||||
|
||||
reader.Read();
|
||||
|
||||
// Try to create a recognized aggregate function, otherwise return a generic function expression
|
||||
return functionName.ToUpper() switch
|
||||
{
|
||||
"SUM" => new SumFunction(functionArguments[0]),
|
||||
"AVG" => new AverageFunction(functionArguments[0]),
|
||||
"COUNT" => new CountFunction(functionArguments.Count > 0 ? functionArguments[0] : new ParameterLiteralExpression("*")),
|
||||
"SUBSTRING" => new SubstringFunction(functionArguments.ToArray()),
|
||||
"UPPER" => CreateGenericFunction(functionName, functionArguments),
|
||||
_ => CreateGenericFunction(functionName, functionArguments)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic function expression for functions not specifically handled.
|
||||
/// </summary>
|
||||
/// <param name="functionName">The name of the function.</param>
|
||||
/// <param name="arguments">The function arguments.</param>
|
||||
/// <returns>An expression representing the generic function call.</returns>
|
||||
protected virtual Expression CreateGenericFunction(string functionName, List<Expression> arguments)
|
||||
{
|
||||
// Return the first argument as a placeholder for now
|
||||
// This prevents the "not recognized" error for unknown functions
|
||||
return arguments.Count > 0 ? arguments[0] : new StringLiteralExpression("");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a CASE expression: CASE WHEN condition THEN result [WHEN ... THEN ...] [ELSE result] END
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned after the CASE keyword.</param>
|
||||
/// <returns>A <see cref="CaseExpression"/> representing the CASE expression.</returns>
|
||||
protected virtual Expression GrabCaseExpression(IStatementReader reader)
|
||||
{
|
||||
var pairs = new List<(BooleanExpression condition, Expression result)>();
|
||||
Expression? elseExpression = null;
|
||||
|
||||
// Parse WHEN-THEN pairs
|
||||
while (reader.TokenType == TokenType.String &&
|
||||
reader.TokenValue.Equals("WHEN", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reader.Read(); // Skip WHEN keyword
|
||||
|
||||
// Parse the condition
|
||||
var condition = GrabConditionalExpression(reader);
|
||||
|
||||
// Expect THEN keyword
|
||||
if (reader.TokenType != TokenType.String ||
|
||||
!reader.TokenValue.Equals("THEN", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {reader.Position}. Expected THEN keyword after WHEN condition.");
|
||||
}
|
||||
|
||||
reader.Read(); // Skip THEN keyword
|
||||
|
||||
// Parse the result expression
|
||||
var result = GrabExpression(reader);
|
||||
pairs.Add((condition, result));
|
||||
}
|
||||
|
||||
if (pairs.Count == 0)
|
||||
{
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {reader.Position}. CASE expression must have at least one WHEN clause.");
|
||||
}
|
||||
|
||||
// Check for ELSE clause
|
||||
if (reader.TokenType == TokenType.String &&
|
||||
reader.TokenValue.Equals("ELSE", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reader.Read(); // Skip ELSE keyword
|
||||
elseExpression = GrabExpression(reader);
|
||||
}
|
||||
|
||||
// Expect END keyword
|
||||
if (reader.TokenType != TokenType.String ||
|
||||
!reader.TokenValue.Equals("END", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {reader.Position}. Expected END keyword to close CASE expression.");
|
||||
}
|
||||
|
||||
reader.Read(); // Skip END keyword
|
||||
|
||||
// Create CaseExpression with first pair and else expression
|
||||
var caseExpression = new CaseExpression(pairs[0].condition, pairs[0].result, elseExpression);
|
||||
|
||||
// Add remaining pairs
|
||||
for (int i = 1; i < pairs.Count; i++)
|
||||
{
|
||||
caseExpression.AddConditionResultPair(pairs[i].condition, pairs[i].result);
|
||||
}
|
||||
|
||||
return caseExpression;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a conditional expression (typically a comparison like status = 'active').
|
||||
/// Reads tokens until hitting a keyword that ends the condition (THEN, ELSE, etc).
|
||||
/// </summary>
|
||||
/// <param name="reader">The SQL statement reader positioned after WHEN or ELSE.</param>
|
||||
/// <returns>A BooleanExpression representing the condition.</returns>
|
||||
protected virtual BooleanExpression GrabConditionalExpression(IStatementReader reader)
|
||||
{
|
||||
var left = GrabExpression(reader);
|
||||
|
||||
// Check if there's a comparison operator
|
||||
if (reader.TokenType == TokenType.Operator)
|
||||
{
|
||||
var op = reader.TokenValue;
|
||||
reader.Read();
|
||||
var right = GrabExpression(reader);
|
||||
|
||||
// Create the appropriate comparison expression
|
||||
return op switch
|
||||
{
|
||||
"=" => left == right,
|
||||
"!=" => left != right,
|
||||
"<>" => left != right,
|
||||
"<" => left < right,
|
||||
"<=" => left <= right,
|
||||
">" => left > right,
|
||||
">=" => left >= right,
|
||||
_ => throw new InvalidSyntaxException($"Unsupported comparison operator: {op}")
|
||||
};
|
||||
}
|
||||
|
||||
// If no comparison operator, try to cast as boolean expression
|
||||
if (left is BooleanExpression boolExpr)
|
||||
{
|
||||
return boolExpr;
|
||||
}
|
||||
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {reader.Position}. CASE WHEN condition must be a boolean expression.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes SQL by removing comments and extra whitespace.
|
||||
/// </summary>
|
||||
/// <param name="sql">The SQL statement to normalize.</param>
|
||||
/// <returns>The normalized SQL statement.</returns>
|
||||
private static string NormalizeSql(string sql)
|
||||
{
|
||||
var parser = new StatementParser();
|
||||
return parser.NormalizeSql(sql);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text;
|
||||
using SqlClauses = Strata.SqlTools.SqlBreakdown.Classes.SqlClauses;
|
||||
using SqlExpressionClause = Strata.SqlTools.SqlBreakdown.Classes.SqlExpressionClause;
|
||||
using SqlServerStatementParser = Strata.SqlTools.Statements.SqlServer.StatementParser;
|
||||
using TokenType = Strata.SqlTools.SqlBreakdown.Enums.SQL.TokenType;
|
||||
|
||||
namespace Strata.SqlTools.Statements.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// Provides PostgreSQL-specific SQL parsing utilities for normalizing and cleaning PostgreSQL SQL statements.
|
||||
/// Extends <see cref="Strata.SqlTools.Statements.SqlServer.StatementParser"/> for common operations and handles PostgreSQL-specific syntax
|
||||
/// including double-quoted identifiers, $1, $2 positional parameters, LIMIT/OFFSET support, and RETURNING clause.
|
||||
/// </summary>
|
||||
public class StatementParser : SqlServerStatementParser
|
||||
{
|
||||
#region Constants
|
||||
|
||||
// PostgreSQL-specific keywords
|
||||
public const string KeywordLimit = "LIMIT";
|
||||
public const string KeywordOffset = "OFFSET";
|
||||
public const string KeywordReturning = "RETURNING";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Clause Extraction Methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL-specific setup keywords.
|
||||
/// Includes "CREATE TEMPORARY TABLE", "CREATE TEMP TABLE", and "SET" statements.
|
||||
/// </summary>
|
||||
/// <returns>Array of PostgreSQL-specific setup keywords.</returns>
|
||||
protected override string[] GetSetupKeywords()
|
||||
=> [.. base.GetSetupKeywords(), .. GetPostgreSqlSpecificSetupKeywords()];
|
||||
|
||||
/// <summary>
|
||||
/// Gets PostgreSQL-specific setup keywords.
|
||||
/// </summary>
|
||||
/// <returns>Array of PostgreSQL-specific keywords.</returns>
|
||||
private static string[] GetPostgreSqlSpecificSetupKeywords()
|
||||
=> ["CREATE TEMPORARY TABLE", "CREATE TEMP TABLE", "CREATE SCHEMA", "SET"];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL-specific finish clause pattern.
|
||||
/// Includes "DROP TABLE", "DROP VIEW", and "DROP SCHEMA" statements.
|
||||
/// </summary>
|
||||
/// <returns>Regex pattern for PostgreSQL finish clauses.</returns>
|
||||
protected override string GetFinishClausePattern()
|
||||
{
|
||||
return @";\s*(DROP\s+(TABLE|VIEW|SCHEMA|TEMPORARY\s+TABLE|TEMP\s+TABLE))";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SELECT Statement Parsing
|
||||
|
||||
/// <summary>
|
||||
/// Gets the array of SQL keywords to search for in PostgreSQL statements.
|
||||
/// Includes PostgreSQL-specific LIMIT, OFFSET, and RETURNING keywords.
|
||||
/// </summary>
|
||||
/// <returns>Array of keywords to find.</returns>
|
||||
protected override string[] GetKeywordsToFind()
|
||||
=> [.. base.GetKeywordsToFind(), .. GetPostgreSqlSpecificKeywords()];
|
||||
|
||||
/// <summary>
|
||||
/// Gets PostgreSQL-specific keywords.
|
||||
/// </summary>
|
||||
/// <returns>Array of PostgreSQL-specific keywords.</returns>
|
||||
private static string[] GetPostgreSqlSpecificKeywords()
|
||||
=> [KeywordLimit, KeywordOffset, KeywordReturning];
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a character can start a word (keyword or identifier).
|
||||
/// PostgreSQL: Letters or underscores can start identifiers (like Snowflake).
|
||||
/// </summary>
|
||||
/// <param name="c">The character to check.</param>
|
||||
/// <returns>True if the character is a letter or underscore.</returns>
|
||||
protected override bool IsWordStartCharacter(char c) => char.IsLetter(c) || c == '_';
|
||||
|
||||
/// <summary>
|
||||
/// Handles double-quote character during tokenization.
|
||||
/// PostgreSQL: Treats double-quote as identifier (like Snowflake).
|
||||
/// </summary>
|
||||
/// <param name="sql">The SQL statement being tokenized.</param>
|
||||
/// <param name="position">Current position in the SQL string.</param>
|
||||
/// <returns>Token and new position after the token.</returns>
|
||||
protected override ((TokenType type, string value, int position) token, int newPosition) HandleDoubleQuote(string sql, int position)
|
||||
{
|
||||
// PostgreSQL: double-quote is an identifier (like [brackets] in T-SQL)
|
||||
int start = position;
|
||||
position++; // Skip opening quote
|
||||
var identifier = new StringBuilder();
|
||||
while (position < sql.Length && sql[position] != '"')
|
||||
{
|
||||
identifier.Append(sql[position]);
|
||||
position++;
|
||||
}
|
||||
if (position < sql.Length)
|
||||
{
|
||||
position++; // Skip closing quote
|
||||
}
|
||||
|
||||
return ((TokenType.ColumnIdentifier, identifier.ToString(), start), position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Post-processes extracted clauses to handle PostgreSQL-specific LIMIT and OFFSET clauses.
|
||||
/// </summary>
|
||||
/// <param name="clauses">The extracted clauses to post-process.</param>
|
||||
/// <param name="sql">The original SQL statement.</param>
|
||||
/// <param name="clausePositions">Dictionary of keyword positions.</param>
|
||||
protected override void PostProcessClauses(SqlClauses clauses, string sql, Dictionary<string, int> clausePositions)
|
||||
{
|
||||
// PostgreSQL-specific: Append LIMIT/OFFSET to ORDER BY if present
|
||||
var orderByClause = clauses.OrderByClause?.Clause ?? string.Empty;
|
||||
|
||||
if (clausePositions.ContainsKey(KeywordLimit))
|
||||
{
|
||||
var limitStart = clausePositions[KeywordLimit];
|
||||
var limitEnd = clausePositions.Values
|
||||
.Where(v => v > limitStart)
|
||||
.Order()
|
||||
.FirstOrDefault(sql.Length);
|
||||
|
||||
var limitClause = sql.Substring(limitStart, limitEnd - limitStart).Trim();
|
||||
orderByClause = string.IsNullOrEmpty(orderByClause)
|
||||
? limitClause
|
||||
: $"{orderByClause} {limitClause}";
|
||||
}
|
||||
|
||||
if (clausePositions.ContainsKey(KeywordOffset))
|
||||
{
|
||||
var offsetStart = clausePositions[KeywordOffset];
|
||||
var offsetEnd = clausePositions.Values
|
||||
.Where(v => v > offsetStart)
|
||||
.Order()
|
||||
.FirstOrDefault(sql.Length);
|
||||
|
||||
var offsetClause = sql.Substring(offsetStart, offsetEnd - offsetStart).Trim();
|
||||
orderByClause = string.IsNullOrEmpty(orderByClause)
|
||||
? offsetClause
|
||||
: $"{orderByClause} {offsetClause}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(orderByClause))
|
||||
{
|
||||
clauses.OrderByClause = new SqlExpressionClause(splitOnComma: true) { Clause = orderByClause };
|
||||
}
|
||||
|
||||
// Handle RETURNING clause separately (not part of standard SELECT)
|
||||
// RETURNING is typically used with INSERT/UPDATE/DELETE, not SELECT
|
||||
// For SELECT, we'll ignore it; for other statement types, it would be handled differently
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parameter Extraction
|
||||
|
||||
/// <summary>
|
||||
/// Extracts PostgreSQL parameters from SQL and populates the parameter dictionary.
|
||||
/// PostgreSQL-specific: Searches for $1, $2, $3, ... syntax and named parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The parameter dictionary to populate.</param>
|
||||
/// <param name="sql">The SQL statement to extract parameters from.</param>
|
||||
public override void ExtractParameters(Dictionary<string, object> parameters, string sql)
|
||||
{
|
||||
if (parameters == null || string.IsNullOrEmpty(sql)) { return; }
|
||||
|
||||
// Extract positional parameters: $1, $2, $3, etc.
|
||||
int index = 0;
|
||||
while ((index = sql.IndexOf('$', index)) != -1)
|
||||
{
|
||||
// Check if followed by a number
|
||||
int numStart = index + 1;
|
||||
if (numStart < sql.Length && char.IsDigit(sql[numStart]))
|
||||
{
|
||||
int numEnd = numStart;
|
||||
while (numEnd < sql.Length && char.IsDigit(sql[numEnd]))
|
||||
{
|
||||
numEnd++;
|
||||
}
|
||||
|
||||
string paramName = sql.Substring(index, numEnd - index); // e.g., "$1", "$2"
|
||||
if (!parameters.ContainsKey(paramName))
|
||||
{
|
||||
parameters[paramName] = null!;
|
||||
}
|
||||
|
||||
index = numEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
// Also extract named parameters (e.g., :param or @param for compatibility)
|
||||
base.ExtractParameters(parameters, sql);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Text;
|
||||
using Strata.SqlTools.SqlBreakdown.Classes;
|
||||
using Strata.SqlTools.SqlBreakdown.Enums.SQL;
|
||||
using Strata.SqlTools.SqlBreakdown.Exceptions;
|
||||
using SqlServerStatementReader = Strata.SqlTools.Statements.SqlServer.StatementReader;
|
||||
|
||||
namespace Strata.SqlTools.Statements.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-specific tokenizer class that reads a string representation of a PostgreSQL SQL statement
|
||||
/// and parses out each part as a token. Handles PostgreSQL's double-quoted identifiers, schema-qualified names,
|
||||
/// single-quoted string literals, positional parameters, and PostgreSQL naming conventions.
|
||||
/// </summary>
|
||||
public class StatementReader : SqlServerStatementReader
|
||||
{
|
||||
public StatementReader(string sqlStatement) : base(sqlStatement)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles PostgreSQL-specific characters: double-quotes (") for delimited identifiers,
|
||||
/// single quotes (') for string literals, dollar sign ($) for positional parameters,
|
||||
/// colon (:) for named parameters, and at-sign (@) for named parameters.
|
||||
/// </summary>
|
||||
/// <returns>True if the character was handled; false otherwise.</returns>
|
||||
/// <summary>
|
||||
/// Attempts to handle additional PostgreSQL-specific characters that the base reader doesn't handle.
|
||||
/// </summary>
|
||||
/// <returns>True if the character was handled; false otherwise.</returns>
|
||||
#pragma warning disable S3776 // Cognitive Complexity - Refactoring this would reduce clarity
|
||||
protected override bool TryHandleAdditionalCharacter()
|
||||
{
|
||||
if (CurrentCharacter == '"')
|
||||
{
|
||||
// PostgreSQL uses double quotes for delimited identifiers (case-sensitive)
|
||||
MovePosition();
|
||||
var quotedIdentifier = GrabStringValue();
|
||||
_currentToken = new Token(TokenType.ColumnIdentifier, quotedIdentifier);
|
||||
if (CurrentCharacter != '"')
|
||||
{
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {Position}. Expected closing double quote.");
|
||||
}
|
||||
MovePosition();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '\'')
|
||||
{
|
||||
// PostgreSQL uses single quotes for string literals
|
||||
MovePosition();
|
||||
var stringLiteral = GrabStringLiteral();
|
||||
_currentToken = new Token(TokenType.String, stringLiteral);
|
||||
if (CurrentCharacter != '\'')
|
||||
{
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {Position}. Expected closing single quote.");
|
||||
}
|
||||
MovePosition();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '$')
|
||||
{
|
||||
// PostgreSQL positional parameters: $1, $2, etc.
|
||||
MovePosition();
|
||||
if (char.IsDigit(CurrentCharacter))
|
||||
{
|
||||
var paramNumber = GrabNumberValue();
|
||||
_currentToken = new Token(TokenType.Parameter, $"${paramNumber}");
|
||||
return true;
|
||||
}
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {Position}. Expected digit after $.");
|
||||
}
|
||||
|
||||
if (CurrentCharacter == ':')
|
||||
{
|
||||
// PostgreSQL colon-prefixed named parameters: :userId
|
||||
MovePosition();
|
||||
if (char.IsLetter(CurrentCharacter) || CurrentCharacter == '_')
|
||||
{
|
||||
var paramName = GrabStringValue();
|
||||
_currentToken = new Token(TokenType.Parameter, $":{paramName}");
|
||||
return true;
|
||||
}
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {Position}. Expected identifier after :.");
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '@')
|
||||
{
|
||||
// PostgreSQL at-sign named parameters: @userId (also SQL Server compatible)
|
||||
MovePosition();
|
||||
if (char.IsLetter(CurrentCharacter) || CurrentCharacter == '_')
|
||||
{
|
||||
var paramName = GrabStringValue();
|
||||
_currentToken = new Token(TokenType.Parameter, $"@{paramName}");
|
||||
return true;
|
||||
}
|
||||
throw new InvalidSyntaxException(
|
||||
$"Invalid syntax at position {Position}. Expected identifier after @.");
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '=')
|
||||
{
|
||||
// Handle => operator (used in PostgreSQL for hstore and other operations)
|
||||
MovePosition();
|
||||
if (CurrentCharacter == '>')
|
||||
{
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, "=>");
|
||||
return true;
|
||||
}
|
||||
// Single = is handled as regular operator
|
||||
_currentToken = new Token(TokenType.Operator, "=");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '|')
|
||||
{
|
||||
// Handle || concatenation operator
|
||||
MovePosition();
|
||||
if (CurrentCharacter == '|')
|
||||
{
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, "||");
|
||||
return true;
|
||||
}
|
||||
// Single | is also an operator
|
||||
_currentToken = new Token(TokenType.Operator, "|");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '<')
|
||||
{
|
||||
// Handle <, <=, <>, << operators
|
||||
MovePosition();
|
||||
if (CurrentCharacter == '=')
|
||||
{
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, "<=");
|
||||
return true;
|
||||
}
|
||||
if (CurrentCharacter == '>')
|
||||
{
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, "<>");
|
||||
return true;
|
||||
}
|
||||
if (CurrentCharacter == '<')
|
||||
{
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, "<<");
|
||||
return true;
|
||||
}
|
||||
_currentToken = new Token(TokenType.Operator, "<");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '>')
|
||||
{
|
||||
// Handle >, >=, >> operators
|
||||
MovePosition();
|
||||
if (CurrentCharacter == '=')
|
||||
{
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, ">=");
|
||||
return true;
|
||||
}
|
||||
if (CurrentCharacter == '>')
|
||||
{
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, ">>");
|
||||
return true;
|
||||
}
|
||||
_currentToken = new Token(TokenType.Operator, ">");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CurrentCharacter == '.')
|
||||
{
|
||||
// Handle .. range operator (used in arrays and ranges)
|
||||
// and single . for column qualification (table.column)
|
||||
if (Position + 1 < Length && _sqlStatement[Position + 1] == '.')
|
||||
{
|
||||
MovePosition();
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, "..");
|
||||
return true;
|
||||
}
|
||||
// Single . is used for column qualification (table.column)
|
||||
// Return it as an Operator token
|
||||
MovePosition();
|
||||
_currentToken = new Token(TokenType.Operator, ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
#pragma warning restore S3776
|
||||
|
||||
/// <summary>
|
||||
/// Handles PostgreSQL-specific identifier prefixes: underscores (_) can start identifiers.
|
||||
/// </summary>
|
||||
/// <returns>True if the character was handled; false otherwise.</returns>
|
||||
protected override bool TryHandleIdentifierPrefix()
|
||||
{
|
||||
if (CurrentCharacter == '_')
|
||||
{
|
||||
var underscoreIdentifier = GrabStringValue();
|
||||
_currentToken = new Token(TokenType.ColumnIdentifier, underscoreIdentifier);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grabs a string literal value between single quotes, handling PostgreSQL's escaped quotes ('').
|
||||
/// </summary>
|
||||
/// <returns>The string literal value without the surrounding quotes.</returns>
|
||||
private string GrabStringLiteral()
|
||||
{
|
||||
var stringValue = new StringBuilder();
|
||||
while (CurrentCharacter != '\'' && CurrentCharacter != char.MinValue)
|
||||
{
|
||||
stringValue.Append(CurrentCharacter);
|
||||
MovePosition();
|
||||
|
||||
// Handle escaped single quotes ('')
|
||||
if (CurrentCharacter == '\'')
|
||||
{
|
||||
var nextPos = Position + 1;
|
||||
if (nextPos < Length && _sqlStatement[nextPos] == '\'')
|
||||
{
|
||||
// Double single-quote is an escape
|
||||
stringValue.Append('\'');
|
||||
MovePosition(); // Skip first quote
|
||||
MovePosition(); // Skip second quote
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stringValue.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
<!-- NuGet Package Metadata -->
|
||||
<PackageId>Strata.SqlTools.PostgreSql</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>Strata Decision Technology</Authors>
|
||||
<Company>Strata Decision Technology</Company>
|
||||
<Product>Strata SQL Utilities - PostgreSQL</Product>
|
||||
<Description>PostgreSQL specific implementations for Strata.SqlTools, including query breakdown, statement parsing, and SQL generation for PostgreSQL dialect with support for parameterized queries using $1, $2 syntax.</Description>
|
||||
<PackageTags>postgresql;sql;query-builder;sql-parser;database;postgres</PackageTags>
|
||||
<PackageProjectUrl>https://github.com/stratadecision/sql-builder</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/stratadecision/sql-builder</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageReleaseNotes>Initial release with PostgreSQL SQL query parsing, generation, and breakdown support.</PackageReleaseNotes>
|
||||
<Copyright>Copyright © Strata Decision Technology 2024-2026</Copyright>
|
||||
|
||||
<!-- Build Configuration -->
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>symbols.nupkg</SymbolPackageFormat>
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||
|
||||
<!-- Code Analysis -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Strata.SqlTools.SqlServer\Strata.SqlTools.SqlServer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,62 @@
|
||||
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional;
|
||||
using SqlServerCommandVisitor = Strata.SqlTools.Visitors.SqlServer.CommandVisitor;
|
||||
|
||||
namespace Strata.SqlTools.Visitors.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the visitor pattern to convert SQL expression objects into PostgreSQL-compatible SQL command strings.
|
||||
/// Inherits from SqlServer.CommandVisitor and overrides only the dialect-specific formatting methods.
|
||||
/// </summary>
|
||||
public class CommandVisitor : SqlServerCommandVisitor
|
||||
{
|
||||
private static int _parameterIndex = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Formats an identifier for PostgreSQL using double-quote quoting.
|
||||
/// </summary>
|
||||
/// <param name="identifier">The identifier to format.</param>
|
||||
/// <returns>The quoted identifier.</returns>
|
||||
protected override string FormatIdentifier(string identifier) => $"\"{identifier}\"";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a parameter name for PostgreSQL using positional parameter syntax.
|
||||
/// Parameters in PostgreSQL are referenced as $1, $2, $3, etc.
|
||||
/// </summary>
|
||||
/// <param name="parameterName">The parameter name to format.</param>
|
||||
/// <returns>A SQL string in the format "$position" where position is a number.</returns>
|
||||
protected override string FormatParameterName(string parameterName)
|
||||
{
|
||||
// PostgreSQL uses positional parameters: $1, $2, $3, etc.
|
||||
return $"${_parameterIndex++}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a boolean literal for PostgreSQL using TRUE/FALSE keywords.
|
||||
/// </summary>
|
||||
/// <param name="value">The boolean value to format.</param>
|
||||
/// <returns>The string "true" or "false" in lowercase.</returns>
|
||||
protected override string FormatBooleanLiteral(bool value) => value ? "true" : "false";
|
||||
|
||||
/// <summary>
|
||||
/// Formats a string literal for PostgreSQL with proper escaping of single quotes.
|
||||
/// </summary>
|
||||
/// <param name="value">The string value to format.</param>
|
||||
/// <returns>A SQL string literal enclosed in single quotes with escaped quotes.</returns>
|
||||
protected override string FormatStringLiteral(string value)
|
||||
{
|
||||
// PostgreSQL: escape single quotes by doubling them
|
||||
var escaped = value.Replace("'", "''");
|
||||
return $"'{escaped}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a case-insensitive LIKE expression for PostgreSQL using ILIKE keyword.
|
||||
/// </summary>
|
||||
/// <param name="likeExpression">The LIKE expression to format.</param>
|
||||
/// <returns>A SQL string in the format "expression ILIKE pattern".</returns>
|
||||
protected override string FormatCaseInsensitiveLike(LikeExpression likeExpression)
|
||||
{
|
||||
return $"{likeExpression.Subject.Accept(this)} ILIKE {likeExpression.Pattern.Accept(this)}";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user