複数テーブルの統合ビュー
一覧画面は 1 つのサイト(テーブル)に 1 つです。複数のテーブルのレコードを 1 つの一覧にまとめて見る標準機能はありません。このページは、複数テーブルを横断する一覧を新しいサイト種別として足す改修の設計メモです。本体の標準機能ではありません。
要件
| 要件 | 内容 |
|---|---|
| 統合一覧 | 複数の記録テーブル・期限付きテーブルのレコードを 1 つの一覧に出す |
| 新しいサイト種別 | ReferenceType = 'IntegratedView' |
| JSON 定義モード(標準) | 元のテーブル・列・結合方法を JSON で書く |
| 拡張 SQL モード(上級者向け) | 拡張 SQL の結果をそのまま出す。JSON 定義モードとはどちらか一方 |
| 2 段階のフィルタ・ソート | 元のテーブルごと(結合前)と、まとめた結果(結合後) |
| 既存の UI | 結合後のフィルタ・ソートは、通常の一覧と同じヘッダ・フィルター領域で操作する |
| 編集 | 各行から元のテーブルの編集画面へ移動でき、ダイアログでも編集できる |
| 権限 | 元のテーブルごとに、ユーザーの権限の条件を自動で入れる(JSON 定義モード) |
| 性能 | 目標 500ms 以下。件数上限の必須化・結合前のフィルタ・元テーブル数の上限で抑える |
前提にした現行実装(1.5.8.1)
近い既存機能
| 機能 | できること | 足りないこと |
|---|---|---|
ダッシュボードの一覧パーツ(DashboardPart.IndexSites) | 複数サイトの一覧を並べて出す | 1 つの一覧にはまとまらない。列はそれぞれのサイトの一覧設定のまま |
リンク先の列(ClassA~200,Title) | リンクしたテーブルの列を JOIN して出す | サイトのリンク定義に沿った JOIN だけで、任意の条件では結合できない |
| 拡張 SQL | OnSelectingColumn・OnSelectingWhere・OnSelectingOrderBy などで一覧の SQL に手を入れる | FROM 句(どのテーブルを読むか)は変えられない |
リンク先の列の仕組みは リンク先の列(チルダ構文)と JOIN の生成 にまとめています。
一覧のデータ取得
GridData.Get() は、一覧の列から SELECT 列を作り、View.Where()・View.OrderBy() で WHERE・ORDER BY を作り、ss.Join() で JOIN を足して Rds.Select() を実行します(GridData.cs)。View.Where() の中で Permissions.SetPermissionsWhere() が権限の条件を足します。詳しくは 内部で動く SQL 文 を参照してください。
フィルタ・ソートの状態は View の ColumnFilterHash(列名 → 値)、ColumnFilterSearchTypes(列名 → 検索の種類)、ColumnSorterHash(列名 → 昇順・降順)に入ります。
一覧でのダイアログ編集は SiteSettings.GridEditorType(None = 0・Grid = 10・Dialog = 20)の Dialog です(SiteSettings.cs)。
データアクセス層
本体は EF Core・Dapper・SqlKata などを使わず、独自の Rds 層(Implem.IRds のインターフェースと、SQL Server・PostgreSQL・MySQL の実装)で ADO.NET を直接使っています。1.5.8.1 で参照しているドライバは Microsoft.Data.SqlClient 7.0.2、Npgsql 10.0.3、MySqlConnector 2.6.1 です。SQL は Implem.Libraries/DataSources/SqlServer/ の SqlSelect・SqlWhere・SqlOrderBy・SqlJoin などの部品で組み立てます。
クエリの組み立て方の選択
| 候補 | 長所 | 短所 |
|---|---|---|
| EF Core | LINQ で型安全 | Rds 層と共存しにくく、DbContext の導入が大きい |
| SqlKata | UNION・JOIN を組め、DB の方言も変換する | 依存が増え、Rds 層と二重管理になる |
| Dapper | 軽い | クエリの組み立ては手書き |
| Rds 層を拡張(推奨) | 依存が増えない。3 つの DB の方言に対応済み。View.Where()・View.OrderBy() の結果をそのまま使える | UNION の部品が無いので足す必要がある |
UNION ALL 用の部品(例: SqlUnion)を足し、既存の ISqlCommandText の方式で DB ごとの違いを吸収します。クエリが複雑になってきたら SqlKata をあらためて検討します。
設計
データソースのモード
| モード | 取得 | フィルタ・ソート | 用途 |
|---|---|---|---|
| JSON 定義 | JSON の定義から SQL を組み立てる | 2 段階で使える | 標準 |
| 拡張 SQL | 拡張 SQL の CommandText を実行する | 使えない(SQL の中で書く) | 複雑な SQL が要るとき |
1 つの統合ビューで使えるのはどちらか一方です。設定画面で「データソース」を切り替え、選ばなかった方の設定は無視します。件数上限 Limit は両方で使います。
2 段階のフィルタ・ソート(JSON 定義モード)
図を読み込み中…
| 段階 | 対象 | フィルタ・ソートの列 | 設定する場所 |
|---|---|---|---|
| 1(ソース別) | 元のテーブル | 元のテーブルの列 | JSON 定義に保存 |
| 2(統合) | まとめた結果 | As で付けた別名 | 一覧のヘッダ・フィルター領域。状態はビューのセッションに保存(既存の仕組み) |
どちらも既存の ColumnFilterHash・ColumnSorterHash と同じ形にして、View.Where()・View.OrderBy() を使います。
JSON 定義
{
"Title": "課題・成果物統合ビュー",
"DataSourceType": "Json",
"Sources": [
{
"SiteId": 100,
"Alias": "a",
"Editable": true,
"Columns": [
{ "Name": "IssueId", "As": "Id" },
{ "Name": "Title" },
{ "Name": "Status" },
{ "Name": "ClassA", "As": "Category" }
],
"ColumnFilterHash": { "Status": "[\"100\",\"200\",\"300\"]" },
"ColumnSorterHash": { "UpdatedTime": "desc" }
},
{
"SiteId": 200,
"Alias": "b",
"Editable": false,
"Columns": [
{ "Name": "ResultId", "As": "Id" },
{ "Name": "Title" },
{ "Name": "Status" },
{ "Name": "ClassA", "As": "Category" }
],
"ColumnFilterHash": { "Status": "[\"100\",\"200\"]" },
"ColumnSorterHash": {}
}
],
"CombineType": "UnionAll",
"JoinCondition": null,
"IntegratedFilterHash": { "Category": "インフラ" },
"IntegratedSorterHash": { "Title": "asc" },
"Limit": 100
}| プロパティ | 型 | 内容 |
|---|---|---|
Title | string | 表示名 |
DataSourceType | string | Json(既定)または ExtendedSql |
Sources | 配列 | 元のテーブル(JSON 定義モードだけ) |
Sources[].SiteId | long | サイト ID |
Sources[].Alias | string | JOIN で使う別名 |
Sources[].Editable | bool | 編集画面への移動を許すか(既定 false) |
Sources[].Columns[].Name / As | string | 列名と、まとめた結果での別名 |
Sources[].ColumnFilterHash / ColumnSorterHash | 辞書 | 段階 1 のフィルタ・ソート |
CombineType | string | UnionAll・InnerJoin・LeftOuterJoin |
JoinCondition | string | JOIN の条件(例: a.ClassA = b.ResultId) |
ExtendedSqlName | string | 拡張 SQL の名前(拡張 SQL モードだけ) |
IntegratedFilterHash / IntegratedSorterHash | 辞書 | 段階 2 のフィルタ・ソート(JSON 定義モードだけ) |
Limit | int | 件数の上限(両モード) |
UNION ALL の例は、次の SQL と同じ意味です。
(SELECT IssueId AS Id, Title, Status, ClassA AS Category
FROM Issues
WHERE /* 権限の条件 */ AND Status IN (100, 200, 300))
UNION ALL
(SELECT ResultId AS Id, Title, Status, ClassA AS Category
FROM Results
WHERE /* 権限の条件 */ AND Status IN (100, 200))
-- 段階 2
ORDER BY Title ASCJOIN の場合は CombineType を InnerJoin などにし、JoinCondition に a.ClassA = b.ResultId のように別名付きで条件を書きます。
拡張 SQL モードは次のように書き、ExtendedSqlName で 拡張 SQL の定義を指します。
{
"Title": "カスタム SQL 統合ビュー",
"DataSourceType": "ExtendedSql",
"ExtendedSqlName": "IntegratedView_CustomQuery",
"Limit": 200
}保存先とクラス
SiteSettings に IntegratedViews(List<IntegratedView>)を足して定義を持ちます。JSON は本体標準の Newtonsoft.Json で読みます。
図を読み込み中…
UNION ALL の組み立て(案)
public SqlStatement ToUnionSqlStatement(Context context)
{
var statements = new List<SqlStatement>();
foreach (var source in Sources)
{
var ss = SiteSettingsUtilities.Get(context: context, siteId: source.SiteId);
var column = new SqlColumnCollection();
source.Columns.ForEach(col => column.Add(new SqlColumn(
columnBracket: $"\"{col.Name}\"",
tableName: ss.ReferenceType,
_as: col.As ?? col.Name)));
// 編集画面へのリンク用の隠し列
column.Add(new SqlColumn(
columnBracket: "\"SiteId\"", tableName: ss.ReferenceType, _as: "_SourceSiteId"));
column.Add(new SqlColumn(
columnBracket: $"\"{Rds.IdColumn(ss.ReferenceType)}\"",
tableName: ss.ReferenceType,
_as: "_SourceId"));
// 段階 1: 権限の条件とソース別のフィルタ・ソート
var sourceView = new View
{
ColumnFilterHash = source.ColumnFilterHash,
ColumnSorterHash = source.ColumnSorterHash
};
var where = sourceView.Where(context: context, ss: ss);
statements.Add(Rds.Select(
tableName: ss.ReferenceType,
column: column,
where: where,
orderBy: sourceView.OrderBy(context: context, ss: ss)));
}
// 段階 2 は UNION ALL の結果に対して IntegratedFilterHash / IntegratedSorterHash を当てる
return Rds.Union(statements); // Rds.Union は追加する部品
}View.Where() は既定で権限の条件(SetPermissionsWhere)も足すので、別に呼ぶ必要はありません。
一覧と編集
一覧は既存の一覧の HTML の構造を流用し、元のテーブル名の列と操作の列を足します。
<tr data-source-site-id="100" data-source-id="1">
<td class="iv-source"><span class="iv-badge issues">課題管理</span></td>
<td>サーバ移行作業</td>
<td>進行中</td>
<td class="iv-actions">
<a href="/items/1/edit" class="iv-edit-link" title="編集画面を開く"></a>
<button class="iv-edit-modal" data-site-id="100" data-id="1" title="ダイアログで編集"></button>
</td>
</tr>- 編集のリンクとボタンは
Editable: trueのソースの行にだけ出します。URL は_SourceIdから/items/{id}/editを作ります。/items/{id}はレコード ID からItemModelがサイトを解決するので、元のテーブルの編集画面が開きます。 - さらに行ごとに更新権限を見て、権限が無ければ出しません。
- ダイアログは既存の
#EditorDialog($p.openEditorDialog)の仕組みを流用し、閉じたら一覧を読み直します(grid.js)。
実装方式の比較
| 観点 | A: サーバースクリプト + 拡張 SQL | B: 新しいサイト種別(推奨) | C: ダッシュボードのパーツ |
|---|---|---|---|
| 仕組み | 拡張 SQL の CommandText に UNION ALL を書く | ReferenceType = 'IntegratedView' と専用の設定タブ | DashboardPartType に統合ビューを足す |
| 実装の手間 | ◎ 小さい | ○ 中〜大 | ○ 中 |
| 保守 | △ パラメータファイルが散らばる | ◎ サイト設定にまとまる | ○ ダッシュボードにまとまる |
| 操作 | △ JSON・SQL の手書き | ◎ 専用 UI と既存のヘッダ | ○ ダッシュボードの UI |
| 権限 | △ 自前で書く | ◎ 既存の仕組み | ○ 既存の仕組み |
| フィルタ・ソート | △ SQL に書く | ◎ 2 段階 + 既存のヘッダ | ○ 2 段階 |
| 拡張性 | ◎ SQL の自由度 | ◎ 独立した種別なので足しやすい | ○ |
B を推奨します。通常のテーブルと同じ並びにサイトとして置け、一覧のフィルタ・ソートの UI をそのまま使え、統合ビュー自体の閲覧・編集の権限も既存の仕組みで管理できます。最初は UNION ALL だけ、次に JOIN、そのあとエクスポート・API と段階的に足せます。
| ファイル | 内容 |
|---|---|
Libraries/Settings/SiteSettings.cs | IntegratedViews と関連の処理 |
Models/Sites/SiteUtilities.cs | IntegratedView 種別の一覧画面・設定画面 |
Models/Items/ItemModel.cs | IntegratedView の振り分け |
新規 Libraries/Settings/IntegratedView.cs・IntegratedViewSource.cs | 定義のクラス |
新規 Models/IntegratedViews/IntegratedViewUtilities.cs | 一覧の HTML(既存のヘッダを流用) |
新規 wwwroot/src/scripts/generals/ のスクリプト | ダイアログ編集 |
性能
| 対策 | 優先度 | 内容 |
|---|---|---|
| 件数上限の必須化 | 必須 | Limit が無ければ GridPageSize(未設定ならパラメータの GridPageSize、既定 20)を使い、上限なしの SQL を出さない |
| 結合前のフィルタ | 必須 | ソース別のフィルタで結合前に件数を減らす。設定画面で勧める |
| 元テーブル数の上限 | 必須 | 推奨 5、最大 10 を超えたら保存時にエラー |
| ページ分け | 高 | OFFSET ... FETCH NEXT(SQL Server)・LIMIT ... OFFSET(PostgreSQL・MySQL)で分けて取る |
| JOIN のキーの索引 | 高 | JOIN の条件にする列の索引を勧める |
| Ajax での読み込み・短いキャッシュ | 中〜低 | 必要なら |
public int EffectiveLimit(SiteSettings ss)
{
return Limit ?? ss.GridPageSize ?? Parameters.General.GridPageSize;
}重いのは、結合前のフィルタが無く索引も効かない全件の読み込み、UNION ALL のあとの ORDER BY(件数に比例する並べ替え)、JOIN のキーに索引が無い場合、表示行数が多い場合の HTML です。実装時は、クエリ 500ms 以下・応答全体 1,000ms 以下を目安に、実行計画に警告が無いことを確かめます。
安全面
| 観点 | 対策 |
|---|---|
| SQL インジェクション | JSON の値は Rds 層の部品を通してパラメータ化する。JSON から生の SQL は実行しない。JoinCondition は「別名.列名 = 別名.列名」の形だけ許し、関数・サブクエリ・;・コメントは拒否する |
| テナント | SiteSettingsUtilities.Get() でテナントを確かめ、別テナントのサイトは拒否する |
| サイトの読み取り | ソースごとに読み取り権限を確かめ、無ければそのソースを飛ばす |
| 行の権限 | ソースごとに SetPermissionsWhere() の条件を入れる |
| 列 | Columns[].Name は各ソースの列定義(ColumnDefinitionHash)にあるものだけ。非表示の列や、列の閲覧権限がある列はまとめた結果でも同じ扱いにする |
| 編集 | Editable に加え、行ごとに更新権限を確かめる |
| 定義の変更 | サイトの管理権限を持つユーザーだけ |
| JSON の読み込み | TypeNameHandling.None のまま。定義の大きさ(例: 64KB)とソースの数に上限。不明なプロパティは無視 |
| 内部の列 | _SourceSiteId・_SourceId は表示せず data-* に入れる |
| XSS・CSRF | 値の出力は HtmlBuilder でエスケープする。送信は既存のトークンの仕組みを使う |
拡張 SQL モード
拡張 SQL モードでは、管理者が書いた SQL がそのまま実行され、権限の条件は自動では入りません。拡張 SQL はパラメータファイルに置くので、設定できるのはサーバーのファイルを触れる管理者だけです。権限の条件は SQL の中に自分で書く必要があります。
既存機能との関係
| 項目 | 扱い |
|---|---|
| 既存のサイト種別 | 影響なし |
| フィルタの保存 | ソース別は JSON 定義、統合は既存のビューのセッション(JSON 定義モードだけ) |
| エクスポート・API | あとで対応 |