SQL Serverの累積残高(SUM OVER)をEF Core/LINQで再現する方法|前残高とランニングサム

SQL Server では SUM(...) OVER(ORDER BY ...) で簡単に「累積残高」を出せますが、EF Core の LINQ へそのまま移植すると詰まりがちです。本記事では「前残高+期間内明細+ランニングサム」を C# で安全に再現する手順を整理します。

目次

なぜ「SUM(…) OVER(ORDER BY …)」が LINQ でそのまま書けないのか

SQL Server のウィンドウ関数(SUM + OVER)は、行を並べた順序に対して累積計算(ランニングサム)を行える強力な仕組みです。一方、EF Core の LINQ は「SQL に翻訳できる式」だけがサーバー側で実行されます。ウィンドウ関数は一般的な LINQ 演算子(Select/GroupBy/Join など)だけでは表現しづらく、無理に 1 クエリへ押し込むと、翻訳不能エラーや意図しないクライアント評価に繋がりがちです。

そこで現実的な落とし所として、集計・抽出は SQL(=EF Core が翻訳できる LINQ)で、累積(ウィンドウ計算)は C#(foreach)で、という 2 段階に分けるのが安定します。ポイントは「DB から必要最小限の行だけを持ってくる」ことです。

ゴール:支払い+購入から残高推移を作る出力イメージ

この記事で作りたいのは、次のような「台帳」形式の一覧です。期間開始前の累計から 前残高(Previous Balance) を 1 行作り、期間内の取引(支払い/購入)を日付順に並べ、支払い − 購入 を累積して残高(Balance)を出します。

日付区分メモ購入支払い増減(支払い−購入)残高
(期間開始前)前残高前日までの累計000125,000
2025-12-01購入INV-000130,0000-30,00095,000
2025-12-03支払い入金050,00050,000145,000

以降では、この出力を EF Core + LINQ で組み立てる手順を、SQL の CTE 構造を分解する感覚で説明します。

前提となるテーブル例

購入テーブル(tbl_purchase)と支払いテーブル(tbl_payment)がある想定で話を進めます。列名は一例ですが、考え方は同じです。

-- 購入
tbl_purchase
  CompanyId      int
  Date           date/datetime
  InvoiceNo      nvarchar(...)
  Value          decimal(...)

-- 支払い
tbl_payment
  CompanyId      int
  Date           date/datetime
  PaymentAmount  decimal(...)

全体方針:処理を段階に分ける

  • 前残高を作る(期間開始前の累計)
  • 期間内の取引を整形して結合し、日付順に並べる
  • foreach で累積を計算して残高を埋める

この「分割」ができると、SQL の SUM(...) OVER(ORDER BY ...) を無理に LINQ で 1 発変換しようとしてハマるケースを避けられます。

前残高(期間開始前の累計)を作る

前残高は、期間開始日 より前(通常は 期間開始日 - 1日 まで)に発生した取引の累計です。残高の定義が 支払い − 購入 なら、前残高は次の式になります。

前残高 = 支払い合計(期間開始前) − 購入合計(期間開始前)

単一の CompanyId だけを出したい場合(最短ルート)

会社が 1 社に絞れているなら、Full Outer Join を考えずに「それぞれ別に合計して引き算」すれば十分です。空のときに 0 へ落とす(NULL 対策)だけ忘れないようにします。

var from = startDate; // 期間開始日(DateTime)
var to = endDate;     // 期間終了日(DateTime)
var companyId = targetCompanyId;

var prevPurchase = await db.tbl_purchase
    .Where(x => x.CompanyId == companyId && x.Date < from)
    .Select(x => (decimal?)x.Value)
    .SumAsync() ?? 0m;

var prevPayment = await db.tbl_payment
    .Where(x => x.CompanyId == companyId && x.Date < from)
    .Select(x => (decimal?)x.PaymentAmount)
    .SumAsync() ?? 0m;

var previousBalance = prevPayment - prevPurchase;

この方法はシンプルで、「購入しか無い/支払いしか無い」ケースでも確実に前残高が作れます。

複数 CompanyId をまとめて出したい場合:Full Outer Join の考え方

会社ごとに前残高を出す場合、購入側と支払い側を単純に Join(内部結合)すると、片側しかない会社の行が消えます。SQL なら FULL OUTER JOIN を使いたくなる場面です。

ただし LINQ では Full Outer Join が標準で用意されていないため、次のいずれかの形に分解します。

やり方メリット注意点
Left Join + Right Join を Concat して擬似 Full Outer JoinSQL の考え方に近く理解しやすい重複排除(片側だけの行抽出)が必要
符号付き金額に変換してから Concat → GroupBy → Sum最短で書けて欠損にも強い(実質 Full Outer Join 不要)「残高=支払い−購入」の定義をコードで明確にする

おすすめ:符号付き金額(Delta)で前残高を作る

前残高は「購入と支払いを足し合わせた結果」なので、実務的には 先に同じ形へ寄せてしまうのが最も事故が減ります。購入はマイナス、支払いはプラスとして 1 本の系列にし、会社ごとに合計します。

var prevDeltaQuery =
    db.tbl_payment
        .Where(x => x.Date < from)
        .Select(x => new { x.CompanyId, Delta = x.PaymentAmount })
    .Concat(
        db.tbl_purchase
            .Where(x => x.Date < from)
            .Select(x => new { x.CompanyId, Delta = -x.Value })
    );

var prevBalanceByCompany = await prevDeltaQuery
    .GroupBy(x => x.CompanyId)
    .Select(g => new { CompanyId = g.Key, Balance = g.Sum(x => x.Delta) })
    .ToListAsync();

この方式なら、購入しかない/支払いしかない会社も自然に集計されます(Join を挟まないため)。また、SQL Server が得意な「集計」は DB 側で済ませられるのでパフォーマンス面でも有利です。

期間内の取引を同じ形に整形して結合する

次に、期間内の購入と支払いを「同じ DTO(表示用の形)」に揃えてから結合します。ここで意識したいのは次の 2 点です。

  • 購入:PurchaseAmount に値を入れ、PaymentAmount は 0(または null)
  • 支払い:PaymentAmount に値を入れ、PurchaseAmount は 0(または null)

そして、単に行をつなげたいだけなら、Union より Concat を推奨します。Union は重複排除が働くため、同値行が意図せず消える可能性があります(特に「支払いと購入の金額が同じ」「メモが空」などが重なると危険です)。

演算子挙動台帳用途での相性
Concat単純に連結(重複排除しない)◎(「全行出す」が目的に合う)
Union集合の和(重複排除する)△(意図せず行が消える可能性)

DTO(表示用モデル)例

出力する 1 行を表す DTO を用意しておくと、後段の foreach が読みやすくなります。

public sealed class BalanceRow
{
    public DateTime Date { get; set; }
    public int CompanyId { get; set; }

    // 画面に出したい補助情報
    public string? Note { get; set; }
    public string Type { get; set; } = ""; // "前残高" / "購入" / "支払い"

    // 金額
    public decimal PurchaseAmount { get; set; }
    public decimal PaymentAmount { get; set; }

    // 累積後に埋める
    public decimal Balance { get; set; }

    // 同一日付の並びを安定させるため
    public int SortOrder { get; set; }
}

「同じ日付に複数行ある」「支払いと購入が同日に混ざる」ケースでは、並び順が変わると中間残高が変わります。最終残高は同じでも、行ごとの残高を見せる台帳では重要です。SortOrder や元テーブルの ID(PaymentId/PurchaseId)で安定順を作るのが安全です。

期間内データを整形して結合する LINQ 例

会社が 1 社の場合の例です(複数社でも考え方は同じで、Where 条件を外すだけです)。

var purchaseRowsQuery =
    db.tbl_purchase
        .Where(x => x.CompanyId == companyId && x.Date >= from && x.Date <= to)
        .Select(x => new BalanceRow
        {
            Date = x.Date,
            CompanyId = x.CompanyId,
            Type = "購入",
            Note = x.InvoiceNo,
            PurchaseAmount = x.Value,
            PaymentAmount = 0m,
            SortOrder = 1
        });

var paymentRowsQuery =
    db.tbl_payment
        .Where(x => x.CompanyId == companyId && x.Date >= from && x.Date <= to)
        .Select(x => new BalanceRow
        {
            Date = x.Date,
            CompanyId = x.CompanyId,
            Type = "支払い",
            Note = null,
            PurchaseAmount = 0m,
            PaymentAmount = x.PaymentAmount,
            SortOrder = 0
        });

var rowsInPeriod = await purchaseRowsQuery
    .Concat(paymentRowsQuery)
    .OrderBy(x => x.Date)
    .ThenBy(x => x.SortOrder)
    .ToListAsync();

ThenBy 以降に「元テーブルの主キー」を入れられるなら、さらに安定します。例えば支払いに PaymentId、購入に PurchaseId があるなら、DTO に SourceId を持たせて並び替えに使うと安全です。

foreach で累積残高(ランニングサム)を計算する

最後に、前残高からスタートして、期間内の行を順に足し引きしていきます。ここが SQL の SUM(...) OVER(ORDER BY ...) に相当する処理です。

重要:runningBalance の初期値は「前残高」

前残高行を別途作っているのに残高が 0 から始まってしまう原因の多くは、runningBalance を 0 で初期化している点にあります。累積の初期値は、必ず前残高にします。

// 1) 前残高を作る
var previousRow = new BalanceRow
{
    Date = from.AddDays(-1), // or DateTime.MinValue 等。並び順を明確にする
    CompanyId = companyId,
    Type = "前残高",
    Note = "前日までの累計",
    PurchaseAmount = 0m,
    PaymentAmount = 0m,
    Balance = previousBalance,
    SortOrder = -1
};

// 2) 前残高を初期値にして累積
decimal runningBalance = previousRow.Balance;

foreach (var r in rowsInPeriod)
{
    runningBalance += (r.PaymentAmount) - (r.PurchaseAmount);
    r.Balance = runningBalance;
}

// 3) 前残高 + 期間内行 を結合して返す
var result = new List<BalanceRow>(rowsInPeriod.Count + 1);
result.Add(previousRow);
result.AddRange(rowsInPeriod);

この形にしておくと、「前残高の行は表示上の 1 行」「期間内の行は取引行」「残高はすべてループで埋める」という責務分離が明確になります。

よくあるハマりどころと対策

前残高が 0 になる

  • 原因:runningBalance を 0 で初期化している/前残高行の金額列を 0 にしたまま累積している
  • 対策:runningBalance を previousBalance(前残高)で初期化する。前残高行は Balance に値を入れておき、期間内行のループで残高を上書きする

Join(内部結合)にしてしまい、前残高行が落ちる

  • 原因:会社ごとの集計で、購入側か支払い側のどちらかしか存在しない会社が内部結合により除外される
  • 対策:擬似 Full Outer Join を作るか、この記事で紹介した 符号付き Delta 方式で Join を不要にする

差分の符号が逆で残高が反転する

  • 原因:購入 − 支払い の順にしてしまう
  • 対策:残高の定義をコードの中に明示する(例:running += Payment - Purchase)。レビュー時も見落としづらくなります

Union による重複排除で行が消える

  • 原因:Union は集合演算なので、DTO が「同値」と判定されると 1 行にまとめられる
  • 対策:単なる連結は Concat を使う。どうしても Union したい場合は「同値判定が起きないキー」を持たせる

前残高行を先頭に出したいのに並び順が崩れる

  • 原因:前残高の Date を null にし、並び順が環境やクエリによってブレる
  • 対策:前残高の日時は from.AddDays(-1) など「確実に先頭になる値」を入れる/もしくは SortOrder を負数にして明示的に先頭へ

実務で効く:行数を減らす「日次集計」パターン

期間内に取引が多いと、明細行をそのまま持ってくるだけで重くなります。残高推移だけが必要なら、SQL(LINQ)側で 日付単位に集約してから C# で累積すると高速です。

var dailyDeltaQuery =
    db.tbl_payment
        .Where(x => x.CompanyId == companyId && x.Date >= from && x.Date <= to)
        .GroupBy(x => x.Date.Date)
        .Select(g => new BalanceRow
        {
            Date = g.Key,
            CompanyId = companyId,
            Type = "支払い",
            Note = "日次合計",
            PurchaseAmount = 0m,
            PaymentAmount = g.Sum(x => x.PaymentAmount),
            SortOrder = 0
        })
    .Concat(
        db.tbl_purchase
            .Where(x => x.CompanyId == companyId && x.Date >= from && x.Date <= to)
            .GroupBy(x => x.Date.Date)
            .Select(g => new BalanceRow
            {
                Date = g.Key,
                CompanyId = companyId,
                Type = "購入",
                Note = "日次合計",
                PurchaseAmount = g.Sum(x => x.Value),
                PaymentAmount = 0m,
                SortOrder = 1
            })
    );

var dailyRows = await dailyDeltaQuery
    .OrderBy(x => x.Date)
    .ThenBy(x => x.SortOrder)
    .ToListAsync();

この dailyRows に対して同じ foreach を回せば、日々の残高推移が作れます。明細が不要なダッシュボードやグラフ用途では、体感速度が大きく変わることが多いです。

「どうしても SQL Server のウィンドウ関数で出したい」場合の現実的な選択肢

累積が必要な期間が長く、明細も大量で、クライアント側ループがボトルネックになる場合は、DB 側で SUM OVER を使った方がよいこともあります。その場合は、無理に LINQ で再現するより次のどれかが運用しやすいです。

  • ストアドプロシージャにして EF Core から呼ぶ(要件変更があっても SQL 側で完結しやすい)
  • VIEW を作って、EF Core でキーレスエンティティとして読み取る(画面用途の「読み取り専用テーブル」化)
  • EF Core の FromSqlInterpolated でウィンドウ関数を含む SQL を直接実行する(最小実装で移植できる)

特に台帳系は「並び順」「期間の切り方」「前残高の扱い」などが運用で増えやすく、LINQ だけで表現しようとすると可読性が急落します。SQL で作る方が安全・保守しやすい領域があることも、あらかじめチームで合意しておくと揉めにくいです。

実装チェックリスト

  • 残高の定義は 支払い − 購入 で統一しているか(符号ミス防止)
  • 前残高は 期間開始前(< from) で集計できているか
  • 期間内明細は from〜to の範囲が仕様どおりか(<= を入れるか、to の時刻をどうするか)
  • 購入・支払いの結合は Concat になっているか
  • 並び順は Date + SortOrder +(可能なら主キー)で安定しているか
  • runningBalance の初期値は 前残高 になっているか
  • 大規模データの場合、日次集計や VIEW/ストアド化の検討をしたか

このチェックを押さえておけば、SQL Server の SUM OVER と同等の残高推移を、EF Core/LINQ ベースの実装でも安定して再現できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次