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,39 @@
using Strata.SqlTools.Breakdowns.SqlServer;
using Strata.SqlTools.EFCore.Models;
namespace Strata.SqlTools.EFCore.Abstractions;
/// <summary>
/// Interface for converting between QueryBreakdown and QueryBreakdownEntity for Entity Framework Core integration.
/// </summary>
public interface IQueryBreakdownMapper
{
/// <summary>
/// Converts a QueryBreakdown (SQL Tools) to a QueryBreakdownEntity (EF Core).
/// </summary>
/// <param name="queryBreakdown">The QueryBreakdown to convert.</param>
/// <returns>A QueryBreakdownEntity that can be persisted to the database.</returns>
QueryBreakdownEntity MapToEntity(QueryBreakdown queryBreakdown);
/// <summary>
/// Converts a QueryBreakdownEntity (EF Core) back to a QueryBreakdown (SQL Tools).
/// </summary>
/// <param name="entity">The QueryBreakdownEntity to convert.</param>
/// <returns>A QueryBreakdown instance with all clauses and parameters restored.</returns>
QueryBreakdown MapToDomainModel(QueryBreakdownEntity entity);
/// <summary>
/// Converts a QueryBreakdown to a QueryBreakdownEntity with related entities (parameters and WITH clauses).
/// </summary>
/// <param name="queryBreakdown">The QueryBreakdown to convert.</param>
/// <returns>A QueryBreakdownEntity with all related entities populated.</returns>
(QueryBreakdownEntity Entity, List<QueryParameterEntity> Parameters, List<WithClauseEntity> WithClauses) MapToEntityWithRelations(QueryBreakdown queryBreakdown);
/// <summary>
/// Converts a QueryBreakdownEntity with related entities back to a QueryBreakdown.
/// </summary>
/// <param name="entity">The QueryBreakdownEntity with navigation properties loaded.</param>
/// <returns>A fully reconstructed QueryBreakdown instance.</returns>
QueryBreakdown MapToDomainModelWithRelations(QueryBreakdownEntity entity);
}
@@ -0,0 +1,125 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Strata.SqlTools.EFCore.Models;
namespace Strata.SqlTools.EFCore.Configurations;
/// <summary>
/// Entity Framework Core configuration for the QueryBreakdownEntity.
/// Defines the table structure, relationships, and constraints.
/// </summary>
public class QueryBreakdownEntityConfiguration : IEntityTypeConfiguration<QueryBreakdownEntity>
{
/// <summary>
/// Configures the QueryBreakdownEntity for Entity Framework Core.
/// </summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<QueryBreakdownEntity> builder)
{
builder.ToTable("QueryBreakdowns");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
// Configure SELECT clause
builder.Property(e => e.SelectClause)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.SelectClauseComment)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure FROM clause
builder.Property(e => e.FromClause)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.FromClauseComment)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure WHERE clause
builder.Property(e => e.WhereClause)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.WhereClauseComment)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure GROUP BY clause
builder.Property(e => e.GroupByClause)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.GroupByClauseComment)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure HAVING clause
builder.Property(e => e.HavingClause)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.HavingClauseComment)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure ORDER BY clause
builder.Property(e => e.OrderByClause)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.OrderByClauseComment)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure WITH clause (CTEs)
builder.Property(e => e.WithClause)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure raw SQL
builder.Property(e => e.RawSql)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure JSON properties
builder.Property(e => e.SetupClausesJson)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.FinishClausesJson)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.ParametersJson)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
// Configure timestamps
builder.Property(e => e.CreatedAt)
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
builder.Property(e => e.UpdatedAt)
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
// Configure relationships
builder.HasMany<QueryParameterEntity>()
.WithOne(p => p.QueryBreakdownEntity)
.HasForeignKey(p => p.QueryBreakdownEntityId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany<WithClauseEntity>()
.WithOne(w => w.QueryBreakdownEntity)
.HasForeignKey(w => w.QueryBreakdownEntityId)
.OnDelete(DeleteBehavior.Cascade);
// Create indexes for common queries
builder.HasIndex(e => e.CreatedAt);
builder.HasIndex(e => e.UpdatedAt);
}
}
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Strata.SqlTools.EFCore.Models;
namespace Strata.SqlTools.EFCore.Configurations;
/// <summary>
/// Entity Framework Core configuration for the QueryParameterEntity.
/// </summary>
public class QueryParameterEntityConfiguration : IEntityTypeConfiguration<QueryParameterEntity>
{
/// <summary>
/// Configures the QueryParameterEntity for Entity Framework Core.
/// </summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<QueryParameterEntity> builder)
{
builder.ToTable("QueryParameters");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.QueryBreakdownEntityId)
.IsRequired();
builder.Property(e => e.ParameterName)
.HasColumnType("nvarchar(256)")
.IsRequired();
builder.Property(e => e.ParameterValue)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.ParameterTypeName)
.HasColumnType("nvarchar(256)")
.IsRequired(false);
// Create index for faster lookups
builder.HasIndex(e => new { e.QueryBreakdownEntityId, e.ParameterName })
.IsUnique();
}
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Strata.SqlTools.EFCore.Models;
namespace Strata.SqlTools.EFCore.Configurations;
/// <summary>
/// Entity Framework Core configuration for the WithClauseEntity.
/// </summary>
public class WithClauseEntityConfiguration : IEntityTypeConfiguration<WithClauseEntity>
{
/// <summary>
/// Configures the WithClauseEntity for Entity Framework Core.
/// </summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<WithClauseEntity> builder)
{
builder.ToTable("WithClauses");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.QueryBreakdownEntityId)
.IsRequired();
builder.Property(e => e.CteName)
.HasColumnType("nvarchar(256)")
.IsRequired();
builder.Property(e => e.ColumnList)
.HasColumnType("nvarchar(max)")
.IsRequired(false);
builder.Property(e => e.CteDefinition)
.HasColumnType("nvarchar(max)")
.IsRequired();
builder.Property(e => e.OrderIndex)
.IsRequired();
// Create index for ordering and lookups
builder.HasIndex(e => new { e.QueryBreakdownEntityId, e.OrderIndex });
}
}
@@ -0,0 +1,122 @@
namespace Strata.SqlTools.EFCore.Models;
/// <summary>
/// Represents a SQL query breakdown entity for Entity Framework Core mapping.
/// This entity encapsulates the query components (SELECT, FROM, WHERE, etc.)
/// and is designed to be compatible with EF Core DbContext and database models.
/// </summary>
public class QueryBreakdownEntity
{
/// <summary>
/// Gets or sets the unique identifier for this query breakdown.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the SELECT clause of the query.
/// </summary>
public string? SelectClause { get; set; }
/// <summary>
/// Gets or sets the comment for the SELECT clause.
/// </summary>
public string? SelectClauseComment { get; set; }
/// <summary>
/// Gets or sets the FROM clause of the query.
/// </summary>
public string? FromClause { get; set; }
/// <summary>
/// Gets or sets the comment for the FROM clause.
/// </summary>
public string? FromClauseComment { get; set; }
/// <summary>
/// Gets or sets the WHERE clause of the query.
/// </summary>
public string? WhereClause { get; set; }
/// <summary>
/// Gets or sets the comment for the WHERE clause.
/// </summary>
public string? WhereClauseComment { get; set; }
/// <summary>
/// Gets or sets the GROUP BY clause of the query.
/// </summary>
public string? GroupByClause { get; set; }
/// <summary>
/// Gets or sets the comment for the GROUP BY clause.
/// </summary>
public string? GroupByClauseComment { get; set; }
/// <summary>
/// Gets or sets the HAVING clause of the query.
/// </summary>
public string? HavingClause { get; set; }
/// <summary>
/// Gets or sets the comment for the HAVING clause.
/// </summary>
public string? HavingClauseComment { get; set; }
/// <summary>
/// Gets or sets the ORDER BY clause of the query.
/// </summary>
public string? OrderByClause { get; set; }
/// <summary>
/// Gets or sets the comment for the ORDER BY clause.
/// </summary>
public string? OrderByClauseComment { get; set; }
/// <summary>
/// Gets or sets the WITH clause (Common Table Expressions) as a JSON string.
/// </summary>
public string? WithClause { get; set; }
/// <summary>
/// Gets or sets the raw/original SQL statement before parsing and breakdown.
/// </summary>
public string? RawSql { get; set; }
/// <summary>
/// Gets or sets the setup clauses as a JSON string.
/// These are clauses to execute before the main statement.
/// </summary>
public string? SetupClausesJson { get; set; }
/// <summary>
/// Gets or sets the finish clauses as a JSON string.
/// These are clauses to execute after the main statement.
/// </summary>
public string? FinishClausesJson { get; set; }
/// <summary>
/// Gets or sets the parameters as a JSON string.
/// Contains parameter names and their values.
/// </summary>
public string? ParametersJson { get; set; }
/// <summary>
/// Gets or sets the timestamp when this entity was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the timestamp when this entity was last updated.
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Navigation property for the related query parameters.
/// </summary>
public virtual ICollection<QueryParameterEntity> Parameters { get; set; } = new List<QueryParameterEntity>();
/// <summary>
/// Navigation property for the related WITH clauses (CTEs).
/// </summary>
public virtual ICollection<WithClauseEntity> WithClauses { get; set; } = new List<WithClauseEntity>();
}
@@ -0,0 +1,38 @@
namespace Strata.SqlTools.EFCore.Models;
/// <summary>
/// Represents a query parameter entity for use with Entity Framework Core.
/// Stores query parameter names and their values with type information.
/// </summary>
public class QueryParameterEntity
{
/// <summary>
/// Gets or sets the unique identifier for this parameter.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the identifier of the parent query breakdown entity.
/// </summary>
public int QueryBreakdownEntityId { get; set; }
/// <summary>
/// Gets or sets the parameter name (e.g., "@ParameterName" or "ParameterName").
/// </summary>
public string ParameterName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the parameter value as a string representation.
/// </summary>
public string? ParameterValue { get; set; }
/// <summary>
/// Gets or sets the CLR type name of the parameter value for deserialization.
/// </summary>
public string? ParameterTypeName { get; set; }
/// <summary>
/// Navigation property to the parent QueryBreakdownEntity.
/// </summary>
public virtual QueryBreakdownEntity? QueryBreakdownEntity { get; set; }
}
@@ -0,0 +1,42 @@
namespace Strata.SqlTools.EFCore.Models;
/// <summary>
/// Represents a WITH clause (Common Table Expression) entity for Entity Framework Core mapping.
/// </summary>
public class WithClauseEntity
{
/// <summary>
/// Gets or sets the unique identifier for this WITH clause.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the identifier of the parent query breakdown entity.
/// </summary>
public int QueryBreakdownEntityId { get; set; }
/// <summary>
/// Gets or sets the name of the CTE (Common Table Expression).
/// </summary>
public string CteName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the column list for the CTE (optional).
/// </summary>
public string? ColumnList { get; set; }
/// <summary>
/// Gets or sets the definition/query of the CTE.
/// </summary>
public string CteDefinition { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the order of this CTE in the WITH clause.
/// </summary>
public int OrderIndex { get; set; }
/// <summary>
/// Navigation property to the parent QueryBreakdownEntity.
/// </summary>
public virtual QueryBreakdownEntity? QueryBreakdownEntity { get; set; }
}
+240
View File
@@ -0,0 +1,240 @@
# Strata.SqlTools.EFCore
> Entity Framework Core integration and support for Strata.SqlTools QueryBreakdown functionality
This project provides seamless integration between the Strata.SqlTools query analysis framework and Entity Framework Core, allowing you to persist, query, and manage `QueryBreakdown` objects within your existing EF Core DbContext.
## Key Features
- **EF Core Integration**: Map QueryBreakdown objects directly to your DbContext
- **Entity Models**: Fully normalized entity models for QueryBreakdownEntity, QueryParameterEntity, and WithClauseEntity
- **Automatic Mapping**: IQueryBreakdownMapper for converting between SQL Tools and EF Core models
- **Repository Pattern**: IQueryBreakdownRepository for simplified CRUD operations
- **DbContext Extensions**: Easy-to-use extension methods for DbContext integration
- **JSON Serialization**: Intelligent serialization of complex types (parameters, clauses) to JSON for efficient storage
## Installation
Add the NuGet package reference:
```xml
<PackageReference Include="Strata.SqlTools.EFCore" Version="1.0.0" />
```
Or via the .NET CLI:
```bash
dotnet add package Strata.SqlTools.EFCore
```
## Quick Start
### 1. Configure Your DbContext
Add the QueryBreakdown entities to your DbContext:
```csharp
using Microsoft.EntityFrameworkCore;
using Strata.SqlTools.EFCore.Models;
using Strata.SqlTools.EFCore.Configurations;
public class YourDbContext : DbContext
{
public DbSet<QueryBreakdownEntity> QueryBreakdowns { get; set; }
public DbSet<QueryParameterEntity> QueryParameters { get; set; }
public DbSet<WithClauseEntity> WithClauses { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure QueryBreakdown entities
modelBuilder.ConfigureQueryBreakdownEntities();
}
}
```
### 2. Register Services
Register the mapper and repository in your dependency injection container:
```csharp
services.AddScoped<IQueryBreakdownMapper, QueryBreakdownMapper>();
services.AddScoped<IQueryBreakdownRepository>(
provider => new QueryBreakdownRepository(
provider.GetRequiredService<YourDbContext>(),
provider.GetRequiredService<IQueryBreakdownMapper>()
)
);
```
### 3. Use the Repository
Inject and use the repository in your application:
```csharp
public class QueryService
{
private readonly IQueryBreakdownRepository _repository;
public QueryService(IQueryBreakdownRepository repository)
{
_repository = repository;
}
public async Task SaveQueryAsync(QueryBreakdown queryBreakdown)
{
int id = await _repository.AddAsync(queryBreakdown);
Console.WriteLine($"Query saved with ID: {id}");
}
public async Task<QueryBreakdown?> GetQueryAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
public async Task<List<QueryBreakdown>> GetAllQueriesAsync()
{
return await _repository.GetAllAsync();
}
public async Task UpdateQueryAsync(int id, QueryBreakdown queryBreakdown)
{
await _repository.UpdateAsync(id, queryBreakdown);
}
public async Task DeleteQueryAsync(int id)
{
bool deleted = await _repository.DeleteAsync(id);
Console.WriteLine(deleted ? "Query deleted." : "Query not found.");
}
}
```
## Entity Models
### QueryBreakdownEntity
The main entity that represents a SQL query breakdown:
- **Id**: Primary key
- **SelectClause**: The SELECT clause
- **FromClause**: The FROM clause
- **WhereClause**: The WHERE clause
- **GroupByClause**: The GROUP BY clause
- **HavingClause**: The HAVING clause
- **OrderByClause**: The ORDER BY clause
- **WithClause**: Common Table Expressions (CTEs)
- **RawSql**: Original SQL statement
- **SetupClausesJson**: JSON serialized setup clauses
- **FinishClausesJson**: JSON serialized finish clauses
- **ParametersJson**: JSON serialized parameters
- **CreatedAt**: Creation timestamp
- **UpdatedAt**: Last update timestamp
#### Related Entities
- **QueryParameterEntity**: Represents parameters used in the query
- **WithClauseEntity**: Represents individual Common Table Expressions (CTEs)
## Mapper Interface
The `IQueryBreakdownMapper` provides the following operations:
```csharp
public interface IQueryBreakdownMapper
{
QueryBreakdownEntity MapToEntity(QueryBreakdown queryBreakdown);
QueryBreakdown MapToDomainModel(QueryBreakdownEntity entity);
(QueryBreakdownEntity Entity, List<QueryParameterEntity> Parameters, List<WithClauseEntity> WithClauses) MapToEntityWithRelations(QueryBreakdown queryBreakdown);
QueryBreakdown MapToDomainModelWithRelations(QueryBreakdownEntity entity);
}
```
## Repository Interface
The `IQueryBreakdownRepository` provides the following operations:
```csharp
public interface IQueryBreakdownRepository
{
Task<int> AddAsync(QueryBreakdown queryBreakdown);
Task<QueryBreakdown?> GetByIdAsync(int id);
Task<QueryBreakdownEntity?> GetEntityByIdAsync(int id);
Task<List<QueryBreakdown>> GetAllAsync();
Task<List<QueryBreakdownEntity>> GetAllEntitiesAsync();
Task UpdateAsync(int id, QueryBreakdown queryBreakdown);
Task<bool> DeleteAsync(int id);
Task<int> GetCountAsync();
}
```
## Database Schema
The project includes three main tables:
### QueryBreakdowns Table
Stores the main query breakdown information
### QueryParameters Table
Stores individual query parameters with foreign key to QueryBreakdowns
### WithClauses Table
Stores Common Table Expressions with foreign key to QueryBreakdowns
## DbContext Extension Methods
```csharp
// Configure query breakdown entities during model creation
modelBuilder.ConfigureQueryBreakdownEntities();
// Get queryable sets from context
var queryBreakdowns = dbContext.GetQueryBreakdowns();
var parameters = dbContext.GetQueryParameters();
var withClauses = dbContext.GetWithClauses();
// Get a query breakdown with related data
var entity = await dbContext.GetQueryBreakdownWithRelatedDataAsync(id);
```
## Advanced Usage
### Custom Entity Configuration
If you need to customize the entity configuration, you can create your own configuration classes that implement `IEntityTypeConfiguration<T>`:
```csharp
public class CustomQueryBreakdownConfiguration : IEntityTypeConfiguration<QueryBreakdownEntity>
{
public void Configure(EntityTypeBuilder<QueryBreakdownEntity> builder)
{
// Apply custom configuration
builder.ToTable("CustomQueryBreakdowns", "dbo");
// ... other configurations
}
}
```
### Working with Existing DbContext
If you already have an existing DbContext, simply:
1. Add the DbSets for QueryBreakdown entities
2. Call `modelBuilder.ConfigureQueryBreakdownEntities()` in `OnModelCreating`
3. Create a migration: `dotnet ef migrations add AddQueryBreakdownEntities`
4. Update the database: `dotnet ef database update`
## Dependencies
- **Microsoft.EntityFrameworkCore** (8.0.0+)
- **Microsoft.EntityFrameworkCore.Relational** (8.0.0+)
- **Strata.SqlTools** (1.0.0+)
- **Strata.SqlTools.SqlServer** (1.0.0+)
## License
MIT
## Support
For issues, feature requests, or questions, please visit the [GitHub repository](https://github.com/stratadecision/sql-builder).
@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using Strata.SqlTools.EFCore.Models;
namespace Strata.SqlTools.EFCore.Services;
/// <summary>
/// Extension methods for DbContext to support QueryBreakdown entities.
/// </summary>
public static class DbContextExtensions
{
/// <summary>
/// Adds an entity configuration to the ModelBuilder for QueryBreakdown-related entities.
/// Call this in your DbContext.OnModelCreating method.
/// </summary>
/// <param name="modelBuilder">The ModelBuilder instance.</param>
/// <returns>The ModelBuilder instance for fluent chaining.</returns>
public static ModelBuilder ConfigureQueryBreakdownEntities(this ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
modelBuilder.ApplyConfiguration(new Configurations.QueryBreakdownEntityConfiguration());
modelBuilder.ApplyConfiguration(new Configurations.QueryParameterEntityConfiguration());
modelBuilder.ApplyConfiguration(new Configurations.WithClauseEntityConfiguration());
return modelBuilder;
}
/// <summary>
/// Gets a queryable set of QueryBreakdownEntity instances from the DbContext.
/// </summary>
/// <param name="context">The DbContext instance.</param>
/// <returns>An IQueryable of QueryBreakdownEntity.</returns>
public static IQueryable<QueryBreakdownEntity> GetQueryBreakdowns(this DbContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.Set<QueryBreakdownEntity>();
}
/// <summary>
/// Gets a queryable set of QueryParameterEntity instances from the DbContext.
/// </summary>
/// <param name="context">The DbContext instance.</param>
/// <returns>An IQueryable of QueryParameterEntity.</returns>
public static IQueryable<QueryParameterEntity> GetQueryParameters(this DbContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.Set<QueryParameterEntity>();
}
/// <summary>
/// Gets a queryable set of WithClauseEntity instances from the DbContext.
/// </summary>
/// <param name="context">The DbContext instance.</param>
/// <returns>An IQueryable of WithClauseEntity.</returns>
public static IQueryable<WithClauseEntity> GetWithClauses(this DbContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.Set<WithClauseEntity>();
}
/// <summary>
/// Includes query breakdown related data and returns a single QueryBreakdownEntity by ID.
/// </summary>
/// <param name="context">The DbContext instance.</param>
/// <param name="id">The ID of the QueryBreakdownEntity to retrieve.</param>
/// <returns>The QueryBreakdownEntity with related entities included, or null if not found.</returns>
public static async Task<QueryBreakdownEntity?> GetQueryBreakdownWithRelatedDataAsync(this DbContext context, int id)
{
ArgumentNullException.ThrowIfNull(context);
return await context.Set<QueryBreakdownEntity>()
.FirstOrDefaultAsync(q => q.Id == id);
}
}
@@ -0,0 +1,279 @@
using System.Collections;
using System.Text.Json;
using Strata.SqlTools.Breakdowns.SqlServer;
using Strata.SqlTools.EFCore.Abstractions;
using Strata.SqlTools.EFCore.Models;
namespace Strata.SqlTools.EFCore.Services;
/// <summary>
/// Implementation of IQueryBreakdownMapper for converting between QueryBreakdown and QueryBreakdownEntity.
/// </summary>
public class QueryBreakdownMapper : IQueryBreakdownMapper
{
/// <summary>
/// Converts a QueryBreakdown (SQL Tools) to a QueryBreakdownEntity (EF Core).
/// </summary>
public QueryBreakdownEntity MapToEntity(QueryBreakdown queryBreakdown)
{
ArgumentNullException.ThrowIfNull(queryBreakdown);
var entity = new QueryBreakdownEntity
{
SelectClause = queryBreakdown.SelectClause?.Clause,
SelectClauseComment = queryBreakdown.SelectClause?.Comment,
FromClause = queryBreakdown.FromClause?.Clause,
FromClauseComment = queryBreakdown.FromClause?.Comment,
WhereClause = queryBreakdown.WhereClause?.Clause,
WhereClauseComment = queryBreakdown.WhereClause?.Comment,
GroupByClause = queryBreakdown.GroupByClause?.Clause,
GroupByClauseComment = queryBreakdown.GroupByClause?.Comment,
HavingClause = queryBreakdown.HavingClause?.Clause,
HavingClauseComment = queryBreakdown.HavingClause?.Comment,
OrderByClause = queryBreakdown.OrderByClause?.Clause,
OrderByClauseComment = queryBreakdown.OrderByClause?.Comment,
WithClause = queryBreakdown.GetWithClauseValue(),
RawSql = queryBreakdown.RawSql,
SetupClausesJson = SerializeList(queryBreakdown.SetupClauses),
FinishClausesJson = SerializeArrayList(queryBreakdown.FinishClauses),
ParametersJson = SerializeDictionary(queryBreakdown.Parameters),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
return entity;
}
/// <summary>
/// Converts a QueryBreakdownEntity (EF Core) back to a QueryBreakdown (SQL Tools).
/// </summary>
public QueryBreakdown MapToDomainModel(QueryBreakdownEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
var queryBreakdown = new QueryBreakdown();
// Set clause properties
if (!string.IsNullOrEmpty(entity.SelectClause))
{
queryBreakdown.SelectClause.Clause = entity.SelectClause;
queryBreakdown.SelectClause.Comment = entity.SelectClauseComment;
}
if (!string.IsNullOrEmpty(entity.FromClause))
{
queryBreakdown.FromClause.Clause = entity.FromClause;
queryBreakdown.FromClause.Comment = entity.FromClauseComment;
}
if (!string.IsNullOrEmpty(entity.WhereClause))
{
queryBreakdown.WhereClause.Clause = entity.WhereClause;
queryBreakdown.WhereClause.Comment = entity.WhereClauseComment;
}
if (!string.IsNullOrEmpty(entity.GroupByClause))
{
queryBreakdown.GroupByClause.Clause = entity.GroupByClause;
queryBreakdown.GroupByClause.Comment = entity.GroupByClauseComment;
}
if (!string.IsNullOrEmpty(entity.HavingClause))
{
queryBreakdown.HavingClause.Clause = entity.HavingClause;
queryBreakdown.HavingClause.Comment = entity.HavingClauseComment;
}
if (!string.IsNullOrEmpty(entity.OrderByClause))
{
queryBreakdown.OrderByClause.Clause = entity.OrderByClause;
queryBreakdown.OrderByClause.Comment = entity.OrderByClauseComment;
}
if (!string.IsNullOrEmpty(entity.WithClause))
{
queryBreakdown.SetWithClauseValue(entity.WithClause);
}
if (!string.IsNullOrEmpty(entity.RawSql))
{
queryBreakdown.RawSql = entity.RawSql;
}
// Restore setup clauses
if (!string.IsNullOrEmpty(entity.SetupClausesJson))
{
var setupClauses = DeserializeList(entity.SetupClausesJson);
queryBreakdown.SetupClauses.Clear();
foreach (var clause in setupClauses)
{
queryBreakdown.SetupClauses.Add(clause);
}
}
// Restore finish clauses
if (!string.IsNullOrEmpty(entity.FinishClausesJson))
{
var finishClauses = DeserializeArrayList(entity.FinishClausesJson);
queryBreakdown.FinishClauses.Clear();
foreach (var clause in finishClauses)
{
queryBreakdown.FinishClauses.Add(clause);
}
}
// Restore parameters
if (!string.IsNullOrEmpty(entity.ParametersJson))
{
var parameters = DeserializeDictionary(entity.ParametersJson);
queryBreakdown.Parameters.Clear();
foreach (var kvp in parameters)
{
queryBreakdown.Parameters[kvp.Key] = kvp.Value;
}
}
return queryBreakdown;
}
/// <summary>
/// Converts a QueryBreakdown to a QueryBreakdownEntity with related entities.
/// </summary>
public (QueryBreakdownEntity Entity, List<QueryParameterEntity> Parameters, List<WithClauseEntity> WithClauses) MapToEntityWithRelations(QueryBreakdown queryBreakdown)
{
ArgumentNullException.ThrowIfNull(queryBreakdown);
var entity = MapToEntity(queryBreakdown);
// Map parameters
var parameterEntities = new List<QueryParameterEntity>();
foreach (var param in queryBreakdown.ParameterList)
{
parameterEntities.Add(new QueryParameterEntity
{
ParameterName = param.Name,
ParameterValue = param.Value?.ToString(),
ParameterTypeName = param.Value?.GetType().FullName
});
}
// Map WITH clauses
var withClauseEntities = new List<WithClauseEntity>();
int orderIndex = 0;
foreach (var withClause in queryBreakdown.WithClauses)
{
withClauseEntities.Add(new WithClauseEntity
{
CteName = withClause.TableName,
ColumnList = withClause.Clause,
CteDefinition = withClause.Sql?.SelectClause?.Clause ?? string.Empty,
OrderIndex = orderIndex++
});
}
return (entity, parameterEntities, withClauseEntities);
}
/// <summary>
/// Converts a QueryBreakdownEntity with related entities back to a QueryBreakdown.
/// </summary>
public QueryBreakdown MapToDomainModelWithRelations(QueryBreakdownEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
var queryBreakdown = MapToDomainModel(entity);
// Reconstruct Parameters dictionary from parameter entities if they are loaded
if (entity.Parameters != null && entity.Parameters.Count > 0)
{
queryBreakdown.Parameters.Clear();
foreach (var paramEntity in entity.Parameters)
{
// Store with @ prefix to match how AddParameter works
var key = paramEntity.ParameterName.StartsWith('@')
? paramEntity.ParameterName
: $"@{paramEntity.ParameterName}";
// Deserialize value if type information is available
object? value = paramEntity.ParameterValue;
if (!string.IsNullOrEmpty(paramEntity.ParameterTypeName) && !string.IsNullOrEmpty(paramEntity.ParameterValue))
{
var type = Type.GetType(paramEntity.ParameterTypeName);
if (type != null)
{
try
{
value = Convert.ChangeType(paramEntity.ParameterValue, type);
}
catch
{
// If conversion fails, use string value
value = paramEntity.ParameterValue;
}
}
}
queryBreakdown.Parameters[key] = value;
}
}
return queryBreakdown;
}
#region Helper Methods
internal static string SerializeList(List<string> list)
{
return JsonSerializer.Serialize(list);
}
internal static List<string> DeserializeList(string json)
{
return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
}
internal static string SerializeArrayList(ArrayList list)
{
var stringList = new List<string>();
foreach (var item in list)
{
stringList.Add(item?.ToString() ?? string.Empty);
}
return JsonSerializer.Serialize(stringList);
}
internal static ArrayList DeserializeArrayList(string json)
{
var stringList = JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
var arrayList = new ArrayList();
foreach (var item in stringList)
{
arrayList.Add(item);
}
return arrayList;
}
internal static string SerializeDictionary(Dictionary<string, object> dict)
{
var stringDict = new Dictionary<string, string>();
foreach (var kvp in dict)
{
stringDict[kvp.Key] = kvp.Value?.ToString() ?? string.Empty;
}
return JsonSerializer.Serialize(stringDict);
}
internal static Dictionary<string, object> DeserializeDictionary(string json)
{
var stringDict = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new Dictionary<string, string>();
var result = new Dictionary<string, object>();
foreach (var kvp in stringDict)
{
result[kvp.Key] = kvp.Value;
}
return result;
}
#endregion
}
@@ -0,0 +1,243 @@
using Microsoft.EntityFrameworkCore;
using Strata.SqlTools.Breakdowns.SqlServer;
using Strata.SqlTools.EFCore.Abstractions;
using Strata.SqlTools.EFCore.Models;
namespace Strata.SqlTools.EFCore.Services;
/// <summary>
/// Interface for a generic repository pattern for QueryBreakdown entities.
/// Provides a simplified API for common database operations.
/// </summary>
public interface IQueryBreakdownRepository
{
/// <summary>
/// Adds a new QueryBreakdown to the repository and saves changes.
/// </summary>
/// <param name="queryBreakdown">The QueryBreakdown to add.</param>
/// <returns>The ID of the added entity.</returns>
Task<int> AddAsync(QueryBreakdown queryBreakdown);
/// <summary>
/// Retrieves a QueryBreakdown by ID and converts it from the entity.
/// </summary>
/// <param name="id">The ID of the QueryBreakdown entity.</param>
/// <returns>The QueryBreakdown, or null if not found.</returns>
Task<QueryBreakdown?> GetByIdAsync(int id);
/// <summary>
/// Retrieves a QueryBreakdownEntity by ID.
/// </summary>
/// <param name="id">The ID of the entity.</param>
/// <returns>The QueryBreakdownEntity, or null if not found.</returns>
Task<QueryBreakdownEntity?> GetEntityByIdAsync(int id);
/// <summary>
/// Gets all QueryBreakdowns.
/// </summary>
/// <returns>A list of all QueryBreakdowns.</returns>
Task<List<QueryBreakdown>> GetAllAsync();
/// <summary>
/// Gets all QueryBreakdownEntities.
/// </summary>
/// <returns>A list of all QueryBreakdownEntities.</returns>
Task<List<QueryBreakdownEntity>> GetAllEntitiesAsync();
/// <summary>
/// Updates an existing QueryBreakdown and saves changes.
/// </summary>
/// <param name="id">The ID of the entity to update.</param>
/// <param name="queryBreakdown">The updated QueryBreakdown.</param>
Task UpdateAsync(int id, QueryBreakdown queryBreakdown);
/// <summary>
/// Deletes a QueryBreakdown by ID and saves changes.
/// </summary>
/// <param name="id">The ID of the entity to delete.</param>
/// <returns>True if the entity was deleted; false if not found.</returns>
Task<bool> DeleteAsync(int id);
/// <summary>
/// Gets the count of all QueryBreakdown entities.
/// </summary>
/// <returns>The count of entities.</returns>
Task<int> GetCountAsync();
}
/// <summary>
/// Implementation of IQueryBreakdownRepository for managing QueryBreakdown entities in Entity Framework Core.
/// </summary>
public class QueryBreakdownRepository : IQueryBreakdownRepository
{
private readonly DbContext _context;
private readonly IQueryBreakdownMapper _mapper;
/// <summary>
/// Initializes a new instance of the QueryBreakdownRepository.
/// </summary>
/// <param name="context">The EF Core DbContext.</param>
/// <param name="mapper">The mapper for converting between QueryBreakdown and QueryBreakdownEntity.</param>
public QueryBreakdownRepository(DbContext context, IQueryBreakdownMapper mapper)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(mapper);
_context = context;
_mapper = mapper;
}
/// <summary>
/// Adds a new QueryBreakdown to the repository and saves changes.
/// </summary>
public async Task<int> AddAsync(QueryBreakdown queryBreakdown)
{
ArgumentNullException.ThrowIfNull(queryBreakdown);
var (entity, parameters, withClauses) = _mapper.MapToEntityWithRelations(queryBreakdown);
// Add the main entity
_context.Set<QueryBreakdownEntity>().Add(entity);
await _context.SaveChangesAsync();
// Add related entities with foreign key set
foreach (var param in parameters)
{
param.QueryBreakdownEntityId = entity.Id;
_context.Set<QueryParameterEntity>().Add(param);
}
foreach (var withClause in withClauses)
{
withClause.QueryBreakdownEntityId = entity.Id;
_context.Set<WithClauseEntity>().Add(withClause);
}
await _context.SaveChangesAsync();
return entity.Id;
}
/// <summary>
/// Retrieves a QueryBreakdown by ID and converts it from the entity.
/// </summary>
public async Task<QueryBreakdown?> GetByIdAsync(int id)
{
var entity = await _context.Set<QueryBreakdownEntity>()
.Include(e => e.Parameters)
.Include(e => e.WithClauses)
.FirstOrDefaultAsync(e => e.Id == id);
return entity != null ? _mapper.MapToDomainModelWithRelations(entity) : null;
}
/// <summary>
/// Retrieves a QueryBreakdownEntity by ID.
/// </summary>
public async Task<QueryBreakdownEntity?> GetEntityByIdAsync(int id)
{
return await _context.Set<QueryBreakdownEntity>()
.FirstOrDefaultAsync(e => e.Id == id);
}
/// <summary>
/// Gets all QueryBreakdowns.
/// </summary>
public async Task<List<QueryBreakdown>> GetAllAsync()
{
var entities = await _context.Set<QueryBreakdownEntity>().ToListAsync();
return entities.ConvertAll(e => _mapper.MapToDomainModel(e));
}
/// <summary>
/// Gets all QueryBreakdownEntities.
/// </summary>
public async Task<List<QueryBreakdownEntity>> GetAllEntitiesAsync()
{
return await _context.Set<QueryBreakdownEntity>().ToListAsync();
}
/// <summary>
/// Updates an existing QueryBreakdown and saves changes.
/// </summary>
public async Task UpdateAsync(int id, QueryBreakdown queryBreakdown)
{
ArgumentNullException.ThrowIfNull(queryBreakdown);
var entity = await _context.Set<QueryBreakdownEntity>().FirstOrDefaultAsync(e => e.Id == id);
if (entity == null)
{
throw new InvalidOperationException($"QueryBreakdown with ID {id} not found.");
}
var updatedEntity = _mapper.MapToEntity(queryBreakdown);
// Update the main entity
entity.SelectClause = updatedEntity.SelectClause;
entity.SelectClauseComment = updatedEntity.SelectClauseComment;
entity.FromClause = updatedEntity.FromClause;
entity.FromClauseComment = updatedEntity.FromClauseComment;
entity.WhereClause = updatedEntity.WhereClause;
entity.WhereClauseComment = updatedEntity.WhereClauseComment;
entity.GroupByClause = updatedEntity.GroupByClause;
entity.GroupByClauseComment = updatedEntity.GroupByClauseComment;
entity.HavingClause = updatedEntity.HavingClause;
entity.HavingClauseComment = updatedEntity.HavingClauseComment;
entity.OrderByClause = updatedEntity.OrderByClause;
entity.OrderByClauseComment = updatedEntity.OrderByClauseComment;
entity.WithClause = updatedEntity.WithClause;
entity.RawSql = updatedEntity.RawSql;
entity.SetupClausesJson = updatedEntity.SetupClausesJson;
entity.FinishClausesJson = updatedEntity.FinishClausesJson;
entity.ParametersJson = updatedEntity.ParametersJson;
entity.UpdatedAt = DateTime.UtcNow;
// Delete and recreate related entities
var existingParameters = _context.Set<QueryParameterEntity>().Where(p => p.QueryBreakdownEntityId == id);
_context.Set<QueryParameterEntity>().RemoveRange(existingParameters);
var existingWithClauses = _context.Set<WithClauseEntity>().Where(w => w.QueryBreakdownEntityId == id);
_context.Set<WithClauseEntity>().RemoveRange(existingWithClauses);
var (_, parameters, withClauses) = _mapper.MapToEntityWithRelations(queryBreakdown);
foreach (var param in parameters)
{
param.QueryBreakdownEntityId = id;
_context.Set<QueryParameterEntity>().Add(param);
}
foreach (var withClause in withClauses)
{
withClause.QueryBreakdownEntityId = id;
_context.Set<WithClauseEntity>().Add(withClause);
}
await _context.SaveChangesAsync();
}
/// <summary>
/// Deletes a QueryBreakdown by ID and saves changes.
/// </summary>
public async Task<bool> DeleteAsync(int id)
{
var entity = await _context.Set<QueryBreakdownEntity>().FirstOrDefaultAsync(e => e.Id == id);
if (entity == null)
{
return false;
}
_context.Set<QueryBreakdownEntity>().Remove(entity);
await _context.SaveChangesAsync();
return true;
}
/// <summary>
/// Gets the count of all QueryBreakdown entities.
/// </summary>
public async Task<int> GetCountAsync()
{
return await _context.Set<QueryBreakdownEntity>().CountAsync();
}
}
@@ -0,0 +1,52 @@
<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.EFCore</PackageId>
<Version>1.0.0</Version>
<Authors>Strata Decision Technology</Authors>
<Company>Strata Decision Technology</Company>
<Product>Strata SQL Utilities - EF Core</Product>
<Description>Entity Framework Core integration and support for Strata.SqlTools QueryBreakdown functionality, allowing seamless mapping of SQL query breakdowns onto existing DbContext and database models.</Description>
<PackageTags>sql;efcore;entity-framework;query-builder;database;orm</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 EF Core integration for QueryBreakdown functionality.</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.SqlBreakdown\Strata.SqlTools.SqlBreakdown.csproj" />
<ProjectReference Include="..\Strata.SqlTools.SqlServer\Strata.SqlTools.SqlServer.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,281 @@
using Strata.SqlTools.Breakdowns.LinqToSql;
using Strata.SqlTools.Comparers.LinqToSql;
namespace Strata.SqlTools.Analyzers.LinqToSql;
/// <summary>
/// Statistics about a collection of analyzed queries.
/// </summary>
public record QueryCollectionStatistics(
int TotalQueries,
int UniqueQueries,
List<LinqQueryBreakdown> DuplicateQueries,
Dictionary<string, int> TableUsageFrequency,
Dictionary<string, int> ColumnSelectionFrequency,
int QueriesWithoutWhere,
int QueriesWithoutOrderBy,
int QueriesWithSelectAll,
double AverageComplexity,
int ComplexQueriesCount
)
{
/// <summary>
/// Gets the deduplication rate (unique queries / total queries).
/// </summary>
public double DeduplicationRate => TotalQueries == 0 ? 0.0 : (double)UniqueQueries / TotalQueries;
/// <summary>
/// Gets a formatted statistics report.
/// </summary>
public string GetReport()
{
var report = new System.Text.StringBuilder();
report.AppendLine("Query Collection Analysis Report");
report.AppendLine("================================");
report.AppendLine($"Total Queries: {TotalQueries}");
report.AppendLine($"Unique Queries: {UniqueQueries} ({DeduplicationRate * 100:F1}%)");
report.AppendLine($"Duplicate Queries: {DuplicateQueries.Count}");
report.AppendLine();
report.AppendLine("Query Characteristics:");
report.AppendLine($" Queries without WHERE: {QueriesWithoutWhere}");
report.AppendLine($" Queries without ORDER BY: {QueriesWithoutOrderBy}");
report.AppendLine($" Queries with SELECT *: {QueriesWithSelectAll}");
report.AppendLine();
report.AppendLine("Complexity Analysis:");
report.AppendLine($" Average Complexity Level: {AverageComplexity:F2}");
report.AppendLine($" Complex Queries: {ComplexQueriesCount}");
report.AppendLine();
if (TableUsageFrequency.Count > 0)
{
report.AppendLine("Most Frequently Used Tables:");
foreach (var kvp in TableUsageFrequency.OrderByDescending(x => x.Value).Take(5))
{
report.AppendLine($" {kvp.Key}: {kvp.Value} times");
}
report.AppendLine();
}
if (ColumnSelectionFrequency.Count > 0)
{
report.AppendLine("Most Frequently Selected Columns:");
foreach (var kvp in ColumnSelectionFrequency.OrderByDescending(x => x.Value).Take(5))
{
report.AppendLine($" {kvp.Key}: {kvp.Value} times");
}
}
return report.ToString();
}
}
/// <summary>
/// Analyzes a collection of LinqQueryBreakdown queries for patterns, duplicates, and statistics.
/// </summary>
public class QueryCollectionAnalyzer
{
private readonly List<LinqQueryBreakdown> _queries;
/// <summary>
/// Initializes a new instance of the <see cref="QueryCollectionAnalyzer"/> class.
/// </summary>
/// <param name="queries">The queries to analyze.</param>
public QueryCollectionAnalyzer(IEnumerable<LinqQueryBreakdown> queries)
{
_queries = queries?.ToList() ?? throw new ArgumentNullException(nameof(queries));
}
/// <summary>
/// Analyzes the query collection and returns comprehensive statistics.
/// </summary>
/// <returns>Statistics about the query collection.</returns>
public QueryCollectionStatistics Analyze()
{
if (_queries.Count == 0)
{
return new QueryCollectionStatistics(
0, 0, new List<LinqQueryBreakdown>(),
new Dictionary<string, int>(),
new Dictionary<string, int>(),
0, 0, 0, 0.0, 0);
}
var duplicates = FindDuplicates();
var uniqueCount = _queries.Count - duplicates.Count;
var tableUsage = AnalyzeTableUsage();
var columnUsage = AnalyzeColumnUsage();
var queriesWithoutWhere = _queries.Count(q => string.IsNullOrWhiteSpace(q.WhereClause?.Clause));
var queriesWithoutOrderBy = _queries.Count(q => string.IsNullOrWhiteSpace(q.OrderByClause?.Clause));
var queriesWithSelectAll = _queries.Count(q =>
q.SelectClause?.Clause?.Trim() == "*");
var complexityScores = _queries.Select(q => GetComplexityScore(q)).ToList();
var avgComplexity = complexityScores.Average();
var complexQueries = complexityScores.Count(c => c >= 7);
return new QueryCollectionStatistics(
_queries.Count,
uniqueCount,
duplicates,
tableUsage,
columnUsage,
queriesWithoutWhere,
queriesWithoutOrderBy,
queriesWithSelectAll,
avgComplexity,
complexQueries);
}
/// <summary>
/// Finds duplicate queries in the collection.
/// </summary>
/// <returns>List of queries that are identical to another query in the collection.</returns>
public List<LinqQueryBreakdown> FindDuplicates()
{
var duplicates = new List<LinqQueryBreakdown>();
for (int i = 0; i < _queries.Count; i++)
{
for (int j = i + 1; j < _queries.Count; j++)
{
if (QueryComparator.AreQueriesIdentical(_queries[i], _queries[j]) && !duplicates.Contains(_queries[j]))
{
duplicates.Add(_queries[j]);
}
}
}
return duplicates;
}
/// <summary>
/// Finds similar queries that are not identical but have high similarity.
/// </summary>
/// <param name="minimumSimilarity">Minimum similarity score (0.0-1.0).</param>
/// <returns>Pairs of similar queries and their similarity scores.</returns>
public List<(LinqQueryBreakdown Query1, LinqQueryBreakdown Query2, double Similarity)> FindSimilarQueries(double minimumSimilarity = 0.75)
{
var similarPairs = new List<(LinqQueryBreakdown, LinqQueryBreakdown, double)>();
for (int i = 0; i < _queries.Count; i++)
{
for (int j = i + 1; j < _queries.Count; j++)
{
var similarity = QueryComparator.GetSimilarity(_queries[i], _queries[j]);
if (similarity >= minimumSimilarity && similarity < 1.0)
{
similarPairs.Add((_queries[i], _queries[j], similarity));
}
}
}
return similarPairs.OrderByDescending(x => x.Item3).ToList();
}
/// <summary>
/// Analyzes table usage frequency across all queries.
/// </summary>
/// <returns>Dictionary of table names and their usage counts.</returns>
private Dictionary<string, int> AnalyzeTableUsage()
{
var tableUsage = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var query in _queries)
{
var table = query.FromClause?.Clause?.Trim();
if (!string.IsNullOrWhiteSpace(table))
{
if (tableUsage.ContainsKey(table))
{
tableUsage[table]++;
}
else
{
tableUsage[table] = 1;
}
}
}
return tableUsage;
}
/// <summary>
/// Analyzes column selection frequency across all queries.
/// </summary>
/// <returns>Dictionary of column names and their selection frequency.</returns>
private Dictionary<string, int> AnalyzeColumnUsage()
{
var columnUsage = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var query in _queries)
{
var selectClause = query.SelectClause?.Clause;
if (string.IsNullOrWhiteSpace(selectClause) || selectClause.Trim() == "*")
{
continue;
}
// Split columns and count them
var columns = selectClause.Split(',');
foreach (var col in columns)
{
var columnName = col.Trim();
if (columnUsage.ContainsKey(columnName))
{
columnUsage[columnName]++;
}
else
{
columnUsage[columnName] = 1;
}
}
}
return columnUsage;
}
/// <summary>
/// Calculates a complexity score for a query (0-10).
/// </summary>
private static int GetComplexityScore(LinqQueryBreakdown query)
{
int score = 1; // Base score for having a query
if (!string.IsNullOrWhiteSpace(query.WhereClause?.Clause))
{ score += 2; }
if (!string.IsNullOrWhiteSpace(query.GroupByClause?.Clause))
{ score += 2; }
if (!string.IsNullOrWhiteSpace(query.HavingClause?.Clause))
{ score += 2; }
if (!string.IsNullOrWhiteSpace(query.OrderByClause?.Clause))
{ score += 1; }
// Bonus points for complex WHERE conditions
var whereClause = query.WhereClause?.Clause ?? string.Empty;
var complexityIndicators = new[] { " AND ", " OR ", "IN (", "BETWEEN", "LIKE" };
var complexParts = complexityIndicators.Count(ind => whereClause.Contains(ind, StringComparison.OrdinalIgnoreCase));
score += Math.Min(complexParts, 2); // Cap at +2
return Math.Min(score, 10); // Cap at 10
}
/// <summary>
/// Creates a new analyzer for the given queries.
/// </summary>
/// <param name="queries">The queries to analyze.</param>
/// <returns>A new QueryCollectionAnalyzer instance.</returns>
public static QueryCollectionAnalyzer Analyze(IEnumerable<LinqQueryBreakdown> queries)
{
return new QueryCollectionAnalyzer(queries);
}
/// <summary>
/// Gets a summary report for the query collection.
/// </summary>
/// <returns>A formatted analysis report.</returns>
public string GetReport()
{
return Analyze().GetReport();
}
}
@@ -0,0 +1,675 @@
using System.Linq.Expressions;
using Strata.SqlTools.Breakdowns.SqlServer;
using PostgreSqlBreakdown = Strata.SqlTools.Breakdowns.PostgreSql.QueryBreakdown;
using SnowflakeBreakdown = Strata.SqlTools.Breakdowns.Snowflake.QueryBreakdown;
namespace Strata.SqlTools.Breakdowns.LinqToSql;
/// <summary>
/// Represents a LINQ to SQL query breakdown, analyzing IQueryable expressions
/// and converting them to SQL Server QueryBreakdown format.
/// </summary>
/// <remarks>
/// This class analyzes LINQ expression trees to extract query components such as
/// SELECT, WHERE, JOIN, GROUP BY, and ORDER BY clauses, making them accessible
/// through the QueryBreakdown interface.
/// </remarks>
[Serializable]
public class LinqQueryBreakdown : QueryBreakdown
{
/// <summary>
/// Gets or sets the original LINQ expression that was analyzed.
/// </summary>
public Expression? OriginalExpression { get; set; }
/// <summary>
/// Gets or sets the type of the entity being queried.
/// </summary>
public Type? EntityType { get; set; }
/// <summary>
/// Gets or sets whether this query uses LINQ method syntax.
/// </summary>
public bool IsMethodSyntax { get; set; } = true;
/// <summary>
/// Gets or sets the list of LINQ method calls in the query chain.
/// </summary>
public List<string> MethodCallChain { get; set; } = new();
/// <summary>
/// Initializes a new instance of the <see cref="LinqQueryBreakdown"/> class.
/// </summary>
public LinqQueryBreakdown() : base()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="LinqQueryBreakdown"/> class with SELECT and FROM clauses.
/// </summary>
/// <param name="selectClause">The SELECT clause.</param>
/// <param name="fromClause">The FROM clause (table name or data source).</param>
public LinqQueryBreakdown(string selectClause, string fromClause) : base(selectClause, fromClause)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="LinqQueryBreakdown"/> class with SELECT, FROM, and WHERE clauses.
/// </summary>
/// <param name="selectClause">The SELECT clause.</param>
/// <param name="fromClause">The FROM clause (table name or data source).</param>
/// <param name="whereClause">The WHERE clause.</param>
public LinqQueryBreakdown(string selectClause, string fromClause, string whereClause)
: base(selectClause, fromClause, whereClause)
{
}
/// <summary>
/// Analyzes an IQueryable LINQ query and creates a LinqQueryBreakdown.
/// </summary>
/// <typeparam name="T">The entity type being queried.</typeparam>
/// <param name="query">The IQueryable query to analyze.</param>
/// <returns>A LinqQueryBreakdown representing the query structure.</returns>
public static LinqQueryBreakdown Analyze<T>(IQueryable<T> query)
{
if (query == null)
{
throw new ArgumentNullException(nameof(query));
}
var breakdown = new LinqQueryBreakdown
{
OriginalExpression = query.Expression,
EntityType = typeof(T)
};
var visitor = new Visitors.LinqToSql.LinqExpressionVisitor();
visitor.Visit(query.Expression);
// Extract components from visitor
breakdown.SelectClause.Clause = visitor.SelectClause ?? "*";
breakdown.FromClause.Clause = visitor.FromClause ?? typeof(T).Name;
if (!string.IsNullOrEmpty(visitor.WhereClause))
{
breakdown.WhereClause.Clause = visitor.WhereClause;
}
if (!string.IsNullOrEmpty(visitor.OrderByClause))
{
breakdown.OrderByClause.Clause = visitor.OrderByClause;
}
if (!string.IsNullOrEmpty(visitor.GroupByClause))
{
breakdown.GroupByClause.Clause = visitor.GroupByClause;
}
breakdown.MethodCallChain = visitor.MethodCallChain;
return breakdown;
}
/// <summary>
/// Tries to analyze an IQueryable LINQ query and create a LinqQueryBreakdown.
/// </summary>
/// <typeparam name="T">The entity type being queried.</typeparam>
/// <param name="query">The IQueryable query to analyze.</param>
/// <param name="result">The resulting LinqQueryBreakdown if successful.</param>
/// <param name="errorMessage">Error message if analysis fails.</param>
/// <returns>True if analysis succeeded; otherwise, false.</returns>
public static bool TryAnalyze<T>(IQueryable<T> query, out LinqQueryBreakdown result, out string errorMessage)
{
result = new LinqQueryBreakdown();
errorMessage = string.Empty;
try
{
result = Analyze(query);
return true;
}
catch (Exception ex)
{
errorMessage = ex.Message;
return false;
}
}
/// <summary>
/// Gets a summary of the LINQ query structure.
/// </summary>
/// <returns>A string describing the query composition.</returns>
public string GetQuerySummary()
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(SelectClause?.Clause))
{
parts.Add($"SELECT {SelectClause.Clause}");
}
if (!string.IsNullOrEmpty(FromClause?.Clause))
{
parts.Add($"FROM {FromClause.Clause}");
}
if (!string.IsNullOrEmpty(WhereClause?.Clause))
{
parts.Add($"WHERE {WhereClause.Clause}");
}
if (!string.IsNullOrEmpty(GroupByClause?.Clause))
{
parts.Add($"GROUP BY {GroupByClause.Clause}");
}
if (!string.IsNullOrEmpty(OrderByClause?.Clause))
{
parts.Add($"ORDER BY {OrderByClause.Clause}");
}
return string.Join(" ", parts);
}
/// <summary>
/// Gets the LINQ method call chain as a string.
/// </summary>
/// <returns>A string representing the method chain.</returns>
public string GetMethodChain()
{
if (MethodCallChain.Count == 0)
{
return "No method calls";
}
return string.Join(" -> ", MethodCallChain);
}
/// <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>An IQueryable of the specified type reconstructed from the breakdown, or null if the type doesn't match the original entity type.</returns>
/// <remarks>
/// This method attempts to reconstruct a LINQ query from the analyzed components (WHERE, ORDER BY, etc.).
/// If a data source (IQueryable) is available in the breakdown's OriginalExpression, it will be used.
/// Otherwise, returns null to indicate the query cannot be reconstructed without the original data source.
/// </remarks>
public override IQueryable<T>? GetQuery<T>() where T : class
{
// If we don't have the original expression, we cannot reconstruct the LINQ query
if (OriginalExpression == null)
{
return null;
}
// The original expression is the full LINQ query that was analyzed
// To use it, we need it to be an IQueryable<T>
try
{
// If the original expression can be converted to IQueryable<T>, use it
// Otherwise, we cannot safely reconstruct without the original query provider
if (OriginalExpression is Expression expr && EntityType == typeof(T))
{
// We have the expression, but we don't have the provider to create IQueryable<T>
// The breakdown analysis is one-way; reconstruction requires the original provider
return null;
}
return null;
}
catch
{
// If any error occurs during reconstruction, return null
return null;
}
}
/// <summary>
/// Analyzes an INSERT operation for the given entity.
/// </summary>
/// <typeparam name="T">The entity type being inserted.</typeparam>
/// <param name="entity">The entity instance being inserted.</param>
/// <returns>An InsertBreakdown representing the insert operation.</returns>
public static Breakdowns.SqlServer.InsertBreakdown AnalyzeInsert<T>(T entity) where T : class
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
var breakdown = new Breakdowns.SqlServer.InsertBreakdown();
breakdown.TableName.Clause = typeof(T).Name;
// Extract property names and values from entity
var properties = typeof(T).GetProperties();
var columnNames = new List<string>();
var valuesList = new List<string>();
foreach (var prop in properties)
{
var value = prop.GetValue(entity);
columnNames.Add(prop.Name);
valuesList.Add(value?.ToString() ?? "NULL");
}
breakdown.InsertIntoClause.Clause = string.Join(", ", columnNames);
breakdown.ValuesClause.Clause = string.Join(", ", valuesList);
return breakdown;
}
/// <summary>
/// Analyzes an INSERT operation for multiple entities.
/// </summary>
/// <typeparam name="T">The entity type being inserted.</typeparam>
/// <param name="entities">The entities being inserted.</param>
/// <returns>An InsertBreakdown representing the bulk insert operation.</returns>
public static Breakdowns.SqlServer.InsertBreakdown AnalyzeInsertRange<T>(IEnumerable<T> entities) where T : class
{
var entitiesList = entities?.ToList() ?? new List<T>();
if (entitiesList.Count == 0)
{
throw new ArgumentException("Must provide at least one entity to insert.", nameof(entities));
}
var breakdown = new Breakdowns.SqlServer.InsertBreakdown();
breakdown.TableName.Clause = typeof(T).Name;
// Use first entity to get column names
var firstEntity = entitiesList.First();
var properties = typeof(T).GetProperties();
var columnNames = new List<string>();
foreach (var prop in properties)
{
columnNames.Add(prop.Name);
}
breakdown.InsertIntoClause.Clause = string.Join(", ", columnNames);
// Add values for each entity
var allValues = new List<string>();
foreach (var entity in entitiesList)
{
var rowValues = new List<string>();
foreach (var prop in properties)
{
var value = prop.GetValue(entity);
rowValues.Add(value?.ToString() ?? "NULL");
}
allValues.Add($"({string.Join(", ", rowValues)})");
}
breakdown.ValuesClause.Clause = string.Join(", ", allValues);
return breakdown;
}
/// <summary>
/// Analyzes a DELETE operation based on a filter expression.
/// </summary>
/// <typeparam name="T">The entity type being deleted.</typeparam>
/// <param name="filterExpression">The filter expression defining which entities to delete.</param>
/// <returns>A DeleteBreakdown representing the delete operation.</returns>
public static Breakdowns.SqlServer.DeleteBreakdown AnalyzeDelete<T>(Expression<Func<T, bool>> filterExpression) where T : class
{
if (filterExpression == null)
{
throw new ArgumentNullException(nameof(filterExpression));
}
var breakdown = new Breakdowns.SqlServer.DeleteBreakdown();
breakdown.FromClause.Clause = typeof(T).Name;
// Analyze the filter expression to extract WHERE clause
var visitor = new Visitors.LinqToSql.LinqExpressionVisitor();
visitor.Visit(filterExpression);
if (!string.IsNullOrEmpty(visitor.WhereClause))
{
breakdown.WhereClause.Clause = visitor.WhereClause;
}
return breakdown;
}
/// <summary>
/// Analyzes an UPDATE operation based on filter and update expressions.
/// </summary>
/// <typeparam name="T">The entity type being updated.</typeparam>
/// <param name="filterExpression">The filter expression defining which entities to update.</param>
/// <param name="updateExpression">The update expression defining what to update.</param>
/// <returns>An UpdateBreakdown representing the update operation.</returns>
public static Breakdowns.SqlServer.UpdateBreakdown AnalyzeUpdate<T>(
Expression<Func<T, bool>> filterExpression,
Expression<Func<T, T>> updateExpression) where T : class
{
if (filterExpression == null)
{
throw new ArgumentNullException(nameof(filterExpression));
}
if (updateExpression == null)
{
throw new ArgumentNullException(nameof(updateExpression));
}
var breakdown = new Breakdowns.SqlServer.UpdateBreakdown();
breakdown.TableName.Clause = typeof(T).Name;
// Analyze filter expression for WHERE clause
var filterVisitor = new Visitors.LinqToSql.LinqExpressionVisitor();
filterVisitor.Visit(filterExpression);
if (!string.IsNullOrEmpty(filterVisitor.WhereClause))
{
breakdown.WhereClause.Clause = filterVisitor.WhereClause;
}
// For the SET clause, we collect property assignments
var setClauseParts = new List<string>();
if (updateExpression.Body is System.Linq.Expressions.NewExpression newExpr)
{
for (int i = 0; i < newExpr.Arguments.Count; i++)
{
var arg = newExpr.Arguments[i];
var member = newExpr.Members?[i];
if (member != null)
{
setClauseParts.Add($"{member.Name} = {arg}");
}
}
}
if (setClauseParts.Count > 0)
{
breakdown.SetClause.Clause = string.Join(", ", setClauseParts);
}
return breakdown;
}
/// <summary>
/// Analyzes a procedure call breakdown.
/// </summary>
/// <param name="procedureName">The name of the stored procedure.</param>
/// <param name="parameters">The procedure parameters.</param>
/// <returns>A ProcedureBreakdown representing the procedure call.</returns>
public static Breakdowns.SqlServer.ProcedureBreakdown AnalyzeProcedure(string procedureName, params object[] parameters)
{
if (string.IsNullOrWhiteSpace(procedureName))
{
throw new ArgumentException("Procedure name cannot be null or empty.", nameof(procedureName));
}
var breakdown = new Breakdowns.SqlServer.ProcedureBreakdown();
breakdown.ProcedureName.Clause = procedureName;
if (parameters != null && parameters.Length > 0)
{
for (int i = 0; i < parameters.Length; i++)
{
var paramName = $"@param{i}";
var paramValue = parameters[i]?.ToString() ?? "NULL";
breakdown.Parameters.Add(paramName, paramValue);
}
}
return breakdown;
}
/// <summary>
/// Analyzes a query execution trace context.
/// </summary>
/// <typeparam name="T">The entity type being traced.</typeparam>
/// <param name="query">The query being traced.</param>
/// <param name="executionContext">Additional execution context.</param>
/// <returns>A string representation of the trace analysis.</returns>
public static string AnalyzeTrace<T>(IQueryable<T> query, string? executionContext = null) where T : class
{
if (query == null)
{
throw new ArgumentNullException(nameof(query));
}
var lines = new List<string>
{
$"Trace Context for {typeof(T).Name}",
$"Entity Type: {typeof(T).FullName}",
$"Query Provider: {query.Provider?.GetType().Name ?? "Unknown"}",
$"Expression: {query.Expression}"
};
if (!string.IsNullOrWhiteSpace(executionContext))
{
lines.Add($"Execution Context: {executionContext}");
}
lines.Add($"Timestamp: {DateTime.UtcNow:O}");
return string.Join(Environment.NewLine, lines);
}
/// <summary>
/// Converts this LINQ breakdown to a SQL Server QueryBreakdown.
/// </summary>
/// <returns>A SQL Server QueryBreakdown with the same clauses as this breakdown.</returns>
public Strata.SqlTools.Breakdowns.SqlServer.QueryBreakdown ConvertToSqlServerBreakdown()
{
var sqlServerBreakdown = new Strata.SqlTools.Breakdowns.SqlServer.QueryBreakdown();
// Copy all clause information from this breakdown
sqlServerBreakdown.SelectClause.Clause = SelectClause?.Clause;
sqlServerBreakdown.SelectClause.Comment = SelectClause?.Comment;
sqlServerBreakdown.FromClause.Clause = FromClause?.Clause;
sqlServerBreakdown.FromClause.Comment = FromClause?.Comment;
sqlServerBreakdown.WhereClause.Clause = WhereClause?.Clause;
sqlServerBreakdown.WhereClause.Comment = WhereClause?.Comment;
sqlServerBreakdown.GroupByClause.Clause = GroupByClause?.Clause;
sqlServerBreakdown.GroupByClause.Comment = GroupByClause?.Comment;
sqlServerBreakdown.HavingClause.Clause = HavingClause?.Clause;
sqlServerBreakdown.HavingClause.Comment = HavingClause?.Comment;
sqlServerBreakdown.OrderByClause.Clause = OrderByClause?.Clause;
sqlServerBreakdown.OrderByClause.Comment = OrderByClause?.Comment;
return sqlServerBreakdown;
}
/// <summary>
/// Converts this LINQ breakdown to a PostgreSQL QueryBreakdown.
/// </summary>
/// <returns>A PostgreSQL QueryBreakdown with the same clauses as this breakdown.</returns>
public PostgreSqlBreakdown ConvertToPostgreSqlBreakdown()
{
var postgresBreakdown = new PostgreSqlBreakdown();
// Copy all clause information from this breakdown
postgresBreakdown.SelectClause.Clause = SelectClause?.Clause;
postgresBreakdown.SelectClause.Comment = SelectClause?.Comment;
postgresBreakdown.FromClause.Clause = FromClause?.Clause;
postgresBreakdown.FromClause.Comment = FromClause?.Comment;
postgresBreakdown.WhereClause.Clause = WhereClause?.Clause;
postgresBreakdown.WhereClause.Comment = WhereClause?.Comment;
postgresBreakdown.GroupByClause.Clause = GroupByClause?.Clause;
postgresBreakdown.GroupByClause.Comment = GroupByClause?.Comment;
postgresBreakdown.HavingClause.Clause = HavingClause?.Clause;
postgresBreakdown.HavingClause.Comment = HavingClause?.Comment;
postgresBreakdown.OrderByClause.Clause = OrderByClause?.Clause;
postgresBreakdown.OrderByClause.Comment = OrderByClause?.Comment;
return postgresBreakdown;
}
/// <summary>
/// Converts this LINQ breakdown to a Snowflake QueryBreakdown.
/// </summary>
/// <returns>A Snowflake QueryBreakdown with the same clauses as this breakdown.</returns>
public SnowflakeBreakdown ConvertToSnowflakeBreakdown()
{
var snowflakeBreakdown = new SnowflakeBreakdown();
// Copy all clause information from this breakdown
snowflakeBreakdown.SelectClause.Clause = SelectClause?.Clause;
snowflakeBreakdown.SelectClause.Comment = SelectClause?.Comment;
snowflakeBreakdown.FromClause.Clause = FromClause?.Clause;
snowflakeBreakdown.FromClause.Comment = FromClause?.Comment;
snowflakeBreakdown.WhereClause.Clause = WhereClause?.Clause;
snowflakeBreakdown.WhereClause.Comment = WhereClause?.Comment;
snowflakeBreakdown.GroupByClause.Clause = GroupByClause?.Clause;
snowflakeBreakdown.GroupByClause.Comment = GroupByClause?.Comment;
snowflakeBreakdown.HavingClause.Clause = HavingClause?.Clause;
snowflakeBreakdown.HavingClause.Comment = HavingClause?.Comment;
snowflakeBreakdown.OrderByClause.Clause = OrderByClause?.Clause;
snowflakeBreakdown.OrderByClause.Comment = OrderByClause?.Comment;
return snowflakeBreakdown;
}
#region Dialect-Specific SQL Generation
/// <summary>
/// Generates SQL Server T-SQL from this breakdown.
/// </summary>
/// <returns>SQL Server formatted SQL statement.</returns>
public string ToSqlServerSql()
{
return ConvertToSqlServerBreakdown().GetSql();
}
/// <summary>
/// Generates PostgreSQL SQL from this breakdown.
/// </summary>
/// <returns>PostgreSQL formatted SQL statement.</returns>
public string ToPostgreSqlSql()
{
return ConvertToPostgreSqlBreakdown().GetSql();
}
/// <summary>
/// Generates Snowflake SQL from this breakdown.
/// </summary>
/// <returns>Snowflake formatted SQL statement.</returns>
public string ToSnowflakeSql()
{
return ConvertToSnowflakeBreakdown().GetSql();
}
#endregion
#region Query Analysis and Validation
/// <summary>
/// Determines if this query has a WHERE clause for safe modification operations.
/// </summary>
/// <returns>True if WHERE clause exists; otherwise, false.</returns>
public bool HasWhereClause()
{
return !string.IsNullOrWhiteSpace(WhereClause?.Clause);
}
/// <summary>
/// Determines if this query has GROUP BY clause.
/// </summary>
/// <returns>True if GROUP BY clause exists; otherwise, false.</returns>
public bool HasGroupByClause()
{
return !string.IsNullOrWhiteSpace(GroupByClause?.Clause);
}
/// <summary>
/// Determines if this query selects all columns (SELECT *).
/// </summary>
/// <returns>True if SELECT contains *; otherwise, false.</returns>
public bool SelectsAllColumns()
{
return SelectClause?.Clause?.Contains("*") ?? false;
}
/// <summary>
/// Gets query complexity estimate based on clause count.
/// </summary>
/// <returns>Complexity level: Simple, Moderate, or Complex.</returns>
public string GetComplexityLevel()
{
var clauseCount = 0;
if (HasWhereClause())
{
clauseCount++;
}
if (HasGroupByClause())
{
clauseCount++;
}
if (!string.IsNullOrWhiteSpace(HavingClause?.Clause))
{
clauseCount++;
}
if (!string.IsNullOrWhiteSpace(OrderByClause?.Clause))
{
clauseCount++;
}
return clauseCount switch
{
0 => "Simple",
1 or 2 => "Moderate",
_ => "Complex"
};
}
/// <summary>
/// Gets a detailed natural language explanation of what this query does.
/// </summary>
/// <returns>Human-readable query explanation.</returns>
public string GetDetailedExplanation()
{
var lines = new List<string>();
// Basic query structure
if (!string.IsNullOrWhiteSpace(SelectClause?.Clause))
{
var what = SelectsAllColumns() ? "all columns" : "specific columns";
lines.Add($"This query selects {what}");
}
if (!string.IsNullOrWhiteSpace(FromClause?.Clause))
{
lines.Add($"from the {FromClause.Clause} table");
}
// Filtering
if (HasWhereClause())
{
lines.Add($"where {WhereClause.Clause}");
}
// Grouping
if (HasGroupByClause())
{
lines.Add($"grouped by {GroupByClause.Clause}");
}
// Filtering grouped results
if (!string.IsNullOrWhiteSpace(HavingClause?.Clause))
{
lines.Add($"with groups filtered where {HavingClause.Clause}");
}
// Sorting
if (!string.IsNullOrWhiteSpace(OrderByClause?.Clause))
{
lines.Add($"sorted by {OrderByClause.Clause}");
}
// Complexity note
var complexity = GetComplexityLevel();
if (complexity != "Simple")
{
lines.Add($"(Complexity: {complexity})");
}
return string.Join(" ", lines);
}
#endregion
}
@@ -0,0 +1,231 @@
using Strata.SqlTools.Breakdowns.LinqToSql;
namespace Strata.SqlTools.Builders.LinqToSql;
/// <summary>
/// Fluent builder for constructing LinqQueryBreakdown instances programmatically.
/// Useful for scenarios where you don't have a live IQueryable to analyze.
/// </summary>
public class LinqQueryBreakdownBuilder
{
private readonly LinqQueryBreakdown _breakdown;
private readonly List<string> _selectColumns = new();
private string? _fromTable;
private string? _whereClause;
private string? _groupByClause;
private string? _havingClause;
private string? _orderByClause;
/// <summary>
/// Initializes a new instance of the <see cref="LinqQueryBreakdownBuilder"/> class.
/// </summary>
public LinqQueryBreakdownBuilder()
{
_breakdown = new LinqQueryBreakdown();
}
/// <summary>
/// Sets the SELECT columns for the query.
/// </summary>
/// <param name="columns">Column names to select.</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder SelectColumns(params string[] columns)
{
if (columns.Length == 0)
{
_selectColumns.Add("*");
}
else
{
_selectColumns.AddRange(columns);
}
return this;
}
/// <summary>
/// Sets the SELECT to all columns (*).
/// </summary>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder SelectAll()
{
_selectColumns.Clear();
_selectColumns.Add("*");
return this;
}
/// <summary>
/// Sets the FROM table for the query.
/// </summary>
/// <param name="tableName">The table name.</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder FromTable(string tableName)
{
if (string.IsNullOrWhiteSpace(tableName))
{
throw new ArgumentException("Table name cannot be null or empty.", nameof(tableName));
}
_fromTable = tableName;
return this;
}
/// <summary>
/// Sets the WHERE clause for the query.
/// </summary>
/// <param name="condition">The WHERE condition.</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder Where(string condition)
{
if (!string.IsNullOrWhiteSpace(condition))
{
_whereClause = condition;
}
return this;
}
/// <summary>
/// Sets the GROUP BY clause for the query.
/// </summary>
/// <param name="columns">The columns to group by.</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder GroupBy(params string[] columns)
{
if (columns.Length > 0)
{
_groupByClause = string.Join(", ", columns);
}
return this;
}
/// <summary>
/// Sets the HAVING clause for the query.
/// </summary>
/// <param name="condition">The HAVING condition.</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder Having(string condition)
{
if (!string.IsNullOrWhiteSpace(condition))
{
_havingClause = condition;
}
return this;
}
/// <summary>
/// Sets the ORDER BY clause for the query.
/// </summary>
/// <param name="orderSpecification">The ORDER BY specification (e.g., "Name ASC, Age DESC").</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder OrderBy(string orderSpecification)
{
if (!string.IsNullOrWhiteSpace(orderSpecification))
{
_orderByClause = orderSpecification;
}
return this;
}
/// <summary>
/// Adds an ORDER BY clause in ascending order.
/// </summary>
/// <param name="column">The column to order by.</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder OrderByAscending(string column)
{
if (string.IsNullOrWhiteSpace(column))
{
throw new ArgumentException("Column name cannot be null or empty.", nameof(column));
}
_orderByClause = $"{column} ASC";
return this;
}
/// <summary>
/// Adds an ORDER BY clause in descending order.
/// </summary>
/// <param name="column">The column to order by.</param>
/// <returns>This builder for method chaining.</returns>
public LinqQueryBreakdownBuilder OrderByDescending(string column)
{
if (string.IsNullOrWhiteSpace(column))
{
throw new ArgumentException("Column name cannot be null or empty.", nameof(column));
}
_orderByClause = $"{column} DESC";
return this;
}
/// <summary>
/// Builds and returns the LinqQueryBreakdown instance.
/// </summary>
/// <returns>A new LinqQueryBreakdown with the configured clauses.</returns>
/// <exception cref="InvalidOperationException">Thrown when required clauses are missing.</exception>
public LinqQueryBreakdown Build()
{
if (_selectColumns.Count == 0)
{
throw new InvalidOperationException("At least one SELECT column must be specified.");
}
if (string.IsNullOrWhiteSpace(_fromTable))
{
throw new InvalidOperationException("FROM table must be specified.");
}
var breakdown = new LinqQueryBreakdown(
string.Join(", ", _selectColumns),
_fromTable,
_whereClause ?? string.Empty
);
if (!string.IsNullOrWhiteSpace(_groupByClause))
{
breakdown.GroupByClause.Clause = _groupByClause;
}
if (!string.IsNullOrWhiteSpace(_havingClause))
{
breakdown.HavingClause.Clause = _havingClause;
}
if (!string.IsNullOrWhiteSpace(_orderByClause))
{
breakdown.OrderByClause.Clause = _orderByClause;
}
return breakdown;
}
/// <summary>
/// Gets a SQL Server formatted preview of the query being built.
/// </summary>
/// <returns>Preview SQL statement.</returns>
public string PreviewSql()
{
try
{
return Build().ToSqlServerSql();
}
catch (InvalidOperationException)
{
return "-- Incomplete query (missing required clauses)";
}
}
/// <summary>
/// Creates a new builder with the default state.
/// </summary>
/// <returns>A new LinqQueryBreakdownBuilder instance.</returns>
public static LinqQueryBreakdownBuilder Create()
{
return new LinqQueryBreakdownBuilder();
}
/// <summary>
/// Creates a builder with a table already specified.
/// </summary>
/// <param name="tableName">The table to select from.</param>
/// <returns>A new builder with the table set.</returns>
public static LinqQueryBreakdownBuilder CreateForTable(string tableName)
{
return new LinqQueryBreakdownBuilder().FromTable(tableName);
}
}
@@ -0,0 +1,277 @@
using Strata.SqlTools.Breakdowns.LinqToSql;
namespace Strata.SqlTools.Comparers.LinqToSql;
/// <summary>
/// Result of comparing two LinqQueryBreakdown instances.
/// </summary>
public record QueryComparisonResult(
bool AreEquivalent,
double SimilarityScore, // 0.0 to 1.0
List<string> Differences,
bool HaveSameSelectColumns,
bool HaveSameFromTable,
bool HaveSameWhereClause,
bool HaveSameGroupBy,
bool HaveSameHaving,
bool HaveSameOrderBy
)
{
/// <summary>
/// Gets a formatted comparison report.
/// </summary>
public string GetReport()
{
var report = new System.Text.StringBuilder();
report.AppendLine($"Query Comparison Report");
report.AppendLine($"Similarity: {(SimilarityScore * 100):F1}%");
report.AppendLine($"Equivalent: {(AreEquivalent ? "Yes" : "No")}");
report.AppendLine();
if (Differences.Count == 0)
{
report.AppendLine("✓ Queries are identical");
return report.ToString();
}
report.AppendLine("Differences:");
foreach (var diff in Differences)
{
report.AppendLine($" • {diff}");
}
return report.ToString();
}
}
/// <summary>
/// Compares LinqQueryBreakdown instances to detect similarity, equivalence, and duplicates.
/// </summary>
public class QueryComparator
{
private readonly LinqQueryBreakdown _query1;
private readonly LinqQueryBreakdown _query2;
private QueryComparisonResult? _result;
/// <summary>
/// Initializes a new instance of the <see cref="QueryComparator"/> class.
/// </summary>
/// <param name="query1">The first query to compare.</param>
/// <param name="query2">The second query to compare.</param>
public QueryComparator(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
_query1 = query1 ?? throw new ArgumentNullException(nameof(query1));
_query2 = query2 ?? throw new ArgumentNullException(nameof(query2));
}
/// <summary>
/// Gets the comparison result, calculating it if needed.
/// </summary>
public QueryComparisonResult Result =>
_result ??= PerformComparison();
/// <summary>
/// Gets a value indicating whether the queries are equivalent (same structure).
/// </summary>
public bool AreEquivalent => Result.AreEquivalent;
/// <summary>
/// Gets the similarity score from 0.0 (completely different) to 1.0 (identical).
/// </summary>
public double SimilarityScore => Result.SimilarityScore;
/// <summary>
/// Gets the list of differences found between the queries.
/// </summary>
public List<string> Differences => Result.Differences;
/// <summary>
/// Performs the actual comparison between the two queries.
/// </summary>
/// <returns>The comparison result.</returns>
private QueryComparisonResult PerformComparison()
{
var differences = new List<string>();
var scoreComponents = 0;
var scoreMatches = 0;
// Compare SELECT clause
var selectMatch = CompareSelectClauses(_query1, _query2);
if (!selectMatch)
{
differences.Add("SELECT clauses differ");
}
scoreComponents++;
if (selectMatch)
{ scoreMatches++; }
// Compare FROM clause
var fromMatch = CompareFromClauses(_query1, _query2);
if (!fromMatch)
{
differences.Add("FROM clauses differ");
}
scoreComponents++;
if (fromMatch)
{ scoreMatches++; }
// Compare WHERE clause
var whereMatch = CompareWhereClauses(_query1, _query2);
if (!whereMatch)
{
differences.Add("WHERE clauses differ");
}
scoreComponents++;
if (whereMatch)
{ scoreMatches++; }
// Compare GROUP BY clause
var groupByMatch = CompareGroupByClauses(_query1, _query2);
if (!groupByMatch)
{
differences.Add("GROUP BY clauses differ");
}
scoreComponents++;
if (groupByMatch)
{ scoreMatches++; }
// Compare HAVING clause
var havingMatch = CompareHavingClauses(_query1, _query2);
if (!havingMatch)
{
differences.Add("HAVING clauses differ");
}
scoreComponents++;
if (havingMatch)
{ scoreMatches++; }
// Compare ORDER BY clause
var orderByMatch = CompareOrderByClauses(_query1, _query2);
if (!orderByMatch)
{
differences.Add("ORDER BY clauses differ");
}
scoreComponents++;
if (orderByMatch)
{ scoreMatches++; }
var similarityScore = scoreComponents > 0 ? (double)scoreMatches / scoreComponents : 0.0;
var areEquivalent = differences.Count == 0;
return new QueryComparisonResult(
areEquivalent,
similarityScore,
differences,
selectMatch,
fromMatch,
whereMatch,
groupByMatch,
havingMatch,
orderByMatch);
}
private static bool CompareSelectClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var select1 = NormalizeClause(query1.SelectClause?.Clause ?? string.Empty);
var select2 = NormalizeClause(query2.SelectClause?.Clause ?? string.Empty);
return StringEquals(select1, select2);
}
private static bool CompareFromClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var from1 = NormalizeClause(query1.FromClause?.Clause ?? string.Empty);
var from2 = NormalizeClause(query2.FromClause?.Clause ?? string.Empty);
return StringEquals(from1, from2);
}
private static bool CompareWhereClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var where1 = NormalizeClause(query1.WhereClause?.Clause ?? string.Empty);
var where2 = NormalizeClause(query2.WhereClause?.Clause ?? string.Empty);
return StringEquals(where1, where2);
}
private static bool CompareGroupByClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var groupBy1 = NormalizeClause(query1.GroupByClause?.Clause ?? string.Empty);
var groupBy2 = NormalizeClause(query2.GroupByClause?.Clause ?? string.Empty);
return StringEquals(groupBy1, groupBy2);
}
private static bool CompareHavingClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var having1 = NormalizeClause(query1.HavingClause?.Clause ?? string.Empty);
var having2 = NormalizeClause(query2.HavingClause?.Clause ?? string.Empty);
return StringEquals(having1, having2);
}
private static bool CompareOrderByClauses(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var orderBy1 = NormalizeClause(query1.OrderByClause?.Clause ?? string.Empty);
var orderBy2 = NormalizeClause(query2.OrderByClause?.Clause ?? string.Empty);
return StringEquals(orderBy1, orderBy2);
}
private static string NormalizeClause(string clause)
{
if (string.IsNullOrWhiteSpace(clause))
{
return string.Empty;
}
// Normalize whitespace and case
return System.Text.RegularExpressions.Regex
.Replace(clause.Trim(), @"\s+", " ")
.ToUpperInvariant();
}
private static bool StringEquals(string? str1, string? str2)
{
return string.Equals(str1, str2, StringComparison.Ordinal);
}
/// <summary>
/// Creates a new comparator for two queries.
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>A new QueryComparator instance.</returns>
public static QueryComparator Compare(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
return new QueryComparator(query1, query2);
}
/// <summary>
/// Checks if two queries are equivalent.
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>True if the queries are equivalent; otherwise, false.</returns>
public static bool AreQueriesEquivalent(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
return new QueryComparator(query1, query2).AreEquivalent;
}
/// <summary>
/// Checks if two queries are identical (same text after normalization).
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>True if the queries are identical; otherwise, false.</returns>
public static bool AreQueriesIdentical(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
var comparator = new QueryComparator(query1, query2);
return comparator.SimilarityScore >= 1.0;
}
/// <summary>
/// Gets the similarity score between two queries (0.0 to 1.0).
/// </summary>
/// <param name="query1">The first query.</param>
/// <param name="query2">The second query.</param>
/// <returns>A similarity score from 0.0 (completely different) to 1.0 (identical).</returns>
public static double GetSimilarity(LinqQueryBreakdown query1, LinqQueryBreakdown query2)
{
return new QueryComparator(query1, query2).SimilarityScore;
}
}
@@ -0,0 +1,190 @@
using Strata.SqlTools.Breakdowns.LinqToSql;
using Strata.SqlTools.Breakdowns.SqlServer;
using PostgreSqlBreakdown = Strata.SqlTools.Breakdowns.PostgreSql.QueryBreakdown;
using SnowflakeBreakdown = Strata.SqlTools.Breakdowns.Snowflake.QueryBreakdown;
namespace Strata.SqlTools.Converters.LinqToSql;
/// <summary>
/// Converts dialect-specific QueryBreakdown instances back to the generic LinqQueryBreakdown format.
/// Enables parsing from any dialect and converting between all supported dialects.
/// </summary>
public static class ReverseConverterExtensions
{
/// <summary>
/// Converts a SQL Server QueryBreakdown to a LinqQueryBreakdown.
/// </summary>
/// <param name="breakdown">The SQL Server breakdown to convert.</param>
/// <returns>A new LinqQueryBreakdown with the same clauses.</returns>
public static LinqQueryBreakdown ToLinqQueryBreakdown(this QueryBreakdown breakdown)
{
if (breakdown == null)
{
throw new ArgumentNullException(nameof(breakdown));
}
var linq = new LinqQueryBreakdown(
breakdown.SelectClause?.Clause ?? "*",
breakdown.FromClause?.Clause ?? string.Empty,
breakdown.WhereClause?.Clause ?? string.Empty
);
if (!string.IsNullOrWhiteSpace(breakdown.GroupByClause?.Clause))
{
linq.GroupByClause.Clause = breakdown.GroupByClause.Clause;
}
if (!string.IsNullOrWhiteSpace(breakdown.HavingClause?.Clause))
{
linq.HavingClause.Clause = breakdown.HavingClause.Clause;
}
if (!string.IsNullOrWhiteSpace(breakdown.OrderByClause?.Clause))
{
linq.OrderByClause.Clause = breakdown.OrderByClause.Clause;
}
return linq;
}
/// <summary>
/// Converts a PostgreSQL QueryBreakdown to a LinqQueryBreakdown.
/// </summary>
/// <param name="breakdown">The PostgreSQL breakdown to convert.</param>
/// <returns>A new LinqQueryBreakdown with the same clauses.</returns>
public static LinqQueryBreakdown ToLinqQueryBreakdown(this PostgreSqlBreakdown breakdown)
{
if (breakdown == null)
{
throw new ArgumentNullException(nameof(breakdown));
}
var linq = new LinqQueryBreakdown(
breakdown.SelectClause?.Clause ?? "*",
breakdown.FromClause?.Clause ?? string.Empty,
breakdown.WhereClause?.Clause ?? string.Empty
);
if (!string.IsNullOrWhiteSpace(breakdown.GroupByClause?.Clause))
{
linq.GroupByClause.Clause = breakdown.GroupByClause.Clause;
}
if (!string.IsNullOrWhiteSpace(breakdown.HavingClause?.Clause))
{
linq.HavingClause.Clause = breakdown.HavingClause.Clause;
}
if (!string.IsNullOrWhiteSpace(breakdown.OrderByClause?.Clause))
{
linq.OrderByClause.Clause = breakdown.OrderByClause.Clause;
}
return linq;
}
/// <summary>
/// Converts a Snowflake QueryBreakdown to a LinqQueryBreakdown.
/// </summary>
/// <param name="breakdown">The Snowflake breakdown to convert.</param>
/// <returns>A new LinqQueryBreakdown with the same clauses.</returns>
public static LinqQueryBreakdown ToLinqQueryBreakdown(this SnowflakeBreakdown breakdown)
{
if (breakdown == null)
{
throw new ArgumentNullException(nameof(breakdown));
}
var linq = new LinqQueryBreakdown(
breakdown.SelectClause?.Clause ?? "*",
breakdown.FromClause?.Clause ?? string.Empty,
breakdown.WhereClause?.Clause ?? string.Empty
);
if (!string.IsNullOrWhiteSpace(breakdown.GroupByClause?.Clause))
{
linq.GroupByClause.Clause = breakdown.GroupByClause.Clause;
}
if (!string.IsNullOrWhiteSpace(breakdown.HavingClause?.Clause))
{
linq.HavingClause.Clause = breakdown.HavingClause.Clause;
}
if (!string.IsNullOrWhiteSpace(breakdown.OrderByClause?.Clause))
{
linq.OrderByClause.Clause = breakdown.OrderByClause.Clause;
}
return linq;
}
/// <summary>
/// Converts a SQL Server QueryBreakdown to a different dialect.
/// </summary>
/// <param name="breakdown">The SQL Server breakdown to convert.</param>
/// <param name="targetDialect">The target dialect: "postgresql", "snowflake", or "linq".</param>
/// <returns>A new breakdown in the target dialect format.</returns>
public static object ConvertToDialect(this QueryBreakdown breakdown, string targetDialect)
{
if (breakdown == null)
{
throw new ArgumentNullException(nameof(breakdown));
}
return targetDialect.ToLowerInvariant() switch
{
"postgresql" or "postgres" => breakdown.ToLinqQueryBreakdown().ConvertToPostgreSqlBreakdown(),
"snowflake" => breakdown.ToLinqQueryBreakdown().ConvertToSnowflakeBreakdown(),
"linq" => breakdown.ToLinqQueryBreakdown(),
"sqlserver" or "sql_server" => breakdown,
_ => throw new ArgumentException($"Unknown target dialect: {targetDialect}", nameof(targetDialect))
};
}
/// <summary>
/// Converts a PostgreSQL QueryBreakdown to a different dialect.
/// </summary>
/// <param name="breakdown">The PostgreSQL breakdown to convert.</param>
/// <param name="targetDialect">The target dialect: "sqlserver", "snowflake", or "linq".</param>
/// <returns>A new breakdown in the target dialect format.</returns>
public static object ConvertToDialect(this PostgreSqlBreakdown breakdown, string targetDialect)
{
if (breakdown == null)
{
throw new ArgumentNullException(nameof(breakdown));
}
return targetDialect.ToLowerInvariant() switch
{
"sqlserver" or "sql_server" => breakdown.ToLinqQueryBreakdown().ConvertToSqlServerBreakdown(),
"snowflake" => breakdown.ToLinqQueryBreakdown().ConvertToSnowflakeBreakdown(),
"linq" => breakdown.ToLinqQueryBreakdown(),
"postgresql" or "postgres" => breakdown,
_ => throw new ArgumentException($"Unknown target dialect: {targetDialect}", nameof(targetDialect))
};
}
/// <summary>
/// Converts a Snowflake QueryBreakdown to a different dialect.
/// </summary>
/// <param name="breakdown">The Snowflake breakdown to convert.</param>
/// <param name="targetDialect">The target dialect: "sqlserver", "postgresql", or "linq".</param>
/// <returns>A new breakdown in the target dialect format.</returns>
public static object ConvertToDialect(this SnowflakeBreakdown breakdown, string targetDialect)
{
if (breakdown == null)
{
throw new ArgumentNullException(nameof(breakdown));
}
return targetDialect.ToLowerInvariant() switch
{
"sqlserver" or "sql_server" => breakdown.ToLinqQueryBreakdown().ConvertToSqlServerBreakdown(),
"postgresql" or "postgres" => breakdown.ToLinqQueryBreakdown().ConvertToPostgreSqlBreakdown(),
"linq" => breakdown.ToLinqQueryBreakdown(),
"snowflake" => breakdown,
_ => throw new ArgumentException($"Unknown target dialect: {targetDialect}", nameof(targetDialect))
};
}
}
+137
View File
@@ -0,0 +1,137 @@
# Strata.SqlTools.LinqToSql
LINQ to SQL support for Strata SQL Utilities, providing query breakdown and analysis capabilities for LINQ to SQL queries.
## Overview
This library extends Strata.SqlTools to work with LINQ to SQL queries, allowing you to:
- Analyze LINQ query expressions
- Break down LINQ queries into their component parts
- Convert LINQ expressions to QueryBreakdown objects
- Generate SQL representations from LINQ queries
- Visualize LINQ query structure
## Features
### LINQ Query Analysis
- Extract SELECT, WHERE, JOIN, GROUP BY, and ORDER BY operations from LINQ expressions
- Identify data sources and table references
- Analyze query composition and complexity
### QueryBreakdown Integration
- Convert LINQ `IQueryable<T>` to `QueryBreakdown` objects
- Support for common LINQ methods: `Where`, `Select`, `OrderBy`, `GroupBy`, `Join`, etc.
- Parameter extraction and analysis
### Expression Visitors
- Custom expression visitors for LINQ expression trees
- Support for method call expressions, lambda expressions, and member access
- Handles both query syntax and method syntax
## Installation
```bash
dotnet add package Strata.SqlTools.LinqToSql
```
## Usage
### Basic Query Breakdown
```csharp
using Strata.SqlTools.Breakdowns.LinqToSql;
using System.Linq;
// Your LINQ to SQL query
var query = from user in context.Users
where user.Age > 21
orderby user.Name
select new { user.Id, user.Name, user.Email };
// Analyze the query
var breakdown = LinqQueryBreakdown.Analyze(query);
// Access breakdown components
Console.WriteLine($"Select: {breakdown.SelectClause}");
Console.WriteLine($"From: {breakdown.FromClause}");
Console.WriteLine($"Where: {breakdown.WhereClause}");
Console.WriteLine($"OrderBy: {breakdown.OrderByClause}");
```
### Expression Analysis
```csharp
using Strata.SqlTools.Visitors.LinqToSql;
// Analyze a specific expression
Expression<Func<User, bool>> predicate = u => u.Age > 21 && u.Status == "Active";
var visitor = new LinqExpressionVisitor();
visitor.Visit(predicate);
// Get analysis results
var conditions = visitor.GetConditions();
var parameters = visitor.GetParameters();
```
### SQL Generation
```csharp
// Generate SQL from LINQ query
var breakdown = LinqQueryBreakdown.Analyze(query);
string sql = breakdown.GetSql();
Console.WriteLine(sql);
// Output: SELECT u.Id, u.Name, u.Email FROM Users u WHERE u.Age > 21 ORDER BY u.Name
```
## Architecture
### Key Components
- **LinqQueryBreakdown**: Main class for analyzing LINQ queries and converting them to breakdown format
- **LinqExpressionVisitor**: Expression visitor for traversing LINQ expression trees
- **LinqToSqlConverter**: Converts LINQ expressions to SQL Server QueryBreakdown objects
### Supported LINQ Methods
- `Where` → WHERE clause
- `Select` → SELECT clause
- `OrderBy`, `OrderByDescending`, `ThenBy`, `ThenByDescending` → ORDER BY clause
- `GroupBy` → GROUP BY clause
- `Join`, `GroupJoin` → JOIN clauses
- `First`, `FirstOrDefault`, `Single`, `SingleOrDefault` → TOP 1
- `Take`, `Skip` → TOP n / OFFSET-FETCH
- `Distinct` → DISTINCT
- `Count`, `Sum`, `Average`, `Min`, `Max` → Aggregate functions
## Limitations
- LINQ to SQL translates to SQL Server T-SQL dialect
- Complex expressions may not be fully analyzed
- Some LINQ features may not have direct SQL equivalents
- Requires the query to be `IQueryable<T>` (not `IEnumerable<T>`)
## Integration with Markdown
Use with `Strata.SqlTools.Markdown` to generate visual diagrams:
```csharp
using Strata.SqlTools.Markdown.LinqToSql;
var breakdown = LinqQueryBreakdown.Analyze(query);
var generator = new QueryBreakdownGenerator();
string mermaidDiagram = generator.GenerateMermaidDiagram(breakdown, "User Query");
```
## See Also
- [Strata.SqlTools](../Strata.SqlTools/README.md) - Core SQL utilities
- [Strata.SqlTools.SqlServer](../Strata.SqlTools.SqlServer/README.md) - SQL Server support
- [Strata.SqlTools.Markdown](../Strata.SqlTools.Markdown/README.md) - Markdown generation
## License
MIT License - Copyright © Strata Decision Technology 2024-2026
@@ -0,0 +1,51 @@
<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.LinqToSql</PackageId>
<Version>1.0.0</Version>
<Authors>Strata Decision Technology</Authors>
<Company>Strata Decision Technology</Company>
<Product>Strata SQL Utilities - LINQ to SQL</Product>
<Description>LINQ to SQL specific implementations for Strata.SqlTools, including LINQ expression analysis, query breakdown, and SQL generation from LINQ queries. Provides tools to analyze and visualize LINQ to SQL query structures.</Description>
<PackageTags>linq;linq-to-sql;sql;query-builder;expression-trees;database;dotnet</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 LINQ to SQL query analysis, breakdown, and visualization 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.SqlBreakdown\Strata.SqlTools.SqlBreakdown.csproj" />
<ProjectReference Include="..\Strata.SqlTools.SqlServer\Strata.SqlTools.SqlServer.csproj" />
<ProjectReference Include="..\Strata.SqlTools.PostgreSql\Strata.SqlTools.PostgreSql.csproj" />
<ProjectReference Include="..\Strata.SqlTools.Snowflake\Strata.SqlTools.Snowflake.csproj" />
</ItemGroup>
<!-- No additional package references needed - works with System.Linq.Expressions from .NET -->
</Project>
@@ -0,0 +1,311 @@
using System.Collections.Immutable;
using Strata.SqlTools.Breakdowns.LinqToSql;
namespace Strata.SqlTools.Validators.LinqToSql;
/// <summary>
/// Severity level for validation issues.
/// </summary>
public enum ValidationSeverity
{
/// <summary>Informational message, no action required.</summary>
Info = 0,
/// <summary>Warning - potential issue that should be reviewed.</summary>
Warning = 1,
/// <summary>Error - definite issue that should be fixed.</summary>
Error = 2
}
/// <summary>
/// Represents a single validation issue found in a query.
/// </summary>
public record QueryValidationIssue(
ValidationSeverity Severity,
string Code,
string Message,
string? Details = null
)
{
/// <summary>
/// Gets a formatted string representation of the validation issue.
/// </summary>
public override string ToString()
{
var result = $"[{Severity}] {Code}: {Message}";
if (!string.IsNullOrWhiteSpace(Details))
{
result += $" - {Details}";
}
return result;
}
}
/// <summary>
/// Validates LinqQueryBreakdown instances and detects common anti-patterns.
/// </summary>
public class QueryValidator
{
private readonly List<QueryValidationIssue> _issues = new();
/// <summary>
/// Gets the list of validation issues found.
/// </summary>
public IReadOnlyList<QueryValidationIssue> Issues => _issues.AsReadOnly();
/// <summary>
/// Gets a value indicating whether any errors were found.
/// </summary>
public bool HasErrors => _issues.Any(i => i.Severity == ValidationSeverity.Error);
/// <summary>
/// Gets a value indicating whether any warnings were found.
/// </summary>
public bool HasWarnings => _issues.Any(i => i.Severity == ValidationSeverity.Warning);
/// <summary>
/// Validates a LinqQueryBreakdown instance and returns the result.
/// </summary>
/// <param name="breakdown">The breakdown to validate.</param>
/// <returns>This validator for method chaining.</returns>
public QueryValidator Validate(LinqQueryBreakdown breakdown)
{
if (breakdown == null)
{
throw new ArgumentNullException(nameof(breakdown));
}
_issues.Clear();
ValidateSelectClause(breakdown);
ValidateFromClause(breakdown);
ValidateWhereClause(breakdown);
ValidateGroupByClause(breakdown);
ValidateHavingClause(breakdown);
ValidateOrderByClause(breakdown);
ValidateCommonAntiPatterns(breakdown);
return this;
}
/// <summary>
/// Adds a custom validation issue.
/// </summary>
/// <param name="severity">The severity level.</param>
/// <param name="code">The issue code (e.g., "RULE_001").</param>
/// <param name="message">The issue message.</param>
/// <param name="details">Optional detailed information.</param>
/// <returns>This validator for method chaining.</returns>
public QueryValidator AddIssue(
ValidationSeverity severity,
string code,
string message,
string? details = null)
{
_issues.Add(new QueryValidationIssue(severity, code, message, details));
return this;
}
/// <summary>
/// Clears all validation issues.
/// </summary>
/// <returns>This validator for method chaining.</returns>
public QueryValidator Clear()
{
_issues.Clear();
return this;
}
/// <summary>
/// Gets validation issues by severity level.
/// </summary>
/// <param name="severity">The severity to filter by.</param>
/// <returns>Issues matching the severity level.</returns>
public IReadOnlyList<QueryValidationIssue> GetIssuesBySeverity(ValidationSeverity severity)
{
return _issues.Where(i => i.Severity == severity).ToList().AsReadOnly();
}
/// <summary>
/// Gets a formatted validation report.
/// </summary>
/// <returns>A formatted string containing all validation issues.</returns>
public string GetReport()
{
if (_issues.Count == 0)
{
return "✓ No validation issues found.";
}
var report = new System.Text.StringBuilder();
report.AppendLine($"Validation Report ({_issues.Count} issue{(_issues.Count != 1 ? "s" : "")}:");
report.AppendLine();
var errors = GetIssuesBySeverity(ValidationSeverity.Error);
if (errors.Count > 0)
{
report.AppendLine("ERRORS:");
foreach (var issue in errors)
{
report.AppendLine($" • {issue}");
}
report.AppendLine();
}
var warnings = GetIssuesBySeverity(ValidationSeverity.Warning);
if (warnings.Count > 0)
{
report.AppendLine("WARNINGS:");
foreach (var issue in warnings)
{
report.AppendLine($" • {issue}");
}
report.AppendLine();
}
var infos = GetIssuesBySeverity(ValidationSeverity.Info);
if (infos.Count > 0)
{
report.AppendLine("INFO:");
foreach (var issue in infos)
{
report.AppendLine($" • {issue}");
}
}
return report.ToString();
}
private void ValidateSelectClause(LinqQueryBreakdown breakdown)
{
if (breakdown.SelectClause == null || string.IsNullOrWhiteSpace(breakdown.SelectClause.Clause))
{
_issues.Add(new QueryValidationIssue(
ValidationSeverity.Error,
"SELECT_MISSING",
"SELECT clause is missing or empty",
"Every query must specify which columns to select."));
return;
}
var selectClause = breakdown.SelectClause.Clause;
// Check for SELECT *
if (selectClause.Trim() == "*")
{
_issues.Add(new QueryValidationIssue(
ValidationSeverity.Warning,
"SELECT_ALL_COLUMNS",
"Query selects all columns with SELECT *",
"Consider being explicit about which columns you need to avoid returning unnecessary data."));
}
// Check for excessive columns
var columnCount = selectClause.Split(',').Length;
if (columnCount > 20)
{
_issues.Add(new QueryValidationIssue(
ValidationSeverity.Warning,
"SELECT_TOO_MANY",
$"Query selects {columnCount} columns",
"Consider narrowing the selection to reduce data transfer and improve performance."));
}
}
private void ValidateFromClause(LinqQueryBreakdown breakdown)
{
if (breakdown.FromClause == null || string.IsNullOrWhiteSpace(breakdown.FromClause.Clause))
{
_issues.Add(new QueryValidationIssue(
ValidationSeverity.Error,
"FROM_MISSING",
"FROM clause is missing",
"Every query must specify a source table."));
}
}
private void ValidateWhereClause(LinqQueryBreakdown breakdown)
{
// No validation needed - WHERE is optional
}
private void ValidateGroupByClause(LinqQueryBreakdown breakdown)
{
var hasGroupBy = !string.IsNullOrWhiteSpace(breakdown.GroupByClause?.Clause);
var hasHaving = !string.IsNullOrWhiteSpace(breakdown.HavingClause?.Clause);
if (hasHaving && !hasGroupBy)
{
_issues.Add(new QueryValidationIssue(
ValidationSeverity.Error,
"HAVING_WITHOUT_GROUPBY",
"HAVING clause found without GROUP BY",
"HAVING must be used with GROUP BY to filter aggregated results."));
}
}
private void ValidateHavingClause(LinqQueryBreakdown breakdown)
{
// Validation delegated to ValidateGroupByClause
}
private void ValidateOrderByClause(LinqQueryBreakdown breakdown)
{
// No validation needed - ORDER BY is optional
}
private void ValidateCommonAntiPatterns(LinqQueryBreakdown breakdown)
{
// Check for DELETE/UPDATE without WHERE (dangerous!)
// Note: This is primarily for LINQ operations, but we can flag it for awareness
if (string.IsNullOrWhiteSpace(breakdown.WhereClause?.Clause))
{
_issues.Add(new QueryValidationIssue(
ValidationSeverity.Warning,
"NO_WHERE_CLAUSE",
"Query has no WHERE clause",
"Consider whether this is intentional. Queries without WHERE clauses affect all rows."));
}
// Check for missing ORDER BY on large results
var hasOrderBy = !string.IsNullOrWhiteSpace(breakdown.OrderByClause?.Clause);
var hasGroupBy = !string.IsNullOrWhiteSpace(breakdown.GroupByClause?.Clause);
if (!hasOrderBy && !hasGroupBy)
{
_issues.Add(new QueryValidationIssue(
ValidationSeverity.Info,
"NO_ORDER_BY",
"Query has no ORDER BY clause",
"Consider adding ORDER BY to ensure consistent result ordering, especially for pagination scenarios."));
}
// Check for SELECT without FROM (invalid in most SQL dialects except for SELECT constants)
var selectClause = breakdown.SelectClause?.Clause ?? string.Empty;
if (!string.IsNullOrWhiteSpace(selectClause) &&
string.IsNullOrWhiteSpace(breakdown.FromClause?.Clause))
{
// This is already caught by ValidateFromClause
}
}
/// <summary>
/// Creates a new instance of QueryValidator.
/// </summary>
/// <returns>A new QueryValidator instance.</returns>
public static QueryValidator Create()
{
return new QueryValidator();
}
/// <summary>
/// Validates a breakdown and returns a new validator with the results.
/// </summary>
/// <param name="breakdown">The breakdown to validate.</param>
/// <returns>A new validator containing the validation results.</returns>
public static QueryValidator ValidateQuery(LinqQueryBreakdown breakdown)
{
return new QueryValidator().Validate(breakdown);
}
}
@@ -0,0 +1,398 @@
using System.Linq.Expressions;
using System.Text;
namespace Strata.SqlTools.Visitors.LinqToSql;
/// <summary>
/// Expression visitor for analyzing LINQ to SQL expression trees.
/// Extracts query components such as SELECT, WHERE, JOIN, GROUP BY, and ORDER BY.
/// </summary>
public class LinqExpressionVisitor : ExpressionVisitor
{
private readonly StringBuilder _whereBuilder = new();
private readonly StringBuilder _orderByBuilder = new();
private readonly List<string> _methodCalls = new();
private bool _isInWhereClause;
#pragma warning disable IDE0052, S4487
private bool _isInSelectClause;
private bool _isInOrderByClause;
private bool _isInGroupByClause;
private string? _tableName;
#pragma warning restore IDE0052, S4487
/// <summary>
/// Gets the SELECT clause extracted from the expression.
/// </summary>
public string? SelectClause { get; private set; }
/// <summary>
/// Gets the FROM clause (table name) extracted from the expression.
/// </summary>
public string? FromClause { get; private set; }
/// <summary>
/// Gets the WHERE clause extracted from the expression.
/// </summary>
public string? WhereClause { get; private set; }
/// <summary>
/// Gets the ORDER BY clause extracted from the expression.
/// </summary>
public string? OrderByClause { get; private set; }
/// <summary>
/// Gets the GROUP BY clause extracted from the expression.
/// </summary>
public string? GroupByClause { get; private set; }
/// <summary>
/// Gets the list of LINQ method calls in the query chain.
/// </summary>
public List<string> MethodCallChain => _methodCalls;
/// <summary>
/// Visits a method call expression.
/// </summary>
protected override Expression VisitMethodCall(MethodCallExpression node)
{
var methodName = node.Method.Name;
_methodCalls.Add(methodName);
switch (methodName)
{
case "Where":
VisitWhereMethod(node);
break;
case "Select":
VisitSelectMethod(node);
break;
case "OrderBy":
case "OrderByDescending":
case "ThenBy":
case "ThenByDescending":
VisitOrderByMethod(node);
break;
case "GroupBy":
VisitGroupByMethod(node);
break;
case "Join":
case "GroupJoin":
VisitJoinMethod(node);
break;
case "Take":
case "Skip":
VisitTakeSkipMethod(node);
break;
default:
// Visit the source expression
Visit(node.Arguments[0]);
break;
}
return node;
}
/// <summary>
/// Visits a constant expression to extract the table name.
/// </summary>
protected override Expression VisitConstant(ConstantExpression node)
{
// Handle WHERE clause constants
if (_isInWhereClause)
{
if (node.Value is string)
{
_whereBuilder.Append($"'{node.Value}'");
}
else if (node.Value != null)
{
_whereBuilder.Append(node.Value.ToString());
}
else
{
_whereBuilder.Append("NULL");
}
return node;
}
// Handle table name extraction
if (node.Type.IsGenericType)
{
var genericType = node.Type.GetGenericTypeDefinition();
if (genericType.Name.Contains("Table") || genericType.Name.Contains("Query"))
{
var entityType = node.Type.GetGenericArguments().FirstOrDefault();
if (entityType != null)
{
_tableName = entityType.Name;
FromClause = _tableName;
}
}
}
return base.VisitConstant(node);
}
private void VisitWhereMethod(MethodCallExpression node)
{
// Visit the source
Visit(node.Arguments[0]);
// Extract the predicate
if (node.Arguments.Count > 1)
{
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null)
{
_isInWhereClause = true;
Visit(lambda.Body);
_isInWhereClause = false;
if (_whereBuilder.Length > 0)
{
WhereClause = _whereBuilder.ToString();
}
}
}
}
private void VisitSelectMethod(MethodCallExpression node)
{
// Visit the source
Visit(node.Arguments[0]);
// Extract the selector
if (node.Arguments.Count > 1)
{
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null)
{
_isInSelectClause = true;
var selectExpression = ExtractSelectExpression(lambda.Body);
_isInSelectClause = false;
if (!string.IsNullOrEmpty(selectExpression))
{
SelectClause = selectExpression;
}
}
}
}
private void VisitOrderByMethod(MethodCallExpression node)
{
// Visit the source
Visit(node.Arguments[0]);
// Extract the key selector
if (node.Arguments.Count > 1)
{
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null)
{
_isInOrderByClause = true;
var orderByExpression = ExtractMemberName(lambda.Body);
_isInOrderByClause = false;
if (!string.IsNullOrEmpty(orderByExpression))
{
var direction = node.Method.Name.Contains("Descending") ? " DESC" : " ASC";
if (_orderByBuilder.Length > 0)
{
_orderByBuilder.Append(", ");
}
_orderByBuilder.Append(orderByExpression + direction);
OrderByClause = _orderByBuilder.ToString();
}
}
}
}
private void VisitGroupByMethod(MethodCallExpression node)
{
// Visit the source
Visit(node.Arguments[0]);
// Extract the key selector
if (node.Arguments.Count > 1)
{
var lambda = StripQuotes(node.Arguments[1]) as LambdaExpression;
if (lambda != null)
{
_isInGroupByClause = true;
var groupByExpression = ExtractMemberName(lambda.Body);
_isInGroupByClause = false;
if (!string.IsNullOrEmpty(groupByExpression))
{
GroupByClause = groupByExpression;
}
}
}
}
private void VisitJoinMethod(MethodCallExpression node)
{
// Visit the source
Visit(node.Arguments[0]);
// For joins, we'd need more complex logic to extract full join information
// This is a simplified version
_methodCalls.Add($"{node.Method.Name} (complex join analysis not fully implemented)");
}
private void VisitTakeSkipMethod(MethodCallExpression node)
{
// Visit the source
Visit(node.Arguments[0]);
// Extract the count
if (node.Arguments.Count > 1 && node.Arguments[1] is ConstantExpression constant)
{
_methodCalls.Add($"{node.Method.Name}({constant.Value})");
}
}
/// <summary>
/// Visits a binary expression (e.g., comparisons, logical operations).
/// </summary>
protected override Expression VisitBinary(BinaryExpression node)
{
if (_isInWhereClause)
{
_whereBuilder.Append("(");
Visit(node.Left);
_whereBuilder.Append($" {GetOperator(node.NodeType)} ");
Visit(node.Right);
_whereBuilder.Append(")");
return node;
}
return base.VisitBinary(node);
}
/// <summary>
/// Visits a member access expression.
/// </summary>
protected override Expression VisitMember(MemberExpression node)
{
if (_isInWhereClause)
{
var memberName = GetFullMemberName(node);
_whereBuilder.Append(memberName);
return node;
}
return base.VisitMember(node);
}
private string ExtractSelectExpression(Expression expression)
{
if (expression is NewExpression newExpr)
{
var members = new List<string>();
for (int i = 0; i < newExpr.Arguments.Count; i++)
{
var memberName = ExtractMemberName(newExpr.Arguments[i]);
var alias = newExpr.Members?[i].Name;
if (!string.IsNullOrEmpty(alias) && alias != memberName)
{
members.Add($"{memberName} AS {alias}");
}
else
{
members.Add(memberName);
}
}
return string.Join(", ", members);
}
var name = ExtractMemberName(expression);
return string.IsNullOrEmpty(name) ? "*" : name;
}
private string ExtractMemberName(Expression expression)
{
if (expression is MemberExpression member)
{
return GetFullMemberName(member);
}
if (expression is ParameterExpression param)
{
return "*";
}
if (expression is MethodCallExpression methodCall)
{
return $"{methodCall.Method.Name}(...)";
}
return expression.ToString();
}
private string GetFullMemberName(MemberExpression expression)
{
var parts = new Stack<string>();
var current = expression;
while (current != null)
{
parts.Push(current.Member.Name);
if (current.Expression is MemberExpression memberExpr)
{
current = memberExpr;
}
else if (current.Expression is ParameterExpression paramExpr)
{
// Use parameter name as table alias if it's not the default
if (paramExpr.Name != null && paramExpr.Name.Length == 1)
{
parts.Push(paramExpr.Name);
}
break;
}
else
{
break;
}
}
return string.Join(".", parts);
}
private string GetOperator(ExpressionType nodeType)
{
return nodeType switch
{
ExpressionType.Equal => "=",
ExpressionType.NotEqual => "!=",
ExpressionType.GreaterThan => ">",
ExpressionType.GreaterThanOrEqual => ">=",
ExpressionType.LessThan => "<",
ExpressionType.LessThanOrEqual => "<=",
ExpressionType.AndAlso => "AND",
ExpressionType.OrElse => "OR",
ExpressionType.Add => "+",
ExpressionType.Subtract => "-",
ExpressionType.Multiply => "*",
ExpressionType.Divide => "/",
_ => nodeType.ToString()
};
}
private static Expression StripQuotes(Expression expression)
{
while (expression.NodeType == ExpressionType.Quote)
{
expression = ((UnaryExpression)expression).Operand;
}
return expression;
}
}
@@ -0,0 +1,671 @@
using System.Text;
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Expressions;
using Strata.SqlTools.SqlBreakdown.Expressions.Arithmetic;
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional;
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional.Comparisons;
using Strata.SqlTools.SqlBreakdown.Expressions.Conditional.Logical;
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.Core;
namespace Strata.SqlTools.Markdown.Expressions;
/// <summary>
/// Generates markdown documentation for Expression trees.
/// Creates human-readable documentation with expression structure, type information, and visual representations.
/// </summary>
public class ExpressionGenerator : IVisitor<string>
{
private int _indentLevel = 0;
private readonly string _indentString = " ";
/// <summary>
/// Generates markdown documentation from an Expression tree.
/// </summary>
/// <param name="expression">The expression to document.</param>
/// <param name="title">Optional title for the documentation.</param>
/// <returns>A markdown formatted string documenting the expression.</returns>
public string GenerateMarkdown(Expression expression, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
sb.AppendLine("## Expression Structure");
sb.AppendLine();
sb.AppendLine("```");
_indentLevel = 0;
sb.AppendLine(expression.Accept(this));
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("## Expression Type");
sb.AppendLine();
sb.AppendLine($"**Type:** `{expression.GetType().Name}`");
sb.AppendLine();
sb.AppendLine("## Mermaid Diagram");
sb.AppendLine();
sb.AppendLine(GenerateMermaidDiagram(expression));
sb.AppendLine();
sb.AppendLine("## Mathematical Expression");
sb.AppendLine();
sb.AppendLine(GenerateMathematicalExpression(expression));
return sb.ToString();
}
/// <summary>
/// Generates a mathematical expression using LaTeX notation for GitHub markdown.
/// </summary>
/// <param name="expression">The expression to convert to mathematical notation.</param>
/// <param name="inline">If true, generates inline math ($...$), otherwise block math ($$...$$).</param>
/// <returns>A string containing the LaTeX mathematical expression.</returns>
public static string GenerateMathematicalExpression(Expression expression, bool inline = false)
{
var latex = ConvertToLatex(expression);
return inline ? $"${latex}$" : $"$$\n{latex}\n$$";
}
/// <summary>
/// Generates a mathematical expression from raw LaTeX in markdown format.
/// </summary>
/// <param name="latex">The LaTeX expression.</param>
/// <param name="format">The format to use: "dollar" for $/$$ delimiters, "math" for ```math code fence.</param>
/// <param name="inline">If true and format is "dollar", generates inline math ($...$), otherwise block math ($$...$$). Ignored for "math" format.</param>
/// <returns>A string containing the formatted mathematical expression.</returns>
public static string GenerateRawMathematicalExpression(string latex, string format = "dollar", bool inline = false)
{
return format.ToLower() switch
{
"math" => $"```math\n{latex}\n```",
_ => inline ? $"${latex}$" : $"$$\n{latex}\n$$"
};
}
private static string ConvertToLatex(Expression expression)
{
return expression switch
{
// Arithmetic expressions
ArithmeticExpression arith => ConvertArithmeticToLatex(arith),
// Comparison expressions
ComparisonOperatorExpression comp => ConvertComparisonToLatex(comp),
// Logical expressions
AndExpression and => $"({ConvertToLatex(and.ExpressionA)} \\land {ConvertToLatex(and.ExpressionB)})",
OrExpression or => $"({ConvertToLatex(or.ExpressionA)} \\lor {ConvertToLatex(or.ExpressionB)})",
NotExpression not => $"\\neg({ConvertToLatex(not.ExpressionA)})",
// Literals
NumberLiteralExpression num => num.Value.ToString() ?? "0",
StringLiteralExpression str => $"\\text{{\"{EscapeLatex(str.Value)}\"}}",
BooleanLiteralExpression b => b.Value ? "\\text{true}" : "\\text{false}",
NullLiteralExpression => "\\text{NULL}",
// Column expressions
ColumnExpression col => $"\\text{{{EscapeLatex(col.ColumnName)}}}",
// Parameter expressions
ParameterExpression param => $"@{EscapeLatex(param.ParameterName)}",
// Case expressions (before FunctionExpression since it's a subclass)
CaseExpression caseExpr => ConvertCaseToLatex(caseExpr),
// Functions
FunctionExpression func => ConvertFunctionToLatex(func),
// Between expressions
BetweenExpression between => $"{ConvertToLatex(between.Expression)} \\in [{ConvertToLatex(between.LowerBound)}, {ConvertToLatex(between.UpperBound)}]",
// IN expressions
InExpression inExpr => $"{ConvertToLatex(inExpr.SearchExpression)} \\in \\{{{string.Join(", ", inExpr.ValuesToCompare.Select(ConvertToLatex))}\\}}",
// LIKE expressions
LikeExpression like => $"{ConvertToLatex(like.Subject)} \\approx \\text{{\"{EscapeLatex(ConvertExpressionToString(like.Pattern))}\"}}",
_ => $"\\text{{{EscapeLatex(expression.GetType().Name)}}}"
};
}
private static string ConvertArithmeticToLatex(ArithmeticExpression arith)
{
var left = ConvertToLatex(arith.ExpressionA);
var right = ConvertToLatex(arith.ExpressionB);
var op = arith.ArithmeticOperator switch
{
"+" => "+",
"-" => "-",
"*" => "\\times",
"/" => "\\div",
"%" => "\\bmod",
_ => "?"
};
return $"({left} {op} {right})";
}
private static string ConvertComparisonToLatex(ComparisonOperatorExpression comp)
{
var left = ConvertToLatex(comp.ExpressionA);
var right = ConvertToLatex(comp.ExpressionB);
var op = comp.Operator switch
{
"=" => "=",
"<>" => "\\neq",
"!=" => "\\neq",
">" => ">",
">=" => "\\geq",
"<" => "<",
"<=" => "\\leq",
_ => "?"
};
return $"({left} {op} {right})";
}
private static string ConvertFunctionToLatex(FunctionExpression func)
{
var args = string.Join(", ", func.Arguments.Select(ConvertToLatex));
var funcName = EscapeLatex(func.FunctionName);
return func.FunctionName.ToUpper() switch
{
// Aggregate functions
"COUNT" => $"\\text{{COUNT}}({args})",
"SUM" => $"\\sum({args})",
"AVG" => $"\\text{{AVG}}({args})",
"MIN" => $"\\min({args})",
"MAX" => $"\\max({args})",
// Math functions
"ABS" => $"|{args}|",
"SQRT" => $"\\sqrt{{{args}}}",
"POWER" when func.Arguments.Length >= 2 =>
$"{ConvertToLatex(func.Arguments[0])}^{{{ConvertToLatex(func.Arguments[1])}}}",
"LOG" => $"\\log({args})",
"EXP" => $"e^{{{args}}}",
// Default
_ => $"\\text{{{funcName}}}({args})"
};
}
private static string ConvertCaseToLatex(CaseExpression caseExpr)
{
var sb = new StringBuilder();
sb.Append("\\begin{cases}\n");
foreach (var (condition, result) in caseExpr.ConditionResultPairs)
{
sb.Append($" {ConvertToLatex(result)} & \\text{{if }} {ConvertToLatex(condition)} \\\\\n");
}
if (caseExpr.ElseResultExpression is not null)
{
sb.Append($" {ConvertToLatex(caseExpr.ElseResultExpression)} & \\text{{otherwise}}\n");
}
sb.Append("\\end{cases}");
return sb.ToString();
}
private static string ConvertExpressionToString(Expression expression)
{
return expression switch
{
StringLiteralExpression str => str.Value,
_ => expression.ToString() ?? ""
};
}
private static string EscapeLatex(string text)
{
return text
.Replace("\\", "\\\\")
.Replace("{", "\\{")
.Replace("}", "\\}")
.Replace("_", "\\_")
.Replace("$", "\\$")
.Replace("%", "\\%")
.Replace("&", "\\&")
.Replace("#", "\\#");
}
/// <summary>
/// Generates a Mermaid tree diagram from an Expression tree.
/// </summary>
private string GenerateMermaidDiagram(Expression expression)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.AppendLine("graph TD");
sb.AppendLine();
int nodeCounter = 0;
var nodeMap = new Dictionary<object, int>();
GenerateMermaidNodes(expression, sb, nodeMap, ref nodeCounter);
sb.AppendLine("```");
return sb.ToString();
}
private int GenerateMermaidNodes(Expression expression, StringBuilder sb, Dictionary<object, int> nodeMap, ref int nodeCounter)
{
var currentNode = nodeCounter++;
nodeMap[expression] = currentNode;
var nodeLabel = GetNodeLabel(expression);
var nodeShape = GetNodeShape(expression);
sb.AppendLine($" Node{currentNode}{nodeShape[0]}\"{EscapeMarkdown(nodeLabel)}\"{nodeShape[1]}");
// Process child expressions
switch (expression)
{
case ComparisonOperatorExpression comp:
var leftId = GenerateMermaidNodes(comp.ExpressionA, sb, nodeMap, ref nodeCounter);
var rightId = GenerateMermaidNodes(comp.ExpressionB, sb, nodeMap, ref nodeCounter);
sb.AppendLine($" Node{currentNode} --> Node{leftId}");
sb.AppendLine($" Node{currentNode} --> Node{rightId}");
break;
case AndExpression and:
var andLeftId = GenerateMermaidNodes(and.ExpressionA, sb, nodeMap, ref nodeCounter);
var andRightId = GenerateMermaidNodes(and.ExpressionB, sb, nodeMap, ref nodeCounter);
sb.AppendLine($" Node{currentNode} -->|Left| Node{andLeftId}");
sb.AppendLine($" Node{currentNode} -->|Right| Node{andRightId}");
break;
case OrExpression or:
var orLeftId = GenerateMermaidNodes(or.ExpressionA, sb, nodeMap, ref nodeCounter);
var orRightId = GenerateMermaidNodes(or.ExpressionB, sb, nodeMap, ref nodeCounter);
sb.AppendLine($" Node{currentNode} -->|Left| Node{orLeftId}");
sb.AppendLine($" Node{currentNode} -->|Right| Node{orRightId}");
break;
case NotExpression not:
var notId = GenerateMermaidNodes(not.ExpressionA, sb, nodeMap, ref nodeCounter);
sb.AppendLine($" Node{currentNode} --> Node{notId}");
break;
case ArithmeticExpression arith:
var arithmLeftId = GenerateMermaidNodes(arith.ExpressionA, sb, nodeMap, ref nodeCounter);
var arithmRightId = GenerateMermaidNodes(arith.ExpressionB, sb, nodeMap, ref nodeCounter);
sb.AppendLine($" Node{currentNode} --> Node{arithmLeftId}");
sb.AppendLine($" Node{currentNode} --> Node{arithmRightId}");
break;
case FunctionExpression func:
foreach (var arg in func.Arguments)
{
var argId = GenerateMermaidNodes(arg, sb, nodeMap, ref nodeCounter);
sb.AppendLine($" Node{currentNode} --> Node{argId}");
}
break;
}
return currentNode;
}
private static string GetNodeLabel(Expression expression)
{
return expression switch
{
NumberLiteralExpression num => $"Number: {num.Value}",
StringLiteralExpression str => $"String: {TruncateText(str.Value, 20)}",
DateTimeLiteralExpression dt => $"DateTime: {dt.Value:yyyy-MM-dd}",
BooleanLiteralExpression b => $"Boolean: {b.Value}",
NullLiteralExpression => "NULL",
ComparisonOperatorExpression comp => $"Comparison: {comp.Operator}",
AndExpression => "AND",
OrExpression => "OR",
NotExpression => "NOT",
ArithmeticExpression arith => $"Arithmetic: {arith.ArithmeticOperator}",
FunctionExpression func => $"Function: {func.FunctionName}",
ParameterExpression param => $"Parameter: @{param.ParameterName}",
_ => expression.GetType().Name
};
}
private static string[] GetNodeShape(Expression expression)
{
return expression switch
{
NumberLiteralExpression or StringLiteralExpression or DateTimeLiteralExpression or BooleanLiteralExpression or NullLiteralExpression => new[] { "[", "]" },
ComparisonOperatorExpression => new[] { "{", "}" },
AndExpression or OrExpression or NotExpression => new[] { "{", "}" },
FunctionExpression => new[] { "[[", "]]" },
_ => new[] { "(", ")" }
};
}
private string Indent() => new string(' ', _indentLevel * _indentString.Length);
private static string EscapeMarkdown(string text)
{
return text
.Replace("\"", "&quot;")
.Replace("[", "&#91;")
.Replace("]", "&#93;");
}
private static string TruncateText(string text, int maxLength)
{
if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength)
{
return text;
}
return text.Substring(0, maxLength) + "...";
}
#region IVisitor Implementation
public string VisitTableSource(TableSource tableSource)
{
return $"{Indent()}TableSource: {tableSource.TableName}";
}
public string VisitColumnExpression<TSource>(ColumnExpression<TSource> column) where TSource : SelectSource
{
return $"{Indent()}Column: {column.ColumnName}";
}
public string VisitSelectClauseColumn(SelectClauseColumn selectClauseColumn)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}SelectClauseColumn:");
_indentLevel++;
sb.AppendLine(selectClauseColumn.Expression.Accept(this));
if (!string.IsNullOrWhiteSpace(selectClauseColumn.Alias))
{
sb.AppendLine($"{Indent()}Alias: {selectClauseColumn.Alias}");
}
_indentLevel--;
return sb.ToString();
}
public string VisitParameterExpression(ParameterExpression parameterExpression)
{
return $"{Indent()}Parameter: @{parameterExpression.ParameterName}";
}
public string VisitNumberLiteralExpression(NumberLiteralExpression numberLiteral)
{
return $"{Indent()}Number: {numberLiteral.Value}";
}
public string VisitStringLiteralExpression(StringLiteralExpression stringLiteral)
{
return $"{Indent()}String: '{stringLiteral.Value}'";
}
public string VisitDateTimeLiteralExpression(DateTimeLiteralExpression dateTimeLiteral)
{
return $"{Indent()}DateTime: {dateTimeLiteral.Value:yyyy-MM-dd HH:mm:ss}";
}
public string VisitNullLiteralExpression(NullLiteralExpression nullLiteral)
{
return $"{Indent()}NULL";
}
public string VisitBooleanLiteralExpression(BooleanLiteralExpression booleanLiteral)
{
return $"{Indent()}Boolean: {booleanLiteral.Value}";
}
public string VisitParameterLiteralExpression(ParameterLiteralExpression parameterLiteral)
{
return $"{Indent()}Parameter: {parameterLiteral.Value}";
}
public string VisitSymbolLiteralExpression(SymbolLiteralExpression symbolLiteral)
{
return $"{Indent()}Symbol: {symbolLiteral.Value}";
}
public string VisitComparisonExpression(ComparisonOperatorExpression comparison)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}Comparison ({comparison.Operator}):");
_indentLevel++;
sb.AppendLine($"{Indent()}Left:");
_indentLevel++;
sb.AppendLine(comparison.ExpressionA.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Right:");
_indentLevel++;
sb.AppendLine(comparison.ExpressionB.Accept(this));
_indentLevel--;
_indentLevel--;
return sb.ToString();
}
public string VisitAndExpression(AndExpression logical)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}AND:");
_indentLevel++;
sb.AppendLine(logical.ExpressionA.Accept(this));
sb.AppendLine(logical.ExpressionB.Accept(this));
_indentLevel--;
return sb.ToString();
}
public string VisitOrExpression(OrExpression logical)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}OR:");
_indentLevel++;
sb.AppendLine(logical.ExpressionA.Accept(this));
sb.AppendLine(logical.ExpressionB.Accept(this));
_indentLevel--;
return sb.ToString();
}
public string VisitNotExpression(NotExpression logical)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}NOT:");
_indentLevel++;
sb.AppendLine(logical.ExpressionA.Accept(this));
_indentLevel--;
return sb.ToString();
}
public string VisitInExpression(InExpression inExpression)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}IN:");
_indentLevel++;
sb.AppendLine($"{Indent()}Search Expression:");
_indentLevel++;
sb.AppendLine(inExpression.SearchExpression.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Values:");
_indentLevel++;
foreach (var value in inExpression.ValuesToCompare)
{
sb.AppendLine(value.Accept(this));
}
_indentLevel--;
_indentLevel--;
return sb.ToString();
}
public string VisitNotInExpression(NotInExpression inExpression)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}NOT IN:");
_indentLevel++;
sb.AppendLine($"{Indent()}Search Expression:");
_indentLevel++;
sb.AppendLine(inExpression.SearchExpression.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Values:");
_indentLevel++;
foreach (var value in inExpression.ValuesToCompare)
{
sb.AppendLine(value.Accept(this));
}
_indentLevel--;
_indentLevel--;
return sb.ToString();
}
public string VisitLikeExpression(LikeExpression likeExpression)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}LIKE (Case {(likeExpression.CaseInsensitive ? "Insensitive" : "Sensitive")}):");
_indentLevel++;
sb.AppendLine($"{Indent()}Subject:");
_indentLevel++;
sb.AppendLine(likeExpression.Subject.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Pattern:");
_indentLevel++;
sb.AppendLine(likeExpression.Pattern.Accept(this));
_indentLevel--;
_indentLevel--;
return sb.ToString();
}
public string VisitNotLikeExpression(NotLikeExpression notLikeExpression)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}NOT LIKE:");
_indentLevel++;
sb.AppendLine($"{Indent()}Subject:");
_indentLevel++;
sb.AppendLine(notLikeExpression.Subject.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Pattern:");
_indentLevel++;
sb.AppendLine(notLikeExpression.Pattern.Accept(this));
_indentLevel--;
_indentLevel--;
return sb.ToString();
}
public string VisitBetweenExpression(BetweenExpression betweenExpression)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}BETWEEN:");
_indentLevel++;
sb.AppendLine($"{Indent()}Expression:");
_indentLevel++;
sb.AppendLine(betweenExpression.Expression.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Lower Bound:");
_indentLevel++;
sb.AppendLine(betweenExpression.LowerBound.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Upper Bound:");
_indentLevel++;
sb.AppendLine(betweenExpression.UpperBound.Accept(this));
_indentLevel--;
_indentLevel--;
return sb.ToString();
}
public string VisitFunctionExpression(FunctionExpression function)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}Function: {function.FunctionName}");
if (function.Arguments.Any())
{
_indentLevel++;
sb.AppendLine($"{Indent()}Arguments:");
_indentLevel++;
foreach (var arg in function.Arguments)
{
sb.AppendLine(arg.Accept(this));
}
_indentLevel--;
_indentLevel--;
}
return sb.ToString();
}
public string VisitAggregateFunctionExpression(AggregateFunctionExpression aggregateFunction)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}Aggregate Function: {aggregateFunction.FunctionName}");
if (aggregateFunction.Arguments.Any())
{
_indentLevel++;
sb.AppendLine($"{Indent()}Arguments:");
_indentLevel++;
foreach (var arg in aggregateFunction.Arguments)
{
sb.AppendLine(arg.Accept(this));
}
_indentLevel--;
_indentLevel--;
}
return sb.ToString();
}
public string VisitCaseFunctionExpression(CaseExpression caseFunction)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}CASE:");
_indentLevel++;
foreach (var (condition, result) in caseFunction.ConditionResultPairs)
{
sb.AppendLine($"{Indent()}WHEN:");
_indentLevel++;
sb.AppendLine(condition.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}THEN:");
_indentLevel++;
sb.AppendLine(result.Accept(this));
_indentLevel--;
}
if (caseFunction.ElseResultExpression is not null)
{
sb.AppendLine($"{Indent()}ELSE:");
_indentLevel++;
sb.AppendLine(caseFunction.ElseResultExpression.Accept(this));
_indentLevel--;
}
_indentLevel--;
return sb.ToString();
}
public string VisitArithmeticExpression(ArithmeticExpression arithmeticExpression)
{
var sb = new StringBuilder();
sb.AppendLine($"{Indent()}Arithmetic ({arithmeticExpression.ArithmeticOperator}):");
_indentLevel++;
sb.AppendLine($"{Indent()}Left:");
_indentLevel++;
sb.AppendLine(arithmeticExpression.ExpressionA.Accept(this));
_indentLevel--;
sb.AppendLine($"{Indent()}Right:");
_indentLevel++;
sb.AppendLine(arithmeticExpression.ExpressionB.Accept(this));
_indentLevel--;
_indentLevel--;
return sb.ToString();
}
public string VisitInputPropertyExpression(InputPropertyExpression inputPropertyExpression)
{
return $"{Indent()}InputProperty: {inputPropertyExpression.DataKeyLookup}";
}
#endregion
}
@@ -0,0 +1,137 @@
using System.Text;
using Strata.SqlTools.SqlBreakdown.Expressions;
using Strata.SqlTools.Visitors.SqlServer;
namespace Strata.SqlTools.Markdown.Expressions;
/// <summary>
/// Generates simplified markdown documentation for Expression trees focused on readability.
/// </summary>
public class SimpleExpressionGenerator
{
private readonly CommandVisitor _sqlVisitor = new();
/// <summary>
/// Generates a simple markdown document from an Expression.
/// </summary>
/// <param name="expression">The expression to document.</param>
/// <param name="title">Optional title for the documentation.</param>
/// <returns>A markdown formatted string documenting the expression.</returns>
public string GenerateMarkdown(Expression expression, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
sb.AppendLine("## Expression");
sb.AppendLine();
sb.AppendLine("```sql");
sb.AppendLine(expression.Accept(_sqlVisitor));
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("## Type Information");
sb.AppendLine();
sb.AppendLine($"- **Expression Type:** `{expression.GetType().Name}`");
sb.AppendLine($"- **Namespace:** `{expression.GetType().Namespace}`");
sb.AppendLine();
sb.AppendLine("## Description");
sb.AppendLine();
sb.AppendLine(GetExpressionDescription(expression));
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Generates a comparison table for multiple expressions.
/// </summary>
/// <param name="expressions">Dictionary of expression names to expressions.</param>
/// <param name="title">Optional title for the table.</param>
/// <returns>A markdown formatted comparison table.</returns>
public string GenerateComparisonTable(Dictionary<string, Expression> expressions, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
sb.AppendLine("| Name | Expression | Type |");
sb.AppendLine("|------|------------|------|");
foreach (var (name, expr) in expressions)
{
var sql = expr.Accept(_sqlVisitor).Replace("|", "\\|").Replace("\n", " ");
var type = expr.GetType().Name;
sb.AppendLine($"| {name} | `{sql}` | `{type}` |");
}
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Generates a bulleted list of expressions.
/// </summary>
/// <param name="expressions">List of expressions to document.</param>
/// <param name="title">Optional title for the list.</param>
/// <returns>A markdown formatted bulleted list.</returns>
public string GenerateBulletList(IEnumerable<Expression> expressions, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"## {title}");
sb.AppendLine();
}
foreach (var expr in expressions)
{
var sql = expr.Accept(_sqlVisitor).Replace("\n", " ");
sb.AppendLine($"- `{sql}` - *{expr.GetType().Name}*");
}
sb.AppendLine();
return sb.ToString();
}
private string GetExpressionDescription(Expression expression)
{
var typeName = expression.GetType().Name;
return typeName switch
{
"AndExpression" => "A logical AND expression that combines two boolean expressions. Both expressions must evaluate to true for the result to be true.",
"OrExpression" => "A logical OR expression that combines two boolean expressions. Either expression can evaluate to true for the result to be true.",
"NotExpression" => "A logical NOT expression that negates a boolean expression.",
"ComparisonOperatorExpression" => "A comparison expression that compares two values using an operator (=, <>, <, >, <=, >=).",
"ArithmeticExpression" => "An arithmetic expression that performs mathematical operations (+, -, *, /) on numeric values.",
"FunctionExpression" => "A SQL function call expression that invokes a database function with arguments.",
"AggregateFunctionExpression" => "An aggregate function expression (SUM, COUNT, AVG, MIN, MAX) that operates on sets of values.",
"CaseExpression" => "A CASE expression that provides conditional logic similar to if-then-else statements.",
"InExpression" => "An IN expression that checks if a value exists in a set of values.",
"BetweenExpression" => "A BETWEEN expression that checks if a value falls within a range.",
"LikeExpression" => "A LIKE expression that performs pattern matching on strings using wildcards.",
"NumberLiteralExpression" => "A numeric literal value.",
"StringLiteralExpression" => "A string literal value enclosed in quotes.",
"DateTimeLiteralExpression" => "A date/time literal value.",
"BooleanLiteralExpression" => "A boolean literal value (true/false).",
"NullLiteralExpression" => "A NULL literal value representing absence of data.",
"ParameterExpression" => "A parameterized value placeholder that will be substituted at runtime.",
"ColumnExpression" => "A reference to a database column from a table or view.",
_ => $"A {typeName} expression."
};
}
}
@@ -0,0 +1,110 @@
using Strata.SqlTools.Breakdowns.LinqToSql;
namespace Strata.SqlTools.Markdown.LinqToSql;
/// <summary>
/// Generates Mermaid diagram markdown from LINQ to SQL QueryBreakdown objects.
/// Creates flowchart visualizations showing the LINQ query structure and flow.
/// </summary>
public class QueryBreakdownGenerator
{
private readonly SqlServer.QueryBreakdownGenerator _baseGenerator;
/// <summary>
/// Initializes a new instance of the QueryBreakdownGenerator class.
/// </summary>
public QueryBreakdownGenerator()
{
_baseGenerator = new SqlServer.QueryBreakdownGenerator();
}
/// <summary>
/// Generates a Mermaid flowchart diagram from a LINQ to SQL QueryBreakdown.
/// </summary>
/// <param name="queryBreakdown">The LINQ QueryBreakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid markdown diagram.</returns>
public string GenerateMermaidDiagram(LinqQueryBreakdown queryBreakdown, string? title = null)
{
// Since LinqQueryBreakdown inherits from SqlServer.QueryBreakdown,
// we can use the base generator which works with the shared properties
return _baseGenerator.GenerateMermaidDiagram(queryBreakdown, title);
}
/// <summary>
/// Generates a Mermaid diagram showing the LINQ method call chain.
/// </summary>
/// <param name="queryBreakdown">The LINQ QueryBreakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid flowchart showing method calls.</returns>
public string GenerateMethodChainDiagram(LinqQueryBreakdown queryBreakdown, string? title = null)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"### {title}");
sb.AppendLine();
}
sb.AppendLine("```mermaid");
sb.AppendLine("flowchart LR");
sb.AppendLine();
if (queryBreakdown.MethodCallChain.Count == 0)
{
sb.AppendLine(" Start([IQueryable]) --> End([Result])");
}
else
{
sb.AppendLine(" Start([IQueryable])");
for (int i = 0; i < queryBreakdown.MethodCallChain.Count; i++)
{
var method = queryBreakdown.MethodCallChain[i];
var nodeId = $"M{i}";
var prevNodeId = i == 0 ? "Start" : $"M{i - 1}";
sb.AppendLine($" {nodeId}[\"{method}\"]");
sb.AppendLine($" {prevNodeId} --> {nodeId}");
}
var lastNodeId = $"M{queryBreakdown.MethodCallChain.Count - 1}";
sb.AppendLine($" {lastNodeId} --> End([Result])");
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Generates a combined diagram showing both the query structure and method chain.
/// </summary>
/// <param name="queryBreakdown">The LINQ QueryBreakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing both diagrams.</returns>
public string GenerateCombinedDiagram(LinqQueryBreakdown queryBreakdown, string? title = null)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"## {title}");
sb.AppendLine();
}
// Method chain
sb.AppendLine("### LINQ Method Chain");
sb.AppendLine();
sb.Append(GenerateMethodChainDiagram(queryBreakdown));
// SQL Structure
sb.AppendLine("### SQL Query Structure");
sb.AppendLine();
sb.Append(GenerateMermaidDiagram(queryBreakdown));
return sb.ToString();
}
}
@@ -0,0 +1,115 @@
using Strata.SqlTools.SqlBreakdown.Interfaces;
using Strata.SqlTools.SqlBreakdown.Interfaces.QueryEngine;
namespace Strata.SqlTools.Markdown.LinqToSql;
/// <summary>
/// Generates Mermaid diagrams for LINQ to SQL statements, including sequence diagrams
/// for statement execution flow and entity-relationship diagrams.
/// </summary>
public class SqlStatementGenerator
{
private readonly SqlServer.SqlStatementGenerator _baseGenerator;
/// <summary>
/// Initializes a new instance of the SqlStatementGenerator class.
/// </summary>
public SqlStatementGenerator()
{
_baseGenerator = new SqlServer.SqlStatementGenerator();
}
/// <summary>
/// Generates a Mermaid sequence diagram showing LINQ to SQL statement execution flow.
/// </summary>
/// <param name="sqlBreakdown">The LINQ to SQL breakdown object.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid sequence diagram markdown.</returns>
public string GenerateSequenceDiagram(ISqlBreakdown sqlBreakdown, string? title = null)
{
return _baseGenerator.GenerateSequenceDiagram(sqlBreakdown, title);
}
/// <summary>
/// Generates a Mermaid entity-relationship diagram from a SQL breakdown.
/// </summary>
/// <param name="sqlBreakdown">The SQL breakdown containing query information.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid ER diagram markdown.</returns>
public string GenerateEntityRelationshipDiagram(ISqlBreakdown sqlBreakdown, string? title = null)
{
//Extract table names from breakdown - just use FROM clause for now
var queryBreakdown = sqlBreakdown as IQueryBreakdown;
var tableNames = new List<string>();
if (queryBreakdown != null && !string.IsNullOrWhiteSpace(queryBreakdown.FromClause?.ToString()))
{
tableNames.Add(queryBreakdown.FromClause.ToString());
}
return _baseGenerator.GenerateEntityRelationshipDiagram(tableNames, title);
}
/// <summary>
/// Generates a diagram showing LINQ execution pipeline from a SQL breakdown.
/// </summary>
/// <param name="queryBreakdown">The query breakdown containing query information.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid diagram markdown.</returns>
public string GenerateLinqPipelineDiagram(IQueryBreakdown queryBreakdown, string? title = null)
{
var sb = new System.Text.StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"### {title}");
sb.AppendLine();
}
sb.AppendLine("```mermaid");
sb.AppendLine("sequenceDiagram");
sb.AppendLine(" participant Client as Client Application");
sb.AppendLine(" participant LINQ as LINQ Provider");
sb.AppendLine(" participant ET as Expression Tree");
sb.AppendLine(" participant SQL as SQL Generator");
sb.AppendLine(" participant DB as Database");
sb.AppendLine();
sb.AppendLine(" Client->>LINQ: LINQ Query");
sb.AppendLine(" activate LINQ");
// Check if there's a WHERE clause
var whereClause = queryBreakdown.WhereClause?.Clause;
if (!string.IsNullOrWhiteSpace(whereClause))
{
sb.AppendLine(" LINQ->>ET: Where Predicate");
sb.AppendLine(" activate ET");
}
// Check if there's a custom SELECT
var selectClause = queryBreakdown.SelectClause?.Clause;
if (!string.IsNullOrWhiteSpace(selectClause) && selectClause.Trim() != "*")
{
sb.AppendLine(" LINQ->>ET: Select Projection");
if (string.IsNullOrWhiteSpace(whereClause))
{
sb.AppendLine(" activate ET");
}
}
sb.AppendLine(" ET->>SQL: Expression Tree");
sb.AppendLine(" deactivate ET");
sb.AppendLine(" SQL->>DB: Generate SQL");
sb.AppendLine(" activate DB");
sb.AppendLine(" DB-->>SQL: Result Set");
sb.AppendLine(" deactivate DB");
sb.AppendLine(" SQL-->>LINQ: Mapped Objects");
sb.AppendLine(" LINQ-->>Client: IEnumerable Result");
sb.AppendLine(" deactivate LINQ");
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
}
@@ -0,0 +1,432 @@
using System.Text;
using Strata.SqlTools.Breakdowns.PostgreSql;
using QuerySummary = Strata.SqlTools.Breakdowns.SqlServer.QuerySummary;
namespace Strata.SqlTools.Markdown.PostgreSql;
/// <summary>
/// Generates Markdown documentation from QueryBreakdownCollection objects for PostgreSQL.
/// Creates comprehensive reports including collection summaries, parameter analysis, and batch flow visualization
/// with PostgreSQL-specific features.
/// </summary>
public static class QueryBreakdownCollectionGenerator
{
/// <summary>
/// Generates a comprehensive collection report in Markdown format with PostgreSQL-specific information.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to document.</param>
/// <param name="title">Optional title for the report.</param>
/// <returns>A string containing the Markdown documentation.</returns>
public static string GenerateCollectionReport(QueryBreakdownCollection collection, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
// Collection Summary
sb.Append(GenerateCollectionSummary(collection));
sb.AppendLine();
// Parameter Analysis
sb.Append(GenerateParameterAnalysis(collection));
sb.AppendLine();
// Query Composition Report
sb.Append(GenerateQueryCompositionReport(collection));
return sb.ToString();
}
/// <summary>
/// Generates a summary section for the collection.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to summarize.</param>
/// <returns>Markdown summary section.</returns>
public static string GenerateCollectionSummary(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("## Collection Summary");
sb.AppendLine();
sb.AppendLine("| Metric | Value |");
sb.AppendLine("|--------|-------|");
sb.AppendLine($"| Total Queries | {collection.QueryBreakdowns.Count} |");
sb.AppendLine($"| Total Parameters | {collection.GetAllUniqueParameters().Count()} |");
sb.AppendLine($"| Total Columns Selected | {collection.GetTotalSelectedColumns()} |");
sb.AppendLine($"| Unique Tables | {collection.GetUniqueTableReferences().Count()} |");
return sb.ToString();
}
/// <summary>
/// Generates a parameter analysis report with PostgreSQL parameter syntax support.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to analyze.</param>
/// <returns>Markdown parameter analysis section.</returns>
public static string GenerateParameterAnalysis(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
var paramReport = collection.GetParameterUsageReport().ToList();
sb.AppendLine("## Parameter Analysis");
sb.AppendLine();
if (paramReport.Count == 0)
{
sb.AppendLine("### Parameters");
sb.AppendLine();
sb.AppendLine("No parameters are used in this collection.");
return sb.ToString();
}
sb.AppendLine("### Parameters");
sb.AppendLine();
sb.AppendLine("| Parameter | Type | Used In | Value |");
sb.AppendLine("|-----------|------|---------|-------|");
foreach (var param in paramReport.OrderBy(p => p.ParameterName))
{
var usageIndicator = param.IsUsedInAllQueries ? "✓ All" : $"{param.UsedInQueryCount}/{param.TotalQueries}";
var value = param.Value?.ToString() ?? "NULL";
// PostgreSQL supports both $n positional and :named parameters
var paramSyntax = int.TryParse(param.ParameterName, out _)
? $"${param.ParameterName}"
: $":{param.ParameterName}";
sb.AppendLine($"| {paramSyntax} | {GetParameterType(param.Value)} | {usageIndicator} | `{EscapeMarkdown(value)}` |");
}
sb.AppendLine();
sb.AppendLine("### Parameter Dependency Diagram");
sb.AppendLine();
sb.Append(GenerateParameterDependencyDiagram(collection));
return sb.ToString();
}
/// <summary>
/// Generates a Mermaid diagram showing parameter dependencies across queries.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to visualize.</param>
/// <returns>Mermaid diagram markdown.</returns>
public static string GenerateParameterDependencyDiagram(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.AppendLine("graph TD");
sb.AppendLine();
var queryBreakdowns = collection.QueryBreakdowns;
// Collect all unique parameter names from both ParameterList and Parameters dictionary
var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in queryBreakdowns)
{
foreach (var param in query.ParameterList)
{
allParamNames.Add(param.Name);
}
foreach (var paramName in query.Parameters.Keys)
{
allParamNames.Add(paramName);
}
}
var parameters = allParamNames.OrderBy(p => p).ToList();
// Create parameter nodes
for (int i = 0; i < parameters.Count; i++)
{
var paramNode = $"param{i}";
var paramSyntax = int.TryParse(parameters[i], out _)
? $"${parameters[i]}"
: $":{parameters[i]}";
sb.AppendLine($" {paramNode}[\"{paramSyntax}\"]");
sb.AppendLine($" style {paramNode} fill:#e8f5e9");
}
sb.AppendLine();
// Create query nodes and connections
for (int i = 0; i < queryBreakdowns.Count; i++)
{
var query = queryBreakdowns[i];
var queryNode = $"query{i}";
var queryType = DetermineQueryType(query);
sb.AppendLine($" {queryNode}[\"Query #{i}: {queryType}\"]");
sb.AppendLine($" style {queryNode} fill:#fff3e0");
// Collect all parameter names used by this query
var queryParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
queryParamNames.Add(param.Name);
}
// Add from Parameters dictionary (manually added parameters)
foreach (var paramName in query.Parameters.Keys)
{
queryParamNames.Add(paramName);
}
// Connect parameters to this query
foreach (var paramName in queryParamNames)
{
var paramIndex = parameters.FindIndex(p => p.Equals(paramName, StringComparison.OrdinalIgnoreCase));
if (paramIndex >= 0)
{
var paramNode = $"param{paramIndex}";
sb.AppendLine($" {paramNode} --> {queryNode}");
}
}
sb.AppendLine();
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Generates a detailed query composition report.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to report on.</param>
/// <returns>Markdown composition report section.</returns>
public static string GenerateQueryCompositionReport(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("## Query Composition Report");
sb.AppendLine();
var summaries = collection.GetQuerySummaries().ToList();
for (int i = 0; i < summaries.Count; i++)
{
var summary = summaries[i];
var query = collection.QueryBreakdowns[i];
AppendQueryCompositionTable(sb, i, summary);
AppendQueryParameters(sb, query);
AppendQueryCteSections(sb, summary, query);
}
return sb.ToString();
}
/// <summary>
/// Appends the query composition table for a single query.
/// </summary>
private static void AppendQueryCompositionTable(StringBuilder sb, int queryIndex, QuerySummary summary)
{
sb.AppendLine($"### Query #{queryIndex}");
sb.AppendLine();
sb.AppendLine("| Aspect | Present |");
sb.AppendLine("|--------|---------|");
sb.AppendLine($"| SELECT Clause | {FormatClausePresence(summary.HasSelectClause)} |");
sb.AppendLine($"| FROM Clause | {FormatClausePresence(summary.HasFromClause)} |");
sb.AppendLine($"| WHERE Clause | {FormatClausePresence(summary.HasWhereClause)} |");
sb.AppendLine($"| GROUP BY Clause | {FormatClausePresence(summary.HasGroupByClause)} |");
sb.AppendLine($"| HAVING Clause | {FormatClausePresence(summary.HasHavingClause)} |");
sb.AppendLine($"| ORDER BY Clause | {FormatClausePresence(summary.HasOrderByClause)} |");
sb.AppendLine($"| CTE (WITH) | {FormatClausePresence(summary.HasCTE)} |");
sb.AppendLine($"| Columns | {summary.ColumnCount} |");
sb.AppendLine($"| Parameters | {summary.ParameterCount} |");
sb.AppendLine();
}
/// <summary>
/// Appends parameter information for a query.
/// </summary>
private static void AppendQueryParameters(StringBuilder sb, QueryBreakdown query)
{
// Collect all unique parameters from both ParameterList and Parameters dictionary
var allParams = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
allParams[param.Name] = param.Value;
}
// Add from Parameters dictionary (manually added parameters)
foreach (var param in query.Parameters)
{
allParams[param.Key] = param.Value;
}
if (allParams.Count == 0)
{
return;
}
sb.AppendLine("**Parameters Used:**");
sb.AppendLine();
foreach (var paramName in allParams.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))
{
var value = allParams[paramName];
var paramSyntax = int.TryParse(paramName, out _)
? $"${paramName}"
: $":{paramName}";
sb.AppendLine($"- `{paramSyntax}` = `{value?.ToString() ?? "NULL"}`");
}
sb.AppendLine();
}
/// <summary>
/// Appends CTE section for a query.
/// </summary>
private static void AppendQueryCteSections(StringBuilder sb, QuerySummary summary, QueryBreakdown query)
{
if (!summary.HasCTE)
{
return;
}
sb.AppendLine("**CTEs Defined:**");
sb.AppendLine();
foreach (var cte in query.WithClauses)
{
sb.AppendLine($"- `{cte.TableName}`");
}
sb.AppendLine();
}
/// <summary>
/// Generates a batch execution flow diagram for PostgreSQL.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to visualize.</param>
/// <param name="includeTransaction">Whether to show transaction wrapping.</param>
/// <returns>Mermaid diagram markdown.</returns>
public static string GenerateBatchFlowDiagram(QueryBreakdownCollection collection, bool includeTransaction = false)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.AppendLine("flowchart TD");
sb.AppendLine();
int nodeId = 0;
// Handle empty collection
if (collection.QueryBreakdowns.Count == 0)
{
if (includeTransaction)
{
sb.AppendLine($" Start([Batch Start]) --> node0[\"BEGIN\"]");
sb.AppendLine($" node0 --> node1[\"COMMIT\"]");
sb.AppendLine($" node1 --> End([Batch Complete])");
}
else
{
sb.AppendLine($" Start([Batch Start]) --> End([Batch Complete])");
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
// Start node
if (includeTransaction)
{
sb.AppendLine($" node{nodeId}[\"BEGIN\"]");
sb.AppendLine($" Start([Batch Start]) --> node{nodeId}");
nodeId++;
sb.AppendLine($" node{nodeId - 1} --> node{nodeId}");
}
else
{
sb.AppendLine($" Start([Batch Start]) --> node{nodeId}");
}
// Query nodes
for (int i = 0; i < collection.QueryBreakdowns.Count; i++)
{
if (i < collection.QueryBreakdowns.Count - 1)
{
// Not the last query - connect to next
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> node{nodeId + 1}");
nodeId++;
}
else
{
// Last query - connect to End (or COMMIT if transaction)
if (includeTransaction)
{
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> node{nodeId + 1}");
nodeId++;
sb.AppendLine($" node{nodeId}[\"COMMIT\"]");
sb.AppendLine($" node{nodeId} --> End([Batch Complete])");
}
else
{
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> End([Batch Complete])");
}
}
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Gets the parameter type name from a parameter value using PostgreSQL types.
/// </summary>
private static string GetParameterType(object? value)
{
return value switch
{
null => "NULL",
bool => "BOOLEAN",
byte or short => "SMALLINT",
int => "INTEGER",
long => "BIGINT",
float => "REAL",
double => "DOUBLE PRECISION",
decimal => "NUMERIC",
string => "TEXT",
DateTime => "TIMESTAMP",
_ => "UNKNOWN"
};
}
/// <summary>
/// Escapes special Markdown characters.
/// </summary>
private static string EscapeMarkdown(string text)
{
return text
.Replace("\\", "\\\\")
.Replace("|", "\\|")
.Replace("\n", "\\n");
}
/// <summary>
/// Formats clause presence as Yes/No with checkmark/cross.
/// </summary>
private static string FormatClausePresence(bool isPresent)
=> isPresent ? "✓ Yes" : "✗ No";
/// <summary>
/// Determines the query type from a QueryBreakdown.
/// </summary>
private static string DetermineQueryType(QueryBreakdown query)
{
var hasSelect = !string.IsNullOrWhiteSpace(query.SelectClause?.Clause);
if (hasSelect)
{
return "SELECT";
}
var hasFrom = !string.IsNullOrWhiteSpace(query.FromClause?.Clause);
return hasFrom ? "FROM" : "QUERY";
}
}
@@ -0,0 +1,33 @@
using Strata.SqlTools.Breakdowns.PostgreSql;
namespace Strata.SqlTools.Markdown.PostgreSql;
/// <summary>
/// Generates Mermaid diagram markdown from PostgreSQL SQL QueryBreakdown objects.
/// Creates flowchart visualizations showing the query structure and flow.
/// </summary>
public class QueryBreakdownGenerator
{
private readonly SqlServer.QueryBreakdownGenerator _baseGenerator;
/// <summary>
/// Initializes a new instance of the QueryBreakdownGenerator class.
/// </summary>
public QueryBreakdownGenerator()
{
_baseGenerator = new SqlServer.QueryBreakdownGenerator();
}
/// <summary>
/// Generates a Mermaid flowchart diagram from a PostgreSQL QueryBreakdown.
/// </summary>
/// <param name="queryBreakdown">The PostgreSQL QueryBreakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid markdown diagram.</returns>
public string GenerateMermaidDiagram(QueryBreakdown queryBreakdown, string? title = null)
{
// Since PostgreSql.QueryBreakdown inherits from SqlServer.QueryBreakdown,
// we can use the base generator which works with the shared properties
return _baseGenerator.GenerateMermaidDiagram(queryBreakdown, title);
}
}
@@ -0,0 +1,42 @@
using Strata.SqlTools.SqlBreakdown.Classes;
namespace Strata.SqlTools.Markdown.PostgreSql;
/// <summary>
/// Generates Mermaid diagrams for PostgreSQL SQL statements, including sequence diagrams
/// for statement execution flow and entity-relationship diagrams.
/// </summary>
public class SqlStatementGenerator
{
private readonly SqlServer.SqlStatementGenerator _baseGenerator;
/// <summary>
/// Initializes a new instance of the SqlStatementGenerator class.
/// </summary>
public SqlStatementGenerator()
{
_baseGenerator = new SqlServer.SqlStatementGenerator();
}
/// <summary>
/// Generates a Mermaid sequence diagram showing PostgreSQL SQL statement execution flow.
/// </summary>
/// <param name="sqlBreakdown">The PostgreSQL SQL breakdown object.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid sequence diagram markdown.</returns>
public string GenerateSequenceDiagram(SqlBreakdownBase sqlBreakdown, string? title = null)
{
return _baseGenerator.GenerateSequenceDiagram(sqlBreakdown, title);
}
/// <summary>
/// Generates a Mermaid entity-relationship diagram from table names.
/// </summary>
/// <param name="tables">Collection of table names to include in the diagram.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid ER diagram markdown.</returns>
public string GenerateEntityRelationshipDiagram(IEnumerable<string> tables, string? title = null)
{
return _baseGenerator.GenerateEntityRelationshipDiagram(tables, title);
}
}
+326
View File
@@ -0,0 +1,326 @@
# Strata.SqlTools.Markdown
Markdown documentation generation for SQL queries and expressions from Strata.SqlTools.
## Overview
This library provides tools to generate markdown documentation and Mermaid diagrams from SQL query breakdowns and expression trees. It's designed to help document SQL queries and their structure in a human-readable format.
## Features
### SqlServer Folder - Mermaid Diagram Generation
#### QueryBreakdownGenerator
Generates Mermaid flowchart diagrams from SQL `QueryBreakdown` objects, visualizing:
- WITH clauses (Common Table Expressions)
- SELECT, FROM, WHERE clauses
- GROUP BY, HAVING, ORDER BY clauses
- Setup and Finish clauses
**Example Usage:**
```csharp
using Strata.SqlTools.Breakdowns.SqlServer;
using Strata.SqlTools.Markdown.SqlServer;
var query = QueryBreakdown.Parse(@"
SELECT u.ID, u.Name, COUNT(o.OrderID) as OrderCount
FROM Users u
JOIN Orders o ON u.ID = o.UserID
WHERE u.Active = 1
GROUP BY u.ID, u.Name
HAVING COUNT(o.OrderID) > 5
ORDER BY OrderCount DESC
");
var generator = new QueryBreakdownGenerator();
string markdown = generator.GenerateMermaidDiagram(query, "User Orders Query");
// Output the markdown to a file or display
Console.WriteLine(markdown);
```
#### SqlStatementGenerator
Generates Mermaid sequence diagrams showing SQL statement execution flow and entity-relationship diagrams.
**Example Usage:**
```csharp
var seqGenerator = new SqlStatementGenerator();
string sequenceDiagram = seqGenerator.GenerateSequenceDiagram(sqlBreakdown, "Query Execution Flow");
// Generate ER diagram for tables
var tables = new[] { "Users", "Orders", "OrderDetails" };
string erDiagram = seqGenerator.GenerateEntityRelationshipDiagram(tables, "Database Schema");
```
### Snowflake Folder - Snowflake SQL Support
The library fully supports Snowflake SQL syntax, including Snowflake-specific features like:
- `:parameter` syntax (in addition to `@parameter`)
- Double-quoted identifiers `"identifier"`
- QUALIFY clauses for window functions
- Type casting with `::` operator
- JSON path notation with `:` accessor
#### QueryBreakdownGenerator (Snowflake)
Generates Mermaid flowchart diagrams from Snowflake `QueryBreakdown` objects.
**Example Usage:**
```csharp
using Strata.SqlTools.Breakdowns.Snowflake;
using Strata.SqlTools.Markdown.Snowflake;
// Parse Snowflake SQL with :parameter syntax
var query = QueryBreakdown.Parse(@"
WITH ACTIVE_USERS AS (
SELECT USER_ID, USER_NAME, EMAIL
FROM USERS
WHERE STATUS = :status AND REGION = :region
)
SELECT
AU.USER_ID,
AU.USER_NAME,
COUNT(O.ORDER_ID) AS ORDER_COUNT,
SUM(O.AMOUNT):: DECIMAL(10,2) AS TOTAL_AMOUNT
FROM ACTIVE_USERS AU
LEFT JOIN ORDERS O ON AU.USER_ID = O.USER_ID
WHERE O.ORDER_DATE >= :startDate
GROUP BY AU.USER_ID, AU.USER_NAME
HAVING COUNT(O.ORDER_ID) > 0
ORDER BY TOTAL_AMOUNT DESC
", isMicrosoftSql: false);
var generator = new QueryBreakdownGenerator();
string markdown = generator.GenerateMermaidDiagram(query, "Snowflake User Orders Analysis");
Console.WriteLine(markdown);
```
#### SqlStatementGenerator (Snowflake)
Generates sequence and ER diagrams for Snowflake SQL statements.
**Example Usage:**
```csharp
var seqGenerator = new SqlStatementGenerator();
// Generate sequence diagram for Snowflake query flow
string sequenceDiagram = seqGenerator.GenerateSequenceDiagram(snowflakeQuery, "Snowflake Query Flow");
// Generate ER diagram for Snowflake tables (typically uppercase)
var tables = new[] { "CUSTOMERS", "ORDERS", "ORDER_ITEMS", "PRODUCTS" };
string erDiagram = seqGenerator.GenerateEntityRelationshipDiagram(tables, "Snowflake Schema");
```
### Expressions Folder - Expression Documentation
#### ExpressionGenerator
Generates comprehensive markdown documentation for SQL expression trees with:
- Hierarchical structure visualization
- Type information
- Mermaid tree diagrams
- Mathematical notation using LaTeX (GitHub compatible)
**Example Usage:**
```csharp
using Strata.SqlTools.SqlBreakdown.Expressions;
using Strata.SqlTools.Markdown.Expressions;
// Build an expression
Expression quantity = new ColumnExpression<TableSource>(tableSource, "Quantity");
Expression unitPrice = new ColumnExpression<TableSource>(tableSource, "UnitPrice");
Expression discount = new ColumnExpression<TableSource>(tableSource, "Discount");
var totalExpression = (quantity * unitPrice) * (1 - discount);
var generator = new ExpressionGenerator();
string markdown = generator.GenerateMarkdown(totalExpression, "Order Line Total Calculation");
// Output includes:
// - Expression structure tree
// - Type information
// - Mermaid diagram visualization
// - Mathematical expression in LaTeX format
Console.WriteLine(markdown);
// Or generate just the mathematical expression
string mathExpr = generator.GenerateMathematicalExpression(totalExpression);
// Produces: $$(Quantity \times UnitPrice) \times (1 - Discount)$$
// For inline math notation
string inlineMath = generator.GenerateMathematicalExpression(totalExpression, inline: true);
// Produces: $(Quantity \times UnitPrice) \times (1 - Discount)$
// For raw LaTeX expressions (e.g., mathematical formulas)
var cauchySchwarz = @"\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)";
string dollarFormat = generator.GenerateRawMathematicalExpression(cauchySchwarz);
// Produces: $$
// \left( \sum_{k=1}^n a_k b_k \right)^2 \leq ...
// $$
string mathCodeFence = generator.GenerateRawMathematicalExpression(cauchySchwarz, format: "math");
// Produces: ```math
// \left( \sum_{k=1}^n a_k b_k \right)^2 \leq ...
// ```
```
**Mathematical Notation Features:**
- Arithmetic operators: `+`, `-`, `×` (`\times`), `÷` (`\div`), `mod` (`\bmod`)
- Comparison operators: `=`, `≠` (`\neq`), `<`, `>`, `≤` (`\leq`), `≥` (`\geq`)
- Logical operators: `∧` (`\land`), `` (`\lor`), `¬` (`\neg`)
- Functions: `SUM` (`\sum`), `MIN` (`\min`), `MAX` (`\max`), `|x|` (ABS), `√` (`\sqrt`), powers, etc.
- Set operations: `∈` for BETWEEN and IN expressions
- Case expressions using piecewise notation (`\begin{cases}`)
#### SimpleExpressionGenerator
Generates simplified, readable markdown documentation for expressions with:
- SQL representation
- Type information
- Human-readable descriptions
- Comparison tables for multiple expressions
- Bulleted lists
**Example Usage:**
```csharp
var simpleGenerator = new SimpleExpressionGenerator();
// Generate simple markdown for a single expression
string simpleMarkdown = simpleGenerator.GenerateMarkdown(expression, "Price Filter");
// Generate comparison table for multiple expressions
var expressions = new Dictionary<string, Expression>
{
["Basic Filter"] = status == "Active",
["Date Filter"] = orderDate > new DateTime(2024, 1, 1),
["Complex Filter"] = (quantity > 10) & (price < 100)
};
string comparisonTable = simpleGenerator.GenerateComparisonTable(expressions, "Filter Expressions");
// Generate bullet list
var expressionList = new List<Expression> { expr1, expr2, expr3 };
string bulletList = simpleGenerator.GenerateBulletList(expressionList, "Common Filters");
```
## Installation
Add a reference to this project in your .csproj file:
```xml
<ItemGroup>
<ProjectReference Include="..\Strata.SqlTools.Markdown\Strata.SqlTools.Markdown.csproj" />
</ItemGroup>
```
## Dependencies
- Strata.SqlTools - Core SQL utilities library
- Strata.SqlTools.SqlServer - SQL Server specific implementations
- Strata.SqlTools.Snowflake - Snowflake specific implementations
- .NET 9.0 or later
## Use Cases
1. **Documentation Generation**: Automatically generate documentation for complex SQL queries
2. **Code Review**: Visualize query structure for easier code reviews
3. **Learning Tool**: Help developers understand complex SQL queries through visual diagrams
4. **Query Analysis**: Analyze query patterns and structures
5. **API Documentation**: Document SQL expressions used in query builders
## Output Examples
### Mermaid Flowchart
The `QueryBreakdownGenerator` produces flowcharts like:
```mermaid
flowchart TD
Start([Query Start]) --> Node1
Node1["SELECT<br/>u.ID, u.Name, COUNT(o.OrderID)"]
Node1 --> Node2
Node2["FROM<br/>Users u JOIN Orders o"]
Node2 --> Node3
Node3{"WHERE<br/>u.Active = 1"}
Node3 --> Node4
Node4["GROUP BY<br/>u.ID, u.Name"]
Node4 --> Node5
Node5{"HAVING<br/>COUNT(o.OrderID) > 5"}
Node5 --> Node6
Node6["ORDER BY<br/>OrderCount DESC"]
Node6 --> End([Query End])
```
### Expression Documentation
#### Comprehensive Expression Markdown (ExpressionGenerator)
The `ExpressionGenerator` produces detailed documentation including structure, type info, diagrams, and mathematical notation:
```markdown
# Order Line Total Calculation
## Expression Structure
- **Type**: ArithmeticExpression
- **Operator**: Multiply (*)
- **Left Expression**: ArithmeticExpression (Quantity * UnitPrice)
- **Right Expression**: ArithmeticExpression (1 - Discount)
## Mermaid Diagram
```mermaid
graph TD
Root["* (Multiply)"]
Root --> Left["* (Multiply)"]
Root --> Right["- (Subtract)"]
Left --> LeftLeft["Quantity (Column)"]
Left --> LeftRight["UnitPrice (Column)"]
Right --> RightLeft["1 (Constant)"]
Right --> RightRight["Discount (Column)"]
```
## Mathematical Expression
$$(Quantity \times UnitPrice) \times (1 - Discount)$$
```
#### Simple Expression Markdown (SimpleExpressionGenerator)
The `SimpleExpressionGenerator` produces concise, readable output:
**Single Expression:**
```markdown
# Price Filter
**Expression Type**: ComparisonExpression
**SQL Representation**:
```sql
UnitPrice < 100
```
**Description**: Filters records where UnitPrice is less than 100
```
**Comparison Table:**
```markdown
# Filter Expressions Comparison
| Name | Expression Type | SQL Representation |
|------|----------------|-------------------|
| Basic Filter | ComparisonExpression | `Status = 'Active'` |
| Date Filter | ComparisonExpression | `OrderDate > '2024-01-01'` |
| Complex Filter | LogicalExpression | `(Quantity > 10) AND (Price < 100)` |
```
**Bullet List:**
```markdown
# Common Filters
- **Status = 'Active'** (ComparisonExpression)
- **OrderDate > '2024-01-01'** (ComparisonExpression)
- **(Quantity > 10) AND (Price < 100)** (LogicalExpression)
```
## Contributing
Contributions are welcome! Please ensure all code follows the existing patterns and includes appropriate documentation.
## License
MIT License - Copyright © Strata Decision Technology 2024-2026
@@ -0,0 +1,459 @@
using System.Text;
using Strata.SqlTools.Breakdowns.Snowflake;
namespace Strata.SqlTools.Markdown.Snowflake;
/// <summary>
/// Generates Markdown documentation from QueryBreakdownCollection objects for Snowflake.
/// Creates comprehensive reports including collection summaries, parameter analysis, and batch flow visualization
/// with Snowflake-specific features.
/// </summary>
public static class QueryBreakdownCollectionGenerator
{
/// <summary>
/// Generates a comprehensive collection report in Markdown format with Snowflake-specific information.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to document.</param>
/// <param name="title">Optional title for the report.</param>
/// <returns>A string containing the Markdown documentation.</returns>
public static string GenerateCollectionReport(QueryBreakdownCollection collection, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
// Collection Summary
sb.Append(GenerateCollectionSummary(collection));
sb.AppendLine();
// Snowflake Features Analysis
sb.Append(GenerateSnowflakeFeaturesAnalysis(collection));
sb.AppendLine();
// Parameter Analysis
sb.Append(GenerateParameterAnalysis(collection));
sb.AppendLine();
// Query Composition Report
sb.Append(GenerateQueryCompositionReport(collection));
return sb.ToString();
}
/// <summary>
/// Generates a summary section for the collection.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to summarize.</param>
/// <returns>Markdown summary section.</returns>
public static string GenerateCollectionSummary(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("## Collection Summary");
sb.AppendLine();
sb.AppendLine("| Metric | Value |");
sb.AppendLine("|--------|-------|");
sb.AppendLine($"| Total Queries | {collection.QueryBreakdowns.Count} |");
sb.AppendLine($"| Total Parameters | {collection.GetAllUniqueParameters().Count()} |");
sb.AppendLine($"| Total Columns Selected | {collection.GetTotalSelectedColumns()} |");
sb.AppendLine($"| Unique Tables | {collection.GetUniqueTableReferences().Count()} |");
return sb.ToString();
}
/// <summary>
/// Generates a Snowflake-specific features analysis section.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to analyze.</param>
/// <returns>Markdown Snowflake features section.</returns>
public static string GenerateSnowflakeFeaturesAnalysis(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("## Snowflake Features");
sb.AppendLine();
var queriesWithStages = collection.WhereUseStageReference().ToList();
var queriesWithSemiStructured = collection.WhereUseSemiStructuredData().ToList();
sb.AppendLine("| Feature | Used | Count |");
sb.AppendLine("|---------|------|-------|");
sb.AppendLine($"| Stage References | {FormatFeaturePresence(queriesWithStages.Count > 0)} | {queriesWithStages.Count} |");
sb.AppendLine($"| Semi-Structured Data | {FormatFeaturePresence(queriesWithSemiStructured.Count > 0)} | {queriesWithSemiStructured.Count} |");
return sb.ToString();
}
/// <summary>
/// Generates a parameter analysis report with Snowflake parameter syntax support.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to analyze.</param>
/// <returns>Markdown parameter analysis section.</returns>
public static string GenerateParameterAnalysis(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
var paramReport = collection.GetParameterUsageReport().ToList();
sb.AppendLine("## Parameter Analysis");
sb.AppendLine();
if (paramReport.Count == 0)
{
sb.AppendLine("### Parameters");
sb.AppendLine();
sb.AppendLine("No parameters are used in this collection.");
return sb.ToString();
}
sb.AppendLine("### Parameters");
sb.AppendLine();
sb.AppendLine("| Parameter | Type | Used In | Value |");
sb.AppendLine("|-----------|------|---------|-------|");
foreach (var param in paramReport.OrderBy(p => p.ParameterName))
{
var usageIndicator = param.IsUsedInAllQueries ? "✓ All" : $"{param.UsedInQueryCount}/{param.TotalQueries}";
var value = param.Value?.ToString() ?? "NULL";
// Snowflake supports both : and @ syntax for parameters
sb.AppendLine($"| :{param.ParameterName} / @{param.ParameterName} | {GetParameterType(param.Value)} | {usageIndicator} | `{EscapeMarkdown(value)}` |");
}
sb.AppendLine();
sb.AppendLine("### Parameter Dependency Diagram");
sb.AppendLine();
sb.Append(GenerateParameterDependencyDiagram(collection));
return sb.ToString();
}
/// <summary>
/// Generates a Mermaid diagram showing parameter dependencies across queries.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to visualize.</param>
/// <returns>Mermaid diagram markdown.</returns>
public static string GenerateParameterDependencyDiagram(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.AppendLine("graph TD");
sb.AppendLine();
var queryBreakdowns = collection.QueryBreakdowns;
// Collect all unique parameter names from both ParameterList and Parameters dictionary
var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in queryBreakdowns)
{
foreach (var param in query.ParameterList)
{
allParamNames.Add(param.Name);
}
foreach (var paramName in query.Parameters.Keys)
{
allParamNames.Add(paramName);
}
}
var parameters = allParamNames.OrderBy(p => p).ToList();
// Create parameter nodes
for (int i = 0; i < parameters.Count; i++)
{
var paramNode = $"param{i}";
sb.AppendLine($" {paramNode}[\":{parameters[i]}\"]");
sb.AppendLine($" style {paramNode} fill:#e0f2f1");
}
sb.AppendLine();
// Create query nodes and connections
for (int i = 0; i < queryBreakdowns.Count; i++)
{
var query = queryBreakdowns[i];
var queryNode = $"query{i}";
var queryType = DetermineQueryType(query);
sb.AppendLine($" {queryNode}[\"Query #{i}: {queryType}\"]");
sb.AppendLine($" style {queryNode} fill:#f1f8e9");
// Collect all parameter names used by this query
var queryParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
queryParamNames.Add(param.Name);
}
// Add from Parameters dictionary (manually added parameters)
foreach (var paramName in query.Parameters.Keys)
{
queryParamNames.Add(paramName);
}
// Connect parameters to this query
foreach (var paramName in queryParamNames)
{
var paramIndex = parameters.FindIndex(p => p.Equals(paramName, StringComparison.OrdinalIgnoreCase));
if (paramIndex >= 0)
{
var paramNode = $"param{paramIndex}";
sb.AppendLine($" {paramNode} --> {queryNode}");
}
}
sb.AppendLine();
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Generates a detailed query composition report with Snowflake-specific information.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to report on.</param>
/// <returns>Markdown composition report section.</returns>
public static string GenerateQueryCompositionReport(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("## Query Composition Report");
sb.AppendLine();
var summaries = collection.GetQuerySummaries().ToList();
var stageQueries = collection.WhereUseStageReference().ToList();
var semiStructured = collection.WhereUseSemiStructuredData().ToList();
for (int i = 0; i < summaries.Count; i++)
{
var summary = summaries[i];
var query = collection.QueryBreakdowns[i];
AppendQueryCompositionTable(sb, i, summary);
AppendQueryParameters(sb, query);
AppendQueryCteSections(sb, summary, query);
AppendSnowflakeFeatures(sb, query, stageQueries, semiStructured);
}
return sb.ToString();
}
/// <summary>
/// Appends the query composition table for a single query.
/// </summary>
private static void AppendQueryCompositionTable(StringBuilder sb, int queryIndex, SnowflakeQueryAnalysis summary)
{
sb.AppendLine($"### Query #{queryIndex}");
sb.AppendLine();
sb.AppendLine("| Aspect | Present |");
sb.AppendLine("|--------|---------|");
sb.AppendLine($"| SELECT Clause | {FormatClausePresence(summary.HasSelectClause)} |");
sb.AppendLine($"| FROM Clause | {FormatClausePresence(summary.HasFromClause)} |");
sb.AppendLine($"| WHERE Clause | {FormatClausePresence(summary.HasWhereClause)} |");
sb.AppendLine($"| GROUP BY Clause | {FormatClausePresence(summary.HasGroupByClause)} |");
sb.AppendLine($"| ORDER BY Clause | {FormatClausePresence(summary.HasOrderByClause)} |");
sb.AppendLine($"| CTE (WITH) | {FormatClausePresence(summary.HasCTE)} |");
sb.AppendLine($"| Columns | {summary.ColumnCount} |");
sb.AppendLine($"| Parameters | {summary.ParameterCount} |");
sb.AppendLine();
}
/// <summary>
/// Appends parameter information for a query.
/// </summary>
private static void AppendQueryParameters(StringBuilder sb, QueryBreakdown query)
{
// Collect all unique parameters from both ParameterList and Parameters dictionary
var allParams = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
allParams[param.Name] = param.Value;
}
// Add from Parameters dictionary (manually added parameters)
foreach (var param in query.Parameters)
{
allParams[param.Key] = param.Value;
}
if (allParams.Count == 0)
{
return;
}
sb.AppendLine("**Parameters Used:**");
sb.AppendLine();
foreach (var paramName in allParams.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))
{
var value = allParams[paramName];
sb.AppendLine($"- `:{paramName}` = `{value?.ToString() ?? "NULL"}`");
}
sb.AppendLine();
}
/// <summary>
/// Appends CTE section for a query.
/// </summary>
private static void AppendQueryCteSections(StringBuilder sb, SnowflakeQueryAnalysis summary, QueryBreakdown query)
{
if (!summary.HasCTE)
{
return;
}
sb.AppendLine("**CTEs Defined:**");
sb.AppendLine();
foreach (var cte in query.WithClauses)
{
sb.AppendLine($"- `{cte.TableName}`");
}
sb.AppendLine();
}
/// <summary>
/// Appends Snowflake-specific feature information for a query.
/// </summary>
private static void AppendSnowflakeFeatures(StringBuilder sb, QueryBreakdown query, List<QueryBreakdown> stageQueries, List<QueryBreakdown> semiStructured)
{
if (stageQueries.Contains(query))
{
sb.AppendLine("**Snowflake Features:** Stage References");
sb.AppendLine();
}
if (semiStructured.Contains(query))
{
sb.AppendLine("**Snowflake Features:** Semi-Structured Data");
sb.AppendLine();
}
}
/// <summary>
/// Formats clause presence as Yes/No with checkmark/cross.
/// </summary>
private static string FormatClausePresence(bool isPresent)
=> isPresent ? "✓ Yes" : "✗ No";
/// <summary>
/// Generates a batch execution flow diagram for Snowflake.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to visualize.</param>
/// <param name="includeSessionSetup">Whether to show session setup statements.</param>
/// <returns>Mermaid diagram markdown.</returns>
public static string GenerateBatchFlowDiagram(QueryBreakdownCollection collection, bool includeSessionSetup = false)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.AppendLine("flowchart TD");
sb.AppendLine();
// Handle empty collection
if (collection.QueryBreakdowns.Count == 0)
{
if (includeSessionSetup)
{
sb.AppendLine($" Start([Batch Start]) --> node0[\"Session Setup\"]");
sb.AppendLine($" node0 --> End([Batch Complete])");
}
else
{
sb.AppendLine($" Start([Batch Start]) --> End([Batch Complete])");
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
int nodeId = 0;
// Start node
if (includeSessionSetup)
{
sb.AppendLine($" node{nodeId}[\"Session Setup\"]");
sb.AppendLine($" Start([Batch Start]) --> node{nodeId}");
nodeId++;
sb.AppendLine($" node{nodeId - 1} --> node{nodeId}");
}
else
{
sb.AppendLine($" Start([Batch Start]) --> node{nodeId}");
}
// Query nodes
for (int i = 0; i < collection.QueryBreakdowns.Count; i++)
{
if (i < collection.QueryBreakdowns.Count - 1)
{
// Not the last query - connect to next
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> node{nodeId + 1}");
nodeId++;
}
else
{
// Last query - connect to End
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> End([Batch Complete])");
}
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Gets the parameter type name from a parameter value.
/// </summary>
private static string GetParameterType(object? value)
{
return value switch
{
null => "NULL",
bool => "BOOLEAN",
byte or short or int or long => "NUMBER",
float or double or decimal => "FLOAT",
string => "VARCHAR",
DateTime => "TIMESTAMP",
_ => "VARIANT"
};
}
/// <summary>
/// Escapes special Markdown characters.
/// </summary>
private static string EscapeMarkdown(string text)
{
return text
.Replace("\\", "\\\\")
.Replace("|", "\\|")
.Replace("\n", "\\n");
}
/// <summary>
/// Formats feature presence as Yes/No with checkmark/cross.
/// </summary>
private static string FormatFeaturePresence(bool isPresent)
=> isPresent ? "✓ Yes" : "✗ No";
/// <summary>
/// Determines the query type from a QueryBreakdown.
/// </summary>
private static string DetermineQueryType(QueryBreakdown query)
{
var hasSelect = !string.IsNullOrWhiteSpace(query.SelectClause?.Clause);
if (hasSelect)
{
return "SELECT";
}
var hasFrom = !string.IsNullOrWhiteSpace(query.FromClause?.Clause);
return hasFrom ? "FROM" : "QUERY";
}
}
@@ -0,0 +1,33 @@
using Strata.SqlTools.Breakdowns.Snowflake;
namespace Strata.SqlTools.Markdown.Snowflake;
/// <summary>
/// Generates Mermaid diagram markdown from Snowflake SQL QueryBreakdown objects.
/// Creates flowchart visualizations showing the query structure and flow.
/// </summary>
public class QueryBreakdownGenerator
{
private readonly SqlServer.QueryBreakdownGenerator _baseGenerator;
/// <summary>
/// Initializes a new instance of the QueryBreakdownGenerator class.
/// </summary>
public QueryBreakdownGenerator()
{
_baseGenerator = new SqlServer.QueryBreakdownGenerator();
}
/// <summary>
/// Generates a Mermaid flowchart diagram from a Snowflake QueryBreakdown.
/// </summary>
/// <param name="queryBreakdown">The Snowflake QueryBreakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid markdown diagram.</returns>
public string GenerateMermaidDiagram(QueryBreakdown queryBreakdown, string? title = null)
{
// Since Snowflake.QueryBreakdown inherits from SqlServer.QueryBreakdown,
// we can use the base generator which works with the shared properties
return _baseGenerator.GenerateMermaidDiagram(queryBreakdown, title);
}
}
@@ -0,0 +1,42 @@
using Strata.SqlTools.SqlBreakdown.Classes;
namespace Strata.SqlTools.Markdown.Snowflake;
/// <summary>
/// Generates Mermaid diagrams for Snowflake SQL statements, including sequence diagrams
/// for statement execution flow and entity-relationship diagrams.
/// </summary>
public class SqlStatementGenerator
{
private readonly SqlServer.SqlStatementGenerator _baseGenerator;
/// <summary>
/// Initializes a new instance of the SqlStatementGenerator class.
/// </summary>
public SqlStatementGenerator()
{
_baseGenerator = new SqlServer.SqlStatementGenerator();
}
/// <summary>
/// Generates a Mermaid sequence diagram showing Snowflake SQL statement execution flow.
/// </summary>
/// <param name="sqlBreakdown">The Snowflake SQL breakdown object.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid sequence diagram markdown.</returns>
public string GenerateSequenceDiagram(SqlBreakdownBase sqlBreakdown, string? title = null)
{
return _baseGenerator.GenerateSequenceDiagram(sqlBreakdown, title);
}
/// <summary>
/// Generates a Mermaid entity-relationship diagram from table names.
/// </summary>
/// <param name="tables">Collection of table names to include in the diagram.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid ER diagram markdown.</returns>
public string GenerateEntityRelationshipDiagram(IEnumerable<string> tables, string? title = null)
{
return _baseGenerator.GenerateEntityRelationshipDiagram(tables, title);
}
}
@@ -0,0 +1,422 @@
using System.Text;
using Strata.SqlTools.Breakdowns.SqlServer;
namespace Strata.SqlTools.Markdown.SqlServer;
/// <summary>
/// Generates Markdown documentation from QueryBreakdownCollection objects.
/// Creates comprehensive reports including collection summaries, parameter analysis, and batch flow visualization.
/// </summary>
public static class QueryBreakdownCollectionGenerator
{
/// <summary>
/// Generates a comprehensive collection report in Markdown format.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to document.</param>
/// <param name="title">Optional title for the report.</param>
/// <returns>A string containing the Markdown documentation.</returns>
public static string GenerateCollectionReport(QueryBreakdownCollection collection, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
// Collection Summary
sb.Append(GenerateCollectionSummary(collection));
sb.AppendLine();
// Parameter Analysis
sb.Append(GenerateParameterAnalysis(collection));
sb.AppendLine();
// Query Composition Report
sb.Append(GenerateQueryCompositionReport(collection));
return sb.ToString();
}
/// <summary>
/// Generates a summary section for the collection.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to summarize.</param>
/// <returns>Markdown summary section.</returns>
public static string GenerateCollectionSummary(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("## Collection Summary");
sb.AppendLine();
sb.AppendLine("| Metric | Value |");
sb.AppendLine("|--------|-------|");
sb.AppendLine($"| Total Queries | {collection.QueryBreakdowns.Count} |");
sb.AppendLine($"| Total Parameters | {collection.GetAllUniqueParameters().Count()} |");
sb.AppendLine($"| Total Columns Selected | {collection.GetTotalSelectedColumns()} |");
sb.AppendLine($"| Unique Tables | {collection.GetUniqueTableReferences().Count()} |");
return sb.ToString();
}
/// <summary>
/// Generates a parameter analysis report.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to analyze.</param>
/// <returns>Markdown parameter analysis section.</returns>
public static string GenerateParameterAnalysis(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
var paramReport = collection.GetParameterUsageReport().ToList();
sb.AppendLine("## Parameter Analysis");
sb.AppendLine();
if (paramReport.Count == 0)
{
sb.AppendLine("### Parameters");
sb.AppendLine();
sb.AppendLine("No parameters are used in this collection.");
return sb.ToString();
}
sb.AppendLine("### Parameters");
sb.AppendLine();
sb.AppendLine("| Parameter | Type | Used In | Value |");
sb.AppendLine("|-----------|------|---------|-------|");
foreach (var param in paramReport.OrderBy(p => p.ParameterName))
{
var usageIndicator = param.IsUsedInAllQueries ? "✓ All" : $"{param.UsedInQueryCount}/{param.TotalQueries}";
var value = param.Value?.ToString() ?? "NULL";
sb.AppendLine($"| @{param.ParameterName} | {GetParameterType(param.Value)} | {usageIndicator} | `{EscapeMarkdown(value)}` |");
}
sb.AppendLine();
sb.AppendLine("### Parameter Dependency Diagram");
sb.AppendLine();
sb.Append(GenerateParameterDependencyDiagram(collection));
return sb.ToString();
}
/// <summary>
/// Generates a Mermaid diagram showing parameter dependencies across queries.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to visualize.</param>
/// <returns>Mermaid diagram markdown.</returns>
public static string GenerateParameterDependencyDiagram(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.AppendLine("graph TD");
sb.AppendLine();
var queryBreakdowns = collection.QueryBreakdowns;
// Collect all unique parameter names from both ParameterList and Parameters dictionary
var allParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in queryBreakdowns)
{
foreach (var param in query.ParameterList)
{
allParamNames.Add(param.Name);
}
foreach (var paramName in query.Parameters.Keys)
{
allParamNames.Add(paramName);
}
}
var parameters = allParamNames.OrderBy(p => p).ToList();
// Create parameter nodes
for (int i = 0; i < parameters.Count; i++)
{
var paramNode = $"param{i}";
sb.AppendLine($" {paramNode}[\"@{parameters[i]}\"]");
sb.AppendLine($" style {paramNode} fill:#e1f5ff");
}
sb.AppendLine();
// Create query nodes and connections
for (int i = 0; i < queryBreakdowns.Count; i++)
{
var query = queryBreakdowns[i];
var queryNode = $"query{i}";
var queryType = DetermineQueryType(query);
sb.AppendLine($" {queryNode}[\"Query #{i}: {queryType}\"]");
sb.AppendLine($" style {queryNode} fill:#f3e5f5");
// Collect all parameter names used by this query
var queryParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
queryParamNames.Add(param.Name);
}
// Add from Parameters dictionary (manually added parameters)
foreach (var paramName in query.Parameters.Keys)
{
queryParamNames.Add(paramName);
}
// Connect parameters to this query
foreach (var paramName in queryParamNames)
{
var paramIndex = parameters.FindIndex(p => p.Equals(paramName, StringComparison.OrdinalIgnoreCase));
if (paramIndex >= 0)
{
var paramNode = $"param{paramIndex}";
sb.AppendLine($" {paramNode} --> {queryNode}");
}
}
sb.AppendLine();
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Generates a detailed query composition report.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to report on.</param>
/// <returns>Markdown composition report section.</returns>
public static string GenerateQueryCompositionReport(QueryBreakdownCollection collection)
{
var sb = new StringBuilder();
sb.AppendLine("## Query Composition Report");
sb.AppendLine();
var summaries = collection.GetQuerySummaries().ToList();
for (int i = 0; i < summaries.Count; i++)
{
var summary = summaries[i];
var query = collection.QueryBreakdowns[i];
AppendQueryCompositionTable(sb, i, summary);
AppendQueryParameters(sb, query);
AppendQueryCteSections(sb, summary, query);
}
return sb.ToString();
}
/// <summary>
/// Appends the query composition table for a single query.
/// </summary>
private static void AppendQueryCompositionTable(StringBuilder sb, int queryIndex, QuerySummary summary)
{
sb.AppendLine($"### Query #{queryIndex}");
sb.AppendLine();
sb.AppendLine("| Aspect | Present |");
sb.AppendLine("|--------|---------|");
sb.AppendLine($"| SELECT Clause | {FormatClausePresence(summary.HasSelectClause)} |");
sb.AppendLine($"| FROM Clause | {FormatClausePresence(summary.HasFromClause)} |");
sb.AppendLine($"| WHERE Clause | {FormatClausePresence(summary.HasWhereClause)} |");
sb.AppendLine($"| GROUP BY Clause | {FormatClausePresence(summary.HasGroupByClause)} |");
sb.AppendLine($"| HAVING Clause | {FormatClausePresence(summary.HasHavingClause)} |");
sb.AppendLine($"| ORDER BY Clause | {FormatClausePresence(summary.HasOrderByClause)} |");
sb.AppendLine($"| CTE (WITH) | {FormatClausePresence(summary.HasCTE)} |");
sb.AppendLine($"| Columns | {summary.ColumnCount} |");
sb.AppendLine($"| Parameters | {summary.ParameterCount} |");
sb.AppendLine();
}
/// <summary>
/// Appends parameter information for a query.
/// </summary>
private static void AppendQueryParameters(StringBuilder sb, QueryBreakdown query)
{
// Collect all unique parameters from both ParameterList and Parameters dictionary
var allParams = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
allParams[param.Name] = param.Value;
}
// Add from Parameters dictionary (manually added parameters)
foreach (var param in query.Parameters)
{
allParams[param.Key] = param.Value;
}
if (allParams.Count == 0)
{
return;
}
sb.AppendLine("**Parameters Used:**");
sb.AppendLine();
foreach (var paramName in allParams.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))
{
var value = allParams[paramName];
sb.AppendLine($"- `@{paramName}` = `{value?.ToString() ?? "NULL"}`");
}
sb.AppendLine();
}
/// <summary>
/// Appends CTE section for a query.
/// </summary>
private static void AppendQueryCteSections(StringBuilder sb, QuerySummary summary, QueryBreakdown query)
{
if (!summary.HasCTE)
{
return;
}
sb.AppendLine("**CTEs Defined:**");
sb.AppendLine();
foreach (var cte in query.WithClauses)
{
sb.AppendLine($"- `{cte.TableName}`");
}
sb.AppendLine();
}
/// <summary>
/// Generates a batch execution flow diagram.
/// </summary>
/// <param name="collection">The QueryBreakdownCollection to visualize.</param>
/// <param name="includeTransaction">Whether to show transaction wrapping.</param>
/// <returns>Mermaid diagram markdown.</returns>
public static string GenerateBatchFlowDiagram(QueryBreakdownCollection collection, bool includeTransaction = false)
{
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.AppendLine("flowchart TD");
sb.AppendLine();
int nodeId = 0;
// Handle empty collection
if (collection.QueryBreakdowns.Count == 0)
{
if (includeTransaction)
{
sb.AppendLine($" Start([Batch Start]) --> node0[\"BEGIN TRANSACTION\"]");
sb.AppendLine($" node0 --> node1[\"COMMIT TRANSACTION\"]");
sb.AppendLine($" node1 --> End([Batch Complete])");
}
else
{
sb.AppendLine($" Start([Batch Start]) --> End([Batch Complete])");
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
// Start node
if (includeTransaction)
{
sb.AppendLine($" node{nodeId}[\"BEGIN TRANSACTION\"]");
sb.AppendLine($" Start([Batch Start]) --> node{nodeId}");
nodeId++;
sb.AppendLine($" node{nodeId - 1} --> node{nodeId}");
}
else
{
sb.AppendLine($" Start([Batch Start]) --> node{nodeId}");
}
// Query nodes
for (int i = 0; i < collection.QueryBreakdowns.Count; i++)
{
if (i < collection.QueryBreakdowns.Count - 1)
{
// Not the last query - connect to next
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> node{nodeId + 1}");
nodeId++;
}
else
{
// Last query - connect to End (or COMMIT if transaction)
if (includeTransaction)
{
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> node{nodeId + 1}");
nodeId++;
sb.AppendLine($" node{nodeId}[\"COMMIT TRANSACTION\"]");
sb.AppendLine($" node{nodeId} --> End([Batch Complete])");
}
else
{
sb.AppendLine($" node{nodeId}[\"Query {i}\"] --> End([Batch Complete])");
}
}
}
sb.AppendLine("```");
sb.AppendLine();
return sb.ToString();
}
/// <summary>
/// Gets the parameter type name from a parameter value.
/// </summary>
private static string GetParameterType(object? value)
{
return value switch
{
null => "NULL",
bool => "BIT",
byte => "TINYINT",
short => "SMALLINT",
int => "INT",
long => "BIGINT",
float => "REAL",
double => "FLOAT",
decimal => "DECIMAL",
string => "NVARCHAR",
DateTime => "DATETIME2",
_ => "VARIANT"
};
}
/// <summary>
/// Escapes special Markdown characters.
/// </summary>
private static string EscapeMarkdown(string text)
{
return text
.Replace("\\", "\\\\")
.Replace("|", "\\|")
.Replace("\n", "\\n");
}
/// <summary>
/// Formats clause presence as Yes/No with checkmark/cross.
/// </summary>
private static string FormatClausePresence(bool isPresent)
=> isPresent ? "✓ Yes" : "✗ No";
/// <summary>
/// Determines the query type from a QueryBreakdown.
/// </summary>
private static string DetermineQueryType(QueryBreakdown query)
{
var hasSelect = !string.IsNullOrWhiteSpace(query.SelectClause?.Clause);
if (hasSelect)
{
return "SELECT";
}
var hasFrom = !string.IsNullOrWhiteSpace(query.FromClause?.Clause);
return hasFrom ? "FROM" : "QUERY";
}
}
@@ -0,0 +1,207 @@
using System.Text;
using Strata.SqlTools.Breakdowns.SqlServer;
using Strata.SqlTools.SqlBreakdown.Interfaces;
namespace Strata.SqlTools.Markdown.SqlServer;
/// <summary>
/// Generates Mermaid diagram markdown from SQL QueryBreakdown objects.
/// Creates flowchart visualizations showing the query structure and flow.
/// </summary>
public class QueryBreakdownGenerator
{
/// <summary>
/// Generates a Mermaid flowchart diagram from a QueryBreakdown.
/// </summary>
/// <param name="queryBreakdown">The QueryBreakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid markdown diagram.</returns>
public string GenerateMermaidDiagram(QueryBreakdown queryBreakdown, string? title = null)
{
var sb = new StringBuilder();
// Add title if provided
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"### {title}");
sb.AppendLine();
}
// Start Mermaid flowchart
sb.AppendLine("```mermaid");
sb.AppendLine("flowchart TD");
sb.AppendLine();
int nodeId = 1;
// Start node
sb.AppendLine($" Start([Query Start]) --> Node{nodeId}");
sb.AppendLine();
// WITH clause (CTE)
if (queryBreakdown.IsUsingWithClause)
{
sb.AppendLine($" Node{nodeId}[\"WITH Clause<br/>Common Table Expressions\"]");
foreach (var withClause in queryBreakdown.WithClauses)
{
sb.AppendLine($" Node{nodeId} --> CTE{nodeId}[\"{EscapeMermaidText(withClause.TableName)}\"]");
nodeId++;
}
sb.AppendLine($" Node{nodeId - 1} --> Node{nodeId}");
sb.AppendLine();
nodeId++;
}
// SELECT clause
if (!string.IsNullOrEmpty(queryBreakdown.SelectClause.Clause))
{
var selectText = TruncateText(queryBreakdown.SelectClause.Clause, 50);
sb.AppendLine($" Node{nodeId}[\"SELECT<br/>{EscapeMermaidText(selectText)}\"]");
sb.AppendLine($" Node{nodeId - 1} --> Node{nodeId}");
sb.AppendLine();
nodeId++;
}
// FROM clause
if (queryBreakdown.IsUsingFromClause && !string.IsNullOrWhiteSpace(queryBreakdown.FromClause?.Clause))
{
var fromText = TruncateText(queryBreakdown.FromClause.Clause, 50);
sb.AppendLine($" Node{nodeId}[\"FROM<br/>{EscapeMermaidText(fromText)}\"]");
sb.AppendLine($" Node{nodeId - 1} --> Node{nodeId}");
sb.AppendLine();
nodeId++;
}
// WHERE clause
if (queryBreakdown.IsUsingWhereClause && !string.IsNullOrWhiteSpace(queryBreakdown.WhereClause?.Clause))
{
var whereText = TruncateText(queryBreakdown.WhereClause.Clause, 50);
sb.AppendLine($" Node{nodeId}{{\"WHERE<br/>{EscapeMermaidText(whereText)}\"}}");
sb.AppendLine($" Node{nodeId - 1} --> Node{nodeId}");
sb.AppendLine();
nodeId++;
}
// GROUP BY clause
if (queryBreakdown.IsUsingGroupByClause && !string.IsNullOrWhiteSpace(queryBreakdown.GroupByClause?.Clause))
{
var groupByText = TruncateText(queryBreakdown.GroupByClause.Clause, 50);
sb.AppendLine($" Node{nodeId}[\"GROUP BY<br/>{EscapeMermaidText(groupByText)}\"]");
sb.AppendLine($" Node{nodeId - 1} --> Node{nodeId}");
sb.AppendLine();
nodeId++;
}
// HAVING clause
if (queryBreakdown.IsUsingHavingClause && !string.IsNullOrWhiteSpace(queryBreakdown.HavingClause?.Clause))
{
var havingText = TruncateText(queryBreakdown.HavingClause.Clause, 50);
sb.AppendLine($" Node{nodeId}{{\"HAVING<br/>{EscapeMermaidText(havingText)}\"}}");
sb.AppendLine($" Node{nodeId - 1} --> Node{nodeId}");
sb.AppendLine();
nodeId++;
}
// ORDER BY clause
if (queryBreakdown.IsUsingOrderByClause && !string.IsNullOrWhiteSpace(queryBreakdown.OrderByClause?.Clause))
{
var orderByText = TruncateText(queryBreakdown.OrderByClause.Clause, 50);
sb.AppendLine($" Node{nodeId}[\"ORDER BY<br/>{EscapeMermaidText(orderByText)}\"]");
sb.AppendLine($" Node{nodeId - 1} --> Node{nodeId}");
sb.AppendLine();
nodeId++;
}
// End node
sb.AppendLine($" Node{nodeId - 1} --> End([Query End])");
// End Mermaid diagram
sb.AppendLine("```");
return sb.ToString();
}
/// <summary>
/// Generates a Mermaid flowchart diagram from any SQL breakdown implementing ISqlBreakdown.
/// </summary>
/// <param name="sqlBreakdown">The SQL breakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid markdown diagram.</returns>
public string GenerateMermaidDiagram(ISqlBreakdown sqlBreakdown, string? title = null)
{
// If it's a QueryBreakdown, use the specialized method
if (sqlBreakdown is QueryBreakdown qb)
{
return GenerateMermaidDiagram(qb, title);
}
// For other SQL breakdowns, generate a simple diagram
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"### {title}");
sb.AppendLine();
}
sb.AppendLine("```mermaid");
sb.AppendLine("flowchart TD");
sb.AppendLine();
sb.AppendLine(" Start([SQL Statement Start])");
if (sqlBreakdown.IsUsingSetupClause)
{
sb.AppendLine(" Start --> Setup[\"Setup Clauses\"]");
sb.AppendLine(" Setup --> Main[\"Main Statement\"]");
}
else
{
sb.AppendLine(" Start --> Main[\"Main Statement\"]");
}
if (sqlBreakdown.IsUsingFinishClause)
{
sb.AppendLine(" Main --> Finish[\"Finish Clauses\"]");
sb.AppendLine(" Finish --> End([SQL Statement End])");
}
else
{
sb.AppendLine(" Main --> End([SQL Statement End])");
}
sb.AppendLine("```");
return sb.ToString();
}
/// <summary>
/// Escapes text for Mermaid diagram labels to prevent syntax errors.
/// </summary>
private string EscapeMermaidText(string text)
{
return text
.Replace("\"", "&quot;")
.Replace("[", "&#91;")
.Replace("]", "&#93;")
.Replace("{", "&#123;")
.Replace("}", "&#125;")
.Replace("(", "&#40;")
.Replace(")", "&#41;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
/// <summary>
/// Truncates text to a maximum length and adds ellipsis if needed.
/// </summary>
private string TruncateText(string text, int maxLength)
{
if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength)
{
return text;
}
return text.Substring(0, maxLength) + "...";
}
}
@@ -0,0 +1,140 @@
using System.Text;
using Strata.SqlTools.SqlBreakdown.Interfaces;
namespace Strata.SqlTools.Markdown.SqlServer;
/// <summary>
/// Generates Mermaid sequence diagrams from SQL statements to visualize statement execution flow.
/// </summary>
public class SqlStatementGenerator
{
/// <summary>
/// Generates a Mermaid sequence diagram showing SQL statement execution.
/// </summary>
/// <param name="sqlBreakdown">The SQL breakdown to visualize.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid markdown sequence diagram.</returns>
public string GenerateSequenceDiagram(ISqlBreakdown sqlBreakdown, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"### {title}");
sb.AppendLine();
}
sb.AppendLine("```mermaid");
sb.AppendLine("sequenceDiagram");
sb.AppendLine(" participant App as Application");
sb.AppendLine(" participant DB as Database");
sb.AppendLine();
// Setup clauses
if (sqlBreakdown.IsUsingSetupClause)
{
sb.AppendLine(" App->>DB: Execute Setup Clauses");
foreach (var setupClause in sqlBreakdown.SetupClauses)
{
var setupText = TruncateText(setupClause, 40);
sb.AppendLine($" activate DB");
sb.AppendLine($" Note right of DB: {EscapeMermaidText(setupText)}");
sb.AppendLine($" DB-->>App: Setup Complete");
sb.AppendLine($" deactivate DB");
}
sb.AppendLine();
}
// Main statement
sb.AppendLine(" App->>DB: Execute Main Statement");
sb.AppendLine(" activate DB");
sb.AppendLine($" Note right of DB: Process SQL Statement");
sb.AppendLine(" DB-->>App: Return Results");
sb.AppendLine(" deactivate DB");
sb.AppendLine();
// Finish clauses
if (sqlBreakdown.IsUsingFinishClause)
{
sb.AppendLine(" App->>DB: Execute Finish Clauses");
sb.AppendLine(" activate DB");
sb.AppendLine($" Note right of DB: Cleanup Operations");
sb.AppendLine(" DB-->>App: Cleanup Complete");
sb.AppendLine(" deactivate DB");
}
sb.AppendLine("```");
return sb.ToString();
}
/// <summary>
/// Generates an entity-relationship diagram for tables referenced in the SQL statement.
/// </summary>
/// <param name="tableNames">List of table names referenced in the query.</param>
/// <param name="title">Optional title for the diagram.</param>
/// <returns>A string containing the Mermaid markdown ER diagram.</returns>
public string GenerateEntityRelationshipDiagram(IEnumerable<string> tableNames, string? title = null)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($"### {title}");
sb.AppendLine();
}
sb.AppendLine("```mermaid");
sb.AppendLine("erDiagram");
sb.AppendLine();
foreach (var tableName in tableNames)
{
var cleanName = CleanTableName(tableName);
sb.AppendLine($" {cleanName} {{");
sb.AppendLine($" string columns \"Referenced in query\"");
sb.AppendLine($" }}");
}
sb.AppendLine("```");
return sb.ToString();
}
/// <summary>
/// Escapes text for Mermaid diagram labels.
/// </summary>
private string EscapeMermaidText(string text)
{
return text
.Replace("\"", "&quot;")
.Replace("\n", " ")
.Replace("\r", "");
}
/// <summary>
/// Truncates text to a maximum length.
/// </summary>
private string TruncateText(string text, int maxLength)
{
if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength)
{
return text;
}
return text.Substring(0, maxLength) + "...";
}
/// <summary>
/// Cleans table name for use in Mermaid diagrams.
/// </summary>
private string CleanTableName(string tableName)
{
return tableName
.Replace("[", "")
.Replace("]", "")
.Replace(".", "_")
.Replace(" ", "_");
}
}
@@ -0,0 +1,45 @@
<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.Markdown</PackageId>
<Version>1.0.0</Version>
<Authors>Strata Decision Technology</Authors>
<Company>Strata Decision Technology</Company>
<Product>Strata SQL Utilities - Markdown</Product>
<Description>Markdown documentation generation for Strata.SqlTools, including Mermaid diagram generation for SQL queries and Expression trees.</Description>
<PackageTags>sql;markdown;mermaid;documentation;query-visualization;expression-trees</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 Mermaid diagram generation for SQL queries and markdown generation for expression trees.</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>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Strata.SqlTools.SqlBreakdown\Strata.SqlTools.SqlBreakdown.csproj" />
<ProjectReference Include="..\Strata.SqlTools.SqlServer\Strata.SqlTools.SqlServer.csproj" />
<ProjectReference Include="..\Strata.SqlTools.Snowflake\Strata.SqlTools.Snowflake.csproj" />
<ProjectReference Include="..\Strata.SqlTools.PostgreSql\Strata.SqlTools.PostgreSql.csproj" />
<ProjectReference Include="..\Strata.SqlTools.LinqToSql\Strata.SqlTools.LinqToSql.csproj" />
</ItemGroup>
</Project>
@@ -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)}";
}
}
@@ -0,0 +1,146 @@
using System.Globalization;
using Strata.SqlTools.Rules.Rule.Expression;
namespace Strata.SqlTools.Rules;
public interface IVisitor<out T>
{
T VisitParameter(Parameter parameter);
T VisitProperty(Property property);
T VisitCollectionProperty(CollectionProperty collectionProperty);
T VisitAny(Any Any);
T VisitLiteral(Literal literalRule);
T VisitEquals(Equal Equal);
T VisitNotEquals(NotEqual Equal);
T VisitGreaterThan(GreaterThan GreaterThan);
T VisitAnd(And And);
T VisitOr(Or Or);
T VisitWith(With With);
}
public abstract class Visitor : IVisitor<Expression>
{
public virtual Expression Visit(IVisitable expression) => expression.Accept(this);
public virtual Expression VisitParameter(Parameter parameter) => parameter;
public virtual Expression VisitProperty(Property property) => property;
public virtual Expression VisitCollectionProperty(CollectionProperty collectionProperty) => collectionProperty;
public virtual Expression VisitAny(Any Any) => new Any(
(CollectionProperty)Visit(Any.CollectionProperty),
(BoolExpr)Visit(Any.BoolExpr),
(Parameter)Visit(Any.PredicateParameter));
public virtual Expression VisitLiteral(Literal literalRule) => literalRule;
public virtual Expression VisitEquals(Equal Equal) => Validate(Equal);
public virtual Expression VisitNotEquals(NotEqual notEqual) => Validate(notEqual);
public virtual Expression VisitGreaterThan(GreaterThan GreaterThan) => Validate(GreaterThan);
public virtual Expression VisitAnd(And And) =>
new And((BoolExpr)Visit(And.Left), (BoolExpr)Visit(And.Right));
public virtual Expression VisitOr(Or Or) =>
new Or((BoolExpr)Visit(Or.Left), (BoolExpr)Visit(Or.Right));
public virtual Expression VisitWith(With With) =>
new With((BoolExpr)Visit(With.Left), (BoolExpr)Visit(With.Right));
protected virtual Expression Validate(Comparison comparison)
{
return comparison.Update(Visit(comparison.Left), Visit(comparison.Right));
}
}
public class LocalVisitor : IVisitor<string>
{
public virtual string Visit(IVisitable expression) => expression.Accept(this);
public virtual string VisitLiteral(Literal literalRule) => literalRule switch
{
NumberLiteral number => number.Value.ToString(CultureInfo.InvariantCulture),
StringLiteral stringRule => $"\"{stringRule.Value}\"",
not null => literalRule.Value.ToString() ?? string.Empty,
_ => string.Empty
};
public virtual string VisitEquals(Equal Equal)
{
return $"{Equal.Left.Accept(this)} == {Equal.Right.Accept(this)}";
}
public virtual string VisitNotEquals(NotEqual notEqual)
{
return $"{notEqual.Left.Accept(this)} != {notEqual.Right.Accept(this)}";
}
public virtual string VisitGreaterThan(GreaterThan GreaterThan)
{
return $"{GreaterThan.Left.Accept(this)} > {GreaterThan.Right.Accept(this)}";
}
public virtual string VisitAnd(And And)
{
return $"{And.Left.Accept(this)} && {And.Right.Accept(this)}";
}
public virtual string VisitOr(Or Or)
{
return $"{Or.Left.Accept(this)} || {Or.Right.Accept(this)}";
}
public virtual string VisitWith(With With)
{
// just converting it to an AND expression for now
var and = new And(With.Left, With.Right);
return and.Accept(this);
//throw new NotImplementedException("not sure what to do with 'WITH' expressions yet");
}
private bool TryGetCollectionItemProperty(Expression Expression, out Property? property)
{
property = null;
if (Expression is not IBinary binary)
{
return false;
}
if (binary.Left is not Property Property)
{
return false;
}
if (Property.Expression is not CollectionProperty collection)
{
return false;
}
property = Property;
return true;
}
public virtual string VisitParameter(Parameter parameter) => $"{parameter.ParameterName}";
public virtual string VisitProperty(Property property)
{
return property.Expression is null
? $"{property.PropertyName}"
: $"{property.Expression.Accept(this)}.{property.PropertyName}";
}
public virtual string VisitCollectionProperty(CollectionProperty collectionProperty) => VisitProperty(collectionProperty);
public virtual string VisitAny(Any Any)
{
return $"{Any.CollectionProperty.Accept(this)}.Any({Any.PredicateParameter.Accept(this)} => {Any.BoolExpr.Accept(this)})";
}
}
@@ -0,0 +1,20 @@
using System.Diagnostics;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a logical AND operation between two BoolExpr expressions.
/// </summary>
[DebuggerDisplay("{Left} AND {Right}")]
public class And : Logical
{
/// <summary>
/// Initializes a new instance of the <see cref="And"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
public And(BoolExpr left, BoolExpr right) : base(left, right) { }
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitAnd(this);
}
@@ -0,0 +1,61 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents an ANY expression that checks if any element in a collection satisfies a condition.
/// </summary>
public class Any : BoolExpr
{
/// <summary>
/// Gets the collection property being evaluated.
/// </summary>
public CollectionProperty CollectionProperty { get; }
/// <summary>
/// Gets the BoolExpr expression that defines the condition to check.
/// </summary>
public BoolExpr BoolExpr { get; }
/// <summary>
/// Gets the parameter used in the predicate expression.
/// </summary>
public Parameter PredicateParameter { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Any"/> class with a function.
/// </summary>
/// <param name="collectionProperty">The collection property to evaluate.</param>
/// <param name="func">A function that defines the condition to check for each element.</param>
public Any(CollectionProperty collectionProperty, Func<Parameter, BoolExpr> func)
{
CollectionProperty = collectionProperty;
PredicateParameter = new Parameter("p");
BoolExpr = func(PredicateParameter);
}
/// <summary>
/// Initializes a new instance of the <see cref="Any"/> class with a BoolExpr expression.
/// </summary>
/// <param name="collectionProperty">The collection property to evaluate.</param>
/// <param name="boolExpr">The BoolExpr expression defining the condition.</param>
public Any(CollectionProperty collectionProperty, BoolExpr boolExpr)
: this(collectionProperty, boolExpr, new Parameter("p"))
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Any"/> class.
/// </summary>
/// <param name="collectionProperty">The collection property to evaluate.</param>
/// <param name="boolExpr">The BoolExpr expression defining the condition.</param>
/// <param name="predicateParameter">The parameter used in the predicate expression.</param>
public Any(CollectionProperty collectionProperty, BoolExpr boolExpr, Parameter predicateParameter)
{
CollectionProperty = collectionProperty;
BoolExpr = boolExpr;
PredicateParameter = predicateParameter;
}
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitAny(this);
}
@@ -0,0 +1,23 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents an expression that evaluates to a BoolExpr value (true/false).
/// </summary>
public abstract class BoolExpr : Expression
{
/// <summary>
/// Creates a logical AND expression combining two BoolExpr expressions.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
/// <returns>An AND expression combining both operands.</returns>
public static BoolExpr operator &(BoolExpr left, BoolExpr right) => new And(left, right);
/// <summary>
/// Creates a logical OR expression combining two BoolExpr expressions.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
/// <returns>An OR expression combining both operands.</returns>
public static BoolExpr operator |(BoolExpr left, BoolExpr right) => new Or(left, right);
}
@@ -0,0 +1,32 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a collection property access in a rule expression.
/// </summary>
public class CollectionProperty : Property
{
/// <summary>
/// Initializes a new instance of the <see cref="CollectionProperty"/> class.
/// </summary>
/// <param name="expression">The containing expression.</param>
/// <param name="propertyName">The name of the collection property.</param>
public CollectionProperty(Expression? expression, string propertyName) : base(expression, propertyName)
{
}
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitCollectionProperty(this);
/// <summary>
/// Creates an ANY expression that checks if any element in the collection satisfies a condition.
/// </summary>
/// <param name="func">A function that defines the condition to check for each element.</param>
/// <returns>An ANY expression.</returns>
public Any Any(Func<Parameter, BoolExpr> func)
{
var parameter = new Parameter("p");
var BoolExpr = func(parameter);
return new Any(this, BoolExpr, parameter);
}
}
@@ -0,0 +1,64 @@
using System.Diagnostics;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a comparison operation between two expressions.
/// </summary>
[DebuggerDisplay("{Left} {Type} {Right}")]
public abstract class Comparison : BoolExpr, IBinary
{
/// <summary>
/// Gets the left operand of the comparison.
/// </summary>
public Expression Left { get; }
/// <summary>
/// Gets the right operand of the comparison.
/// </summary>
public Expression Right { get; }
/// <summary>
/// Gets the type of comparison operation.
/// </summary>
public abstract Type Type { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Comparison"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
protected Comparison(Expression left, Expression right)
{
Left = left;
Right = right;
}
/// <summary>
/// Creates a new comparison expression with updated operands.
/// </summary>
/// <param name="left">The new left operand.</param>
/// <param name="right">The new right operand.</param>
/// <returns>A new comparison expression or this instance if operands are unchanged.</returns>
public Expression Update(Expression left, Expression right)
{
if (ReferenceEquals(left, Left) && ReferenceEquals(right, Right))
{
return this;
}
return Create(left, right, Type);
}
private static Comparison Create(Expression left, Expression right, Type Type)
{
return Type switch
{
Type.Equal => new Equal(left, right),
Type.NotEqual => new NotEqual(left, right),
Type.GreaterThan => new GreaterThan(left, right),
_ => throw new NotImplementedException("not yet")
};
}
}
@@ -0,0 +1,20 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents an equality comparison between two expressions.
/// </summary>
public class Equal : Comparison
{
/// <inheritdoc/>
public override Type Type => Type.Equal;
/// <summary>
/// Initializes a new instance of the <see cref="Equal"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
public Equal(Expression left, Expression right) : base(left, right) { }
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitEquals(this);
}
@@ -0,0 +1,45 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Provides implicit conversion operators and comparison operators for rule expressions.
/// </summary>
#pragma warning disable CS0660, CS0661
public partial class Expression
#pragma warning restore CS0660, CS0661
{
/// <summary>
/// Implicitly converts a decimal value to a rule expression.
/// </summary>
/// <param name="value">The decimal value to convert.</param>
public static implicit operator Expression(decimal value) => new NumberLiteral(value);
/// <summary>
/// Implicitly converts a string value to a rule expression.
/// </summary>
/// <param name="value">The string value to convert.</param>
public static implicit operator Expression(string value) => new StringLiteral(value);
/// <summary>
/// Creates an equality comparison rule expression.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
/// <returns>An equality comparison rule expression.</returns>
public static Comparison operator ==(Expression left, Expression right) => new Equal(left, right);
/// <summary>
/// Creates a not-equal comparison rule expression.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
/// <returns>A not-equal comparison rule expression.</returns>
public static Comparison operator !=(Expression left, Expression right) => new NotEqual(left, right);
/// <summary>
/// Creates an equality comparison rule expression.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
/// <returns>An equality comparison rule expression.</returns>
public static Equal Equal(Expression left, Expression right) => new(left, right);
}
@@ -0,0 +1,17 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Base class for all rule expressions.
/// </summary>
#pragma warning disable CS0660, CS0661
public abstract partial class Expression : IVisitable
#pragma warning restore CS0660, CS0661
{
/// <summary>
/// Accepts a visitor and allows it to process this rule expression.
/// </summary>
/// <typeparam name="T">The return type of the visitor.</typeparam>
/// <param name="visitor">The visitor to accept.</param>
/// <returns>The result of the visitor's processing.</returns>
public abstract T Accept<T>(IVisitor<T> visitor);
}
@@ -0,0 +1,20 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a greater-than comparison between two expressions.
/// </summary>
public class GreaterThan : Comparison
{
/// <inheritdoc/>
public override Type Type => Type.GreaterThan;
/// <summary>
/// Initializes a new instance of the <see cref="GreaterThan"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
public GreaterThan(Expression left, Expression right) : base(left, right) { }
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitGreaterThan(this);
}
@@ -0,0 +1,22 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a binary rule expression with left and right operands.
/// </summary>
public interface IBinary : IVisitable
{
/// <summary>
/// Gets the left operand.
/// </summary>
public Expression Left { get; }
/// <summary>
/// Gets the right operand.
/// </summary>
public Expression Right { get; }
/// <summary>
/// Gets the type of rule expression.
/// </summary>
public Type Type { get; }
}
@@ -0,0 +1,15 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents an object that can be visited by a rule visitor implementing the visitor pattern.
/// </summary>
public interface IVisitable
{
/// <summary>
/// Accepts a visitor and allows it to process this visitable object.
/// </summary>
/// <typeparam name="T">The return type of the visitor.</typeparam>
/// <param name="visitor">The visitor to accept.</param>
/// <returns>The result of the visitor's processing.</returns>
T Accept<T>(IVisitor<T> visitor);
}
@@ -0,0 +1,24 @@
using System.Diagnostics;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a literal value in a rule expression.
/// </summary>
[DebuggerDisplay("\\{{Value}\\}")]
public class Literal : Expression
{
/// <summary>
/// Gets the literal value.
/// </summary>
public object Value { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Literal"/> class.
/// </summary>
/// <param name="value">The literal value.</param>
public Literal(object value) => Value = value;
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitLiteral(this);
}
@@ -0,0 +1,20 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Base class for typed literal rule expressions.
/// </summary>
/// <typeparam name="TValue">The type of the literal value.</typeparam>
public abstract class Literal<TValue> : Literal
where TValue : notnull
{
/// <summary>
/// Gets the strongly-typed literal value.
/// </summary>
public new TValue Value { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Literal{TValue}"/> class.
/// </summary>
/// <param name="value">The literal value.</param>
protected Literal(TValue value) : base(value) => Value = value;
}
@@ -0,0 +1,28 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a logical operation on two BoolExpr input expressions (e.g., AND, OR).
/// </summary>
public abstract class Logical : BoolExpr
{
/// <summary>
/// Gets the left operand of the logical expression.
/// </summary>
public BoolExpr Left { get; }
/// <summary>
/// Gets the right operand of the logical expression.
/// </summary>
public BoolExpr Right { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Logical"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
protected Logical(BoolExpr left, BoolExpr right)
{
Left = left;
Right = right;
}
}
@@ -0,0 +1,398 @@
using System.Text.RegularExpressions;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Parses markdown/LaTeX mathematical expressions and converts them to Expression objects.
/// Supports parsing of logical operations, comparisons, properties, and literals.
/// </summary>
public static class Markdown
{
private static readonly Dictionary<string, Func<Expression, Expression, BoolExpr>> LogicalOperators = new()
{
{ "\\land", (left, right) => new And((BoolExpr)left, (BoolExpr)right) },
{ "\\lor", (left, right) => new Or((BoolExpr)left, (BoolExpr)right) },
{ "\\wedge", (left, right) => new And((BoolExpr)left, (BoolExpr)right) },
{ "\\vee", (left, right) => new Or((BoolExpr)left, (BoolExpr)right) },
{ "AND", (left, right) => new And((BoolExpr)left, (BoolExpr)right) },
{ "OR", (left, right) => new Or((BoolExpr)left, (BoolExpr)right) },
};
private static readonly Dictionary<string, Func<Expression, Expression, Comparison>> ComparisonOperators = new()
{
{ "=", (left, right) => new Equal(left, right) },
{ "\\neq", (left, right) => new NotEqual(left, right) },
{ "!=", (left, right) => new NotEqual(left, right) },
{ ">", (left, right) => new GreaterThan(left, right) },
{ "\\gt", (left, right) => new GreaterThan(left, right) },
};
/// <summary>
/// Parses a markdown/LaTeX string into an Expression object.
/// </summary>
/// <param name="markdown">The markdown/LaTeX string to parse.</param>
/// <returns>The parsed Expression object.</returns>
/// <exception cref="ArgumentException">Thrown when the markdown cannot be parsed.</exception>
public static Expression Parse(string markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
throw new ArgumentException("Markdown cannot be null or empty", nameof(markdown));
}
// Remove common markdown delimiters
markdown = markdown.Trim();
markdown = StripMarkdownDelimiters(markdown);
return ParseExpression(markdown);
}
/// <summary>
/// Attempts to parse a markdown/LaTeX string into an Expression object.
/// </summary>
/// <param name="markdown">The markdown/LaTeX string to parse.</param>
/// <param name="expression">The parsed Expression object if successful.</param>
/// <returns>True if parsing was successful, false otherwise.</returns>
public static bool TryParse(string markdown, out Expression? expression)
{
try
{
expression = Parse(markdown);
return true;
}
catch
{
expression = null;
return false;
}
}
private static string StripMarkdownDelimiters(string text)
{
// Remove $...$ or $$...$$ delimiters
text = Regex.Replace(text, @"^\$\$?\s*", "");
text = Regex.Replace(text, @"\s*\$\$?$", "");
// Remove ```math...``` code fence
text = Regex.Replace(text, @"^```math\s*", "", RegexOptions.Multiline);
text = Regex.Replace(text, @"\s*```$", "", RegexOptions.Multiline);
return text.Trim();
}
private static Expression ParseExpression(string text)
{
text = text.Trim();
// Try to parse logical operations (lowest precedence)
var logicalExpr = TryParseLogicalOperation(text);
if (logicalExpr is not null)
{
return logicalExpr;
}
// Try to parse comparison operations
var comparisonExpr = TryParseComparison(text);
if (comparisonExpr is not null)
{
return comparisonExpr;
}
// Handle parentheses
var parenthesisExpr = TryParseParentheses(text);
if (parenthesisExpr is not null)
{
return parenthesisExpr;
}
// Parse property, literal, or other atomic expressions
return ParseAtomicExpression(text);
}
private static Expression? TryParseLogicalOperation(string text)
{
foreach (var op in LogicalOperators.Keys)
{
var parts = SplitByOperator(text, op);
if (parts.Length == 2)
{
var left = ParseExpression(parts[0]);
var right = ParseExpression(parts[1]);
return LogicalOperators[op](left, right);
}
}
return null;
}
private static Expression? TryParseComparison(string text)
{
foreach (var op in ComparisonOperators.Keys)
{
var parts = SplitByOperator(text, op);
if (parts.Length == 2)
{
var left = ParseExpression(parts[0]);
var right = ParseExpression(parts[1]);
return ComparisonOperators[op](left, right);
}
}
return null;
}
private static Expression? TryParseParentheses(string text)
{
// Handle regular parentheses
if (text.StartsWith('(') && text.EndsWith(')'))
{
var inner = text.Substring(1, text.Length - 2);
if (IsBalanced(inner))
{
return ParseExpression(inner);
}
}
// Handle LaTeX \left( and \right)
if (text.StartsWith("\\left(") && text.EndsWith("\\right)"))
{
var inner = text.Substring(6, text.Length - 13);
if (IsBalanced(inner))
{
return ParseExpression(inner);
}
}
return null;
}
private static Expression ParseAtomicExpression(string text)
{
// Try parsing as property access
var propertyExpr = TryParseProperty(text);
if (propertyExpr is not null)
{
return propertyExpr;
}
// Try parsing as literal
var literalExpr = TryParseLiteral(text);
if (literalExpr is not null)
{
return literalExpr;
}
throw new ArgumentException($"Unable to parse expression: {text}");
}
private static Expression? TryParseProperty(string text)
{
// Parse property access (e.g., x.PropertyName or \text{x.PropertyName})
var propertyMatch = Regex.Match(text, @"^([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)$");
if (propertyMatch.Success)
{
return new Property(propertyMatch.Groups[1].Value, propertyMatch.Groups[2].Value);
}
// Parse \text{...} property access
var textMatch = Regex.Match(text, @"^\\text\{([^}]+)\}$");
if (textMatch.Success)
{
var textContent = textMatch.Groups[1].Value;
var propMatch = Regex.Match(textContent, @"^([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)$");
if (propMatch.Success)
{
return new Property(propMatch.Groups[1].Value, propMatch.Groups[2].Value);
}
// Check for boolean literals in \text{} format
if (textContent.Equals("true", StringComparison.OrdinalIgnoreCase))
{
return new Literal(true);
}
if (textContent.Equals("false", StringComparison.OrdinalIgnoreCase))
{
return new Literal(false);
}
// Single property name
if (Regex.IsMatch(textContent, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{
return new Property(textContent);
}
// String literal
return new StringLiteral(textContent);
}
// Check for boolean literals before simple property
if (text.Equals("true", StringComparison.OrdinalIgnoreCase))
{
return new Literal(true);
}
if (text.Equals("false", StringComparison.OrdinalIgnoreCase))
{
return new Literal(false);
}
// Parse simple property without parameter
if (Regex.IsMatch(text, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{
return new Property(text);
}
return null;
}
private static Expression? TryParseLiteral(string text)
{
// Parse string literals (quoted)
var stringMatch = Regex.Match(text, @"^[""'](.+?)[""']$");
if (stringMatch.Success)
{
return new StringLiteral(stringMatch.Groups[1].Value);
}
// Parse empty string literals
if (text == "\"\"" || text == "''")
{
return new StringLiteral(string.Empty);
}
// Parse numeric literals
if (int.TryParse(text, out var intValue))
{
return new NumberLiteral(intValue);
}
if (decimal.TryParse(text, out var decimalValue))
{
return new NumberLiteral(decimalValue);
}
return null;
}
private static string[] SplitByOperator(string text, string op)
{
var result = new List<string>();
int depth = 0;
int lastIndex = 0;
int i = 0;
while (i < text.Length)
{
i = ProcessParentheses(text, i, ref depth);
if (i >= text.Length)
{
break;
}
// Check if we found the operator at depth 0
if (depth == 0 && i + op.Length <= text.Length && TryMatchOperator(text, i, op))
{
result.Add(text.Substring(lastIndex, i - lastIndex).Trim());
lastIndex = i + op.Length;
i += op.Length;
continue;
}
i++;
}
if (result.Count == 0)
{
return new[] { text };
}
result.Add(text.Substring(lastIndex).Trim());
return result.ToArray();
}
private static int ProcessParentheses(string text, int index, ref int depth)
{
// Track parentheses depth
if (text[index] == '(' || (index + 5 < text.Length && text.Substring(index, 6) == "\\left("))
{
depth++;
if (text[index] == '\\')
{
return index + 6;
}
else
{
return index + 1;
}
}
if (text[index] == ')' || (index + 6 < text.Length && text.Substring(index, 7) == "\\right)"))
{
depth--;
if (text[index] == '\\')
{
return index + 7;
}
else
{
return index + 1;
}
}
return index;
}
private static bool TryMatchOperator(string text, int index, string op)
{
var substring = text.Substring(index, op.Length);
if (substring != op)
{
return false;
}
// Make sure it's a separate operator, not part of a larger token
bool validBefore = (index == 0 || char.IsWhiteSpace(text[index - 1]) || text[index] == '\\');
bool validAfter = (index + op.Length >= text.Length || char.IsWhiteSpace(text[index + op.Length]));
return validBefore && validAfter;
}
private static bool IsBalanced(string text)
{
int depth = 0;
int i = 0;
while (i < text.Length)
{
if (text[i] == '(')
{
depth++;
}
else if (text[i] == ')')
{
depth--;
if (depth < 0)
{
return false;
}
}
else if (i + 5 < text.Length && text.Substring(i, 6) == "\\left(")
{
depth++;
i += 5;
}
else if (i + 6 < text.Length && text.Substring(i, 7) == "\\right)")
{
depth--;
if (depth < 0)
{
return false;
}
i += 6;
}
i++;
}
return depth == 0;
}
}
@@ -0,0 +1,20 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a not-equal comparison between two expressions.
/// </summary>
public class NotEqual : Comparison
{
/// <inheritdoc/>
public override Type Type => Type.NotEqual;
/// <summary>
/// Initializes a new instance of the <see cref="NotEqual"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
public NotEqual(Expression left, Expression right) : base(left, right) { }
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitNotEquals(this);
}
@@ -0,0 +1,19 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a numeric literal value in a rule expression.
/// </summary>
public class NumberLiteral : Literal<decimal>
{
/// <summary>
/// Initializes a new instance of the <see cref=" NumberLiteral"/> class.
/// </summary>
/// <param name="value">The numeric value.</param>
public NumberLiteral(decimal value) : base(value) { }
/// <summary>
/// Implicitly converts a <see cref=" NumberLiteral"/> to a decimal value.
/// </summary>
/// <param name="numberExp">The number expression to convert.</param>
public static implicit operator decimal(NumberLiteral numberExp) => numberExp.Value;
}
@@ -0,0 +1,20 @@
using System.Diagnostics;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a logical OR operation between two BoolExpr expressions.
/// </summary>
[DebuggerDisplay("{Left} OR {Right}")]
public class Or : Logical
{
/// <summary>
/// Initializes a new instance of the <see cref="Or"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
public Or(BoolExpr left, BoolExpr right) : base(left, right) { }
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitOr(this);
}
@@ -0,0 +1,38 @@
using System.Diagnostics;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a parameter in a rule expression.
/// </summary>
[DebuggerDisplay("{ParameterName}")]
public class Parameter : Expression
{
/// <summary>
/// Gets the name of the parameter.
/// </summary>
public string ParameterName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Parameter"/> class.
/// </summary>
/// <param name="parameterName">The name of the parameter.</param>
public Parameter(string parameterName) => ParameterName = parameterName;
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitParameter(this);
/// <summary>
/// Creates a property expression for accessing a property on this parameter.
/// </summary>
/// <param name="propertyName">The name of the property.</param>
/// <returns>A property expression.</returns>
public Property Property(string propertyName) => new(this, propertyName);
/// <summary>
/// Creates a collection property expression for accessing a collection property on this parameter.
/// </summary>
/// <param name="collectionPropertyName">The name of the collection property.</param>
/// <returns>A collection property expression.</returns>
public CollectionProperty CollectionProperty(string collectionPropertyName) => new(this, collectionPropertyName);
}
@@ -0,0 +1,55 @@
using System.Diagnostics;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a property access in a rule expression.
/// </summary>
[DebuggerDisplay("\\{{Expression,nq}.{PropertyName,nq}\\}")]
public class Property : Expression
{
/// <summary>
/// Gets the containing object of the field or property.
/// </summary>
public Expression? Expression { get; }
/// <summary>
/// Gets the name of the property.
/// </summary>
public string PropertyName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Property"/> class with no containing expression.
/// </summary>
/// <param name="propertyName">The name of the property.</param>
public Property(string propertyName) : this((Expression?)null, propertyName)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Property"/> class with a parameter name.
/// </summary>
/// <param name="parameterName">The name of the parameter.</param>
/// <param name="propertyName">The name of the property.</param>
public Property(string parameterName, string propertyName) : this(new Parameter(parameterName), propertyName)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Property"/> class.
/// </summary>
/// <param name="expression">The containing expression.</param>
/// <param name="propertyName">The name of the property.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="propertyName"/> is null.</exception>
public Property(Expression? expression, string propertyName)
{
Expression = expression;
// maybe do some regex validation for args to ensure it's not a bogus name (no whitespace, no punctuation marks, etc)
PropertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName));
}
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitProperty(this);
}
@@ -0,0 +1,13 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a string literal value in a rule expression.
/// </summary>
public class StringLiteral : Literal<string>
{
/// <summary>
/// Initializes a new instance of the <see cref="StringLiteral"/> class.
/// </summary>
/// <param name="value">The string value.</param>
public StringLiteral(string value) : base(value) { }
}
@@ -0,0 +1,27 @@
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Defines the types of rule expressions for comparisons and operations.
/// </summary>
public enum Type
{
/// <summary>Equality comparison.</summary>
Equal,
/// <summary>Inequality comparison.</summary>
NotEqual,
/// <summary>Greater than comparison.</summary>
GreaterThan,
/// <summary>Greater than or equal comparison.</summary>
GreaterThanOrEqual,
/// <summary>Less than comparison.</summary>
LessThan,
/// <summary>Less than or equal comparison.</summary>
LessThanOrEqual,
/// <summary>In operation (value in set).</summary>
In,
/// <summary>None equal operation.</summary>
NoneEqual,
/// <summary>Exclude operation.</summary>
Exclude,
}
@@ -0,0 +1,20 @@
using System.Diagnostics;
namespace Strata.SqlTools.Rules.Rule.Expression;
/// <summary>
/// Represents a WITH operation for sequential rule evaluation.
/// </summary>
[DebuggerDisplay("{Left} WITH {Right}")]
public class With : Logical
{
/// <summary>
/// Initializes a new instance of the <see cref="With"/> class.
/// </summary>
/// <param name="left">The left operand.</param>
/// <param name="right">The right operand.</param>
public With(BoolExpr left, BoolExpr right) : base(left, right) { }
/// <inheritdoc/>
public override T Accept<T>(IVisitor<T> visitor) => visitor.VisitWith(this);
}
@@ -0,0 +1,23 @@
using Strata.SqlTools.Rules.Rule.Expression;
namespace Strata.SqlTools.Rules.Rule.Groups;
/// <summary>
/// Represents a rule group where all rules must evaluate to true (logical AND).
/// </summary>
public class And : Base
{
/// <summary>
/// Merges two BoolExpr expressions using logical AND.
/// </summary>
/// <param name="left">The left BoolExpr expression.</param>
/// <param name="right">The right BoolExpr expression.</param>
/// <returns>An AND expression combining both expressions.</returns>
protected override BoolExpr Merge(BoolExpr left, BoolExpr right) => new Expression.And(left, right);
/// <summary>
/// Initializes a new instance of the <see cref="And"/> class.
/// </summary>
/// <param name="rules">The collection of rules to include in this AND group.</param>
public And(IEnumerable<IRule> rules) : base(rules) { }
}
@@ -0,0 +1,47 @@
using Strata.SqlTools.Rules.Rule.Expression;
namespace Strata.SqlTools.Rules.Rule.Groups;
/// <summary>
/// Base class for rule groups that provides common functionality for grouping and merging rules.
/// </summary>
public abstract class Base : IGroup
{
/// <summary>
/// The internal list of rules in this group.
/// </summary>
protected readonly List<IRule> RuleList;
/// <summary>
/// Gets the collection of rules in this group.
/// </summary>
public IReadOnlyCollection<IRule> Rules => RuleList;
/// <summary>
/// Gets the merged BoolExpr expression for all rules in this group.
/// </summary>
public BoolExpr Expression => GetExpressions().Aggregate(Merge);
/// <summary>
/// Gets the expressions from all rules in this group.
/// </summary>
/// <returns>An enumerable of BoolExpr rule expressions.</returns>
protected virtual IEnumerable<BoolExpr> GetExpressions() => RuleList.Select(r => r.Expression);
/// <summary>
/// Merges two BoolExpr expressions according to the group's logic.
/// </summary>
/// <param name="left">The left BoolExpr expression.</param>
/// <param name="right">The right BoolExpr expression.</param>
/// <returns>The merged BoolExpr expression.</returns>
protected abstract BoolExpr Merge(BoolExpr left, BoolExpr right);
/// <summary>
/// Initializes a new instance of the <see cref="Base"/> class.
/// </summary>
/// <param name="rules">The collection of rules to include in this group.</param>
protected Base(IEnumerable<IRule> rules)
{
RuleList = rules.ToList();
}
}
@@ -0,0 +1,12 @@
namespace Strata.SqlTools.Rules.Rule.Groups;
/// <summary>
/// Represents a group of rules that can be evaluated together.
/// </summary>
public interface IGroup : IRule
{
/// <summary>
/// Gets the collection of rules in this group.
/// </summary>
IReadOnlyCollection<IRule> Rules { get; }
}
@@ -0,0 +1,23 @@
using Strata.SqlTools.Rules.Rule.Expression;
namespace Strata.SqlTools.Rules.Rule.Groups;
/// <summary>
/// Represents a rule group where at least one rule must evaluate to true (logical OR).
/// </summary>
public class Or : Base
{
/// <summary>
/// Merges two BoolExpr expressions using logical OR.
/// </summary>
/// <param name="left">The left BoolExpr expression.</param>
/// <param name="right">The right BoolExpr expression.</param>
/// <returns>An OR expression combining both expressions.</returns>
protected override BoolExpr Merge(BoolExpr left, BoolExpr right) => new Expression.Or(left, right);
/// <summary>
/// Initializes a new instance of the <see cref="Or"/> class.
/// </summary>
/// <param name="rules">The collection of rules to include in this OR group.</param>
public Or(IEnumerable<IRule> rules) : base(rules) { }
}
@@ -0,0 +1,33 @@
using Strata.SqlTools.Rules.Rule.Expression;
namespace Strata.SqlTools.Rules.Rule.Groups;
/// <summary>
/// Represents a rule group with sequential rule evaluation (WITH semantics).
/// </summary>
public class With : Base
{
/// <summary>
/// Gets the expressions from all rules, potentially with ordering applied.
/// </summary>
/// <returns>An enumerable of BoolExpr rule expressions.</returns>
protected override IEnumerable<BoolExpr> GetExpressions()
{
// do some ordering here??
return base.GetExpressions();
}
/// <summary>
/// Merges two BoolExpr expressions using WITH semantics.
/// </summary>
/// <param name="left">The left BoolExpr expression.</param>
/// <param name="right">The right BoolExpr expression.</param>
/// <returns>A WITH expression combining both expressions.</returns>
protected override BoolExpr Merge(BoolExpr left, BoolExpr right) => new Expression.With(left, right);
/// <summary>
/// Initializes a new instance of the <see cref="With"/> class.
/// </summary>
/// <param name="rules">The collection of rules to include in this WITH group.</param>
public With(IEnumerable<IRule> rules) : base(rules) { }
}
+14
View File
@@ -0,0 +1,14 @@
using Strata.SqlTools.Rules.Rule.Expression;
namespace Strata.SqlTools.Rules.Rule;
/// <summary>
/// Represents a rule that contains a BoolExpr expression for evaluation.
/// </summary>
public interface IRule
{
/// <summary>
/// Gets the BoolExpr expression that defines the rule logic.
/// </summary>
public BoolExpr Expression { get; }
}
+71
View File
@@ -0,0 +1,71 @@
using Strata.SqlTools.Rules.Rule.Expression;
using Strata.SqlTools.Rules.Rule.Groups;
namespace Strata.SqlTools.Rules.Rule;
/// <summary>
/// Represents a complete set of rules with a unique identifier and a root rule group.
/// </summary>
public class RuleSet : IGroup
{
/// <summary>
/// Gets the unique identifier for this rule set.
/// </summary>
public Guid RuleSetId { get; }
/// <summary>
/// Gets the root rule group containing all rules in this set.
/// </summary>
public IGroup RootRuleGroup { get; }
/// <summary>
/// Gets the merged BoolExpr expression from the root rule group.
/// </summary>
public BoolExpr Expression => RootRuleGroup.Expression;
/// <summary>
/// Gets the collection of rules from the root rule group.
/// </summary>
public IReadOnlyCollection<IRule> Rules => RootRuleGroup.Rules;
/// <summary>
/// Initializes a new instance of the <see cref="RuleSet"/> class.
/// </summary>
/// <param name="rootRuleGroup">The root rule group containing all rules.</param>
/// <param name="ruleSetId">The unique identifier for this rule set.</param>
public RuleSet(IGroup rootRuleGroup, Guid ruleSetId)
{
RootRuleGroup = rootRuleGroup;
RuleSetId = ruleSetId;
}
/// <summary>
/// Gets all single rules from the rule set, recursively traversing all rule groups.
/// </summary>
/// <returns>An enumerable of all single rules in the rule set.</returns>
public IEnumerable<SingleRule> GetAllSingleRules() => GetAllSingleRules(RootRuleGroup);
/// <summary>
/// Recursively gets all single rules from a rule group and its nested groups.
/// </summary>
/// <param name="group">The rule group to traverse.</param>
/// <returns>An enumerable of all single rules found in the group.</returns>
private static IEnumerable<SingleRule> GetAllSingleRules(IGroup group)
{
var rules = new List<SingleRule>();
foreach (var rule in group.Rules)
{
switch (rule)
{
case Base childGroup:
rules.AddRange(GetAllSingleRules(childGroup));
break;
case SingleRule single:
rules.Add(single);
break;
}
}
return rules;
}
}
@@ -0,0 +1,47 @@
using Strata.SqlTools.Rules.Rule.Expression;
namespace Strata.SqlTools.Rules.Rule;
/// <summary>
/// Represents a single rule with a name and a BoolExpr expression.
/// </summary>
public class SingleRule : IRule
{
/// <summary>
/// Gets the name of the rule.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the BoolExpr expression that defines the rule logic.
/// </summary>
public BoolExpr Expression { get; }
/// <summary>
/// Gets the property of the input that is used in the <see cref="Expression"/>.
/// Returns null if the Expression does not use a property from the input.
/// </summary>
public Property? Property
{
get
{
return Expression switch
{
Comparison { Left: Property property } => property,
Any any => any.CollectionProperty,
_ => null
};
}
}
/// <summary>
/// Initializes a new instance of the <see cref="SingleRule"/> class.
/// </summary>
/// <param name="name">The name of the rule.</param>
/// <param name="expression">The BoolExpr expression that defines the rule logic.</param>
public SingleRule(string name, BoolExpr expression)
{
Name = name;
Expression = expression;
}
}
+137
View File
@@ -0,0 +1,137 @@
using System.Dynamic;
using System.Runtime.CompilerServices;
using RulesEngine.Interfaces;
using RulesEngine.Models;
using Strata.SqlTools.Rules.Rule;
[assembly: InternalsVisibleTo("Strata.SqlTools.Rules.Tests", AllInternalsVisible = true)]
namespace Strata.SqlTools.Rules;
/// <summary>
/// Engine for executing rule sets by translating them to the Microsoft RulesEngine format.
/// </summary>
internal class RuleSetEngine
{
private readonly IRulesEngine _innerRulesEngine;
private readonly RuleTranslator _translator;
/// <summary>
/// Initializes a new instance of the <see cref="RuleSetEngine"/> class with default dependencies.
/// </summary>
public RuleSetEngine() : this(new RulesEngine.RulesEngine(), new RuleTranslator())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RuleSetEngine"/> class with specified dependencies.
/// </summary>
/// <param name="innerRulesEngine">The underlying rules engine to use for execution.</param>
/// <param name="translator">The translator to convert rule sets to workflow format.</param>
internal RuleSetEngine(IRulesEngine innerRulesEngine, RuleTranslator translator)
{
_innerRulesEngine = innerRulesEngine;
_translator = translator;
}
/// <summary>
/// Executes all rules in the rule set against the provided input.
/// </summary>
/// <param name="ruleSet">The rule set to execute.</param>
/// <param name="input">The input object to evaluate against the rules.</param>
/// <returns>A task representing the asynchronous operation, containing true if all rules passed, false otherwise.</returns>
public async ValueTask<bool> RunRules(RuleSet ruleSet, object input)
{
var name = ruleSet.RuleSetId.ToString();
if (!_innerRulesEngine.ContainsWorkflow(name))
{
var workflow = _translator.TranslateRuleSet(ruleSet);
workflow.WorkflowName = name;
_innerRulesEngine.AddOrUpdateWorkflow(workflow);
}
var result = await _innerRulesEngine.ExecuteAllRulesAsync(name, new RuleParameter("input", input));
var success = result?.TrueForAll(tree => tree.IsSuccess) ?? false;
return success;
}
/// <summary>
/// Converts an object to an ExpandoObject by copying all public properties.
/// </summary>
/// <param name="obj">The object to convert.</param>
/// <returns>An ExpandoObject containing all properties from the source object.</returns>
private static ExpandoObject ConvertObjectToExpando(object obj)
{
var expando = new ExpandoObject();
var dictionary = expando as IDictionary<string, object?>;
foreach (var property in obj.GetType().GetProperties())
{
dictionary.Add(property.Name, property.GetValue(obj));
}
return expando;
}
}
/// <summary>
/// Translates rule expressions from Strata Domain-Specific-Language to Microsoft.RulesEngine format.
/// </summary>
internal class RuleTranslator
{
private readonly IVisitor<string> _localRuleVisitor;
/// <summary>
/// Initializes a new instance of the <see cref="RuleTranslator"/> class with a default local rule visitor.
/// </summary>
public RuleTranslator() : this(new LocalVisitor())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RuleTranslator"/> class with a specified rule visitor.
/// </summary>
/// <param name="localRuleVisitor">The rule visitor to use for translating expressions to strings.</param>
public RuleTranslator(IVisitor<string> localRuleVisitor)
{
_localRuleVisitor = localRuleVisitor;
}
/// <summary>
/// Translates a rule set to a Microsoft RulesEngine workflow.
/// </summary>
/// <param name="ruleSet">The rule set to translate.</param>
/// <returns>A workflow containing the translated rules.</returns>
public Workflow TranslateRuleSet(RuleSet ruleSet)
{
var workflowRules = new List<RulesEngine.Models.Rule>();
var ruleSetRule = TranslateRule(ruleSet);
workflowRules.Add(ruleSetRule);
return new Workflow
{
RuleExpressionType = RulesEngine.Models.RuleExpressionType.LambdaExpression,
Rules = workflowRules
};
}
/// <summary>
/// Translates a single rule to a Microsoft RulesEngine rule.
/// </summary>
/// <param name="rule">The rule to translate.</param>
/// <returns>A Microsoft RulesEngine rule with the expression converted to a string.</returns>
public RulesEngine.Models.Rule TranslateRule(IRule rule)
{
var expressionString = rule.Expression.Accept(_localRuleVisitor);
return new RulesEngine.Models.Rule
{
RuleName = "R1",
Expression = expressionString
};
}
}
@@ -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.Rules</PackageId>
<Version>1.0.0</Version>
<Authors>Strata Decision Technology</Authors>
<Company>Strata Decision Technology</Company>
<Product>Strata SQL Utilities - Rules Engine</Product>
<Description>Rules engine for Strata.SqlTools, providing rule-based validation and analysis of SQL queries and expressions.</Description>
<PackageTags>sql;rules-engine;validation;analysis;query-validation</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 rules engine for SQL query validation and analysis.</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>
<PackageReference Include="RulesEngine" Version="5.0.3" PrivateAssets="compile;contentfiles;build;analyzers" />
</ItemGroup>
</Project>
@@ -0,0 +1,191 @@
using System.Collections;
using System.Text;
using SqlServerDeleteBreakdown = Strata.SqlTools.Breakdowns.SqlServer.DeleteBreakdown;
using StatementParser = Strata.SqlTools.Statements.Snowflake.StatementParser;
namespace Strata.SqlTools.Breakdowns.Snowflake;
/// <summary>
/// Represents a DELETE SQL statement breakdown with FROM and WHERE clauses for Snowflake.
/// </summary>
[Serializable]
public class DeleteBreakdown : SqlServerDeleteBreakdown
{
private static readonly StatementParser SnowflakeParserInstance = new StatementParser();
/// <summary>
/// Initializes a new instance of the <see cref="DeleteBreakdown"/> class.
/// </summary>
public DeleteBreakdown() : base()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DeleteBreakdown"/> class.
/// </summary>
/// <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 Snowflake parsing rules. Defaults to false.</param>
public DeleteBreakdown(string fromClause, string whereClause, bool isMicrosoftSql = false)
: base()
{
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
var cleanFrom = parser.ExtractSqlComments(fromClause, out var fromComments);
FromClause.Clause = cleanFrom.Trim();
FromClause.Comment = fromComments.Count > 0 ? string.Join(" ", fromComments) : null;
var cleanWhere = parser.ExtractSqlComments(whereClause, out var whereComments);
WhereClause.Clause = cleanWhere.Trim();
WhereClause.Comment = whereComments.Count > 0 ? string.Join(" ", whereComments) : null;
}
/// <summary>
/// Gets the SQL breakdown as a string for Snowflake.
/// </summary>
/// <returns>The DELETE SQL statement.</returns>
protected override string GetSqlBreakdown()
{
var sb = new StringBuilder();
// Snowflake DELETE syntax is simpler - no DELETE clause with alias
sb.AppendLine("DELETE FROM ");
sb.AppendLine($" {FromClause.Clause}");
if (IsUsingWhereClause)
{
sb.AppendLine("WHERE ");
sb.AppendLine($" {WhereClause.Clause}");
}
return sb.ToString();
}
#region Parse Methods
/// <summary>
/// Parses a Snowflake DELETE SQL statement into a DeleteBreakdown object.
/// </summary>
/// <param name="sql">The DELETE SQL statement to parse.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
/// <returns>A DeleteBreakdown object representing the parsed statement.</returns>
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
public static DeleteBreakdown Parse(string sql, bool isMicrosoftSql = false)
{
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, isMicrosoftSql))
{
throw new FormatException($"Failed to parse {(isMicrosoftSql ? "T-SQL" : "Snowflake SQL")} DELETE statement: {error}");
}
return result;
}
/// <summary>
/// Attempts to parse a Snowflake DELETE SQL statement into a DeleteBreakdown object.
/// </summary>
/// <param name="sql">The DELETE SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed DeleteBreakdown if successful, or null if parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out DeleteBreakdown result, bool isMicrosoftSql = false)
=> TryParse(sql, out result, out _, isMicrosoftSql);
/// <summary>
/// Attempts to parse a Snowflake DELETE SQL statement into a DeleteBreakdown object.
/// Handles Snowflake-specific syntax.
/// </summary>
/// <param name="sql">The DELETE SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed DeleteBreakdown if successful, or null if parsing failed.</param>
/// <param name="errorMessage">When this method returns false, contains a message describing why parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out DeleteBreakdown result, out string errorMessage, bool isMicrosoftSql = false)
{
result = null!;
errorMessage = null!;
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// If Microsoft SQL mode, delegate to base class
if (isMicrosoftSql)
{
if (!SqlServerDeleteBreakdown.TryParse(sql, out var baseResult, out errorMessage))
{
return false;
}
// Convert to Snowflake DeleteBreakdown
result = new DeleteBreakdown
{
FromClause = baseResult.FromClause,
WhereClause = baseResult.WhereClause,
DeleteClause = baseResult.DeleteClause,
SetupClauses = baseResult.SetupClauses,
FinishClauses = baseResult.FinishClauses
};
return true;
}
var parser = SnowflakeParserInstance;
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's a DELETE statement
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*DELETE\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
errorMessage = "SQL statement must start with DELETE.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Snowflake uses simpler DELETE syntax: DELETE FROM table WHERE condition
var deleteMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"DELETE\s+FROM\s+(.*?)(?:\s+WHERE\s+(.*))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
if (!deleteMatch.Success)
{
errorMessage = "Could not parse DELETE statement. Expected format: DELETE FROM table [WHERE condition]";
return false;
}
var fromClause = deleteMatch.Groups[1].Value.Trim();
var whereClause = deleteMatch.Groups.Count > 2 ? deleteMatch.Groups[2].Value.Trim() : string.Empty;
result = new DeleteBreakdown(fromClause, whereClause, isMicrosoftSql: false)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
return true;
}
catch (Exception ex)
{
errorMessage = $"Unexpected error during parsing: {ex.Message}";
return false;
}
}
#endregion
}
@@ -0,0 +1,197 @@
using System.Collections;
using Strata.SqlTools.SqlBreakdown.Utilities;
using SqlServerInsertBreakdown = Strata.SqlTools.Breakdowns.SqlServer.InsertBreakdown;
using StatementParser = Strata.SqlTools.Statements.Snowflake.StatementParser;
namespace Strata.SqlTools.Breakdowns.Snowflake;
/// <summary>
/// Represents an INSERT SQL statement breakdown with column and value clauses for Snowflake.
/// </summary>
[Serializable]
public class InsertBreakdown : SqlServerInsertBreakdown
{
private static readonly StatementParser SnowflakeParserInstance = new StatementParser();
/// <summary>
/// Initializes a new instance of the <see cref="InsertBreakdown"/> class.
/// </summary>
public InsertBreakdown() : base()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="InsertBreakdown"/> class.
/// </summary>
/// <param name="tableName">The table name.</param>
/// <param name="insertIntoClause">The column list for the INSERT.</param>
/// <param name="valuesClause">The values clause.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
public InsertBreakdown(string tableName, string insertIntoClause, string valuesClause, bool isMicrosoftSql = false)
: base()
{
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
var cleanTable = parser.ExtractSqlComments(tableName, out var tableComments);
TableName.Clause = cleanTable.Trim();
TableName.Comment = tableComments.Count > 0 ? string.Join(" ", tableComments) : null;
var cleanInsert = parser.ExtractSqlComments(insertIntoClause, out var insertComments);
InsertIntoClause.Clause = cleanInsert.Trim();
InsertIntoClause.Comment = insertComments.Count > 0 ? string.Join(" ", insertComments) : null;
var cleanValues = parser.ExtractSqlComments(valuesClause, out var valuesComments);
ValuesClause.Clause = cleanValues.Trim();
ValuesClause.Comment = valuesComments.Count > 0 ? string.Join(" ", valuesComments) : null;
}
/// <summary>
/// Initializes a new instance of the <see cref="InsertBreakdown"/> class from a list of column names.
/// </summary>
/// <param name="tableName">The table name.</param>
/// <param name="columnNames">The list of column names.</param>
public InsertBreakdown(string tableName, List<string> columnNames)
: base()
{
TableName.Clause = tableName;
InsertIntoClause.Clause = SqlUtils.GetSqlSafeColumnList(columnNames);
// Generate parameter names for values (Snowflake uses :parameter syntax)
var valuesList = new List<string>();
foreach (string item in columnNames)
{
valuesList.Add($":{item}");
}
ValuesClause.Clause = string.Join(",", valuesList);
}
#region Parse Methods
/// <summary>
/// Parses a Snowflake INSERT SQL statement into an InsertBreakdown object.
/// </summary>
/// <param name="sql">The INSERT SQL statement to parse.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
/// <returns>An InsertBreakdown object representing the parsed statement.</returns>
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
public static InsertBreakdown Parse(string sql, bool isMicrosoftSql = false)
{
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, isMicrosoftSql))
{
throw new FormatException($"Failed to parse {(isMicrosoftSql ? "T-SQL" : "Snowflake SQL")} INSERT statement: {error}");
}
return result;
}
/// <summary>
/// Attempts to parse a Snowflake INSERT SQL statement into an InsertBreakdown object.
/// </summary>
/// <param name="sql">The INSERT SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed InsertBreakdown if successful, or null if parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out InsertBreakdown result, bool isMicrosoftSql = false)
=> TryParse(sql, out result, out _, isMicrosoftSql);
/// <summary>
/// Attempts to parse a Snowflake INSERT SQL statement into an InsertBreakdown object.
/// Handles Snowflake-specific syntax.
/// </summary>
/// <param name="sql">The INSERT SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed InsertBreakdown if successful, or null if parsing failed.</param>
/// <param name="errorMessage">When this method returns false, contains a message describing why parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out InsertBreakdown result, out string errorMessage, bool isMicrosoftSql = false)
{
result = null!;
errorMessage = null!;
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// If Microsoft SQL mode, delegate to base class
if (isMicrosoftSql)
{
if (!SqlServerInsertBreakdown.TryParse(sql, out var baseResult, out errorMessage))
{
return false;
}
// Convert to Snowflake InsertBreakdown
result = new InsertBreakdown
{
TableName = baseResult.TableName,
InsertIntoClause = baseResult.InsertIntoClause,
ValuesClause = baseResult.ValuesClause,
SetupClauses = baseResult.SetupClauses,
FinishClauses = baseResult.FinishClauses
};
return true;
}
var parser = SnowflakeParserInstance;
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's an INSERT statement
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*INSERT\s+INTO\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
errorMessage = "SQL statement must start with INSERT INTO.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse INSERT statement using regex
var insertMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"INSERT\s+INTO\s+([^\(\s]+)\s*\(([^\)]*)\)\s*VALUES\s*\(([^\)]*)\)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
if (!insertMatch.Success)
{
errorMessage = "Could not parse INSERT statement. Expected format: INSERT INTO table (columns) VALUES (values)";
return false;
}
var tableName = insertMatch.Groups[1].Value.Trim();
var columnsClause = insertMatch.Groups[2].Value.Trim();
var valuesClause = insertMatch.Groups[3].Value.Trim();
result = new InsertBreakdown(tableName, columnsClause, valuesClause, isMicrosoftSql: false)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
return true;
}
catch (Exception ex)
{
errorMessage = $"Unexpected error during parsing: {ex.Message}";
return false;
}
}
#endregion
}
@@ -0,0 +1,232 @@
using System.Collections;
using System.Text;
using SqlServerProcedureBreakdown = Strata.SqlTools.Breakdowns.SqlServer.ProcedureBreakdown;
using StatementParser = Strata.SqlTools.Statements.Snowflake.StatementParser;
namespace Strata.SqlTools.Breakdowns.Snowflake;
/// <summary>
/// Represents a Snowflake stored procedure call breakdown with procedure name and parameters.
/// </summary>
[Serializable]
public class ProcedureBreakdown : SqlServerProcedureBreakdown
{
private static readonly StatementParser SnowflakeParserInstance = new StatementParser();
/// <summary>
/// Initializes a new instance of the <see cref="ProcedureBreakdown"/> class.
/// </summary>
public ProcedureBreakdown() : base()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ProcedureBreakdown"/> class.
/// </summary>
/// <param name="procedureName">The stored procedure name.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
public ProcedureBreakdown(string procedureName, bool isMicrosoftSql = false) : base()
{
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
var cleanName = parser.ExtractSqlComments(procedureName, out var nameComments);
ProcedureName.Clause = cleanName.Trim();
ProcedureName.Comment = nameComments.Count > 0 ? string.Join(" ", nameComments) : null;
}
/// <summary>
/// Initializes a new instance of the <see cref="ProcedureBreakdown"/> class.
/// </summary>
/// <param name="procedureName">The stored procedure name.</param>
/// <param name="parameters">The parameters dictionary (parameter name -> value expression).</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
public ProcedureBreakdown(string procedureName, Dictionary<string, string> parameters, bool isMicrosoftSql = false)
: this(procedureName, isMicrosoftSql)
{
Parameters = parameters ?? new Dictionary<string, string>();
}
/// <summary>
/// Gets the SQL breakdown as a string for Snowflake.
/// </summary>
/// <returns>The CALL SQL statement (Snowflake uses CALL instead of EXEC).</returns>
protected override string GetSqlBreakdown()
{
var sb = new StringBuilder();
sb.Append("CALL ");
sb.Append(ProcedureName.Clause);
sb.Append("(");
if (IsUsingParameters)
{
var paramList = new List<string>();
foreach (var param in Parameters)
{
// Snowflake uses positional or named parameters with => syntax
paramList.Add($"{param.Key.TrimStart('@')} => {param.Value}");
}
sb.Append(string.Join(", ", paramList));
}
sb.Append(")");
return sb.ToString();
}
#region Parse Methods
/// <summary>
/// Parses a Snowflake CALL SQL statement into a ProcedureBreakdown object.
/// </summary>
/// <param name="sql">The CALL SQL statement to parse.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
/// <returns>A ProcedureBreakdown object representing the parsed statement.</returns>
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
public static ProcedureBreakdown Parse(string sql, bool isMicrosoftSql = false)
{
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, isMicrosoftSql))
{
throw new FormatException($"Failed to parse {(isMicrosoftSql ? "EXEC" : "CALL")} statement: {error}");
}
return result;
}
/// <summary>
/// Attempts to parse a Snowflake CALL SQL statement into a ProcedureBreakdown object.
/// </summary>
/// <param name="sql">The CALL SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed ProcedureBreakdown if successful, or null if parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out ProcedureBreakdown result, bool isMicrosoftSql = false)
=> TryParse(sql, out result, out _, isMicrosoftSql);
/// <summary>
/// Attempts to parse a Snowflake CALL SQL statement into a ProcedureBreakdown object.
/// Handles Snowflake-specific syntax including CALL procedureName(param &gt;= value).
/// </summary>
/// <param name="sql">The CALL SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed ProcedureBreakdown if successful, or null if parsing failed.</param>
/// <param name="errorMessage">When this method returns false, contains a message describing why parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out ProcedureBreakdown result, out string errorMessage, bool isMicrosoftSql = false)
{
result = null!;
errorMessage = null!;
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// If Microsoft SQL mode, delegate to base class
if (isMicrosoftSql)
{
if (!SqlServerProcedureBreakdown.TryParse(sql, out var baseResult, out errorMessage))
{
return false;
}
// Convert to Snowflake ProcedureBreakdown
result = new ProcedureBreakdown
{
ProcedureName = baseResult.ProcedureName,
Parameters = baseResult.Parameters,
SetupClauses = baseResult.SetupClauses,
FinishClauses = baseResult.FinishClauses
};
return true;
}
var parser = SnowflakeParserInstance;
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's a CALL statement (Snowflake syntax) or EXEC (for compatibility)
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*(CALL|EXEC|EXECUTE)\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
errorMessage = "SQL statement must start with CALL, EXEC, or EXECUTE.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse CALL statement - match procedure name and parameters
// Pattern: CALL procedureName(param => value, ...)
var callMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"(?:CALL|EXEC|EXECUTE)\s+([^\s\(]+)(?:\s*\((.*?)\))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
if (!callMatch.Success)
{
errorMessage = "Could not parse CALL statement. Expected format: CALL procedureName(param => value, ...)";
return false;
}
var procedureName = callMatch.Groups[1].Value.Trim();
var parametersText = callMatch.Groups.Count > 2 ? callMatch.Groups[2].Value.Trim() : string.Empty;
var parameters = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(parametersText))
{
// Parse parameters - Snowflake uses param => value syntax
var paramMatches = System.Text.RegularExpressions.Regex.Matches(parametersText,
@"(\w+)\s*=>\s*([^,]+)(?:,|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match paramMatch in paramMatches)
{
var paramName = paramMatch.Groups[1].Value.Trim();
var paramValue = paramMatch.Groups[2].Value.Trim();
// Store with @ prefix for consistency with SQL Server
parameters["@" + paramName] = paramValue;
}
// If no named parameters found, try positional parameters (just values)
if (parameters.Count == 0 && !string.IsNullOrWhiteSpace(parametersText))
{
var positionalParams = parametersText.Split(',');
for (int i = 0; i < positionalParams.Length; i++)
{
parameters[$"@param{i + 1}"] = positionalParams[i].Trim();
}
}
}
result = new ProcedureBreakdown(procedureName, parameters, isMicrosoftSql: false)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
return true;
}
catch (Exception ex)
{
errorMessage = $"Unexpected error during parsing: {ex.Message}";
return false;
}
}
#endregion
}
@@ -0,0 +1,881 @@
using System.Collections;
using System.Text;
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Expressions;
using Strata.SqlTools.SqlBreakdown.Interfaces;
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
using CommandVisitor = Strata.SqlTools.Visitors.Snowflake.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 StatementExpressionParser = Strata.SqlTools.Statements.Snowflake.StatementExpressionParser;
using StatementParser = Strata.SqlTools.Statements.Snowflake.StatementParser;
namespace Strata.SqlTools.Breakdowns.Snowflake;
/// <summary>
/// Represents a Snowflake SQL query breakdown with all clauses, following Snowflake SQL standards.
/// Handles both :parameter and @parameter syntax for Snowflake compatibility.
/// </summary>
[Serializable]
public class QueryBreakdown : SqlServerQueryBreakdown
{
private const string ExpressionNullErrorMessage = "Expression cannot be null.";
private static readonly StatementParser SnowflakeParserInstance = new StatementParser();
/// <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 Snowflake parsing rules. Defaults to false.</param>
public QueryBreakdown(string selectClause, string fromClause, bool isMicrosoftSql = false) : base()
{
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
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 Snowflake parsing rules. Defaults to false.</param>
public QueryBreakdown(string selectClause, string fromClause, string whereClause, bool isMicrosoftSql = false)
: this(selectClause, fromClause, isMicrosoftSql)
{
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
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 Snowflake parsing rules. Defaults to false.</param>
public QueryBreakdown(string selectClause, string fromClause, string whereClause, string orderByClause, bool isMicrosoftSql = false)
: this(selectClause, fromClause, whereClause, isMicrosoftSql)
{
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
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 Snowflake's :param format.
/// Also adds @param format for compatibility.
/// </summary>
/// <param name="parameterName">The parameter name (with or without : or @).</param>
/// <param name="value">The parameter value.</param>
public new void AddParameter(string parameterName, object value)
{
// Convert to Snowflake format (: prefix)
var colonName = NormalizeParameterName(parameterName);
var atName = "@" + colonName.TrimStart(':', '@');
// Use base class internal list
base.AddParameter(colonName.TrimStart(':', '@'), value);
// Add both formats to dictionary for compatibility
if (Parameters.ContainsKey($"@{colonName.TrimStart(':', '@')}"))
{
Parameters.Remove($"@{colonName.TrimStart(':', '@')}");
}
Parameters[colonName] = value;
Parameters[atName] = value;
}
/// <summary>
/// Sets the value of a parameter using Snowflake's :param format.
/// Also updates @param format for compatibility.
/// </summary>
/// <param name="parameterName">The parameter name (with or without : or @).</param>
/// <param name="value">The parameter value.</param>
public new void SetParameterValue(string parameterName, object value)
{
var colonName = NormalizeParameterName(parameterName);
var atName = "@" + colonName.TrimStart(':', '@');
Parameters[colonName] = value;
Parameters[atName] = value;
}
/// <summary>
/// Normalizes parameter name to Snowflake format (:param).
/// </summary>
private static string NormalizeParameterName(string parameterName)
{
if (string.IsNullOrEmpty(parameterName))
{
return parameterName;
}
// If it already has : or @, preserve the prefix but prefer :
if (parameterName.StartsWith(':'))
{
return parameterName;
}
if (parameterName.StartsWith('@'))
{
return ":" + parameterName.Substring(1);
}
// Add : prefix
return ":" + parameterName;
}
/// <summary>
/// Ensures parameters exist in both @ and : formats for compatibility.
/// </summary>
private void NormalizeParameterFormats()
{
var paramKeys = Parameters.Keys.ToList();
foreach (var paramName in paramKeys)
{
if (paramName.StartsWith(':'))
{
// Add @param version
var atParam = "@" + paramName.Substring(1);
if (!Parameters.ContainsKey(atParam))
{
Parameters[atParam] = Parameters[paramName];
}
}
else if (paramName.StartsWith('@'))
{
// Add :param version
var colonParam = ":" + paramName.Substring(1);
if (!Parameters.ContainsKey(colonParam))
{
Parameters[colonParam] = Parameters[paramName];
}
}
}
}
/// <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 Snowflake 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 = $"{SelectClause.Clause}, {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(SelectClause.Comment))
{
SelectClause.Comment = comment;
}
else
{
SelectClause.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="operation">The logical operation ("and" or "or"). Defaults to "and".</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL formatting. If false, uses Snowflake formatting. Defaults to false.</param>
public void AddWhereExpression(Expression expression, string? comment = null, string operation = "and", 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 = $"{WhereClause.Clause} {operation} {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(WhereClause.Comment))
{
WhereClause.Comment = comment;
}
else
{
WhereClause.Comment = $"{WhereClause.Comment} {comment}";
}
}
}
/// <summary>
/// Adds a WHERE clause condition. Defaults to "and" operation.
/// </summary>
/// <param name="sql">The SQL condition to add.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
public void AddWhereClause(string sql, bool isMicrosoftSql = false)
{
AddWhereClause(sql, "and", isMicrosoftSql);
}
/// <summary>
/// Adds a WHERE clause condition with a specific logical operation.
/// Extracts and preserves any SQL comments in the clause.
/// Uses Snowflake parsing rules by default.
/// </summary>
/// <param name="sql">The SQL condition to add.</param>
/// <param name="operation">The logical operation ("and" or "or").</param>
public override void AddWhereClause(string sql, string operation)
{
AddWhereClause(sql, operation, isMicrosoftSql: false);
}
/// <summary>
/// Adds a WHERE clause condition with a specific logical operation.
/// Extracts and preserves any SQL comments in the clause.
/// Automatically extracts parameters from the WHERE clause and adds them to the Parameters dictionary.
/// </summary>
/// <param name="sql">The SQL condition to add.</param>
/// <param name="operation">The logical operation ("and" or "or").</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
public void AddWhereClause(string sql, string operation, bool isMicrosoftSql)
{
if (string.IsNullOrWhiteSpace(sql))
{
return;
}
// Use appropriate parser based on SQL dialect
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
// Extract comments from the incoming SQL
var cleanSql = parser.ExtractSqlComments(sql, out var comments);
if (string.IsNullOrWhiteSpace(WhereClause.Clause))
{
WhereClause.Clause = cleanSql.Trim();
}
else
{
WhereClause.Clause = $"{WhereClause.Clause} {operation} {cleanSql.Trim()}";
}
// Merge comments
if (comments.Count > 0)
{
var newComment = string.Join(" ", comments);
if (string.IsNullOrWhiteSpace(WhereClause.Comment))
{
WhereClause.Comment = newComment;
}
else
{
WhereClause.Comment = $"{WhereClause.Comment} {newComment}";
}
}
// Extract and add parameters from the WHERE clause using appropriate parser
ExtractAndAddParametersWithParser(cleanSql, parser);
}
/// <summary>
/// Extracts parameters from a SQL clause and adds them to the Parameters dictionary using the specified parser.
/// </summary>
/// <param name="sql">The SQL clause to extract parameters from.</param>
/// <param name="parser">The parser to use for extracting parameters.</param>
private void ExtractAndAddParametersWithParser(string sql, Statements.SqlServer.StatementParser parser)
{
if (string.IsNullOrWhiteSpace(sql))
{
return;
}
// Create a temporary dictionary to extract parameters
var tempParams = new Dictionary<string, object>();
parser.ExtractParameters(tempParams, sql);
// Add each parameter using the managed add method from base class
foreach (var kvp in tempParams)
{
AddOrUpdateParameter(kvp.Key, kvp.Value);
}
}
/// <summary>
/// Adds an expression to the GROUP BY clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
public override void AddGroupByExpression(Expression expression, string? comment = null)
{
AddGroupByExpression(expression, comment, isMicrosoftSql: false);
}
/// <summary>
/// Adds an expression to the GROUP BY 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 Snowflake formatting. Defaults to false.</param>
public void AddGroupByExpression(Expression expression, string? comment, bool isMicrosoftSql)
{
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(GroupByClause.Clause))
{
GroupByClause.Clause = sql;
}
else
{
GroupByClause.Clause = $"{GroupByClause.Clause}, {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(GroupByClause.Comment))
{
GroupByClause.Comment = comment;
}
else
{
GroupByClause.Comment = $"{GroupByClause.Comment} {comment}";
}
}
}
/// <summary>
/// Adds an expression to the ORDER BY clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
public override void AddOrderByExpression(Expression expression, string? comment = null)
{
AddOrderByExpression(expression, comment, isMicrosoftSql: false);
}
/// <summary>
/// Adds an expression to the ORDER BY 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 Snowflake formatting. Defaults to false.</param>
public void AddOrderByExpression(Expression expression, string? comment, bool isMicrosoftSql)
{
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(OrderByClause.Clause))
{
OrderByClause.Clause = sql;
}
else
{
OrderByClause.Clause = $"{OrderByClause.Clause}, {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(OrderByClause.Comment))
{
OrderByClause.Comment = comment;
}
else
{
OrderByClause.Comment = $"{OrderByClause.Comment} {comment}";
}
}
}
/// <summary>
/// Adds an expression to the HAVING clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
/// <param name="operation">The logical operation ("and" or "or"). Defaults to "and".</param>
public override void AddHavingExpression(Expression expression, string? comment = null, string operation = "and")
{
AddHavingExpression(expression, comment, operation, isMicrosoftSql: false);
}
/// <summary>
/// Adds an expression to the HAVING clause.
/// </summary>
/// <param name="expression">The expression to add.</param>
/// <param name="comment">Optional comment to add with the expression.</param>
/// <param name="operation">The logical operation ("and" or "or"). Defaults to "and".</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL formatting. If false, uses Snowflake formatting. Defaults to false.</param>
public void AddHavingExpression(Expression expression, string? comment, string operation, bool isMicrosoftSql)
{
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(HavingClause.Clause))
{
HavingClause.Clause = sql;
}
else
{
HavingClause.Clause = $"{HavingClause.Clause} {operation} {sql}";
}
if (!string.IsNullOrWhiteSpace(comment))
{
if (string.IsNullOrWhiteSpace(HavingClause.Comment))
{
HavingClause.Comment = comment;
}
else
{
HavingClause.Comment = $"{HavingClause.Comment} {comment}";
}
}
}
/// <summary>
/// Gets the complete Snowflake SQL query string with proper formatting.
/// </summary>
/// <param name="includeSetupFinish">Whether to include setup and finish clauses.</param>
/// <returns>The Snowflake SQL query string.</returns>
#pragma warning disable S3776 // Cognitive Complexity of methods should not be too high
public override string GetSql(bool includeSetupFinish = true)
#pragma warning restore S3776
{
var sb = new StringBuilder();
if (includeSetupFinish)
{
foreach (string setup in SetupClauses)
{
sb.AppendLine(setup);
}
}
if (IsUsingWithClause)
{
// Check if any WITH clause is recursive
bool hasRecursive = WithClauses.Any(wc => wc.IsRecursive);
sb.Append("WITH");
if (hasRecursive)
{
sb.Append(" RECURSIVE");
}
sb.AppendLine();
for (int i = 0; i < WithClauses.Count; i++)
{
var withClause = WithClauses[i];
if (i > 0)
{
sb.Append(",");
sb.AppendLine();
}
// Include comment if present
if (!string.IsNullOrWhiteSpace(withClause.Comment))
{
sb.AppendLine($" {withClause.Comment}");
}
// Write CTE name with optional column list
var cteName = withClause.TableName;
if (withClause.ColumnList != null && withClause.ColumnList.Count > 0)
{
var columnList = string.Join(", ", withClause.ColumnList);
cteName = $"{withClause.TableName} ({columnList})";
}
sb.AppendLine($" {cteName} AS (");
if (withClause.IsRecursive && withClause.RecursiveQuery != null)
{
// For recursive CTEs: anchor query UNION ALL recursive query
var anchorSql = withClause.Query?.GetSql(includeSetupFinish: false).Trim() ?? string.Empty;
var recursiveSql = withClause.RecursiveQuery.GetSql(includeSetupFinish: false).Trim() ?? string.Empty;
sb.AppendLine($" {anchorSql}");
sb.AppendLine(" UNION ALL");
sb.AppendLine($" {recursiveSql}");
}
else
{
// For non-recursive CTEs: just the single query
var withSql = withClause.Query?.GetSql(includeSetupFinish: false).Trim() ?? string.Empty;
sb.AppendLine($" {withSql}");
}
sb.Append(" )");
}
sb.AppendLine();
}
// Snowflake SELECT syntax
sb.Append(StatementParser.KeywordSelect);
// Handle TOP equivalent using LIMIT in Snowflake
sb.AppendLine();
if (!string.IsNullOrEmpty(SelectClause.Comment))
{
sb.AppendLine($" {SelectClause.Comment}");
}
sb.AppendLine($" {SelectClause.Clause}");
if (IsUsingFromClause)
{
sb.AppendLine(StatementParser.KeywordFrom);
if (!string.IsNullOrEmpty(FromClause.Comment))
{
sb.AppendLine($" {FromClause.Comment}");
}
sb.AppendLine($" {FromClause.Clause}");
}
if (IsUsingWhereClause)
{
sb.AppendLine(StatementParser.KeywordWhere);
if (!string.IsNullOrEmpty(WhereClause.Comment))
{
sb.AppendLine($" {WhereClause.Comment}");
}
sb.AppendLine($" {WhereClause.Clause}");
}
if (IsUsingGroupByClause)
{
sb.AppendLine(StatementParser.KeywordGroupBy);
if (!string.IsNullOrEmpty(GroupByClause.Comment))
{
sb.AppendLine($" {GroupByClause.Comment}");
}
sb.AppendLine($" {GroupByClause.Clause}");
}
if (IsUsingHavingClause)
{
sb.AppendLine(StatementParser.KeywordHaving);
if (!string.IsNullOrEmpty(HavingClause.Comment))
{
sb.AppendLine($" {HavingClause.Comment}");
}
sb.AppendLine($" {HavingClause.Clause}");
}
if (IsUsingOrderByClause)
{
sb.AppendLine(StatementParser.KeywordOrderBy);
if (!string.IsNullOrEmpty(OrderByClause.Comment))
{
sb.AppendLine($" {OrderByClause.Comment}");
}
sb.AppendLine($" {OrderByClause.Clause}");
}
if (includeSetupFinish)
{
foreach (string finish in FinishClauses)
{
sb.AppendLine(finish);
}
}
return sb.ToString();
}
/// <summary>
/// Creates a deep clone of this Snowflake query breakdown.
/// </summary>
/// <returns>A cloned SnowflakeQueryBreakdown instance.</returns>
public new object Clone()
{
// Use the base class clone method but return as SnowflakeQueryBreakdown
var baseClone = (SqlServerQueryBreakdown)base.Clone();
var clone = new QueryBreakdown
{
SelectClause = baseClone.SelectClause,
FromClause = baseClone.FromClause,
WhereClause = baseClone.WhereClause,
GroupByClause = baseClone.GroupByClause,
HavingClause = baseClone.HavingClause,
OrderByClause = baseClone.OrderByClause,
SetupClauses = new List<string>(baseClone.SetupClauses),
FinishClauses = new ArrayList(baseClone.FinishClauses)
};
// Copy parameters
foreach (var kvp in baseClone.Parameters)
{
clone.Parameters[kvp.Key] = kvp.Value;
}
return clone;
}
/// <summary>
/// Adds a Common Table Expression (CTE) to the WITH clause using a raw SQL string.
/// Parses the SQL using Snowflake SQL rules.
/// </summary>
/// <param name="withTableName">The table name for the WITH clause.</param>
/// <param name="withTableSql">The SQL query for the WITH table.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to true.</param>
public override void AddWithClause(string withTableName, string withTableSql, bool isMicrosoftSql = true)
{
if (string.IsNullOrWhiteSpace(withTableName))
{
throw new ArgumentNullException(nameof(withTableName), "WITH table name cannot be null or empty.");
}
if (string.IsNullOrWhiteSpace(withTableSql))
{
throw new ArgumentNullException(nameof(withTableSql), "WITH table SQL cannot be null or empty.");
}
// Parse the SQL string into a SnowflakeQueryBreakdown object using specified parsing rules
var parsedQuery = QueryBreakdown.Parse(withTableSql, isMicrosoftSql);
// Delegate to the IQueryBreakdown overload
AddWithClause(withTableName, parsedQuery);
}
/// <summary>
/// Creates a Snowflake-specific statement expression parser.
/// </summary>
/// <returns>A Snowflake IStatementExpressionParser instance.</returns>
protected override IStatementExpressionParser CreateExpressionParser()
=> new StatementExpressionParser();
/// <summary>
/// Parses a Snowflake SQL SELECT statement into a SnowflakeQueryBreakdown object.
/// Supports both :parameter and @parameter syntax.
/// </summary>
/// <param name="sql">The Snowflake SQL SELECT statement to parse.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>A SnowflakeQueryBreakdown object representing the parsed query.</returns>
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
public static QueryBreakdown Parse(string sql, bool isMicrosoftSql = false)
{
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, isMicrosoftSql))
{
throw new FormatException($"Failed to parse {(isMicrosoftSql ? "T-SQL" : "Snowflake SQL")} statement: {error}");
}
return result;
}
/// <summary>
/// Attempts to parse a Snowflake SQL SELECT statement into a SnowflakeQueryBreakdown object.
/// </summary>
/// <param name="sql">The Snowflake SQL SELECT statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed SnowflakeQueryBreakdown if successful, or null if parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out QueryBreakdown result, bool isMicrosoftSql = false)
=> TryParse(sql, out result, out _, isMicrosoftSql);
/// <summary>
/// Attempts to parse a Snowflake SQL SELECT statement into a SnowflakeQueryBreakdown object.
/// Handles Snowflake-specific syntax including :parameter and @parameter formats.
/// </summary>
/// <param name="sql">The Snowflake SQL SELECT statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed SnowflakeQueryBreakdown if successful, or null if parsing failed.</param>
/// <param name="errorMessage">When this method returns false, contains a message describing why parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out QueryBreakdown result, out string errorMessage, bool isMicrosoftSql = false)
{
result = null!;
errorMessage = null!;
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// If Microsoft SQL mode, delegate to base class
if (isMicrosoftSql)
{
if (!SqlServerQueryBreakdown.TryParse(sql, out var baseResult, out errorMessage))
{
return false;
}
// Convert base QueryBreakdown to SnowflakeQueryBreakdown
result = new QueryBreakdown
{
SelectClause = baseResult.SelectClause,
FromClause = baseResult.FromClause,
WhereClause = baseResult.WhereClause,
GroupByClause = baseResult.GroupByClause,
HavingClause = baseResult.HavingClause,
OrderByClause = baseResult.OrderByClause,
SetupClauses = baseResult.SetupClauses,
FinishClauses = baseResult.FinishClauses
};
// Preserve WITH clause using protected helper
result.SetWithClauseValue(baseResult.GetWithClauseValue());
// Copy parameters
foreach (var param in baseResult.Parameters)
{
result.Parameters[param.Key] = param.Value;
}
return true;
}
// Normalize the SQL: remove extra whitespace, handle line breaks, preserve comments
sql = SnowflakeParserInstance.NormalizeSqlPreservingComments(sql);
// Extract setup clauses (everything before the main SELECT)
var setupClauses = new List<string>();
sql = SnowflakeParserInstance.ExtractSetupClauses(sql, setupClauses);
// Extract finish clauses (cleanup statements after the main query)
var finishClauses = new ArrayList();
sql = SnowflakeParserInstance.ExtractFinishClauses(sql, finishClauses);
// Parse WITH clause separately if present
string? withClause = null;
if (SnowflakeParserInstance.TryParseWithClause(sql, out withClause, out var mainQuery))
{
sql = mainQuery; // Continue parsing with the main query
}
// Parse the main SELECT statement
if (!SnowflakeParserInstance.TryParseSelectStatement(sql, out var clauses, out errorMessage))
{
return false;
}
// Create the SnowflakeQueryBreakdown object
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
};
// Preserve WITH clause
result.SetWithClauseValue(withClause?.Trim());
// Extract parameters from all clauses (use comment-free version for this)
var sqlWithoutComments = SnowflakeParserInstance.RemoveSqlComments(sql);
SnowflakeParserInstance.ExtractParameters(result.Parameters, sqlWithoutComments);
// Normalize parameters to include both @ and : formats for compatibility
result.NormalizeParameterFormats();
return true;
}
catch (Exception ex)
{
errorMessage = $"Unexpected error during Snowflake SQL parsing: {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 Snowflake-specific implementation returns null since Snowflake 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
{
// Snowflake 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,940 @@
using System.Text;
using Strata.SqlTools.SqlBreakdown.Classes;
using Strata.SqlTools.SqlBreakdown.Interfaces;
namespace Strata.SqlTools.Breakdowns.Snowflake;
/// <summary>
/// Snowflake SQL-specific collection for managing multiple QueryBreakdown objects.
/// </summary>
/// <remarks>
/// This class extends SqlBreakdownCollection with Snowflake-specific functionality,
/// including support for Snowflake features like semi-structured data, stage references,
/// time travel, snowflake-specific parameters (:parameter and @parameter syntax),
/// and proper batch handling.
/// </remarks>
[Serializable]
public class QueryBreakdownCollection : SqlBreakdownCollection
{
private readonly List<QueryBreakdown> _queryBreakdowns;
/// <summary>
/// Initializes a new instance of the <see cref="QueryBreakdownCollection"/> class for Snowflake.
/// </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 = new List<QueryBreakdown>(queryBreakdowns ?? Enumerable.Empty<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 query breakdown to add.</param>
/// <exception cref="ArgumentNullException">Thrown when queryBreakdown is null.</exception>
public void Add(QueryBreakdown queryBreakdown)
{
if (queryBreakdown == null)
{
throw new ArgumentNullException(nameof(queryBreakdown));
}
_queryBreakdowns.Add(queryBreakdown);
base.Add(queryBreakdown);
}
/// <summary>
/// Adds multiple QueryBreakdowns to the collection.
/// </summary>
/// <param name="queryBreakdowns">The query breakdowns to add.</param>
/// <exception cref="ArgumentNullException">Thrown when queryBreakdowns is null.</exception>
public void AddRange(IEnumerable<QueryBreakdown> queryBreakdowns)
{
if (queryBreakdowns == null)
{
throw new ArgumentNullException(nameof(queryBreakdowns));
}
foreach (var breakdown in queryBreakdowns)
{
Add(breakdown);
}
}
/// <summary>
/// Removes a QueryBreakdown from the collection.
/// </summary>
/// <param name="queryBreakdown">The query breakdown to remove.</param>
/// <returns>True if removed; otherwise, false.</returns>
public bool Remove(QueryBreakdown queryBreakdown)
{
var removed = _queryBreakdowns.Remove(queryBreakdown);
if (removed)
{
base.Remove(queryBreakdown);
}
return removed;
}
/// <summary>
/// Clears all query breakdowns from the collection.
/// </summary>
public new void Clear()
{
_queryBreakdowns.Clear();
base.Clear();
}
/// <summary>
/// Gets the Snowflake SQL batch representation with Snowflake-specific formatting.
/// </summary>
/// <remarks>
/// Generates Snowflake SQL with proper statement separation and optional session setup.
/// Snowflake uses semicolons as statement separators instead of GO.
/// </remarks>
/// <param name="includeSetupFinish">Whether to include setup and finish clauses.</param>
/// <param name="includeSessionSetup">Whether to include session context setup statements.</param>
/// <returns>The formatted Snowflake SQL batch.</returns>
public string GetSnowflakeBatch(bool includeSetupFinish = true, bool includeSessionSetup = false)
{
var sb = new StringBuilder();
// Add session setup if requested
if (includeSessionSetup)
{
sb.AppendLine("-- Snowflake Session Setup");
sb.AppendLine("ALTER SESSION SET NULLABLE_AS_NULL = FALSE;");
sb.AppendLine("ALTER SESSION SET ERROR_ON_NONDETERMINISTIC_UPDATE = FALSE;");
sb.AppendLine();
}
// Add all queries with semicolon separators
if (_queryBreakdowns.Count > 0)
{
for (int i = 0; i < _queryBreakdowns.Count; i++)
{
var query = _queryBreakdowns[i];
var sql = query.GetSql(includeSetupFinish);
// Ensure proper termination
var trimmed = sql.TrimEnd();
sb.Append(trimmed);
if (!trimmed.EndsWith(';'))
{
sb.Append(";");
}
// Add spacing between statements
if (i < _queryBreakdowns.Count - 1)
{
sb.AppendLine();
sb.AppendLine();
}
}
}
return sb.ToString();
}
/// <summary>
/// Filters queries that reference Snowflake stages (using @ or @~ syntax).
/// </summary>
/// <remarks>
/// Stage references use the pattern @stage_name/ or @~/stage_name/.
/// This specifically matches stage references and avoids false positives from @parameter syntax.
/// </remarks>
/// <param name="stageName">Optional stage name to filter by. If null, returns all queries using any stage.</param>
/// <returns>Query breakdowns that reference stages.</returns>
public IEnumerable<QueryBreakdown> WhereUseStageReference(string? stageName = null)
{
return _queryBreakdowns.Where(q =>
{
var sql = q.GetSql();
// Use regex to match stage references: @stage_name/ or @~/stage_name/
// This avoids false positives from @parameter syntax
var stagePattern = @"@[\w~]+/";
if (!System.Text.RegularExpressions.Regex.IsMatch(sql, stagePattern))
{
return false;
}
if (stageName == null)
{
return true;
}
var specificPattern = stageName.Contains("~")
? $@"@~/{System.Text.RegularExpressions.Regex.Escape(stageName.TrimStart('@', '~', '/'))}/"
: $@"@{System.Text.RegularExpressions.Regex.Escape(stageName.TrimStart('@'))}/";
return System.Text.RegularExpressions.Regex.IsMatch(sql, specificPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
});
}
/// <summary>
/// Filters queries that reference JSON/semi-structured data using Snowflake's JSON operators.
/// </summary>
/// <returns>Query breakdowns that use JSON functions or colon notation.</returns>
public IEnumerable<QueryBreakdown> WhereUseSemiStructuredData()
{
return _queryBreakdowns.Where(q =>
{
var sql = q.GetSql().ToUpperInvariant();
// Check for JSON functions or colon notation used in semi-structured data
return sql.Contains("JSON_") ||
sql.Contains("OBJECT_") ||
sql.Contains("ARRAY_") ||
sql.Contains("FLATTEN(") ||
sql.Contains(":VALUE") ||
sql.Contains(":NAME") ||
sql.Contains(":TYPE");
});
}
/// <summary>
/// Filters queries that use Snowflake-specific parameter syntax (:param or @param).
/// </summary>
/// <param name="parameterName">The parameter name (with or without : or @).</param>
/// <returns>Query breakdowns using the specified Snowflake parameter.</returns>
public IEnumerable<QueryBreakdown> WhereUseSnowflakeParameter(string parameterName)
{
if (string.IsNullOrWhiteSpace(parameterName))
{
throw new ArgumentNullException(nameof(parameterName));
}
// Normalize parameter name (remove : or @)
var cleanName = parameterName.TrimStart(':', '@');
return _queryBreakdowns.Where(q =>
{
var sql = q.GetSql();
return sql.Contains($":{cleanName}", StringComparison.OrdinalIgnoreCase) ||
sql.Contains($"@{cleanName}", StringComparison.OrdinalIgnoreCase);
});
}
/// <summary>
/// Filters queries that use Snowflake time travel features.
/// </summary>
/// <remarks>
/// Detects use of BEFORE, AT, or MATCH_CONDITION clauses for time travel queries.
/// </remarks>
/// <returns>Query breakdowns using time travel syntax.</returns>
public IEnumerable<QueryBreakdown> WhereUseTimeTravelFeature()
{
return _queryBreakdowns.Where(q =>
{
var sql = q.GetSql().ToUpperInvariant();
return sql.Contains("BEFORE (") ||
sql.Contains("AT (") ||
sql.Contains("MATCH_CONDITION");
});
}
/// <summary>
/// Filters queries that use Snowflake functions (PARSE_JSON, OBJECT_INSERT, ARRAY, etc.).
/// </summary>
/// <returns>Query breakdowns using Snowflake-specific functions.</returns>
public IEnumerable<QueryBreakdown> WhereUseSnowflakeFunctions()
{
return _queryBreakdowns.Where(q =>
{
var sql = q.GetSql().ToUpperInvariant();
var snowflakeFunctions = new[]
{
"PARSE_JSON", "OBJECT_INSERT", "ARRAY_CONSTRUCT", "ARRAY_AGG",
"FLATTEN", "GET_PATH", "TRY_PARSE_JSON", "JSON_EXTRACT_PATH_TEXT",
"JSON_EXTRACT_PATH_WITH_DEFAULT", "HASHAGGREGATE", "LISTAGG",
"APPROX_COUNT_DISTINCT", "APPROX_PERCENTILE", "GREATEST", "LEAST",
"NULLIF", "ZEROIFNULL", "STRTOK", "SPLIT_PART", "PIVOT", "UNPIVOT"
};
return snowflakeFunctions.Any(func => sql.Contains(func));
});
}
/// <summary>
/// Filters queries that reference temporary or dynamic tables.
/// </summary>
/// <returns>Query breakdowns using temporary tables.</returns>
public IEnumerable<QueryBreakdown> WhereUseTemporaryTables()
{
return _queryBreakdowns.Where(q =>
{
var sql = q.GetSql().ToUpperInvariant();
return sql.Contains("TEMPORARY TABLE") ||
sql.Contains("TEMP TABLE") ||
sql.Contains("CREATE TEMP ") ||
sql.Contains("DYNAMIC TABLE");
});
}
/// <summary>
/// Filters queries that reference external tables or stages.
/// </summary>
/// <returns>Query breakdowns using external data sources.</returns>
public IEnumerable<QueryBreakdown> WhereUseExternalData()
{
return _queryBreakdowns.Where(q =>
{
var sql = q.GetSql().ToUpperInvariant();
return sql.Contains("EXTERNAL TABLE") ||
sql.Contains(" FROM @") ||
sql.Contains("COPY INTO @");
});
}
/// <summary>
/// Filters queries by the SELECT clause content using Snowflake's format.
/// </summary>
/// <param name="selectContains">The text to find in the SELECT clause.</param>
/// <returns>Filtered query breakdowns.</returns>
public IEnumerable<QueryBreakdown> WhereSelectContains(string selectContains)
{
if (string.IsNullOrWhiteSpace(selectContains))
{
throw new ArgumentNullException(nameof(selectContains));
}
return _queryBreakdowns.Where(q =>
q.SelectClause?.Clause?.Contains(selectContains, StringComparison.OrdinalIgnoreCase) ?? false);
}
/// <summary>
/// Filters queries that reference specific tables or schemas.
/// </summary>
/// <param name="tableNameContains">The table name or schema pattern to find.</param>
/// <returns>Filtered query breakdowns.</returns>
public IEnumerable<QueryBreakdown> WhereTableContains(string tableNameContains)
{
if (string.IsNullOrWhiteSpace(tableNameContains))
{
throw new ArgumentNullException(nameof(tableNameContains));
}
return _queryBreakdowns.Where(q =>
q.FromClause?.Clause?.Contains(tableNameContains, StringComparison.OrdinalIgnoreCase) ?? false);
}
/// <summary>
/// Filters queries that have WHERE clauses.
/// </summary>
/// <returns>Query breakdowns with WHERE clauses.</returns>
public IEnumerable<QueryBreakdown> WhereHaveWhereClause()
{
return _queryBreakdowns.Where(q =>
!string.IsNullOrWhiteSpace(q.WhereClause?.Clause));
}
/// <summary>
/// Filters queries without WHERE clauses (potentially risky for full table scans).
/// </summary>
/// <returns>Query breakdowns without WHERE clauses.</returns>
public IEnumerable<QueryBreakdown> WhereHaveNoWhereClause()
{
return _queryBreakdowns.Where(q =>
string.IsNullOrWhiteSpace(q.WhereClause?.Clause));
}
/// <summary>
/// Filters queries that have GROUP BY clauses.
/// </summary>
/// <returns>Query breakdowns with GROUP BY clauses.</returns>
public IEnumerable<QueryBreakdown> WhereHaveGroupByClause()
{
return _queryBreakdowns.Where(q =>
!string.IsNullOrWhiteSpace(q.GroupByClause?.Clause));
}
/// <summary>
/// Filters queries that have ORDER BY clauses.
/// </summary>
/// <returns>Query breakdowns with ORDER BY clauses.</returns>
public IEnumerable<QueryBreakdown> WhereHaveOrderByClause()
{
return _queryBreakdowns.Where(q =>
!string.IsNullOrWhiteSpace(q.OrderByClause?.Clause));
}
/// <summary>
/// Gets a comprehensive analysis of all queries in the collection.
/// </summary>
/// <returns>Analysis summary for each query.</returns>
public IEnumerable<SnowflakeQueryAnalysis> AnalyzeQueries()
{
return _queryBreakdowns.Select((q, index) => new SnowflakeQueryAnalysis
{
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),
HasOrderByClause = !string.IsNullOrWhiteSpace(q.OrderByClause?.Clause),
UsesSemiStructuredData = UseSemiStructuredData(q),
UsesStageReference = UsesStageReference(q),
UsesTimeTravelFeature = UsesTimeTravelFeature(q),
UsesSnowflakeFunctions = UsesSnowflakeFunctions(q),
UsesExternalData = UsesExternalData(q),
ColumnCount = !string.IsNullOrWhiteSpace(q.SelectClause?.Clause) ? q.SelectClause.Clause.Split(',').Length : 0,
ParameterCount = q.ParameterList.Count()
});
}
/// <summary>
/// Gets the combined Snowflake SQL from all breakdowns.
/// </summary>
/// <param name="includeSetupFinish">Whether to include setup and finish clauses.</param>
/// <returns>The combined Snowflake SQL.</returns>
public string GetCombinedSql(bool includeSetupFinish = true)
{
return GetSnowflakeBatch(includeSetupFinish);
}
/// <summary>
/// Helper method to check if a query uses semi-structured data.
/// </summary>
private static bool UseSemiStructuredData(QueryBreakdown query)
{
var sql = query.GetSql().ToUpperInvariant();
return sql.Contains("JSON_") ||
sql.Contains("OBJECT_") ||
sql.Contains("ARRAY_") ||
sql.Contains("FLATTEN(");
}
/// <summary>
/// Helper method to check if a query uses stage references.
/// </summary>
private static bool UsesStageReference(QueryBreakdown query)
{
return query.GetSql().Contains("@") &&
(query.GetSql().Contains("FROM @") || query.GetSql().Contains(" @"));
}
/// <summary>
/// Helper method to check if a query uses time travel features.
/// </summary>
private static bool UsesTimeTravelFeature(QueryBreakdown query)
{
var sql = query.GetSql().ToUpperInvariant();
return sql.Contains("BEFORE (") ||
sql.Contains("AT (") ||
sql.Contains("MATCH_CONDITION");
}
/// <summary>
/// Helper method to check if a query uses Snowflake-specific functions.
/// </summary>
private static bool UsesSnowflakeFunctions(QueryBreakdown query)
{
var sql = query.GetSql().ToUpperInvariant();
var snowflakeFunctions = new[]
{
"PARSE_JSON", "OBJECT_INSERT", "ARRAY_CONSTRUCT", "FLATTEN",
"LISTAGG", "APPROX_COUNT_DISTINCT", "HASH", "ZEROIFNULL"
};
return snowflakeFunctions.Any(func => sql.Contains(func));
}
/// <summary>
/// Helper method to check if a query uses external data.
/// </summary>
private static bool UsesExternalData(QueryBreakdown query)
{
var sql = query.GetSql().ToUpperInvariant();
return sql.Contains("EXTERNAL TABLE") ||
sql.Contains(" FROM @") ||
sql.Contains("COPY INTO @");
}
/// <summary>
/// Synchronizes parameter values across all queries in the collection.
/// Ensures that if a parameter with the same name exists in multiple queries, they all have the same value.
/// </summary>
public void SynchronizeParameters()
{
// Get all unique parameter names across all queries
var allParameterNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var query in _queryBreakdowns)
{
foreach (var paramName in query.Parameters.Keys)
{
allParameterNames.Add(paramName);
}
}
// For each parameter, use the last query's value and sync to all queries that have it
foreach (var paramName in allParameterNames)
{
object? lastValue = null;
bool parameterFound = false;
// Find the last query that has this parameter and get its value
for (int i = _queryBreakdowns.Count - 1; i >= 0; i--)
{
if (_queryBreakdowns[i].Parameters.ContainsKey(paramName))
{
lastValue = _queryBreakdowns[i].Parameters[paramName];
parameterFound = true;
break;
}
}
// Synchronize the parameter value to all queries that have it
if (parameterFound)
{
foreach (var query in _queryBreakdowns.Where(q => q.Parameters.ContainsKey(paramName)))
{
query.Parameters[paramName] = lastValue!;
}
}
}
}
/// <summary>
/// Adds a parameter with a specific value to all queries in the collection.
/// </summary>
/// <param name="parameterName">The name of the parameter (without the : or @ prefix).</param>
/// <param name="value">The value to assign to the parameter. Can be null.</param>
public void AddParameterToAll(string parameterName, object? value)
{
if (string.IsNullOrWhiteSpace(parameterName))
{
throw new ArgumentException("Parameter name cannot be null or empty.", nameof(parameterName));
}
foreach (var query in _queryBreakdowns)
{
query.Parameters[parameterName] = value!;
}
}
/// <summary>
/// Gets all unique parameters from all queries in the collection as a combined dictionary.
/// </summary>
/// <returns>A dictionary containing all unique parameters across all queries.</returns>
protected Dictionary<string, object?> GetCombinedParameterDictionary()
{
var combinedParams = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var query in _queryBreakdowns)
{
// Add from ParameterList (parsed parameters)
foreach (var param in query.ParameterList)
{
combinedParams[param.Name] = param.Value;
}
// Add/override from Parameters dictionary (manually added parameters)
foreach (var param in query.Parameters)
{
combinedParams[param.Key] = param.Value;
}
}
return combinedParams;
}
/// <summary>
/// Gets a formatted string representation of all unique parameters with Snowflake-specific syntax.
/// </summary>
/// <param name="includeDataTypes">If true, includes Snowflake data types in the output format.</param>
/// <returns>
/// A formatted string such as ":paramName = value" or ":paramName = value -- VARIANT"
/// for each unique parameter.
/// </returns>
public string GetParametersAsString(bool includeDataTypes = false)
{
var parameters = GetCombinedParameterDictionary();
if (parameters.Count == 0)
{
return string.Empty;
}
var sb = new StringBuilder();
var isFirst = true;
foreach (var param in parameters.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
if (!isFirst)
{
sb.AppendLine(",");
}
sb.Append($":{param.Key} = {FormatParameterValue(param.Value)}");
if (includeDataTypes)
{
var dataType = GetSnowflakeDataType(param.Value);
sb.Append($" -- {dataType}");
}
isFirst = false;
}
return sb.ToString();
}
/// <summary>
/// Gets a detailed usage report for all parameters across the queries in the collection.
/// </summary>
/// <returns>An enumerable of ParameterUsageReport objects with usage statistics.</returns>
/// <summary>
/// Gets a report of parameter usage 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>
/// Gets the total number of columns selected 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 a summary of all queries including their types and basic composition.
/// </summary>
/// <returns>Summary information for each query.</returns>
public IEnumerable<SnowflakeQueryAnalysis> GetQuerySummaries()
{
var stageQueries = WhereUseStageReference().ToHashSet();
var semiStructuredQueries = WhereUseSemiStructuredData().ToHashSet();
return _queryBreakdowns.Select((q, index) => new SnowflakeQueryAnalysis
{
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),
HasOrderByClause = !string.IsNullOrWhiteSpace(q.OrderByClause?.Clause),
HasCTE = q.WithClauses.Count > 0,
UsesSemiStructuredData = semiStructuredQueries.Contains(q),
UsesStageReference = stageQueries.Contains(q),
UsesTimeTravelFeature = UsesTimeTravelFeature(q),
UsesSnowflakeFunctions = false,
UsesExternalData = false,
ColumnCount = !string.IsNullOrWhiteSpace(q.SelectClause?.Clause) ? q.SelectClause.Clause.Split(',').Length : 0,
ParameterCount = q.ParameterList.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;
}
var parts = fromClause.Split(',');
foreach (var part in parts)
{
var trimmed = part.Trim();
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>
/// Helper method to convert a .NET object to its corresponding Snowflake data type string.
/// </summary>
private static string GetSnowflakeDataType(object? value)
{
return value switch
{
null => "VARIANT",
bool => "BOOLEAN",
byte or sbyte or short or ushort or int or uint or long or ulong => "NUMBER",
float or double or decimal => "NUMBER",
DateTime or DateTimeOffset => "TIMESTAMP_NTZ",
TimeSpan => "TIME",
string => value.ToString()!.Length > 255 ? "VARCHAR(MAX)" : "VARCHAR(255)",
byte[] => "BINARY",
_ => "VARIANT"
};
}
/// <summary>
/// Helper method to format a parameter value for safe inclusion in Snowflake SQL statements.
/// </summary>
private static string FormatParameterValue(object? value)
{
return value switch
{
null => "NULL",
bool b => b ? "TRUE" : "FALSE",
string s => $"'{s.Replace("'", "''")}'",
DateTime dt => $"'{dt:yyyy-MM-dd HH:mm:ss}'",
DateTimeOffset dto => $"'{dto:yyyy-MM-dd HH:mm:ss}'",
_ => value.ToString() ?? "NULL"
};
}
}
/// <summary>
/// Analysis information about a Snowflake query.
/// </summary>
public class SnowflakeQueryAnalysis
{
/// <summary>
/// Gets or sets the index of the query in the collection.
/// </summary>
public int Index { get; set; }
/// <summary>
/// Gets or sets whether the query has a SELECT clause.
/// </summary>
public bool HasSelectClause { get; set; }
/// <summary>
/// Gets or sets whether the query has a FROM clause.
/// </summary>
public bool HasFromClause { get; set; }
/// <summary>
/// Gets or sets whether the query has a WHERE clause.
/// </summary>
public bool HasWhereClause { get; set; }
/// <summary>
/// Gets or sets whether the query has a GROUP BY clause.
/// </summary>
public bool HasGroupByClause { get; set; }
/// <summary>
/// Gets or sets whether the query has an ORDER BY clause.
/// </summary>
public bool HasOrderByClause { get; set; }
/// <summary>
/// Gets or sets whether the query has Common Table Expressions (CTEs).
/// </summary>
public bool HasCTE { get; set; }
/// <summary>
/// Gets or sets whether the query uses semi-structured data functions.
/// </summary>
public bool UsesSemiStructuredData { get; set; }
/// <summary>
/// Gets or sets whether the query references Snowflake stages.
/// </summary>
public bool UsesStageReference { get; set; }
/// <summary>
/// Gets or sets whether the query uses Snowflake time travel features.
/// </summary>
public bool UsesTimeTravelFeature { get; set; }
/// <summary>
/// Gets or sets whether the query uses Snowflake-specific functions.
/// </summary>
public bool UsesSnowflakeFunctions { get; set; }
/// <summary>
/// Gets or sets whether the query uses external data sources.
/// </summary>
public bool UsesExternalData { get; set; }
/// <summary>
/// Gets or sets the number of columns in the SELECT clause.
/// </summary>
public int ColumnCount { get; set; }
/// <summary>
/// Gets or sets the number of parameters used.
/// </summary>
public int ParameterCount { get; set; }
/// <summary>
/// Returns a string representation of the query analysis.
/// </summary>
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine($"Snowflake Query #{Index}");
sb.AppendLine($" Basic Structure:");
sb.AppendLine($" SELECT: {(HasSelectClause ? "Yes" : "No")} ({ColumnCount} columns)");
sb.AppendLine($" FROM: {(HasFromClause ? "Yes" : "No")}");
sb.AppendLine($" WHERE: {(HasWhereClause ? "Yes" : "No")}");
sb.AppendLine($" GROUP BY: {(HasGroupByClause ? "Yes" : "No")}");
sb.AppendLine($" ORDER BY: {(HasOrderByClause ? "Yes" : "No")}");
sb.AppendLine($" Snowflake Features:");
sb.AppendLine($" Semi-Structured Data: {(UsesSemiStructuredData ? "Yes" : "No")}");
sb.AppendLine($" Stage Reference: {(UsesStageReference ? "Yes" : "No")}");
sb.AppendLine($" Time Travel: {(UsesTimeTravelFeature ? "Yes" : "No")}");
sb.AppendLine($" Snowflake Functions: {(UsesSnowflakeFunctions ? "Yes" : "No")}");
sb.AppendLine($" External Data: {(UsesExternalData ? "Yes" : "No")}");
sb.Append($" Parameters: {ParameterCount}");
return sb.ToString();
}
}
/// <summary>
/// Reports parameter usage statistics across queries in a Snowflake collection.
/// </summary>
public class ParameterUsageReport
{
/// <summary>
/// Gets or sets the name of the parameter.
/// </summary>
public string ParameterName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the current value of the parameter.
/// </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 a value indicating whether this parameter is used in all queries.
/// </summary>
public bool IsUsedInAllQueries => UsedInQueryCount == TotalQueries && TotalQueries > 0;
/// <summary>
/// Returns a string representation of the parameter usage report.
/// </summary>
public override string ToString()
{
if (TotalQueries == 0)
{
return $"{ParameterName}: No queries";
}
var percentage = (UsedInQueryCount * 100.0) / TotalQueries;
var valueStr = Value switch
{
null => "NULL",
string s => $"'{s}'",
_ => Value.ToString() ?? "NULL"
};
return $"{ParameterName} = {valueStr} ({UsedInQueryCount}/{TotalQueries} queries - {percentage:F1}%)";
}
}
@@ -0,0 +1,90 @@
using System.Data;
using System.Data.Common;
using System.Diagnostics;
namespace Strata.SqlTools.Breakdowns.Snowflake;
/// <summary>
/// A trace listener that writes trace messages to a Snowflake database.
/// </summary>
public class TraceListener : System.Diagnostics.TraceListener
{
private readonly string _serverName;
private readonly string _traceDbConnectionString;
private readonly string _providerName;
/// <summary>
/// Initializes a new instance of the <see cref="TraceListener"/> class.
/// </summary>
/// <param name="serverName">The server name for logging.</param>
/// <param name="traceDbConnectionString">The connection string to the trace database.</param>
/// <param name="providerName">The provider name (default: "Snowflake.Data.Client").</param>
public TraceListener(string serverName, string traceDbConnectionString, string providerName = "Snowflake.Data.Client")
{
_serverName = serverName;
_traceDbConnectionString = traceDbConnectionString;
_providerName = providerName;
}
/// <summary>
/// Writes a message to the trace database.
/// </summary>
/// <param name="message">The message to write.</param>
public override void Write(string? message)
{
WriteTrace(message);
}
/// <summary>
/// Writes a message followed by a line terminator to the trace database.
/// </summary>
/// <param name="message">The message to write.</param>
public override void WriteLine(string? message)
{
WriteTrace(message);
}
/// <summary>
/// Writes a trace message to the Snowflake database.
/// </summary>
/// <param name="message">The message to write.</param>
private void WriteTrace(string? message)
{
try
{
var factory = DbProviderFactories.GetFactory(_providerName);
using var connection = factory.CreateConnection();
if (connection == null)
{
return;
}
connection.ConnectionString = _traceDbConnectionString;
connection.Open();
using var command = connection.CreateCommand();
command.CommandType = CommandType.Text;
command.CommandText = "INSERT INTO Trace (SERVER, MESSAGE) VALUES(:SERVER, :MESSAGE)";
var serverParam = command.CreateParameter();
serverParam.ParameterName = "SERVER";
serverParam.Value = _serverName;
command.Parameters.Add(serverParam);
var messageParam = command.CreateParameter();
messageParam.ParameterName = "MESSAGE";
messageParam.Value = message ?? (object)DBNull.Value;
command.Parameters.Add(messageParam);
command.ExecuteNonQuery();
}
catch (Exception)
{
if (Debugger.IsAttached)
{
Debugger.Break();
}
}
}
}
@@ -0,0 +1,215 @@
using System.Collections;
using System.Text;
using SqlServerUpdateBreakdown = Strata.SqlTools.Breakdowns.SqlServer.UpdateBreakdown;
using StatementParser = Strata.SqlTools.Statements.Snowflake.StatementParser;
namespace Strata.SqlTools.Breakdowns.Snowflake;
/// <summary>
/// Represents an UPDATE SQL statement breakdown with SET, FROM, and WHERE clauses for Snowflake.
/// </summary>
[Serializable]
public class UpdateBreakdown : SqlServerUpdateBreakdown
{
private static readonly StatementParser SnowflakeParserInstance = new StatementParser();
/// <summary>
/// Initializes a new instance of the <see cref="UpdateBreakdown"/> class.
/// </summary>
public UpdateBreakdown() : base()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="UpdateBreakdown"/> class.
/// </summary>
/// <param name="tableName">The table name.</param>
/// <param name="setClause">The SET clause.</param>
/// <param name="whereClause">The WHERE clause.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
public UpdateBreakdown(string tableName, string setClause, string whereClause, bool isMicrosoftSql = false)
: base()
{
var parser = isMicrosoftSql ? Parser : SnowflakeParserInstance;
var cleanTable = parser.ExtractSqlComments(tableName, out var tableComments);
TableName.Clause = cleanTable.Trim();
TableName.Comment = tableComments.Count > 0 ? string.Join(" ", tableComments) : null;
var cleanSet = parser.ExtractSqlComments(setClause, out var setComments);
SetClause.Clause = cleanSet.Trim();
SetClause.Comment = setComments.Count > 0 ? string.Join(" ", setComments) : null;
var cleanWhere = parser.ExtractSqlComments(whereClause, out var whereComments);
WhereClause.Clause = cleanWhere.Trim();
WhereClause.Comment = whereComments.Count > 0 ? string.Join(" ", whereComments) : null;
}
/// <summary>
/// Gets the SQL breakdown as a string for Snowflake.
/// </summary>
/// <returns>The UPDATE SQL statement.</returns>
protected override string GetSqlBreakdown()
{
var sb = new StringBuilder();
sb.AppendLine("UPDATE ");
sb.AppendLine($" {TableName.Clause}");
sb.AppendLine("SET ");
sb.AppendLine($" {SetClause.Clause}");
if (IsUsingFromClause)
{
// Snowflake supports FROM clause in UPDATE
sb.AppendLine("FROM ");
sb.AppendLine($" {FromClause.Clause}");
}
if (IsUsingWhereClause)
{
sb.AppendLine("WHERE ");
sb.AppendLine($" {WhereClause.Clause}");
}
return sb.ToString();
}
#region Parse Methods
/// <summary>
/// Parses a Snowflake UPDATE SQL statement into an UpdateBreakdown object.
/// </summary>
/// <param name="sql">The UPDATE SQL statement to parse.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules. If false, uses Snowflake parsing rules. Defaults to false.</param>
/// <returns>An UpdateBreakdown object representing the parsed statement.</returns>
/// <exception cref="ArgumentNullException">Thrown when sql is null or empty.</exception>
/// <exception cref="FormatException">Thrown when the SQL statement cannot be parsed.</exception>
public static UpdateBreakdown Parse(string sql, bool isMicrosoftSql = false)
{
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, isMicrosoftSql))
{
throw new FormatException($"Failed to parse {(isMicrosoftSql ? "T-SQL" : "Snowflake SQL")} UPDATE statement: {error}");
}
return result;
}
/// <summary>
/// Attempts to parse a Snowflake UPDATE SQL statement into an UpdateBreakdown object.
/// </summary>
/// <param name="sql">The UPDATE SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed UpdateBreakdown if successful, or null if parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out UpdateBreakdown result, bool isMicrosoftSql = false)
=> TryParse(sql, out result, out _, isMicrosoftSql);
/// <summary>
/// Attempts to parse a Snowflake UPDATE SQL statement into an UpdateBreakdown object.
/// Handles Snowflake-specific syntax.
/// </summary>
/// <param name="sql">The UPDATE SQL statement to parse.</param>
/// <param name="result">When this method returns, contains the parsed UpdateBreakdown if successful, or null if parsing failed.</param>
/// <param name="errorMessage">When this method returns false, contains a message describing why parsing failed.</param>
/// <param name="isMicrosoftSql">If true, uses Microsoft T-SQL parsing rules instead of Snowflake rules. Defaults to false.</param>
/// <returns>true if the SQL was successfully parsed; otherwise, false.</returns>
public static bool TryParse(string sql, out UpdateBreakdown result, out string errorMessage, bool isMicrosoftSql = false)
{
result = null!;
errorMessage = null!;
try
{
if (string.IsNullOrWhiteSpace(sql))
{
errorMessage = "SQL statement cannot be null or empty.";
return false;
}
// If Microsoft SQL mode, delegate to base class
if (isMicrosoftSql)
{
if (!SqlServerUpdateBreakdown.TryParse(sql, out var baseResult, out errorMessage))
{
return false;
}
// Convert to Snowflake UpdateBreakdown
result = new UpdateBreakdown
{
TableName = baseResult.TableName,
SetClause = baseResult.SetClause,
FromClause = baseResult.FromClause,
WhereClause = baseResult.WhereClause,
SetupClauses = baseResult.SetupClauses,
FinishClauses = baseResult.FinishClauses
};
return true;
}
var parser = SnowflakeParserInstance;
sql = parser.NormalizeSqlPreservingComments(sql);
// Check if it's an UPDATE statement
var sqlTrimmed = sql.TrimStart();
if (!System.Text.RegularExpressions.Regex.IsMatch(sqlTrimmed, @"^\s*UPDATE\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
errorMessage = "SQL statement must start with UPDATE.";
return false;
}
// Extract setup and finish clauses
var setupClauses = new List<string>();
sql = parser.ExtractSetupClauses(sql, setupClauses);
var finishClauses = new ArrayList();
sql = parser.ExtractFinishClauses(sql, finishClauses);
// Parse UPDATE statement - handle both with and without FROM clause
var updateMatch = System.Text.RegularExpressions.Regex.Match(sql,
@"UPDATE\s+([^\s]+)\s+SET\s+(.*?)(?:\s+FROM\s+(.*?))?(?:\s+WHERE\s+(.*))?$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
if (!updateMatch.Success)
{
errorMessage = "Could not parse UPDATE statement. Expected format: UPDATE table SET column=value [FROM table] [WHERE condition]";
return false;
}
var tableName = updateMatch.Groups[1].Value.Trim();
var setClause = updateMatch.Groups[2].Value.Trim();
var fromClause = updateMatch.Groups.Count > 3 ? updateMatch.Groups[3].Value.Trim() : string.Empty;
var whereClause = updateMatch.Groups.Count > 4 ? updateMatch.Groups[4].Value.Trim() : string.Empty;
result = new UpdateBreakdown(tableName, setClause, whereClause, isMicrosoftSql: false)
{
SetupClauses = setupClauses,
FinishClauses = finishClauses
};
if (!string.IsNullOrWhiteSpace(fromClause))
{
var cleanFrom = parser.ExtractSqlComments(fromClause, out var fromComments);
result.FromClause.Clause = cleanFrom.Trim();
result.FromClause.Comment = fromComments.Count > 0 ? string.Join(" ", fromComments) : null;
}
return true;
}
catch (Exception ex)
{
errorMessage = $"Unexpected error during parsing: {ex.Message}";
return false;
}
}
#endregion
}
@@ -0,0 +1,26 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory;
/// <summary>
/// Snowflake-specific factory class for creating boolean expressions and SQL filter conditions from Filter objects.
/// Inherits from the SQL Server implementation and extends it with Snowflake-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)
{
}
// Snowflake-specific expression methods can be added here as needed
// For example, support for Snowflake-specific date functions, parameter syntax (@param and :param), etc.
}
@@ -0,0 +1,12 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public enum AggregationType
{
Sum = 0,
Count = 1,
CountDistinct = 2,
Avg = 3,
Median = 4,
Min = 5,
Max = 6
}
@@ -0,0 +1,12 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public class CalculationFilter : Filter
{
public IEnumerable<string> AliasedDataColumnIds { get; }
public CalculationFilter(int dataColumnId, IEnumerable<string> aliasedDataColumnIds, IEnumerable<object> values, IEnumerable<FilterCondition> conditions)
: base(dataColumnId, FilterType.Conditions, values, conditions, DatePart.Continuous, false, 0, 0)
{
AliasedDataColumnIds = aliasedDataColumnIds;
}
}
@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public class CalculationFilterGroup
{
[JsonIgnore]
private IEnumerable<CalculationFilter> _filters;
// Hereditary logical operation applied to all Filters
public LogicalOperator LogicalOperator { get; set; }
public IEnumerable<CalculationFilter> Filters
{
get => _filters?.Where(x => x.IsValid()).ToList() ?? new List<CalculationFilter>();
set => _filters = value;
}
public CalculationFilterGroup()
{
LogicalOperator = LogicalOperator.And;
_filters = new List<CalculationFilter>();
}
[JsonConstructor]
public CalculationFilterGroup(IEnumerable<CalculationFilter> filters, LogicalOperator logicalOperator)
{
_filters = filters;
LogicalOperator = logicalOperator;
}
public bool IsValid()
{
return Filters != null && Filters.Any();
}
}
@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public class ColumnQueryConfig
{
public int DataColumnId { get; set; }
public DatePart DatePart { get; set; }
public Filter? Filter { get; set; }
public int RowLimit { get; set; }
[JsonConstructor]
public ColumnQueryConfig(int dataColumnId, DatePart datePart, Filter? filter, int rowLimit)
{
DataColumnId = dataColumnId;
DatePart = datePart;
Filter = filter != null && filter.IsValid() ? filter : null;
RowLimit = rowLimit;
}
}
@@ -0,0 +1,13 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public enum DatePart
{
Continuous = 0,
Year,
Quarter,
Month,
Week,
Day,
FiscalYear,
FiscalQuarter
}
@@ -0,0 +1,8 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public class Field
{
public string ColumnAlias { get; set; } = string.Empty;
public int DataColumnId { get; set; }
public DatePart DatePart { get; set; }
}
@@ -0,0 +1,91 @@
using System.Text.Json.Serialization;
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
/// <summary>
/// Represents a filter criteria for querying data with support for various filter types including lists, date ranges, and timeframes.
/// Filters can be applied to specific data columns and support different date granularities.
/// </summary>
public class Filter
{
/// <summary>
/// Gets the identifier of the data column to which this filter applies.
/// </summary>
public int DataColumnId { get; }
/// <summary>
/// Gets the type of filter being applied (e.g., List, Calendar, Timeframe).
/// </summary>
public FilterType FilterType { get; }
/// <summary>
/// Gets the collection of values to filter by. The interpretation depends on the <see cref="FilterType"/>.
/// </summary>
public IEnumerable<object> Values { get; }
/// <summary>
/// Gets the collection of filter conditions that define complex filtering logic.
/// Only valid conditions are retained.
/// </summary>
public IEnumerable<FilterCondition> Conditions { get; }
/// <summary>
/// Gets the date granularity part for date-based filtering (e.g., Year, Month, Day, FiscalYear).
/// </summary>
public DatePart DatePart { get; }
/// <summary>
/// Gets a value indicating whether to use NOT IN instead of IN for list-type filters.
/// Only applies when <see cref="FilterType"/> is List.
/// </summary>
public bool ListUseNotIn { get; }
/// <summary>
/// Gets the offset from the current time for timeframe-based filters.
/// Used in conjunction with <see cref="DateTimeFrameCount"/> to define relative time periods.
/// </summary>
public int DateTimeFrameOffset { get; }
/// <summary>
/// Gets the zero-based number of time increments from the offset.
/// A value of 0 means current period, -1 means one period backward, and 1 means one period forward.
/// The unit (year, month, day, etc.) is determined by the <see cref="DatePart"/> property.
/// </summary>
public int DateTimeFrameCount { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Filter"/> class with the specified filter criteria.
/// </summary>
/// <param name="dataColumnId">The identifier of the data column to filter.</param>
/// <param name="filterType">The type of filter to apply.</param>
/// <param name="values">The collection of values for the filter.</param>
/// <param name="conditions">The collection of filter conditions (invalid conditions are automatically removed).</param>
/// <param name="datePart">The date granularity for date-based filtering.</param>
/// <param name="listUseNotIn">Whether to use NOT IN for list filters; false to use IN.</param>
/// <param name="dateTimeFrameOffset">The offset from current time for timeframe filters.</param>
/// <param name="dateTimeFrameCount">The number of time increments from the offset (0 = current, negative = past, positive = future).</param>
[JsonConstructor]
public Filter(int dataColumnId, FilterType filterType, IEnumerable<object> values, IEnumerable<FilterCondition> conditions, DatePart datePart, bool listUseNotIn, int dateTimeFrameOffset, int dateTimeFrameCount)
{
DataColumnId = dataColumnId;
FilterType = filterType;
Values = values;
Conditions = conditions.Where(x => x.IsValid()).ToList();
DatePart = datePart;
ListUseNotIn = listUseNotIn;
DateTimeFrameOffset = dateTimeFrameOffset;
DateTimeFrameCount = dateTimeFrameCount;
}
/// <summary>
/// Determines whether this filter has valid criteria that can be applied.
/// A filter is valid if it has values, conditions, or non-default timeframe settings.
/// </summary>
/// <returns>
/// <c>true</c> if the filter has values, conditions, or timeframe settings; otherwise, <c>false</c>.
/// </returns>
public bool IsValid()
{
return (Values != null && Values.Any()) || (Conditions != null && Conditions.Any()) || (DateTimeFrameCount != default || DateTimeFrameOffset != default);
}
}
@@ -0,0 +1,17 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public class FilterCondition
{
public FilterOperator Operator { get; set; }
public IEnumerable<object>? Values { get; set; }
// This is not hereditary to Values; it is used for combination with the next FilterCondition in the set
// todo: That could be indexed to ensure accuracy
public LogicalOperator LogicalOperator { get; set; }
public bool IsValid()
{
return Values != null && Values.Any();
}
}
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public class FilterGroup
{
// Hereditary logical operation applied to all Filters
public LogicalOperator LogicalOperator { get; set; }
public IEnumerable<Filter> Filters { get; }
public FilterGroup()
{
LogicalOperator = LogicalOperator.And;
Filters = new List<Filter>();
}
[JsonConstructor]
public FilterGroup(IEnumerable<Filter> filters, LogicalOperator logicalOperator)
{
Filters = filters.Where(x => x.IsValid()).ToList();
LogicalOperator = logicalOperator;
}
public bool IsValid()
{
return Filters != null && Filters.Any();
}
}
@@ -0,0 +1,15 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public enum FilterOperator
{
Equals = 0,
NotEquals = 1,
LessThan = 2,
LessThanOrEqualTo = 3,
GreaterThan = 4,
GreaterThanOrEqualTo = 5,
Between = 6, // this is a function, not a comparison - x BETWEEN a AND b is the same as: x >= a AND x <= z
Contains = 7,
StartsWith = 8,
EndsWith = 9
}
@@ -0,0 +1,9 @@
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public enum FilterType
{
List = 0,
Conditions = 1,
Calendar = 2,
Timeframe = 3
}
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public enum LogicalOperator
{
[Display(Name = "and")]
And,
[Display(Name = "or")]
Or
}
public static class LogicalOperatorExtensions
{
public static string ToSql(this LogicalOperator logicalOperator, bool withSpaces = true)
{
var sql = "";
switch (logicalOperator)
{
case LogicalOperator.And:
sql = "and";
break;
case LogicalOperator.Or:
sql = "or";
break;
}
return withSpaces ? $" {sql} " : sql;
}
}
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace Strata.SqlTools.Snowflake.ExpressionFactory.Query;
public class QueryConfig
{
public IEnumerable<Row> Rows { get; set; }
public IEnumerable<Value> Values { get; set; }
public IEnumerable<FilterGroup> FilterGroups { get; }
public bool WithTotals { get; set; }
public int RowLimit { get; set; }
public QueryConfig()
{
Rows = new List<Row>();
Values = new List<Value>();
FilterGroups = new List<FilterGroup>();
}
[JsonConstructor]
public QueryConfig(IEnumerable<FilterGroup> filterGroups, IEnumerable<Row> rows, IEnumerable<Value> values, bool withTotals, int rowLimit)
{
FilterGroups = filterGroups.Where(x => x.IsValid()).ToList();
Rows = rows;
Values = values;
WithTotals = withTotals;
RowLimit = rowLimit;
}
}

Some files were not shown because too many files have changed in this diff Show More