EF Core 8×SQL Server Temporal TableでStartTime/EndTimeがSELECT *で返らない原因と対処法(HIDDEN列)

EF Core 8 と SQL Server の Temporal Table を使っていると、StartTime/EndTime(システム生成の期間列)が FromSqlRaw の「SELECT *」では取れないことがあります。Edition差に見えても原因はテーブル定義。確実に取得する書き方と確認ポイントを解説します。

目次

現象:StartTime / EndTime が SELECT * の結果に出てこない

EF Core 8 で SQL Server を使い、更新時に自動生成される列として期間列(StartTime / EndTime)をマッピングしているケースを想定します。代表的な設定は次のようなものです。

modelBuilder.Entity<MyEntity>(entity =>
{
    entity.Property(e => e.StartTime).ValueGeneratedOnAddOrUpdate();
    entity.Property(e => e.EndTime).ValueGeneratedOnAddOrUpdate();
});

LINQ クエリで取得すると値が入っているのに、次のように生SQLで SELECT * を実行した場合だけ「StartTime / EndTime が返らない」「列が存在しないように見える」といった差が出ることがあります。

var rows = await context.MyEntities
    .FromSqlRaw("SELECT * FROM dbo.MyTable WHERE Id = {0}", id)
    .ToListAsync();

さらに厄介なのは、環境(例:開発機の SQL Server Developer Edition と、別環境の Enterprise/Standard など)によって挙動が違うように見える点です。結論から言うと、ここで疑うべきは Edition ではありません。

結論:Edition の違いではなく「Temporal Table の期間列が HIDDEN になっている」

SQL Server の Developer Edition は、機能面では Enterprise Edition 相当として提供されることが多く、単純に「Developer だから列が返らない」ということは基本的に起きません。挙動が違うときは、たいてい スキーマ(テーブル定義)の差 が原因です。

Temporal Table(システムバージョン管理テーブル)では、行の有効期間を表す 2 つの期間列を持ちます。この期間列に HIDDEN 属性 が付いていると、SQL Server は次の挙動をとります。

  • HIDDEN 列は SELECT * で返らない
  • ただし列自体は存在し、列名を明示すれば取得できる

つまり「SELECT * の結果に出てこない = 列が無い」ではなく、見えていないだけ という状態です。

誤解しやすいポイント実際に起きていること典型的な原因
Developer Edition だと列が返らない?Edition ではなく DDL(テーブル定義) の差期間列が HIDDEN で作られている
SSMS の結果に列がないSELECT * が返さないだけで列は存在PERIOD FOR SYSTEM_TIME の期間列が HIDDEN
EF が取得できているのに生SQLでは取れないEF の通常クエリは SELECT * を使わず列名を列挙する生SQL側だけ SELECT * に依存している

Temporal Table の StartTime / EndTime が「普通の列」と違う理由

Temporal Table の期間列(例:StartTime / EndTime、ValidFrom / ValidTo など)は、単なる監査列ではなく、システムバージョン管理の中核です。行の「現在有効な期間」と「過去バージョンの期間」を SQL Server が自動で管理します。

期間列に関して、設計・実装で押さえるべき違いを表にまとめます。

項目内容アプリ側の影響
値の生成SQL Server が自動生成(INSERT/UPDATE 時に更新)アプリで値をセットしない(EF は生成列として扱う)
用途行が有効だった期間を表す(履歴追跡のキー)監査・差分分析・タイムトラベルクエリに使える
HIDDENSELECT * などワイルドカードから除外される属性生SQLで SELECT * を使うと「無いように見える」

Temporal Table を後付けで有効化する場合、既存アプリが SELECT * や列名省略 INSERT をしていても壊れないよう、期間列を HIDDEN にする運用はよくあります。結果として「環境Aは HIDDEN」「環境Bは Visible」のようにズレが生まれると、今回のような混乱が発生します。

最初にやるべき切り分け:列の存在と “Hidden フラグ” を確認する

「本当に列が無いのか」「Hidden 扱いで見えていないのか」を、SQL Server のメタデータから確認します。SSMS の表示やツールの見え方だけで判断しないのがコツです。

sys.columns で is_hidden を確認する

SELECT
    c.name,
    c.column_id,
    c.is_hidden,
    c.generated_always_type_desc
FROM sys.columns AS c
WHERE c.object_id = OBJECT_ID(N'dbo.MyTable')
ORDER BY c.column_id;

ポイントは次のとおりです。

  • is_hidden = 1 なら、その列は SELECT * では返りません
  • generated_always_type_desc が AS_ROW_START / AS_ROW_END であれば、Temporal の期間列である可能性が高いです

期間列(PERIOD)として登録されているかを確認する

期間列の列名が StartTime/EndTime なのか、SysStartTime/SysEndTime なのかは環境で異なることがあります。期間列を特定したい場合は次のように確認します。

SELECT
    t.name AS table_name,
    p.start_column_id,
    sc.name AS start_column_name,
    p.end_column_id,
    ec.name AS end_column_name
FROM sys.tables AS t
INNER JOIN sys.periods AS p
    ON p.object_id = t.object_id
INNER JOIN sys.columns AS sc
    ON sc.object_id = t.object_id AND sc.column_id = p.start_column_id
INNER JOIN sys.columns AS ec
    ON ec.object_id = t.object_id AND ec.column_id = p.end_column_id
WHERE t.object_id = OBJECT_ID(N'dbo.MyTable');

この結果で出てきた列が、いわゆる StartTime / EndTime(有効期間の開始・終了)です。ここで列名が想定と違う場合、EF Core 側のマッピングや生SQL側の列名指定がズレている可能性もあります。

最後に “明示列指定” で取れるか確認する

Hidden が疑わしいときは、いったん次のように列名を明示して SELECT してみてください。列が存在するなら取得できます。

SELECT
    Id,
    /* ...必要な列... */
    StartTime,
    EndTime
FROM dbo.MyTable
WHERE Id = 1;

もし列名が違う場合は、期間列の実名に置き換えるか、別名(AS)で揃えます。

SELECT
    Id,
    /* ...必要な列... */
    StartTime = SysStartTime,
    EndTime   = SysEndTime
FROM dbo.MyTable
WHERE Id = 1;

対処方法:環境差を無視して確実に取得するなら「列名を明示する」

もっとも安全で、長期的にもおすすめできる解決策は SELECT * をやめる ことです。特に EF Core の FromSqlRaw / FromSqlInterpolated は「あなたが書いた SQL をそのまま実行する」ため、ワイルドカードに依存すると環境差を吸収できません。

EF Core で確実に StartTime / EndTime を取りたい場合は、次のように必要列を列挙します。

var rows = await context.MyEntities
    .FromSqlRaw(@"
        SELECT
            [Id],
            [Name],
            [StartTime],
            [EndTime]
        FROM [dbo].[MyTable]
        WHERE [Id] = {0}", id)
    .AsNoTracking()
    .ToListAsync();

期間列の実際の列名が違う場合は、SQL 側で別名を付けて EF のプロパティ名に合わせる と安定します。

var rows = await context.MyEntities
    .FromSqlRaw(@"
        SELECT
            [Id],
            [Name],
            [StartTime] = [SysStartTime],
            [EndTime]   = [SysEndTime]
        FROM [dbo].[MyTable]
        WHERE [Id] = {0}", id)
    .AsNoTracking()
    .ToListAsync();

なぜ “列名明示” が最強なのか

SELECT * を避けることは一般論としても推奨されますが、Temporal Table + EF Core の文脈では特に効果が大きいです。

  • HIDDEN 列の有無に左右されない
  • 将来列が追加されても、意図しない列が混入しない(DTO/Entity の破壊的変更を避けられる)
  • 列の順序変更に影響されない(特に SqlDataReader を直接扱うケースで事故が減る)
  • 読み取りたい列=必要なデータが明確になり、パフォーマンスの説明もしやすい

EF Core の “通常の LINQ” では起きにくい理由

EF Core が生成する SQL は、基本的に SELECT * ではなく列名を列挙します。そのため期間列が HIDDEN でも、マッピングしているなら明示的に参照して取得できます。今回問題になるのは「生SQL側だけが SELECT * に依存している」状況です。

対処方法:HIDDEN を外して “見える状態” に揃える(スキーマ統一)

次の条件に当てはまるなら、HIDDEN を外してしまう(Visible に統一する)のも現実的です。

  • 運用上、SSMS などで SELECT * を多用しており、期間列も常に見える方が便利
  • 複数環境で DDL がバラバラになっており、これを機に統一したい
  • 期間列を “監査ログ的に” 表示する要件があり、隠す理由が薄い

期間列の Hidden フラグは、次のように変更できます。

ALTER TABLE dbo.MyTable
    ALTER COLUMN StartTime DROP HIDDEN;

ALTER TABLE dbo.MyTable
    ALTER COLUMN EndTime DROP HIDDEN;

逆に Hidden にしたい場合は ADD HIDDEN です。

ALTER TABLE dbo.MyTable
    ALTER COLUMN StartTime ADD HIDDEN;

ALTER TABLE dbo.MyTable
    ALTER COLUMN EndTime ADD HIDDEN;

実施後、SELECT * の結果に期間列が出るかを確認します。

SELECT TOP (10) *
FROM dbo.MyTable
ORDER BY Id DESC;

注意点として、これは DDL 変更です。大きいテーブルではスキーマ変更に伴うロックが発生することがあります。特に本番環境では、必ず検証環境で確認し、バックアップやメンテナンス時間を確保してください。

おすすめの折衷案:ビューで “見せる列” を固定する

「基盤テーブルは HIDDEN のまま維持したい。でもアプリや分析用途では StartTime/EndTime も簡単に取りたい」という場合、ビューを用意してそこに必要列を明示する のがきれいな落としどころになります。

CREATE VIEW dbo.vMyTable
AS
SELECT
    Id,
    Name,
    StartTime,
    EndTime
FROM dbo.MyTable;

これなら、アプリ側は SELECT * を使うとしても “ビューの列” が返るため、環境差が出にくくなります。

var rows = await context.MyEntities
    .FromSqlRaw("SELECT * FROM dbo.vMyTable WHERE Id = {0}", id)
    .AsNoTracking()
    .ToListAsync();

ビューを挟むメリットは、列名の別名付け(AS)や、アプリに見せたくない内部列の遮蔽、将来のスキーマ変更の吸収ができる点です。運用で “インターフェースを固定したい” 場合に強力です。

環境差の再発を防ぐ:Edition ではなく “スキーマ差” を管理する

今回のような差分は、「どこかの環境で手作業で DDL を変えた」「移行スクリプトが一部だけ適用されていない」「Temporal 化の手順が環境ごとに違う」といった理由で起こりがちです。再発防止のために、次の観点で運用を見直すのがおすすめです。

観点やること効果
DDL の統一EF Core Migrations からスクリプトを生成し、全環境に同じ順で適用する環境差の根本原因を減らす
差分検知定期的に sys.columns などから列定義をダンプして比較するHidden/Nullable/型のズレを早期発見
生SQLの統制SELECT * を禁止(レビュー基準化)し、列名明示を徹底するTemporal に限らず保守性が上がる
観測性EF のログで “実際に投げている SQL” を環境間で比較できるようにする「どのSQLが差を生むか」を短時間で特定

切り分けに効く:EF Core のログで “実際のSQL” を見る

「EF が何を投げているか」を確認すると、SELECT * と列挙の差、列名の別名、WHERE 条件の違いなどが一発でわかります。開発・検証環境ではログ出力を有効にしておくと、こうした問題の解決が速くなります。

// 例:DbContextOptionsBuilder 側でログを出す(開発用途)
optionsBuilder
    .EnableSensitiveDataLogging()
    .LogTo(Console.WriteLine);

本番での詳細ログは機密情報の取り扱いに注意が必要ですが、少なくとも「どのSQLが実行されたか」を追える状態にしておくと、環境差調査の難易度が下がります。

よくある質問

Hidden 列を “SELECT * でも返す” オプションはない?

ありません。Hidden はワイルドカード選択(*)から除外するための属性なので、取得したいなら 列名を明示する 必要があります。これはツールや ORM の問題というより、SQL Server の仕様に沿った動きです。

StartTime/EndTime が取得できないとき、EF のプロパティ値はどうなる?

SQL が列を返さなければ、EF Core はそのプロパティを埋められません。非NULL列であっても、C# 側では既定値(DateTime なら 0001-01-01)のように見えることがあります。期間列を使って監査や差分を判定している場合、ここで静かにロジックが壊れることがあるため、生SQLは必ず列名明示 を徹底するのが安全です。

HIDDEN を外しても Temporal の仕組み自体は壊れない?

期間列の自動管理(システムバージョン管理)と、Hidden の有無は別の概念です。Hidden を外すのは「SELECT * で見えるかどうか」を変えるだけです。ただし DDL 変更である以上、ロックや運用影響は発生し得ます。適用手順は環境規模に合わせて慎重に進めてください。

まとめ:安全策は “列名明示”、根本対策は “スキーマ統一”

  • StartTime/EndTime が SELECT * に出ないのは、Edition 差ではなく 期間列が HIDDEN である可能性が高い
  • 環境差を無視して確実に取るなら、SELECT * をやめて列名を明示 する
  • 運用として揃えるなら、HIDDEN を外す(またはビューで公開列を固定する)
  • 再発防止は「DDL を統一して適用」「メタデータで差分検知」「EF の生成SQLを観測」が効果的

この記事を書いた人

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

コメント

コメントする

目次