chore: initial git load of code space

This commit is contained in:
Thom Lamb
2026-05-12 08:52:33 -05:00
parent 9abada692f
commit 5e467bcc9c
384 changed files with 65960 additions and 2 deletions
@@ -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.
}
+265
View File
@@ -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., =, &gt;=, &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)}";
}
}