mcpField
D
dotnet-cqrs-query-generator

dotnet-cqrs-query-generator

github.com/ronnythedev開発7764 回インストール15 週間前に更新
リポジトリ

dotnet-cqrs-query-generator について

.NETアプリケーションでCQRSの読み取りクエリを生成するスキルです。アプリケーション開発者向けに、DapperによるSQL、DTO応答、入力検証のほか、ページングや結合、レポートのパターンを扱います。

主な機能

  1. 01読み取り専用CQRSクエリのテンプレート
  2. 02Dapperと直接SQLのパターン
  3. 03DTO応答モデル
  4. 04ページングと検索クエリ
  5. 05結合および集計レポートのパターン

活用シーン

  1. 01ID指定の読み取りエンドポイント作成
  2. 02ページングまたは絞り込みリストの構築
  3. 03結合した詳細情報やレポートの生成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-cqrs-query-generator

インストール後、Claude Code で/dotnet-cqrs-query-generatorを入力して読み込まれたか確認します。

詳細

CQRS クエリジェネレーター

概要

このスキルは、CQRS パターンに従ったクエリを生成します。クエリは、状態を変更せずにデータを返す読み取り専用の操作です。主な原則は次のとおりです。

  • クエリで状態を変更しない - 読み取り専用の操作
  • 読み取りには Dapper を使用する - パフォーマンス向上のために EF Core を介さない
  • エンティティではなく DTO を返す - レスポンスモデルに投影する
  • SQL クエリを直接記述する - 特定のユースケースに合わせて最適化する

クイックリファレンス

クエリの種類ユースケース戻り値
GetByIdID による単一エンティティの取得Result<EntityResponse>
GetAllすべてのエンティティ(任意のフィルタリング付き)Result<IReadOnlyList<EntityResponse>>
GetPagedページ分割されたリストResult<PagedList<EntityResponse>>
Searchフィルタリング/検索結果Result<IReadOnlyList<EntityResponse>>
Existsエンティティの存在確認Result<bool>

クエリの構造

/Application/{Feature}/
├── Get{Entity}ById/
│   ├── Get{Entity}ByIdQuery.cs       # Query + Validator + Handler
│   └── {Entity}Response.cs            # Response DTO
├── GetAll{Entities}/
│   ├── GetAll{Entities}Query.cs
│   └── {Entity}ListResponse.cs
└── Get{Entities}ByOrganization/
    ├── Get{Entities}ByOrganizationQuery.cs
    └── {Entity}ByOrganizationResponse.cs

テンプレート:ID による取得クエリ

// src/{name}.application/{Feature}/Get{Entity}ById/Get{Entity}ByIdQuery.cs
using System.Data;
using Dapper;
using FluentValidation;
using {name}.application.abstractions.data;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;
using {name}.domain.{entities};

namespace {name}.application.{feature}.Get{Entity}ById;

// ═══════════════════════════════════════════════════════════════
// QUERY RECORD
// ═══════════════════════════════════════════════════════════════
public sealed record Get{Entity}ByIdQuery(Guid Id) : IQuery<{Entity}Response>;

// ═══════════════════════════════════════════════════════════════
// VALIDATOR
// ═══════════════════════════════════════════════════════════════
internal sealed class Get{Entity}ByIdQueryValidator : AbstractValidator<Get{Entity}ByIdQuery>
{
    public Get{Entity}ByIdQueryValidator()
    {
        RuleFor(x => x.Id)
            .NotEmpty()
            .WithMessage("{Entity} ID is required");
    }
}

// ═══════════════════════════════════════════════════════════════
// HANDLER
// ═══════════════════════════════════════════════════════════════
internal sealed class Get{Entity}ByIdQueryHandler 
    : IQueryHandler<Get{Entity}ByIdQuery, {Entity}Response>
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public Get{Entity}ByIdQueryHandler(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public async Task<Result<{Entity}Response>> Handle(
        Get{Entity}ByIdQuery request,
        CancellationToken cancellationToken)
    {
        using IDbConnection connection = _sqlConnectionFactory.CreateConnection();

        const string sql = """
            SELECT 
                e.id AS Id,
                e.name AS Name,
                e.description AS Description,
                e.created_at AS CreatedAt,
                e.updated_at AS UpdatedAt
            FROM {table_name} e
            WHERE e.id = @Id
            """;

        var {entity} = await connection.QueryFirstOrDefaultAsync<{Entity}Response>(
            sql,
            new { request.Id });

        if ({entity} is null)
        {
            return Result.Failure<{Entity}Response>({Entity}Errors.NotFound);
        }

        return {entity};
    }
}
レスポンス DTO
// src/{name}.application/{Feature}/Get{Entity}ById/{Entity}Response.cs
namespace {name}.application.{feature}.Get{Entity}ById;

public sealed class {Entity}Response
{
    public Guid Id { get; init; }
    public required string Name { get; init; }
    public string? Description { get; init; }
    public DateTime CreatedAt { get; init; }
    public DateTime UpdatedAt { get; init; }
}

テンプレート:全件取得クエリ

// src/{name}.application/{Feature}/GetAll{Entities}/GetAll{Entities}Query.cs
using System.Data;
using Dapper;
using {name}.application.abstractions.data;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;

namespace {name}.application.{feature}.GetAll{Entities};

public sealed record GetAll{Entities}Query : IQuery<IReadOnlyList<{Entity}ListResponse>>;

internal sealed class GetAll{Entities}QueryHandler 
    : IQueryHandler<GetAll{Entities}Query, IReadOnlyList<{Entity}ListResponse>>
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public GetAll{Entities}QueryHandler(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public async Task<Result<IReadOnlyList<{Entity}ListResponse>>> Handle(
        GetAll{Entities}Query request,
        CancellationToken cancellationToken)
    {
        using IDbConnection connection = _sqlConnectionFactory.CreateConnection();

        const string sql = """
            SELECT 
                e.id AS Id,
                e.name AS Name,
                e.description AS Description
            FROM {table_name} e
            ORDER BY e.name ASC
            """;

        var {entities} = await connection.QueryAsync<{Entity}ListResponse>(sql);

        return {entities}.ToList();
    }
}

テンプレート:親 ID による取得クエリ

// src/{name}.application/{Feature}/Get{Entities}ByOrganizationId/Get{Entities}ByOrganizationIdQuery.cs
using System.Data;
using Dapper;
using FluentValidation;
using {name}.application.abstractions.data;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;

namespace {name}.application.{feature}.Get{Entities}ByOrganizationId;

public sealed record Get{Entities}ByOrganizationIdQuery(
    Guid OrganizationId) : IQuery<IReadOnlyList<{Entity}Response>>;

internal sealed class Get{Entities}ByOrganizationIdQueryValidator 
    : AbstractValidator<Get{Entities}ByOrganizationIdQuery>
{
    public Get{Entities}ByOrganizationIdQueryValidator()
    {
        RuleFor(x => x.OrganizationId).NotEmpty();
    }
}

internal sealed class Get{Entities}ByOrganizationIdQueryHandler 
    : IQueryHandler<Get{Entities}ByOrganizationIdQuery, IReadOnlyList<{Entity}Response>>
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public Get{Entities}ByOrganizationIdQueryHandler(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public async Task<Result<IReadOnlyList<{Entity}Response>>> Handle(
        Get{Entities}ByOrganizationIdQuery request,
        CancellationToken cancellationToken)
    {
        using IDbConnection connection = _sqlConnectionFactory.CreateConnection();

        const string sql = """
            SELECT 
                e.id AS Id,
                e.name AS Name,
                e.description AS Description,
                e.organization_id AS OrganizationId,
                o.name AS OrganizationName
            FROM {table_name} e
            INNER JOIN organization o ON e.organization_id = o.id
            WHERE e.organization_id = @OrganizationId
            ORDER BY e.name ASC
            """;

        var {entities} = await connection.QueryAsync<{Entity}Response>(
            sql,
            new { request.OrganizationId });

        return {entities}.ToList();
    }
}

テンプレート:ページ分割クエリ

// src/{name}.application/{Feature}/Get{Entities}Paged/Get{Entities}PagedQuery.cs
using System.Data;
using Dapper;
using FluentValidation;
using {name}.application.abstractions.data;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;

namespace {name}.application.{feature}.Get{Entities}Paged;

public sealed record Get{Entities}PagedQuery(
    int PageNumber,
    int PageSize,
    string? SearchTerm = null) : IQuery<PagedList<{Entity}Response>>;

internal sealed class Get{Entities}PagedQueryValidator 
    : AbstractValidator<Get{Entities}PagedQuery>
{
    public Get{Entities}PagedQueryValidator()
    {
        RuleFor(x => x.PageNumber).GreaterThan(0);
        RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
    }
}

internal sealed class Get{Entities}PagedQueryHandler 
    : IQueryHandler<Get{Entities}PagedQuery, PagedList<{Entity}Response>>
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public Get{Entities}PagedQueryHandler(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public async Task<Result<PagedList<{Entity}Response>>> Handle(
        Get{Entities}PagedQuery request,
        CancellationToken cancellationToken)
    {
        using IDbConnection connection = _sqlConnectionFactory.CreateConnection();

        var offset = (request.PageNumber - 1) * request.PageSize;
        var searchPattern = request.SearchTerm is not null 
            ? $"%{request.SearchTerm}%" 
            : null;

        const string countSql = """
            SELECT COUNT(*)
            FROM {table_name} e
            WHERE (@SearchTerm IS NULL OR e.name ILIKE @SearchTerm)
            """;

        const string dataSql = """
            SELECT 
                e.id AS Id,
                e.name AS Name,
                e.description AS Description,
                e.created_at AS CreatedAt
            FROM {table_name} e
            WHERE (@SearchTerm IS NULL OR e.name ILIKE @SearchTerm)
            ORDER BY e.created_at DESC
            OFFSET @Offset ROWS
            FETCH NEXT @PageSize ROWS ONLY
            """;

        var totalCount = await connection.ExecuteScalarAsync<int>(
            countSql,
            new { SearchTerm = searchPattern });

        var items = await connection.QueryAsync<{Entity}Response>(
            dataSql,
            new 
            { 
                SearchTerm = searchPattern,
                Offset = offset,
                request.PageSize 
            });

        return new PagedList<{Entity}Response>(
            items.ToList(),
            request.PageNumber,
            request.PageSize,
            totalCount);
    }
}

// Shared paged list model
public sealed class PagedList<T>
{
    public IReadOnlyList<T> Items { get; }
    public int PageNumber { get; }
    public int PageSize { get; }
    public int TotalCount { get; }
    public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
    public bool HasPreviousPage => PageNumber > 1;
    public bool HasNextPage => PageNumber < TotalPages;

    public PagedList(IReadOnlyList<T> items, int pageNumber, int pageSize, int totalCount)
    {
        Items = items;
        PageNumber = pageNumber;
        PageSize = pageSize;
        TotalCount = totalCount;
    }
}

テンプレート:複数マッピングを使用するクエリ(結合)

// src/{name}.application/{Feature}/Get{Entity}WithDetails/Get{Entity}WithDetailsQuery.cs
using System.Data;
using Dapper;
using FluentValidation;
using {name}.application.abstractions.data;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;

namespace {name}.application.{feature}.Get{Entity}WithDetails;

public sealed record Get{Entity}WithDetailsQuery(
    Guid Id) : IQuery<{Entity}DetailResponse>;

internal sealed class Get{Entity}WithDetailsQueryHandler 
    : IQueryHandler<Get{Entity}WithDetailsQuery, {Entity}DetailResponse>
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public Get{Entity}WithDetailsQueryHandler(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public async Task<Result<{Entity}DetailResponse>> Handle(
        Get{Entity}WithDetailsQuery request,
        CancellationToken cancellationToken)
    {
        using IDbConnection connection = _sqlConnectionFactory.CreateConnection();

        const string sql = """
            SELECT 
                e.id AS Id,
                e.name AS Name,
                e.description AS Description,
                c.id AS ChildId,
                c.name AS ChildName,
                c.sort_order AS ChildSortOrder
            FROM {table_name} e
            LEFT JOIN {child_table} c ON c.{entity}_id = e.id
            WHERE e.id = @Id
            ORDER BY c.sort_order ASC
            """;

        // Dictionary to track parent entity for multi-mapping
        Dictionary<Guid, {Entity}DetailResponse> entityDictionary = new();

        var result = await connection.QueryAsync<{Entity}DetailResponse, {Child}Response, {Entity}DetailResponse>(
            sql,
            (entity, child) =>
            {
                if (!entityDictionary.TryGetValue(entity.Id, out var existingEntity))
                {
                    existingEntity = entity;
                    entityDictionary.Add(entity.Id, existingEntity);
                }

                if (child is not null)
                {
                    existingEntity.Children.Add(child);
                }

                return existingEntity;
            },
            new { request.Id },
            splitOn: "ChildId");

        var {entity} = entityDictionary.Values.FirstOrDefault();

        if ({entity} is null)
        {
            return Result.Failure<{Entity}DetailResponse>({Entity}Errors.NotFound);
        }

        return {entity};
    }
}

// Response with nested children
public sealed class {Entity}DetailResponse
{
    public Guid Id { get; init; }
    public required string Name { get; init; }
    public string? Description { get; init; }
    public List<{Child}Response> Children { get; init; } = new();
}

public sealed class {Child}Response
{
    public Guid ChildId { get; init; }
    public required string ChildName { get; init; }
    public int ChildSortOrder { get; init; }
}

テンプレート:集計レポートクエリ

// src/{name}.application/Reports/{ReportName}/{ReportName}Query.cs
using System.Data;
using Dapper;
using FluentValidation;
using {name}.application.abstractions.data;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;

namespace {name}.application.reports.{ReportName};

public sealed record {ReportName}Query(
    Guid OrganizationId,
    DateTime? StartDate = null,
    DateTime? EndDate = null) : IQuery<IReadOnlyList<{ReportName}Response>>;

internal sealed class {ReportName}QueryValidator : AbstractValidator<{ReportName}Query>
{
    public {ReportName}QueryValidator()
    {
        RuleFor(x => x.OrganizationId).NotEmpty();
        
        RuleFor(x => x.EndDate)
            .GreaterThan(x => x.StartDate)
            .When(x => x.StartDate.HasValue && x.EndDate.HasValue)
            .WithMessage("End date must be after start date");
    }
}

internal sealed class {ReportName}QueryHandler 
    : IQueryHandler<{ReportName}Query, IReadOnlyList<{ReportName}Response>>
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;

    public {ReportName}QueryHandler(ISqlConnectionFactory sqlConnectionFactory)
    {
        _sqlConnectionFactory = sqlConnectionFactory;
    }

    public async Task<Result<IReadOnlyList<{ReportName}Response>>> Handle(
        {ReportName}Query request,
        CancellationToken cancellationToken)
    {
        using IDbConnection connection = _sqlConnectionFactory.CreateConnection();

        const string sql = """
            WITH AggregatedData AS (
                SELECT 
                    d.id AS DepartmentId,
                    d.name AS DepartmentName,
                    COUNT(DISTINCT u.id) AS TotalUsers,
                    ROUND(AVG(a.score), 2) AS AverageScore
                FROM department d
                INNER JOIN user_department ud ON d.id = ud.department_id
                INNER JOIN app_user u ON ud.user_id = u.id
                LEFT JOIN assessment a ON a.user_id = u.id
                WHERE 
                    d.organization_id = @OrganizationId
                    AND (@StartDate IS NULL OR a.created_at >= @StartDate)
                    AND (@EndDate IS NULL OR a.created_at <= @EndDate)
                GROUP BY d.id, d.name
            )
            SELECT 
                DepartmentId,
                DepartmentName,
                TotalUsers,
                AverageScore
            FROM AggregatedData
            ORDER BY AverageScore DESC NULLS LAST
            """;

        var results = await connection.QueryAsync<{ReportName}Response>(
            sql,
            new 
            { 
                request.OrganizationId,
                request.StartDate,
                request.EndDate
            });

        return results.ToList();
    }
}

public sealed class {ReportName}Response
{
    public Guid DepartmentId { get; init; }
    public required string DepartmentName { get; init; }
    public int TotalUsers { get; init; }
    public decimal? AverageScore { get; init; }
}

SQL 接続ファクトリー

// src/{name}.application/Abstractions/Data/ISqlConnectionFactory.cs
using System.Data;

namespace {name}.application.abstractions.data;

public interface ISqlConnectionFactory
{
    IDbConnection CreateConnection();
}

// src/{name}.infrastructure/Data/SqlConnectionFactory.cs
using System.Data;
using Npgsql;
using {name}.application.abstractions.data;

namespace {name}.infrastructure.data;

internal sealed class SqlConnectionFactory : ISqlConnectionFactory
{
    private readonly string _connectionString;

    public SqlConnectionFactory(string connectionString)
    {
        _connectionString = connectionString;
    }

    public IDbConnection CreateConnection()
    {
        var connection = new NpgsqlConnection(_connectionString);
        connection.Open();
        return connection;
    }
}

SQL のベストプラクティス

カラム名(スネークケースからパスカルケースへ)
-- PostgreSQL with snake_case columns
SELECT 
    e.id AS Id,                          -- Maps to Id property
    e.first_name AS FirstName,           -- Maps to FirstName property
    e.created_at AS CreatedAt,           -- Maps to CreatedAt property
    e.organization_id AS OrganizationId  -- Maps to OrganizationId property
FROM entity e
N+1 クエリの回避
-- ❌ BAD: Separate queries for children
SELECT * FROM parent WHERE id = @Id;
-- Then for each parent:
SELECT * FROM child WHERE parent_id = @ParentId;

-- ✅ GOOD: Single query with JOIN
SELECT 
    p.id AS Id, p.name AS Name,
    c.id AS ChildId, c.name AS ChildName
FROM parent p
LEFT JOIN child c ON c.parent_id = p.id
WHERE p.id = @Id
複雑なクエリでの CTE の使用
WITH RankedItems AS (
    SELECT 
        *,
        ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY score DESC) as rank
    FROM items
),
TopItems AS (
    SELECT * FROM RankedItems WHERE rank <= 3
)
SELECT * FROM TopItems ORDER BY category_id, rank;

重要なルール

  1. クエリで状態を変更しない - 読み取り専用の操作
  2. クエリには Dapper を使用する - 読み取りでは EF Core より高いパフォーマンスが得られます
  3. エンティティではなく DTO を返す - ドメインモデルを公開しないでください
  4. パラメーター化クエリを使用する - SQL インジェクションを防止します
  5. DTO に合わせてカラムに別名を付ける - AS PropertyName を使用します
  6. 接続を必ず閉じる - using ステートメントを使用します
  7. 結合には複数マッピングを使用する - N+1 クエリを回避します
  8. クエリパラメーターを検証する - 特にページネーションの場合
  9. 複雑なロジックには CTE を使用する - ネストされたクエリより読みやすくなります
  10. null の結果を処理する - 見つからない場合は Result.Failure を返します

避けるべきアンチパターン

// ❌ WRONG: Using EF Core for read queries
public async Task<Result<EntityResponse>> Handle(...)
{
    var entity = await _dbContext.Entities
        .Include(e => e.Children)
        .FirstOrDefaultAsync(e => e.Id == request.Id);
    // Heavy, tracks changes unnecessarily
}

// ✅ CORRECT: Use Dapper
public async Task<Result<EntityResponse>> Handle(...)
{
    using var connection = _sqlConnectionFactory.CreateConnection();
    // Direct SQL, no tracking overhead
}

// ❌ WRONG: Returning domain entities
public sealed record GetEntityQuery(Guid Id) : IQuery<Entity>; // Exposes domain

// ✅ CORRECT: Return DTOs
public sealed record GetEntityQuery(Guid Id) : IQuery<EntityResponse>;

// ❌ WRONG: String concatenation in SQL
var sql = $"SELECT * FROM entity WHERE name = '{request.Name}'"; // SQL injection!

// ✅ CORRECT: Parameterized queries
var sql = "SELECT * FROM entity WHERE name = @Name";
await connection.QueryAsync(sql, new { request.Name });

関連スキル

  • dotnet-cqrs-command-generator - 書き込み側のコマンドを生成する
  • dotnet-domain-entity-generator - ドメインエンティティを生成する
  • dotnet-ef-core-configuration - 書き込み操作に EF Core を使用する
  • dotnet-result-pattern - エラー処理パターン

コメント