概要
- 画面のソートとフィルタをサーバー側で一括適用し、ページングと必ず整合させる。
- フロントの状態(並び順・条件)を安全なJSONに正規化してAPIへ渡し、DomaのテンプレSQLで動的 WHERE/ORDER BYを作る。
根拠(事実)
・FlexGrid の並び順はCollectionView.sortDescriptions
に積まれる。フィルタはFlexGridFilter.filterDefinition
で取れる。([MESCIUS][1])
・ソートのフックはsortingColumn
/sortedColumn
、フィルタはfilterApplied
を使える。([MESCIUS][2])
・DomaのページングはSelectOptions.offset().limit()
(必要なら.count()
)。テンプレSQL(二方向SQL)は条件分岐に強い。([docs.domaframework.org][3])
・Oracle のORDER BY
はNULLS FIRST/LAST
を明示でき、既定は「昇順=LAST/降順=FIRST」。([Oracle Docs][4])
設計の芯(迷わないルール)
-
UIで起きた操作=毎回サーバー再検索
→ ページ番号は必ず0に戻す。連続操作は debounce(200–300ms) と Abort で過負荷を防ぐ。(実装指針) -
列名は論理キーで受ける(例:
shipName
)。
→ サーバーでホワイトリスト解決して物理列に変換(SQLインジェクション回避)。 -
値はすべてバインド、識別子(列名/ORDER BY 断片)だけ埋め込み。
→ Doma のテンプレSQLに最小限のリテラル展開を渡す。([docs.domaframework.org][5]) -
NULL の並びと大小比較の規則をチームで固定(例:常に
NULLS LAST
)。([Oracle Docs][4])
API I/F(固定すると実装がブレない)
{
"page": 0,
"size": 50,
"sorts": [
{ "field": "shipName", "dir": "ASC", "nulls": "LAST" },
{ "field": "updatedAt", "dir": "DESC", "nulls": "LAST" }
],
"filters": [
{ "field": "status", "op": "EQ", "value": "ACTIVE" },
{ "field": "shipName", "op": "CONTAINS", "value": "丸" },
{ "field": "updatedAt", "op": "BETWEEN", "from": "2025-09-01", "to": "2025-10-04" }
]
}
- 使う演算子は最初は少数でよい:
EQ/NE/GT/GE/LT/LE/CONTAINS/STARTS/ENDS/IN/BETWEEN/IS_NULL/IS_NOT_NULL
。
フロント実装(TypeScript・素のWijmo想定)
1) Grid と Filter の初期化
import * as wjGrid from '@mescius/wijmo.grid';
import * as wjFilter from '@mescius/wijmo.grid.filter';
const grid = new wjGrid.FlexGrid('#grid', {
allowSorting: wjGrid.AllowSorting.MultiColumn
});
const filter = new wjFilter.FlexGridFilter(grid); // Excel風フィルタ有効化
FlexGridFilter
はこれで有効化できる。([MESCIUS][6])
2) “操作→再検索”のフック
let page = 0, size = 50;
let abortCtrl: AbortController | null = null;
// ソート:内蔵に任せる運用なら「確定後」に投げる
grid.sortedColumn.addHandler(() => {
page = 0;
requestSearch();
});
// 内蔵を止めて全てサーバー任せにする運用ならこちら
grid.sortingColumn.addHandler((s, e) => {
e.cancel = true; // 内蔵ソートキャンセル
toggleSortState(s, e.col); // 自前で sortDescriptions を更新
page = 0;
requestSearch();
});
// フィルタ:「適用」後に1回だけ
filter.filterApplied.addHandler(() => {
page = 0;
requestSearch();
});
sortedColumn
/sortingColumn
/filterApplied
は公式APIのイベント。([MESCIUS][2])
3) リクエスト送出(debounce+Abort 付き)
const DEBOUNCE_MS = 250;
let t: number | null = null;
function requestSearch() {
if (t) window.clearTimeout(t);
t = window.setTimeout(() => fireSearch(), DEBOUNCE_MS);
}
function fireSearch() {
if (abortCtrl) abortCtrl.abort();
abortCtrl = new AbortController();
const req = {
page, size,
sorts: collectSorts(grid),
filters: collectFilters(filter)
};
fetch('/api/ships/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
signal: abortCtrl.signal
})
.then(r => r.json())
.then(({ content, total }) => {
grid.itemsSource = content; // 1ページ分
updatePager(total, page, size);
})
.catch(e => {
if (e.name !== 'AbortError') console.error(e);
});
}
4) 並び順とフィルタの“正規化”
type SortSpec = { field: string; dir: 'ASC'|'DESC'; nulls?: 'FIRST'|'LAST' };
type FilterSpec = { field: string; op: string; value?: any; from?: string; to?: string; in?: any[] };
// 複数列ソート:CollectionView.sortDescriptions から吸い上げ
function collectSorts(s: wjGrid.FlexGrid): SortSpec[] {
const sds = s.collectionView.sortDescriptions; // 公式が示すやり方
return Array.from(sds).map(sd => ({
field: sd.property,
dir: sd.ascending ? 'ASC' : 'DESC',
nulls: 'LAST'
}));
}
// 参考:sortDescriptions は FlexGrid/CollectionView の機能。:contentReference[oaicite:8]{index=8}
// フィルタ:filterDefinition(JSON) → 必要項目だけ写経
function collectFilters(f: wjFilter.FlexGridFilter): FilterSpec[] {
const defStr = (f as any).filterDefinition as string; // 文字列(JSON)
if (!defStr) return [];
const def = JSON.parse(defStr);
const out: FilterSpec[] = [];
(def.filters ?? []).forEach((col: any) => {
const field = col.binding;
// 条件フィルタを汎用オペレータにマップ
for (const c of (col.conditionFilter?.conditions ?? [])) {
const op = mapWijmoOp(c.operator);
if (!op) continue;
out.push({ field, op, value: c.value });
}
// 値フィルタが使われていたら IN に畳み込み
const vals = col.valueFilter?.showValues;
if (Array.isArray(vals) && vals.length) out.push({ field, op: 'IN', in: vals });
});
return out;
}
// Wijmoの演算子名 → サーバー標準名へ統一
function mapWijmoOp(wjOp: string): string | null {
const m: Record<string,string> = {
EQ: 'EQ', NE: 'NE', GT: 'GT', GE: 'GE', LT: 'LT', LE: 'LE',
BEGINS_WITH: 'STARTS', ENDS_WITH: 'ENDS', CONTAINS: 'CONTAINS'
};
return m[wjOp] ?? null;
}
filterDefinition
は FlexGridFilter が持つ JSON 文字列。実運用では必要な演算子だけ抜き出す。([MESCIUS][7])
サーバー実装(Spring + Doma)
1) 受信用 DTO
public record SearchRequest(
int page, int size,
List<SortSpec> sorts,
List<FilterSpec> filters
) {}
public record SortSpec(String field, String dir, String nulls) {}
public record FilterSpec(String field, String op, Object value, String from, String to, List<Object> in) {}
2) 列ホワイトリストと ORDER BY 生成
// 論理名→物理列(識別子の安全化)
private static final Map<String, String> COLS = Map.of(
"shipId", "s.SHIP_ID",
"shipName", "s.SHIP_NAME",
"status", "s.STATUS",
"updatedAt","s.UPDATED_AT"
);
static String buildOrderBy(List<SortSpec> sorts) {
return sorts.stream()
.map(s -> {
String col = COLS.get(s.field());
if (col == null) return null;
String dir = "DESC".equalsIgnoreCase(s.dir()) ? "DESC" : "ASC";
String nulls = "FIRST".equalsIgnoreCase(s.nulls()) ? "NULLS FIRST" : "NULLS LAST";
return col + " " + dir + " " + nulls; // Oracleの明示(既定は昇順=LAST/降順=FIRST)
})
.filter(Objects::nonNull)
.collect(Collectors.joining(", "));
}
NULLS FIRST/LAST
はORDER BY
で指定可能(Oracle)。既定は昇順=LAST/降順=FIRST。([Oracle Docs][4])
3) Doma のテンプレSQL(two-way SQL)
先にやさしく説明:テンプレSQL=「SQLをテンプレートとして置き、コメント記法で条件分岐や埋め込みができる仕組み」。Doma ではこれを**二方向SQL(two-way SQL)**と呼ぶ。([docs.domaframework.org][5])
ShipDao/selectList.sql
select
s.SHIP_ID,
s.SHIP_NAME,
s.STATUS,
s.UPDATED_AT
from SHIP s
where 1 = 1
/*%for f : filters */
/*%if f.op == "EQ" */
and /*#f.column*/ = /*f.value*/'x'
/*%elif f.op == "CONTAINS" */
and upper(/*#f.column*/) like upper(/*f.likeValue*/'%x%')
/*%elif f.op == "BETWEEN" */
and /*#f.column*/ between /*f.from*/timestamp'1970-01-01 00:00:00' and /*f.to*/timestamp'2099-12-31 23:59:59'
/*%elif f.op == "IN" */
and /*#f.column*/ in /*f.in*/(1,2,3)
/*--- 大小比較 ---*/
/*%elif f.op == "GT" */
and /*#f.column*/ > /*f.value*/0
/*%elif f.op == "GE" */
and /*#f.column*/ >= /*f.value*/0
/*%elif f.op == "LT" */
and /*#f.column*/ < /*f.value*/0
/*%elif f.op == "LE" */
and /*#f.column*/ <= /*f.value*/0
/*%elif f.op == "IS_NULL" */
and /*#f.column*/ is null
/*%elif f.op == "IS_NOT_NULL" */
and /*#f.column*/ is not null
/*%end*/
/*%end*/
/*%if orderBy != null && orderBy != "" */
order by /*#orderBy*/
/*%end*/
-
/*#...*/
は埋め込み(検証済みの列名や ORDER BY 断片のみを入れる)。値は/*param*/
で必ずバインド。([docs.domaframework.org][5])
DAO
@Dao
public interface ShipDao {
@Select
List<ShipRow> selectList(List<FilterParam> filters, String orderBy, SelectOptions options);
}
サービス(ページング+総件数)
SelectOptions opt = SelectOptions.get()
.offset(req.page() * req.size())
.limit(req.size())
.count(); // 必要なら総件数取得
List<ShipRow> rows = dao.selectList(filterParams, orderBy, opt);
long total = opt.getCount();
Doma のページング方法(
offset/limit/count
)は公式が明記。([docs.domaframework.org][3])
よくある詰まりどころ(先回りで回避)
- 列名の直埋め:必ずホワイトリスト解決 → 埋め込むのは識別子だけ。値はすべてバインド(安全のため)。([docs.domaframework.org][5])
- ページングのズレ:ソート/フィルタ変更時はpage=0へ戻す。
-
並べ替えの安定性:最後に主キーを足す(
..., s.SHIP_ID ASC
)と、同値が多い列でもページをまたいだ揺れが減る。 -
NULLの並び:Oracleの既定に頼らず、毎列で
NULLS LAST/FIRST
を明示すると体験が一定になる。([Oracle Docs][4]) - 連打でリクエスト多発:debounce と AbortController を入れる(実装例のとおり)。
動作チェック用・最小E2E手順
- グリッドで列ヘッダを複数回クリック → 並べ替えアイコンが変わる。
- 検索APIがpage=0で呼ばれる。ボディに
sorts
が多段で入っている。 - フィルタを一つ適用 → API の
filters
に変換されている(CONTAINS
など)。 - バックエンドで
order by ... NULLS LAST
が生成される。 -
SelectOptions
のcount()
をオンにしていれば、総件数が UI に反映される。([docs.domaframework.org][3])
付録:内蔵ソートを止めたい場合のユーティリティ
目的:グリッド内の見た目の状態は使いつつ、実データの並びは常にサーバー結果にする。
function toggleSortState(s: wjGrid.FlexGrid, colIndex: number) {
const b = s.columns[colIndex].binding;
const sds = s.collectionView.sortDescriptions;
const i = sds.findIndex(sd => sd.property === b);
if (i < 0) {
sds.push(new wijmo.SortDescription(b, true)); // ASC
} else {
const asc = sds[i].ascending;
if (asc) sds[i] = new wijmo.SortDescription(b, false); // DESC
else sds.removeAt(i); // 解除
}
}
並び順の保持は
sortDescriptions
を使うのが正道。([MESCIUS][1])
結論:内蔵ソートを止めて“すべてサーバー任せ”にする運用のときにだけ使います。
具体的には、sortingColumn
イベントで内蔵処理をe.cancel = true
でキャンセルし、見た目の並び記号と優先順位だけをtoggleSortState
で更新 → その状態をサーバーへ送って再検索、という流れです。
使い所(最小コード)
grid.sortingColumn.addHandler((s, e) => {
e.cancel = true; // 内蔵の並べ替えを止める
toggleSortState(s, e.col); // 見た目のソート状態だけ更新(ASC→DESC→解除のサイクル)
page = 0; // ページ先頭に戻す
requestSearch(); // 並び順DTOを作ってサーバーへ
});
- こうすると、行の実際の順序はサーバーから返ってきた結果に一致します。
-
toggleSortState
は ヘッダの▲▼表示とcollectionView.sortDescriptions
を更新する役目だけ。データの並び替え自体はしません。
いつ使わないか
-
内蔵ソートに任せる運用(=
e.cancel
しない)では、toggleSortState
は不要です。代わりにsortedColumn
後でrequestSearch()
を呼べばOK。
ついでに
- ヘッダ以外に「並べ替えボタン」など独自UIを置く場合も、そのボタンから
toggleSortState(grid, colIndex)
を呼ぶと一貫します(見た目の状態を統一できる)。