0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlexGrid の複数ソート/複合フィルタを「サーバー再検索」で正しく扱う

Last updated at Posted at 2025-10-04

概要

  • 画面のソートフィルタサーバー側で一括適用し、ページングと必ず整合させる。
  • フロントの状態(並び順・条件)を安全な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 BYNULLS FIRST/LAST を明示でき、既定は「昇順=LAST/降順=FIRST」。([Oracle Docs][4])


設計の芯(迷わないルール)

  1. UIで起きた操作=毎回サーバー再検索
    → ページ番号は必ず0に戻す。連続操作は debounce(200–300ms)Abort で過負荷を防ぐ。(実装指針)
  2. 列名は論理キーで受ける(例:shipName)。
    → サーバーでホワイトリスト解決して物理列に変換(SQLインジェクション回避)。
  3. 値はすべてバインド識別子(列名/ORDER BY 断片)だけ埋め込み
    → Doma のテンプレSQLに最小限のリテラル展開を渡す。([docs.domaframework.org][5])
  4. 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/LASTORDER 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手順

  1. グリッドで列ヘッダを複数回クリック → 並べ替えアイコンが変わる。
  2. 検索APIがpage=0で呼ばれる。ボディに sorts が多段で入っている。
  3. フィルタを一つ適用 → API の filters に変換されている(CONTAINS など)。
  4. バックエンドで order by ... NULLS LAST が生成される。
  5. SelectOptionscount() をオンにしていれば、総件数が 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) を呼ぶと一貫します(見た目の状態を統一できる)。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?