EF Coreで動的に増える列を取得する方法|モデル変更なしでADO.NET・JSON設計まで解説

クライアント操作でDBテーブルに列が増える仕組みを採用すると、EF Coreの固定モデルとスキーマがずれて「追加列も含めて取得したいのに取れない」問題に直面します。この記事では、EF Coreの限界とADO.NETでの動的取得例、さらにJSON列/EAVなど設計の落としどころまで整理します。

目次

前提:EF Coreは「固定スキーマを強い型にマップする」ORM

Entity Framework / EF Core は、DBのテーブル(固定スキーマ)をC#のクラス(エンティティ)にマッピングし、LINQで安全にCRUDできるようにするORMです。ここで重要なのは、EF Coreが前提としている世界観が「スキーマは基本的に固定」であり、モデル(エンティティのプロパティ)が契約になっている点です。

そのため、実行中に ALTER TABLE のような操作で列が増減する設計(いわゆる「動的カラム」「自由項目を列追加で表現」)は、EF Coreの得意分野と真逆になりがちです。

「列が増えただけ」ならEF Coreが壊れにくい理由

まず誤解が生まれやすいポイントとして、DB側に列が増えただけで、EF Coreが即座に例外を投げるとは限りません。通常のLINQクエリは、モデルにマップされた列だけをSELECTするためです。

たとえばエンティティに Id と Name しか定義していないなら、EF Coreは概ね次のようなSQLを生成します(概念図)。

SELECT [u].[Id], [u].[Name]
FROM [Users] AS [u]

この場合、DBに CustomField_2026 のような列が増えていても、EF Coreはその列を読みに行かないので、アプリは普通に動き続けます。

それでも「追加列も含めて取得したい」となると詰まる

問題はここからで、要件が「増えた列も含めて取得して返したい」「画面側で列一覧を動的に表示したい」になると、EF Coreの強み(強い型)がそのまま制約になります。

やりたいことEF Coreの現実なぜ
DBに増えた列を、そのままアプリで取得したい標準のエンティティでは難しいプロパティ(型)が存在しない列を受け取る入れ物がない
追加列を「列名→値」の形で返したいLINQだけでは完結しないEF Coreの戻り値は基本的に型付きオブジェクト
Raw SQLでSELECT *して全部取りたい追加列は基本的に無視されるマッピングはモデル定義に従うため、未知の列は参照できない

「じゃあdynamicで受ければ?」と思いがちですが、EF Coreはクエリ結果を任意の Dictionary や ExpandoObject に直接マップする、という使い方には向きません。可能に見える手段(シャドウプロパティ、キーなしエンティティ、FromSqlなど)も、“事前に定義された列”の範囲を出にくいのが実情です。

結論:動的列を回収するならADO.NET(またはマイクロORM)に寄せる

やりたいことが「モデル変更なしで、増えた列を含めてデータを回収する」なら、現実解は動的取得できる手段を使うことです。代表がADO.NETの DbDataReader や DataTable で、列情報を実行時に見ながら列名→値を組み立てられます。

(補足)DapperのようなマイクロORMでも同じ方向性で、Query<dynamic> や ExpandoObject として受け取れます。ここでは「.NET標準で完結」しやすいADO.NETを中心に説明します。

DbContextを活かしつつDbDataReaderで動的に読む

EF Coreを全面的に捨てる必要はありません。接続・トランザクション管理はDbContextに乗せたまま、読み取りだけをADO.NETに寄せるのが現場では扱いやすいパターンです。

using System.Data;
using System.Data.Common;

public static class DynamicColumnReader
{
    // 任意のSQLを実行し、各行を「列名→値」のDictionaryとして返す
    public static async Task<List<Dictionary<string, object?>>> QueryAsDictionariesAsync(
        DbConnection connection,
        string sql,
        IEnumerable<DbParameter>? parameters = null,
        CancellationToken cancellationToken = default)
    {
        bool shouldClose = false;

        if (connection.State != ConnectionState.Open)
        {
            await connection.OpenAsync(cancellationToken);
            shouldClose = true;
        }

        try
        {
            await using var cmd = connection.CreateCommand();
            cmd.CommandText = sql;

            if (parameters != null)
            {
                foreach (var p in parameters)
                {
                    cmd.Parameters.Add(p);
                }
            }

            var rows = new List<Dictionary<string, object?>>();

            await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);

            while (await reader.ReadAsync(cancellationToken))
            {
                var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);

                for (int i = 0; i < reader.FieldCount; i++)
                {
                    var name = reader.GetName(i);
                    row[name] = await reader.IsDBNullAsync(i, cancellationToken) ? null : reader.GetValue(i);
                }

                rows.Add(row);
            }

            return rows;
        }
        finally
        {
            if (shouldClose)
            {
                await connection.CloseAsync();
            }
        }
    }
}

ポイントは次の通りです。

  • FieldCount / GetName / GetValue で実行時に列を走査できる
  • DBに列が増えれば、SELECT *(または列を明示しない動的SQL)で新列も自動的に取れる
  • 戻り値はエンティティではなく Dictionary なので、モデル変更なしで拡張できる

DbContextのトランザクションと同じ文脈で読む(整合性が必要な場合)

「EF Coreで更新した直後に、同じトランザクション内で動的列も含めて読みたい」場合は、DbContextが保持している接続・トランザクションを使うのが安全です。以下は考え方の例です(プロバイダーにより細部は調整してください)。

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; // GetDbTransaction()

public async Task<List<Dictionary<string, object?>>> GetWithExtrasAsync(MyDbContext db, int id)
{
    // 例:EF Core側で何か更新している前提
    // await db.SaveChangesAsync();

    var conn = db.Database.GetDbConnection();

    if (conn.State != ConnectionState.Open)
    {
        await conn.OpenAsync();
    }

    await using var cmd = conn.CreateCommand();

    // 重要:DbContextがトランザクション中なら同じトランザクションを使う
    var current = db.Database.CurrentTransaction;
    if (current != null)
    {
        cmd.Transaction = current.GetDbTransaction();
    }

    cmd.CommandText = "SELECT * FROM Users WHERE Id = @id";

    var p = cmd.CreateParameter();
    p.ParameterName = "@id";
    p.Value = id;
    cmd.Parameters.Add(p);

    var rows = new List<Dictionary<string, object?>>();

    await using var reader = await cmd.ExecuteReaderAsync();
    while (await reader.ReadAsync())
    {
        var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
        for (int i = 0; i < reader.FieldCount; i++)
        {
            var name = reader.GetName(i);
            row[name] = await reader.IsDBNullAsync(i) ? null : reader.GetValue(i);
        }
        rows.Add(row);
    }

    return rows;
}

この形にしておくと、「EF Coreで管理する固定列」と「動的列を含む生データ」を同じ整合性で扱えます。

SELECT * を使うときの割り切りポイント

動的列を確実に拾う一番単純な方法は SELECT * です。ただし、運用が長くなるほど“全部取り”の副作用が出やすいので、次の観点で割り切りを決めておくと事故が減ります。

  • 列が肥大化する前提ならページング必須:1回で大量行を返さない
  • 巨大な列(画像、BLOB、長文)が混じるなら除外:列一覧をメタデータから取得し、除外リストを適用してSELECT句を組み立てる
  • 変更に強いクライアント実装:存在しないキーは無視、未知キーは表示、型は柔軟に扱う

「テーブル名や列名が外部入力」の場合は必ずホワイトリストにする

動的列の話題では、しばしば「画面で選んだテーブル/列をそのままSQLに埋め込む」実装になり、SQLインジェクションの温床になります。値パラメータは @p0 のようにパラメータ化できますが、識別子(テーブル名・列名)はパラメータ化できません。

そのため、少なくとも次のいずれかを徹底してください。

  • APIで受け取るのは「テーブル名」ではなく「固定の識別子(enum等)」にし、サーバ側で実テーブル名へ変換する
  • 列名も同様に「許可リスト(ホワイトリスト)」で検証し、許可されたものだけをSQLに含める

APIの返し方:固定項目+追加項目(辞書)に分けると扱いやすい

自由項目を含む画面は、だいたい「固定列(ID、名前、作成日など)」と「ユーザーが増やした列(自由項目)」が混在します。ここを一つの巨大なDTOにしようとすると破綻しやすいので、返却形式は次のように固定と可変を分離すると運用が安定します。

{
  "id": 1001,
  "name": "サンプル",
  "createdAt": "2026-01-01T09:00:00+09:00",
  "extras": {
    "CustomField_2026": "A",
    "Priority": 3,
    "Memo": null
  }
}

こうしておけば、バックエンドは固定項目をEF Coreで管理し、可変項目はADO.NETで回収して extras に詰める、という役割分担がしやすくなります。

DataTableとDbDataReaderはどちらを選ぶ?

どちらも「動的列を扱える」手段ですが、得意分野が違います。

手段向いているケース注意点
DbDataReader大量データ、低メモリ、ストリーミング処理逐次読み取り。再利用や検索は自前で組み立てが必要
DataTable小〜中規模で「表として扱いたい」ケースメモリに全件展開。大量件数だと重くなりやすい

「まずは動くものを作りたい」「画面へそのまま渡すテーブル構造が欲しい」という段階ではDataTableが楽です。一方でAPI化・性能最適化まで考えるなら、DbDataReaderで必要な形(Dictionary/DTO)に落とす方が後々ラクになります。

そもそも「列を増やす」設計が辛くなりやすい理由

動的に列を増やす方式は、最初はわかりやすく見えます。しかし運用が進むほど、次のような問題が表面化しがちです。

  • 移行管理が難しい:いつ・誰が・どんな列を追加したか追跡しにくい
  • 性能影響:ALTER TABLEはロックや再構築が発生するDBもあり、ピーク時は事故になりやすい
  • スキーマ肥大:列が増えるほどテーブルが横に広がり、I/Oやインデックス設計が難しくなる
  • 監査・権限:列追加を許すと「本来アプリが作るべきでない構造変更」を誰でもできてしまう

「どうしても列追加が必要」な場合でも、少なくともアプリ起動中に勝手に増える前提は避け、マイグレーション管理に寄せた方が安全です。

根本対策:可変データを“列追加以外”で表現する選択肢

ここからは「動的列を取りたい」という症状の根にある、設計の選択肢です。特に「自由項目」「ユーザー定義フィールド」「カスタム属性」の要件では、列追加よりも相性の良い表現があります。

方式概要長所短所EF Coreとの相性
列追加(ALTER TABLE)自由項目=物理列単純なSQLで読める運用・性能・監査が重い/スキーマが肥大化悪い(モデル変更が前提になりやすい)
JSON列自由項目を1列のJSONに格納スキーマ固定/追加が容易集計や型保証は工夫が必要良い(固定モデル+JSONのハイブリッドが可能)
EAV(属性テーブル)(EntityId, Key, Value)で保持検索・履歴・権限を設計しやすいクエリが複雑/型が混ざる普通(設計次第で扱える)
子テーブル(定義+値)項目定義テーブル+値テーブルUI/権限/バリデーションと相性が良い設計コストは上がる良い(リレーションで管理しやすい)

選択肢:JSON列に寄せる(自由項目が“画面表示中心”なら強い)

自由項目の主目的が「画面で表示する」「入力値を保存する」程度で、複雑な集計やJOINが中心ではないなら、JSON列は強力です。テーブルの物理列は固定のまま、自由項目だけを AdditionalData のような列にJSONで格納します。

例えば、DB上は次のようなイメージです。

-- 固定列は通常通り
Id (int), Name (nvarchar), CreatedAt (datetime2), AdditionalData (nvarchar(max) / json / jsonb)

運用で効いてくるのは「検索・ソートをどこまでJSONに求めるか」です。頻出キーだけは生成列(計算列)で取り出してインデックス化し、残りはJSONのまま、というハイブリッドにすると、柔軟性と性能のバランスが取りやすくなります。

EF Core側では、AdditionalData を string / JsonDocument / 独自型として保持し、必要に応じてシリアライズ/デシリアライズします。プロバイダーによってはJSONを構造化してマッピングできる機能もあるため、「自由項目の形がある程度固まってきた」段階で段階的に型を付けていく運用も可能です。

選択肢:EAV(キー・バリュー)に寄せる(検索や履歴が必要なら検討)

自由項目に対して「項目ごとの権限」「型ごとの検証」「変更履歴」「特定キーでの検索」などが求められるなら、EAV(Entity-Attribute-Value)や、項目定義+値テーブルの構成が向きます。

テーブル例役割
CustomFieldDefinition(FieldId, EntityType, Key, DataType, Required, SortOrder…)自由項目の定義(項目名、型、必須、表示順など)
CustomFieldValue(EntityId, FieldId, ValueString, ValueNumber, ValueDate…)自由項目の値(型別列に分けると検索しやすい)

列追加より設計は増えますが、「後から運用で破綻しない」ことを優先するなら、最初からこちらに寄せた方が結果的に安く済むケースが多いです。特に複数テナントや監査要件があるシステムでは、列追加よりも権限・履歴・バリデーションの設計がしやすいのが大きな利点です。

選択肢:スキーマ変更はMigration運用に寄せる(列追加が避けられない場合)

どうしても「物理列を追加したい」なら、アプリの外から勝手にALTER TABLEするのではなく、マイグレーション(migration)で管理して、

  • いつ
  • 誰が
  • どの環境に
  • どんな変更を

適用したかを追跡できる状態にするのが安全です。EF Coreのマイグレーションに限らず、Flyway/LiquibaseなどのDBマイグレーションツールでも構いません。大事なのは、スキーマを「変更してよいもの」ではなく「管理されるべき契約」に戻すことです。

現場でのおすすめ判断フロー(迷ったときの基準)

  • 追加項目が“表示・保存中心”で、複雑な検索が少ない → JSON列が第一候補
  • 追加項目で検索・権限・履歴が必要 → 項目定義+値テーブル(EAV寄り)
  • 追加項目が本質的に固定(いつか必ず全顧客で使う) → 列追加+マイグレーションで管理
  • 今すぐは設計変更できないが、増えた列も返したい → 読み取りだけADO.NETに寄せて凌ぐ

まとめ:EF Coreで「動的に増える列」を自然に扱うのは難しい

EF Coreは、固定スキーマを強い型に落とし込み、保守性と安全性を上げるためのORMです。動的に列が増える設計では、追加列を型付きで扱う“入れ物”がないため、モデル変更なしで完結させるのは現実的ではありません。

短期的には、DbContextの接続(必要ならトランザクションも)を使って DbDataReader で動的に回収し、APIでは「固定+extras(辞書)」の形で返すのが堅実です。中長期的には、JSON列や項目定義テーブルなど、可変データに向いたDB設計へ寄せると、運用コストとトラブルが大きく減ります。

この記事を書いた人

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

コメント

コメントする

目次