アプリ側では 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<Uri>()
.HaveConversion<UriToStringConverter>() // or <string>
.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 目安/不確実なら max | 2,000 文字超の URI もあり得る。確実性重視なら nvarchar(max)。 |
| NULL 許容 | 要件次第(Uri? なら NULL) | 「空文字」と「未設定(NULL)」は区別するのが一般的。 |
| コレーション | DB 既定で OK | URI のホストは大小無視、パスは大小区別が混在するため、アプリ側で比較方針を統一するのが安全。 |
| インデックス | 必要に応じて追加 | 完全一致検索が多いなら URI 列へインデックス。部分一致中心なら補助列(後述)が有効。 |
保存する文字列はどれにする?(AbsoluteUri / ToString / OriginalString)
同じ URI でも表記揺れ(エスケープ・大小・末尾スラッシュ・クエリ順)が異なると文字列は変わります。用途に応じて選択しましょう。
| 候補 | 特徴 | 向いているケース | 注意点 |
|---|---|---|---|
AbsoluteUri | 正規化された絶対 URI を返す | 重複排除・比較・主キー/ユニーク制約 | 元入力の表記(相対・未エスケープ)は失われる |
ToString() | 実装に依存、通常は Absolute に近い | 軽量で汎用 | 厳密な同一性の基準に使うなら明示方針が必要 |
OriginalString | コンストラクタに渡した元文字列 | ユーザー入力をそのまま再表示したい | 比較や重複判定には不向き(表記揺れの影響大) |
実装例:AbsoluteUri を保存する
entity.Property(e => e.Uri)
.HasConversion(v => v.AbsoluteUri, v => 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<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var ctx = (AppDbContext)eventData.Context!;
foreach (var e in ctx.ChangeTracker.Entries<Bookmark>())
{
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 => x.Uri.AbsoluteUri == url.AbsoluteUri);
ホスト+パスで探す(補助列活用)
var r = await db.Bookmarks
.Where(x => x.UriHost == "example.com" && x.UriPath.StartsWith("/docs"))
.OrderBy(x => x.UriPath)
.ToListAsync();
曖昧検索(タイトルや一部のパス)
var r = await db.Bookmarks
.Where(x => 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、スキーム制限、末尾スラッシュ統一

コメント