EF Core/SQL ServerでURIを保存するベストプラクティス|ValueConverter・HasConversion・nvarchar設計の完全ガイド

アプリ側では System.Uri を使って型安全に扱いたいのに、SQL Server には「URI 型」がありません。この記事では、EF Core(Entity Framework Core)と SQL Server の組み合わせで URI を正しく保存・読み出し・検索するための現実解を、基本から運用ノウハウまで一気通貫で解説します。サンプルコードはそのまま貼って動く形で提示します。

目次

結論の先取り:実装方針とポイント

  • DB には文字列列(nvarchar 等)で保存し、EF Core の ValueConverter で Uri ⇔ 文字列を相互変換する。
  • Fluent API の HasConversion でプロパティ単位に設定、または ConfigureConventions で Uri 全プロパティへ一括適用する。
  • 保存する文字列は要件に応じて AbsoluteUri(正規化) または OriginalString(入力そのまま) を選ぶ。
  • 長さは目安として nvarchar(2048)。極端に長い URL が想定されるなら nvarchar(max)。
  • ホスト名やパス単位で検索したい場合は、補助列(Host/Path 等)を別列に保持してインデックスする。
  • 保存前に バリデーション(Uri.TryCreate など)を入れて不正データ流入を防止。

最小サンプル:プロパティに ValueConverter を設定

エンティティ定義

public sealed class Bookmark
{
    public int Id { get; set; }


// URI は null 許容かどうか方針を決める(ここでは必須)
public Uri Uri { get; set; } = default!;

// 補助的なメタデータ
public string? Title { get; set; }


} 

Fluent API で変換(保存:Uri→文字列/取得:文字列→Uri)

public sealed class AppDbContext : DbContext
{
    public DbSet<Bookmark> Bookmarks => Set<Bookmark>();


protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var entity = modelBuilder.Entity<Bookmark>();

    entity.Property(e => e.Uri)
          .HasConversion(
              // 保存時:Uri -> string
              v => v.AbsoluteUri, // or v.ToString() / v.OriginalString(後述の方針参照)
              // 読込時:string -> Uri
              v => new Uri(v, UriKind.Absolute))
          .HasMaxLength(2048)              // 代表的な上限
          .HasColumnType("nvarchar(2048)");// 明示しておくと安心

    entity.Property(e => e.Title)
          .HasMaxLength(256);
}


} 

EF Core 組み込みコンバーターの利用

EF Core 7 以降なら、より簡潔に記述できます。

// 1) 汎用:型から string への既定変換を選択
entity.Property(e => e.Uri).HasConversion<string>();

// 2) 具体的な組み込みコンバーターを明示
// バージョンにより命名が異なる場合があります(例:UriToStringConverter / StringToUriConverter)。
entity.Property(e => e.Uri).HasConversion();
// または
// entity.Property(e => e.Uri).HasConversion(); 

全プロパティに一括適用(ConfigureConventions)

アプリ全体で Uri を使うなら、コンテキストに 1 箇所書くだけで済むようにしておくと保守が楽です。

public sealed class AppDbContext : DbContext
{
    protected override void ConfigureConventions(ModelConfigurationBuilder builder)
    {
        // すべての Uri プロパティに対して「文字列に変換して保存」を既定化
        builder.Properties&lt;Uri&gt;()
               .HaveConversion&lt;UriToStringConverter&gt;() // or &lt;string&gt;
               .HaveMaxLength(2048)
               .HaveColumnType("nvarchar(2048)");
    }
}

(上級)ValueComparer を添えると変更追跡が安定

EF Core の変更追跡は比較ロジックに依存します。Uri は不変参照型で基本的には問題ありませんが、等価判定の基準を明示したい場合は ValueComparer を追加します。

var comparer = new ValueComparer<Uri>(
    // 等価判定:AbsoluteUri で比較
    (l, r) => string.Equals(l?.AbsoluteUri, r?.AbsoluteUri, StringComparison.Ordinal),
    // ハッシュ:AbsoluteUri ベース
    v => v?.AbsoluteUri.GetHashCode() ?? 0,
    // スナップショット:Uri は実質不変なのでそのまま返して良い
    v => v
);

entity.Property(e => e.Uri)
.HasConversion(v => v.AbsoluteUri, v => new Uri(v, UriKind.Absolute))
.Metadata.SetValueComparer(comparer); 

列設計:型・長さ・コレーションの決め方

項目推奨値・考え方補足
データ型nvarchar国際化(日本語や絵文字など)を前提に無難。ASCII 前提なら varchar も可。
長さ2048 目安/不確実なら max2,000 文字超の URI もあり得る。確実性重視なら nvarchar(max)。
NULL 許容要件次第(Uri? なら NULL)「空文字」と「未設定(NULL)」は区別するのが一般的。
コレーションDB 既定で OKURI のホストは大小無視、パスは大小区別が混在するため、アプリ側で比較方針を統一するのが安全。
インデックス必要に応じて追加完全一致検索が多いなら URI 列へインデックス。部分一致中心なら補助列(後述)が有効。

保存する文字列はどれにする?(AbsoluteUri / ToString / OriginalString)

同じ URI でも表記揺れ(エスケープ・大小・末尾スラッシュ・クエリ順)が異なると文字列は変わります。用途に応じて選択しましょう。

候補特徴向いているケース注意点
AbsoluteUri正規化された絶対 URI を返す重複排除・比較・主キー/ユニーク制約元入力の表記(相対・未エスケープ)は失われる
ToString()実装に依存、通常は Absolute に近い軽量で汎用厳密な同一性の基準に使うなら明示方針が必要
OriginalStringコンストラクタに渡した元文字列ユーザー入力をそのまま再表示したい比較や重複判定には不向き(表記揺れの影響大)

実装例:AbsoluteUri を保存する

entity.Property(e =&gt; e.Uri)
      .HasConversion(v =&gt; v.AbsoluteUri, v =&gt; new Uri(v, UriKind.Absolute));

バリデーション:保存前に弾く・整える

DB に入った不正データの修復コストは高いので、保存前の検証を徹底します。

  • 入力文字列 → Uri.TryCreate(期待する UriKind)で安全に生成。
  • 許可するスキーム(https のみ等)をホワイトリストで制御。
  • 末尾スラッシュ統一、クエリの順序・重複キーの扱いなど、アプリの統一ルールを決める。
public static bool TryNormalizeUri(string input, out Uri uri)
{
    if (!Uri.TryCreate(input, UriKind.Absolute, out var u)) { uri = default!; return false; }
    if (!string.Equals(u.Scheme, "https", StringComparison.OrdinalIgnoreCase)) { uri = default!; return false; }


// 末尾スラッシュの統一例(要件に合わせて調整)
var builder = new UriBuilder(u);
if (string.IsNullOrEmpty(builder.Path)) builder.Path = "/";

uri = builder.Uri;
return true;


} 

検索とインデックス:ホストやパスで引きたい場合

Where(x => x.Uri.Host == "example.com") のような式は SQL へ翻訳されにくいのが現実です。補助列を用意してアプリ側で同期させるのが実務で堅い解です。

補助列を持つエンティティ設計

public sealed class Bookmark
{
    public int Id { get; set; }
    public Uri Uri { get; set; } = default!;
    public string? Title { get; set; }


// 補助列(DB に保存) -> 検索・インデックス用
public string UriHost { get; private set; } = string.Empty;
public string UriPath { get; private set; } = string.Empty;

public void SetUri(Uri uri)
{
    Uri = uri;
    UriHost = uri.Host.ToLowerInvariant();  // ホストは大小無視に寄せる
    UriPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
}


} 

マッピングとインデックス

entity.Property(e => e.UriHost)
      .IsRequired()
      .HasMaxLength(255);

entity.Property(e => e.UriPath)
.IsRequired()
.HasMaxLength(2048);

entity.HasIndex(e => e.UriHost);
entity.HasIndex(e => new { e.UriHost, e.UriPath }); // 代表的な複合インデックス 

保存時に補助列を同期(インターセプター)

プロパティの呼び出し漏れを避けるなら、EF Core の SaveChanges インターセプターで同期させる手もあります。

public sealed class UriPartsSyncInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult&lt;int&gt; SavingChanges(
        DbContextEventData eventData,
        InterceptionResult&lt;int&gt; result)
    {
        var ctx = (AppDbContext)eventData.Context!;
        foreach (var e in ctx.ChangeTracker.Entries&lt;Bookmark&gt;())
        {
            if (e.State is EntityState.Added or EntityState.Modified)
            {
                var uri = e.Entity.Uri;
                e.Entity.UriHost = uri.Host.ToLowerInvariant();
                e.Entity.UriPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
            }
        }
        return base.SavingChanges(eventData, result);
    }
}

実運用チェックリスト

テーマ実施項目理由・効果
スキーマnvarchar(2048) or nvarchar(max)将来の長大 URI に備える/インデックス要件とバランスを取る
変換HasConversion or 組み込みコンバーターアプリ側では Uri を維持して型安全・可読性を高める
バリデーションUri.TryCreate、スキーム許可、末尾スラ統一不正・重複・表記揺れの混入防止、可観測性向上
検索補助列(Host/Path)+インデックス翻訳不能な式を回避し、高速に検索・集計できる
一括適用ConfigureConventions で全 Uri を既定化誤設定・設定漏れを防ぐ。コード量を削減
変更追跡必要に応じ ValueComparer厳密な等価性(AbsoluteUri 基準など)を保証
ユニーク制約AbsoluteUri 列や正規化列に Unique Index重複登録防止。OriginalString 保存時は別途正規化列を併設

マイグレーション例

上記マッピングを付与した状態で Add-Migration → Update-Database を行うと、URI 列は文字列のまま作成・変更されます。代表的な生成例です。

migrationBuilder.CreateTable(
    name: "Bookmarks",
    columns: table => new
    {
        Id = table.Column<int>(nullable: false)
                  .Annotation("SqlServer:Identity", "1, 1"),
        Uri = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
        Title = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
        UriHost = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false, defaultValue: ""),
        UriPath = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false, defaultValue: "/")
    },
    constraints: table => { table.PrimaryKey("PK_Bookmarks", x => x.Id); });

migrationBuilder.CreateIndex(
name: "IX_Bookmarks_UriHost",
table: "Bookmarks",
column: "UriHost"); 

クエリ例:用途別に最適化

完全一致で探す(AbsoluteUri 保存時)

var url = new Uri("https://example.com/path?a=1");
var b = await db.Bookmarks.FirstOrDefaultAsync(x =&gt; x.Uri.AbsoluteUri == url.AbsoluteUri);

ホスト+パスで探す(補助列活用)

var r = await db.Bookmarks
    .Where(x =&gt; x.UriHost == "example.com" &amp;&amp; x.UriPath.StartsWith("/docs"))
    .OrderBy(x =&gt; x.UriPath)
    .ToListAsync();

曖昧検索(タイトルや一部のパス)

var r = await db.Bookmarks
    .Where(x =&gt; x.Title!.Contains("EF Core") || x.UriPath.Contains("/ef-core/"))
    .ToListAsync();

エラー/落とし穴と回避策

  • new Uri(v) で例外:null を渡さない(null 許容なら HasConversion 側でガード)。
    v => v == null ? null : new Uri(v, UriKind.Absolute) のような分岐や、列を NULL 許容に。
  • 相対 URI を許容したい:UriKind.RelativeOrAbsolute で生成し、保存時は OriginalString を使うなど要件に合わせる。
  • IDN(国際化ドメイン名):比較は Uri.IdnHost(もしくは DnsSafeHost)に寄せると安定。
  • 重複判定がズレる:?a=1&b=2 と ?b=2&a=1 は同義でも文字列が異なり得る。
    重複排除が重要なら、クエリパラメータの並び替え・重複キー統合など独自正規化ルールを実装する。
  • Contains 検索が遅い:前方一致に寄せる・補助列に分解・varchar を選んでストレージ削減(ただし国際化要件とトレードオフ)。

設計パターン比較

パターン概要メリットデメリット向いているケース
単一列(文字列)Uri ⇔ 文字列の相互変換で 1 列に保存実装が最小、表示が簡単部分検索や集計は遅い参照・詳細画面中心、検索は少ない
単一列+補助列主要部品(Host/Path 等)を別列で同期検索・集計が速い、インデックス可同期ロジックが必要検索要件が明確、件数が多い
完全分解(Owned Type)スキーム・ホスト・ポート・パス・クエリを個別列で管理きめ細かい制約と最適化設計コストが高い、表示で再結合が必要高度な分析・ルールがあるドメイン

Owned Type による分解保存(応用)

URI の各要素に個別制約を掛けたいなら、Owned Type で分解して保存します。

[Owned]
public sealed class UrlParts
{
    public string Scheme { get; private set; } = "https";
    public string Host { get; private set; } = "";
    public int? Port { get; private set; }
    public string Path { get; private set; } = "/";
    public string? Query { get; private set; }


public static UrlParts From(Uri uri) => new()
{
    Scheme = uri.Scheme,
    Host = uri.Host.ToLowerInvariant(),
    Port = uri.IsDefaultPort ? null : uri.Port,
    Path = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath,
    Query = string.IsNullOrEmpty(uri.Query) ? null : uri.Query.TrimStart('?')
};


}

public sealed class Bookmark
{
public int Id { get; set; }
public Uri Uri { get; private set; } = default!;
public UrlParts Parts { get; private set; } = new();


public void SetUri(Uri uri)
{
    Uri = uri;
    Parts = UrlParts.From(uri);
}


}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity().OwnsOne(x => x.Parts, b =>
{
b.Property(p => p.Scheme).HasMaxLength(10);
b.Property(p => p.Host).HasMaxLength(255);
b.Property(p => p.Path).HasMaxLength(2048);
b.Property(p => p.Query).HasMaxLength(2048);
b.HasIndex(p => new { p.Host, p.Path });
});
} 

テストで守る:変換・正規化の回 regress を防止

public class UriMappingTests
{
    [Fact]
    public void 保存復元してもAbsoluteUriが保たれる()
    {
        var uri = new Uri("https://Example.com/path/?b=2&a=1");
        using var db = new AppDbContext(/*...*/);
        db.Database.EnsureCreated();


    var b = new Bookmark { Title = "doc" };
    b.SetUri(uri);
    db.Add(b);
    db.SaveChanges();

    var loaded = db.Bookmarks.Single(x => x.Id == b.Id);
    Assert.Equal(uri.AbsoluteUri, loaded.Uri.AbsoluteUri);
}


} 

パフォーマンスの考え方

  • 変換コスト:Uri ⇔ 文字列の変換は軽微。ボトルネックは大抵 I/O と検索条件。
  • インデックス選定:完全一致中心なら URI 列にインデックス。大量の部分一致は補助列で前方一致に寄せる。
  • nvarchar(max):柔軟だがインデックスに制限。検索主体の列には固定長上限を与える。

セキュリティの勘所

  • スキーム制限:javascript: 等を拒否。埋め込みプレビュー機能がある場合は必須。
  • リダイレクト先の検証:外部遷移時のオープンリダイレクト対策にホワイトリストを適用。
  • XSS:URL 表示時は HTML エスケープ。クエリ文字列にユーザー入力が混ざる場合は特に注意。

よくある質問(FAQ)

Q. SQL Server に URI 専用の型はありますか?
ありません。文字列で保存し、アプリ側で Uri として扱うのが定石です。

Q. 組み込みコンバーターと自作のどちらを使うべき?
まずは組み込み(HasConversion<string> や UriToStringConverter 系)で十分。OriginalString を保存したいなど特殊要件があるなら自作します。

Q. 既存の文字列列を Uri に置き換えたい
HasConversion を付けるだけでアプリ側の型が Uri になります。列型はそのままなので大掛かりな移行は不要です(値の正規化は別途)。

Q. ユニーク制約はどの列に掛ける?
正規化後の文字列(例:AbsoluteUri)に掛けるのが安全。OriginalString 保存時は正規化列を別途用意してそこにユニークインデックスを。

実装テンプレート(貼って使える)

// Entity
public sealed class Bookmark
{
    public int Id { get; set; }
    public Uri Uri { get; private set; } = default!;
    public string? Title { get; set; }


public string UriHost { get; private set; } = string.Empty;
public string UriPath { get; private set; } = "/";

public void SetUri(Uri uri)
{
    Uri = uri;
    UriHost = uri.Host.ToLowerInvariant();
    UriPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
}


}

// DbContext
public sealed class AppDbContext : DbContext
{
public DbSet Bookmarks => Set();


protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
    builder.Properties<Uri>()
           .HaveConversion<UriToStringConverter>() // or .HaveConversion<string>()
           .HaveColumnType("nvarchar(2048)")
           .HaveMaxLength(2048);
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var e = modelBuilder.Entity<Bookmark>();

    // ここで AbsoluteUri を保存するポリシーを明示してもよい
    e.Property(x => x.Uri)
     .HasConversion(v => v.AbsoluteUri, v => new Uri(v, UriKind.Absolute));

    e.Property(x => x.Title).HasMaxLength(256);

    e.Property(x => x.UriHost).IsRequired().HasMaxLength(255);
    e.Property(x => x.UriPath).IsRequired().HasMaxLength(2048);
    e.HasIndex(x => x.UriHost);
    e.HasIndex(x => new { x.UriHost, x.UriPath });
}


} 

まとめ

EF Core/SQL Server 環境では、URI を「URI 型のまま DB に入れる」ことはできません。DB は文字列、アプリは Uri という分担が現実的で、ValueConverter(HasConversion/組み込みコンバーター)を噛ませれば最小のコードで両立できます。検索やユニーク制約が重要なら、保存する文字列の方針(AbsoluteUri か OriginalString か)を決め、必要に応じて 補助列を設けて最適化しましょう。さらに ConfigureConventions で一括適用、バリデーションで正規化、ValueComparer で等価性の明示、と段階的に厚みを持たせれば、保守性・性能・健全性のバランスが取れた URI 管理基盤が完成します。


付録:質問への直接回答

1) データベースへ URI を「URI 型」のまま格納できるか?

できません。SQL Server に URI 専用の列型は存在せず、文字列型(nvarchar など)での保存が必須です。

2) どのように文字列として保存・復元すればよいか?

  • ValueConverter を利用して HasConversion を設定します。
  • EF Core 7 以降なら HasConversion<string>() や UriToStringConverter 系の組み込みコンバーターが利用可能です。
  • 保存文字列の方針(AbsoluteUri/OriginalString)を決め、HasMaxLength と列型を明示し、必要なら補助列で検索性を高めます。

実運用チェック(再掲・ミニ版)

  • スキーマ:nvarchar(2048)(検索重視は固定長)/未知なら max
  • 変換:HasConversion+組み込みコンバーター
  • 正規化:AbsoluteUri 保存 or 正規化列併設
  • 検索:補助列(Host/Path)+インデックス
  • 検証:TryCreate、スキーム制限、末尾スラッシュ統一

この記事を書いた人

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

コメント

コメントする

目次